Skip to content

fix(projects): scope project path uniqueness per SSH connection (#2731)#2747

Open
fiorelorenzo wants to merge 2 commits into
generalaction:mainfrom
fiorelorenzo:fix/project-path-unique-per-connection
Open

fix(projects): scope project path uniqueness per SSH connection (#2731)#2747
fiorelorenzo wants to merge 2 commits into
generalaction:mainfrom
fiorelorenzo:fix/project-path-unique-per-connection

Conversation

@fiorelorenzo

Copy link
Copy Markdown
Contributor

What

projects.path has a global UNIQUE index, so registering a project at a path that another host already uses fails, even though the two live on different machines. Since remote hosts commonly mount projects at the same conventional path, adding the same path on a second SSH connection blows up with UNIQUE constraint failed: projects.path.

Closes #2731.

Fix

Path uniqueness should be per machine, so the global index becomes two partial unique indexes (migration 0019):

  • local projects (ssh_connection_id IS NULL): unique by path
  • SSH projects (ssh_connection_id IS NOT NULL): unique by (ssh_connection_id, path)

Two partial indexes rather than a single (ssh_connection_id, path) one, because SQLite treats NULLs as distinct: a plain composite index would silently drop path uniqueness for local projects (any number of local projects could share a path). The two-index split is the same partial-index pattern already used for idx_workspaces_key in this schema.

DROP INDEX IF EXISTS `idx_projects_path`;
CREATE UNIQUE INDEX `idx_projects_local_path` ON `projects` (`path`) WHERE "ssh_connection_id" is null;
CREATE UNIQUE INDEX `idx_projects_ssh_connection_path` ON `projects` (`ssh_connection_id`,`path`) WHERE "ssh_connection_id" is not null;

The migration only loosens the constraint (the old global unique already forbade every case the new indexes forbid), so no existing database can violate it and there is nothing to backfill.

Follow-on

Allowing a local and a remote project to share a path string (they are different machines) means a path-only lookup can now match across kinds, so getLocalProjectByPath is scoped to local rows (ssh_connection_id IS NULL). This keeps inspectProjectPath's local existence check from returning an SSH project. getSshProjectByPath was already scoped by connectionId, so it needed no change.

Tests

0019_project_path_unique_per_connection.test.ts covers:

  • the old idx_projects_path is dropped
  • both partial unique indexes exist with the right WHERE clause
  • the same path on two different SSH connections is now allowed (the bug)
  • the same path on the same connection is still rejected
  • two local projects at the same path are still rejected
  • a local and an SSH project may share a path string
pnpm run test:migrations   -> 7 files, 24 tests passed
pnpm exec vitest run src/main/core/projects/   -> 11 files, 124 tests passed

format and lint are clean. Followed the migration authoring flow: db:generate, committed the pre-0019 fixture, regenerated fixtures with db:fixtures.

@greptile-apps

greptile-apps Bot commented Jul 2, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR fixes a UNIQUE constraint failed: projects.path error that occurred when adding the same project path on two different SSH connections, because the old index was globally unique across all machines. The solution replaces the single global index with two partial indexes — one for local projects (path unique where ssh_connection_id IS NULL) and one for SSH projects ((connection, path) unique where ssh_connection_id IS NOT NULL) — which is the correct approach for SQLite's NULL semantics.

  • Migration 0019: Drops idx_projects_path, creates idx_projects_local_path and idx_projects_ssh_connection_path as partial unique indexes; purely constraint-relaxing so no existing data can violate the new rules.
  • getLocalProjectByPath: Now filters on both isNull(sshConnectionId) (to hit the partial index) and eq(workspaceProvider, 'local') (to exclude orphaned SSH projects whose ssh_connection_id was set to NULL by the onDelete: 'set null' FK).
  • Tests: Six migration tests cover index structure, the fixed bug, same-connection/same-path rejection, local uniqueness, and local-vs-SSH path sharing.

Confidence Score: 5/5

Safe to merge — the migration only loosens a constraint, no existing data can violate the new indexes, and the query fix is logically complete.

The two-partial-index approach correctly handles SQLite's NULL distinctness semantics. The migration is non-destructive. The previously flagged orphaned-SSH edge case in getLocalProjectByPath is now properly handled by the added workspaceProvider = 'local' predicate alongside the isNull guard. Tests cover all boundary conditions including the original bug, same-connection rejection, local uniqueness, and local-vs-SSH path sharing.

No files require special attention.

Important Files Changed

Filename Overview
apps/emdash-desktop/drizzle/0019_project_path_unique_per_connection.sql New migration: drops global idx_projects_path and creates two correctly scoped partial unique indexes; non-destructive and well-formed.
apps/emdash-desktop/src/main/db/schema.ts Replaces single global uniqueIndex with two partial unique indexes matching the migration; Drizzle ORM definitions are correct and well-commented.
apps/emdash-desktop/src/main/core/projects/operations/getProjects.ts getLocalProjectByPath now uses both isNull(sshConnectionId) and eq(workspaceProvider, 'local') to correctly scope local-project lookups and guard the orphaned-SSH edge case.
apps/emdash-desktop/src/main/db/tests/migrations/0019_project_path_unique_per_connection.test.ts Comprehensive migration tests covering index presence, the fixed bug, and all boundary conditions for local and SSH project path uniqueness.
apps/emdash-desktop/drizzle/meta/_journal.json Journal entry for migration 0019 correctly appended with proper idx, version, and tag fields.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[INSERT into projects] --> B{ssh_connection_id IS NULL?}
    B -- Yes / Local project --> C[idx_projects_local_path\nUNIQUE path\nWHERE ssh_connection_id IS NULL]
    B -- No / SSH project --> D[idx_projects_ssh_connection_path\nUNIQUE ssh_connection_id + path\nWHERE ssh_connection_id IS NOT NULL]
    C --> E{Duplicate local path?}
    D --> F{Duplicate connection+path?}
    E -- Yes --> G[UNIQUE constraint error]
    E -- No --> H[Insert OK]
    F -- Yes --> G
    F -- No --> H

    subgraph getLocalProjectByPath
    I[path lookup] --> J[AND isNull sshConnectionId\nAND workspaceProvider = 'local']
    J --> K{Row found?}
    K -- No --> L[undefined]
    K -- Yes --> M[LocalProject]
    end
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
flowchart TD
    A[INSERT into projects] --> B{ssh_connection_id IS NULL?}
    B -- Yes / Local project --> C[idx_projects_local_path\nUNIQUE path\nWHERE ssh_connection_id IS NULL]
    B -- No / SSH project --> D[idx_projects_ssh_connection_path\nUNIQUE ssh_connection_id + path\nWHERE ssh_connection_id IS NOT NULL]
    C --> E{Duplicate local path?}
    D --> F{Duplicate connection+path?}
    E -- Yes --> G[UNIQUE constraint error]
    E -- No --> H[Insert OK]
    F -- Yes --> G
    F -- No --> H

    subgraph getLocalProjectByPath
    I[path lookup] --> J[AND isNull sshConnectionId\nAND workspaceProvider = 'local']
    J --> K{Row found?}
    K -- No --> L[undefined]
    K -- Yes --> M[LocalProject]
    end
Loading

Reviews (2): Last reviewed commit: "fix(projects): match workspaceProvider i..." | Re-trigger Greptile

…ralaction#2731)

`projects.path` had a global UNIQUE index, so registering a project at a
path already used by a project on a different SSH host failed with
`UNIQUE constraint failed: projects.path`. Paths are independent across
separate remote hosts, so uniqueness should be per machine.

Replace the global unique index with two partial unique indexes:
- local projects (ssh_connection_id IS NULL): unique by path
- SSH projects (ssh_connection_id IS NOT NULL): unique by (ssh_connection_id, path)

Two indexes rather than a single (ssh_connection_id, path) because SQLite
treats NULLs as distinct, which would drop path uniqueness for local
projects. As a side effect a local and a remote project may now share a
path string (different machines), so getLocalProjectByPath is scoped to
local rows to keep inspectProjectPath's lookup correct.

Adds migration 0019 plus a migration test covering the index shape and the
per-connection / per-machine uniqueness behavior.
@fiorelorenzo fiorelorenzo force-pushed the fix/project-path-unique-per-connection branch from 5c81a08 to fad7f0a Compare July 2, 2026 09:50
Add eq(workspaceProvider, 'local') alongside the isNull(sshConnectionId)
filter so an orphaned SSH project (connection deleted via onDelete: 'set
null', leaving ssh_connection_id NULL while workspace_provider stays 'ssh')
is no longer returned as a LocalProject. Keeps the discriminant consistent
with getProjects/getProjectById; isNull is retained so the query still uses
the idx_projects_local_path partial index.
@fiorelorenzo

Copy link
Copy Markdown
Contributor Author

@greptileai please re-review. I pushed the fix for your P2 finding in 35b4d47: getLocalProjectByPath now also matches eq(projects.workspaceProvider, 'local') alongside isNull(sshConnectionId), so an orphaned SSH project (connection deleted via onDelete: 'set null', leaving ssh_connection_id null while workspace_provider stays 'ssh') is no longer returned as a LocalProject. I kept isNull(sshConnectionId) so the query still uses the idx_projects_local_path partial index. CI is green.

@arnestrickmann arnestrickmann requested a review from jschwxrz July 3, 2026 09:06
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[bug]: Remote project path uniqueness is global, not per-SSH-connection

1 participant