Skip to content

refactor(storage): make the project catalog part of the operational database - #2016

Merged
Astro-Han merged 4 commits into
mainfrom
refactor/sqlite-project-authority
Aug 3, 2026
Merged

refactor(storage): make the project catalog part of the operational database#2016
Astro-Han merged 4 commits into
mainfrom
refactor/sqlite-project-authority

Conversation

@Astro-Han

@Astro-Han Astro-Han commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

Summary

main is currently red on e2e_shard (2/2), and the cause is not a flake: #1994 removed the code that resolves a session's project, so e2e/sidebar-navigation.spec.ts:72 times out deterministically.

CreateSessionInput.projectId is documented as three-valued — an id, an explicit null for "no project", and absent meaning "Main resolves undefined automatically" (packages/core/src/runtime-inputs.ts:28-30). The code doing that resolving was deleted alongside the File stores, which turned "never decided" into a permanent state: every such session falls into 未归属项目 forever, and that bucket has no project actions to click. This affects real upgrades, not just the fixture — any session that predates its project assignment loses its grouping.

Investigating it surfaced the reason the bridge could be deleted unnoticed: the project catalog never moved into SQLite. projects.json and a class literally named FileProjectCatalog survived a PR titled "make SQLite the sole operational authority". That left the only copy of every project name, relink alias and archive state outside createOperationalStateBackup, which captures runtime.sqlite plus the Artifact tree and nothing else — a backup/restore round trip silently drops all of it today, and the new validator cannot catch it because the state is not in the database it checks.

So this PR closes the gap rather than reattaching the bridge:

  • Schema 21 adds projects, project_locations, project_aliases, keyed by the random project id the renderer already contracts on, with identity as a unique index rather than the primary key — data-project-id keeps carrying an opaque id instead of a filesystem path.
  • projects.json is imported once and set aside rather than deleted. This is not legacy-format support — it recovers state this refactor would otherwise strand, which is why it is not covered by refactor(storage): make SQLite the sole operational authority #1994's "no old data migration" boundary. An import that cannot complete is reported and skipped: SQLite is the authority now, so it must not take the read path down with it.
  • Every catalog mutation reads, changes and rewrites inside one BEGIN IMMEDIATE. The catalog rewrites its whole table on each write, so splitting the read from the write let a second window's rename or archive be replayed away with no error anywhere.
  • Project resolution is restored, scoped by SQL to sessions that still need it, skipping explicit null so a deliberate detach survives, and skipping subagent sessions — they inherit their parent's project, and their disposable Git worktrees must never become one of the user's project locations.
  • Resolution carries each session's real last activity, so an upgrade rebuilds the catalog's actual recency order instead of collapsing every project to the upgrade's timestamp; sessions are grouped by directory first, so the cost is one Git probe per project rather than per session.
  • A directory that no longer exists is canonicalized through its nearest surviving ancestor, so the same folder does not resolve to /var/... after deletion and /private/var/... before it and split one project in two.

Every method's user-visible semantics are unchanged, so all 17 existing catalog contract tests carry over unchanged.

Refs #1994

Review round

Four independent reviews of the first two commits agreed on one root cause behind most findings — the backfill's input contract — and on one path with no demand. Both are fixed in 390e61f4 rather than patched around:

  • The session_metadata.project_id column, its index and the recency index had zero readers: three-valued membership can only be answered by json_type on the payload, so the column could never serve that query. They are deleted, along with the unused databaseLease dependency. An earlier revision of this description claimed the column made grouping by project a SQL query for the first time; that was wrong, and the column is gone.
  • Concurrency, identity stability, recency and subagent scoping are addressed at their owners as described above.

Verification

Suite Result
@maka/storage 590 pass / 0 fail
@maka/desktop main 1521 pass / 0 fail
Playwright e2e (full) 61 passed
lint / format clean

New tests cover what the reviews found untested — the backup/restore round trip that is this change's whole premise, the legacy import in both branches, identity stability across deletion, recency order, subagent exclusion, unresolvable directories, and the concurrent lost update. Each was confirmed to fail against the unfixed code before being fixed, e.g.:

✖ a deleted directory resolves to the same project it had while it existed
✖ projects are ordered by when their sessions were last active, not by upgrade order
✖ a subagent worktree never becomes one of the user project locations
✖ two catalogs changing one project at the same time keep both changes

The e2e fixture now resolves projects while seeding, so the sidebar tests assert on a settled state instead of racing the startup resolver, which runs concurrently with window creation.

The regression this fixes, before and after, with the fixture untouched:

# before (main @ 1caea265c), reproduced locally 100%
TimeoutError: locator.click: Timeout 30000ms exceeded.
  waiting for ... .locator('.maka-project-item-end').getByRole('button', { name: /项目操作$/ })
# page snapshot showed a single row: "未归属项目 66"

# after
7 passed (4.9s)

…atabase

The project catalog decides how every session is organized, yet it stayed
in its own `projects.json` when the rest of the File stores were retired.
That left the only copy of every project name, relink alias and archive
state outside `createOperationalStateBackup`, which captures `runtime.sqlite`
plus the Artifact tree and nothing else — a restore silently dropped all of
it, and no test could notice because the state was not in the database the
validator checks.

Schema 21 adds `projects`, `project_locations` and `project_aliases`, keyed
by the random project id the renderer already contracts on, with `identity`
as a unique index rather than the primary key so `data-project-id` keeps
carrying an opaque id instead of a filesystem path. `session_metadata` gains
a `project_id` column lifted out of `payload_json`, which makes grouping by
project a SQL query for the first time; the column stays two-valued because
`json_type` already distinguishes "no project" from "never resolved" for the
one caller that needs it.

