fix(host-service): fold duplicate project rows and make repo_path unique

FC
2mo ago
+1361-1

What & why

Fixes #7702.

fraser-langton restarted into 1.30.0 and found nine extra project headings at the top of
the Projects sidebar, each duplicating an entry still present further down with the same
local path and repo URL but a different project id.

The rows were not new. host.db had carried two project rows per repo path since the v1
import on 7 September. The sidebar only renders projects that have a placement row, so the
twins sat invisible. 1.30.0 added usePlaceProjectsInSidebar (#7485), which places every
row-less project the local host serves, and all the long-hidden twins were placed at once.

The August work on this (plans/done/2026-08-10-v1-import-duplicate-projects-*) closed the
importer's write paths but deliberately shipped neither a dedupe nor a unique index,
because existing profiles already held twins. This migration does both, in order:

  1. re-point each twin's workspaces and pull_requests at the oldest row for that path
  2. drop the twin's tag_folder_settings rows, as project.remove does
  3. delete the twin
  4. make projects.repo_path a unique index

Deleting the twin also retires its sidebar placement rows on the next project.list, which
is what removes the extra headings.

How it works

Children move rather than die: the twin's workspaces and PR rows are the user's.
pull_requests is already unique per repo and number with no project id in the key, so
moving PR rows cannot collide, and since 0035 several local workspace rows per project are
allowed, so the twin's local checkout row lands safely beside the keeper's.

Both re-points skip a row whose project row is already gone. That guard is load-bearing:
the db runner tolerates legacy orphans by design ("long-lived DBs can carry legacy
orphans"), and pull_requests.project_id is NOT NULL, so an unguarded re-point would
write the subquery's NULL there, throw, and leave host-service refusing to serve the
database — the runner deliberately never serves a half-migrated DB.

How I tested it

Live in the dev desktop app against the reporter's shape: two project rows sharing one
repo_path, created ~30s apart, as the v1 importer wrote them. Same database file
throughout; only the presence of migration 0036 differs.

rows in projectssidebar
0036 withheld (boots at 0035)2two identical headings — before
0036 restored, app restarted1, the oldest keptone heading — after

CREATE UNIQUE INDEX projects_repo_path_idx ON projects (repo_path) confirmed present
afterwards. Full transcript: live-migration-capture.txt.

Automated:

bun run lint        # 8168 files, clean
bun run typecheck   # 42 tasks
bun test packages/host-service   # full suite green
bun test src/db/migrations.test.ts   # 8 pass

The migration tests cover the fold, rejection of a later duplicate, a three-way fold rather
than a pair, and one orphan of each kind.

bun run check:i18n not run: no user-facing strings change.

A full bun run test on macOS also reports workspace-fs > watch-backend > recovers a recreated root. It is unrelated (this branch touches no file in that package), reproduces
on unmodified code, and CI does not see it because every job runs on ubuntu-latest while
that assertion depends on inotify delete semantics fsevents does not match.

Risks / rollout / rollback

  • Blast radius. Runs once at host-service boot on every existing profile. Profiles
    without duplicates do the four statements as no-ops and gain the index.
  • Forward-only. These migrations have no down step. The twin project rows are deleted;
    their children are moved first, and only the twin's tag_folder_settings rows are
    discarded, matching what project.remove already does.
  • Rolling back the code does not roll back the database, and does not need to. An older
    build simply does not ship 0036; a database that already has the index keeps working,
    because the importer paths that used to mint duplicates were closed in August and nothing
    else inserts a second row for one path.
  • If the index creation ever fails it means duplicates survived the fold, and the runner
    throws rather than serving a half-migrated database. Recovery is to fix the fold, not to
    ship the index alone.

Known limitations

  • Rows whose repo_path differs only by case or a trailing slash are not folded. They are
    distinct strings, so the unique index accepts them and the sidebar would still show two
    headings. Out of scope here; the reporter's rows are byte-identical.
  • The keeper is the oldest row by created_at, then id. If a user had customised the
    newer twin (icon, colour, naming instructions), those settings are lost with it.
  • Multi-repo projects (#7699) group in the renderer and do not touch repo_path, so one row
    per path still holds there.

Checklist

  • PR title follows conventional commits (type(scope): subject)
  • bun run lint and bun run typecheck pass (CI fails on lint warnings too)
  • "Allow edits from maintainers" is checked on fork PRs

Summary by cubic

Fixes duplicate project headings in the Projects sidebar (#7702). The v1 importer wrote multiple project rows per repo path, and 1.30.0's usePlaceProjectsInSidebar began placing every row-less project, so the long-invisible duplicates all appeared at once.

Migration 0036 folds each set of duplicates into the oldest row by created_at, then makes projects.repo_path unique so the importer's mistake can't recur.

Migration details

  • Twin workspaces and pull requests are re-pointed to the keeper; twins' tag_folder_settings are dropped like project.remove does, and any custom settings on the twin (icon, colour, naming) are lost.
  • Children whose project row is already gone are left untouched, so legacy orphans can't abort the migration.
  • Forward-only with no down step; an older build against an already-migrated database keeps working.

Summary by CodeRabbit

  • Bug Fixes

    • Consolidated duplicate projects that reference the same repository path, preserving associated workspaces, pull requests, and settings under the oldest project.
    • Improved migration handling for legacy orphaned references.
  • Data Integrity

    • Repository paths are now required to be unique, preventing duplicate project entries for the same path.
  • Tests

    • Added coverage for duplicate projects, multiple duplicates, orphaned references, and enforcement of repository-path uniqueness.