Only persistence moved. Read-modify-write under a serial queue, whole-catalog
validation on every write and each method's semantics are untouched, so all
17 catalog contract tests carry over unchanged. `projects.json` is imported
once and renamed rather than deleted, so a failed upgrade stays inspectable;
this is not legacy-format support but recovery of state this refactor would
otherwise strand.

The legacy-schema rewind fixtures are brought back in step with the migration
list: #1994 left six `DROP TABLE` statements duplicated inside a single
`exec`, which failed with `no such table` on the second one, and the new
tables need their own teardown. Backup validation failures now carry their
cause in the message — with integrity, foreign-key, ~60 required tables and
Artifact reconciliation behind one error, a bare "is invalid" tells an
operator nothing once `cause` is dropped from a log.
`CreateSessionInput.projectId` is documented as three-valued — an id, an
explicit `null` for "no project", and absent meaning "main resolves it
automatically" (packages/core/src/runtime-inputs.ts:28-30). #1994 deleted
the code that did that resolving along with the File stores, so the third
state became permanent: any session whose project was never decided could
no longer acquire one, and the sidebar's "group by project" view collapsed
every such session into 未归属项目. The e2e case that renames a project
failed deterministically as a result, because the ungrouped bucket has no
project actions to click.

Resolution is restored, scoped by SQL to the sessions that still need it
instead of walking every header, and skipping the explicit `null` so a user
who detached a session keeps that choice. A session whose directory is gone
still resolves — a session outlives the folder it ran in — while a session
that fails to resolve is left alone and retried next start rather than
frozen into a wrong group.

Verified against the failure this fixes: e2e/sidebar-navigation.spec.ts goes
from a reproducible 30s timeout to 7/7, with the fixture untouched — the 66
seeded sessions now find their project from `cwd` on their own.
…oped

The follow-up review of the SQLite project-authority change found that the
rebuilt catalog was correct in structure but wrong in three ways that only
show up on a real upgrade, plus one path that never had a consumer.

Backfill input. `listSessionsWithUnresolvedProject` returned only id and cwd,
so every project was created with "now" as its timestamp and `preferredPath`
fell out of session-id order rather than real activity. It also returned
subagent sessions, whose disposable Git worktrees then became — and outranked —
the user's own project locations. Both are one query: carry the session's last
activity and exclude rows that have a subagent parent. Sessions are grouped by
directory before resolution so an upgrade pays one Git probe per project
instead of one per session.

Identity. A directory that no longer exists was canonicalized with `normalize`
alone, so on macOS the same folder resolved to `/var/...` after deletion and
`/private/var/...` before it, splitting one project in two. The nearest
surviving ancestor is now resolved with `realpath` and the missing segments
re-appended.

Concurrency. Every mutation rewrites the whole catalog, and the read and the
write sat in separate transactions, so a second window's rename or archive was
replayed away with no error. The synchronous mutations now read, change and
rewrite inside one `BEGIN IMMEDIATE`; `select` and `relink` await the
filesystem mid-change and re-derive their commit from the state under the lock.

Dead weight. `session_metadata.project_id` and its index had no reader —
three-valued membership can only be answered by `json_type` on the payload — so
the column, the recency index and the unused `databaseLease` dependency are
gone rather than kept as a second source of truth.

Also: a failed `projects.json` import no longer takes the read path down with
it, the set-aside file is timestamped so a second attempt cannot overwrite the
first, the catalog releases its database lease, and project resolution runs
after session recovery instead of ahead of it.

Tests cover what the reviews found untested: the backup/restore round trip that
is this change's whole premise, the legacy import in both its success and
failure branches, identity stability across deletion, recency order, subagent
exclusion, unresolvable directories, and the concurrent lost update. Each was
confirmed to fail against the unfixed code. The e2e fixture resolves projects
while seeding, so sidebar tests assert on a settled state instead of racing the
startup resolver.
…decisions

Two reviews of the previous commit found the same thing from different angles:
its concurrency guarantees were asserted in comments but not implemented.

The backfill claimed a user detaching a session mid-resolution would win, but
read the header and wrote it in two separate transactions, so a detach landing
between them was overwritten back to a resolved project. `updateHeaderVersioned`
already exists for exactly this; the listing now carries each row's metadata
revision and the assignment is fenced by it. The unreliable extra read is gone
rather than kept alongside the fence — a version conflict simply means someone
decided first, and a session that is still unresolved is retried next start.

`relink` re-derived its merge from the catalog as it stood at commit time. That
kept the catalog self-consistent while leaving it inconsistent with the world:
`beforeCommit` reassigns the sessions of the project being merged away, and
those writes cannot be re-derived. If the merge target moved while the callback
ran, the sessions had already been handed to the wrong owner. The commit now
refuses when the conflict it would merge is not the one the callback was shown.
Relink was already retryable — a throwing callback leaves the catalog untouched
— so this hands the decision back instead of committing a half-true one.

A historical working directory whose ancestor was replaced by a plain file
raises ENOTDIR, not ENOENT, and was left permanently unresolved even though
walking one level further up canonicalizes it exactly like a deleted directory.
Both mean "cannot reach this path"; they are now handled together.

Finally, `mutate` made `withQueue` redundant for every fully synchronous
mutation: the SQLite write lock does that job now. The queue is kept only where
it still earns its place — `select` and `relink`, which await the filesystem or
a caller's callback mid-change. Removing it elsewhere deletes five `Failed to …`
branches that `mutate` had already made unreachable.

Each new test was confirmed to fail against the unfixed code.
@Astro-Han
Astro-Han marked this pull request as ready for review August 3, 2026 14:54
@Astro-Han
Astro-Han merged commit a3d4490 into main Aug 3, 2026
20 of 22 checks passed
@Astro-Han
Astro-Han deleted the refactor/sqlite-project-authority branch August 3, 2026 14:54
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.

1 participant