Repository navigation
fix(sqlite): stamp the last writer's version and refuse older binaries on newer schemas (refs #3609, slice A) - #4602
Conversation
…s on newer schemas Refs thedotmack#3609 (slice A) Nothing recorded which binary version last wrote the SQLite database, so an older binary could open a newer schema and run its DDL over it. The live case in the thedotmack#3609 timeline: a 13.4.2 copy inside a Claude Desktop sandbox reopened a schema-49 DB, ran its v7 rebuild, and dropped discovery_tokens, ON UPDATE CASCADE, and the session_summaries FTS triggers — 15 days of silent summary loss. The database now stamps the writing binary's version into PRAGMA user_version (major*1e6 + minor*1e3 + patch; prerelease suffixes compare by their base, like parseBase). Both DDL entry points — the SessionStore and SessionSearch constructors — read the stamp before any DDL, pragmas included, and a stamp newer than the running binary throws a typed schema_newer_than_binary error naming both versions and the upgrade action; the refused open touches nothing. Stamp-less databases are adopted on first boot, never refused. SessionStore stamps after the full migration chain; SessionSearch stamps only when its FTS DDL actually created anything — a read-only fallback connection writes nothing and records no writer. Same shape as assertChromaStoreCompatible on the chroma data dir. Before: unpatched main opened a user_version-99000000 database, ran the full migration chain, and rebuilt the schema. After: SchemaNewerThanBinaryError with user_version and integrity_check unchanged. Reported-by: @husniadil in thedotmack#3609 Co-Authored-By: Claude <noreply@anthropic.com>
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
|
Greptile review on thedotmack#4602 (Confidence 4/5): 1x P1 + 2x P2. - P1 'Failed upgrades leave stale stamps': the chain's steps commit separately, so a mid-chain failure (e.g. the v46 REAL-revision refusal) left earlier DDL on disk while user_version kept the old/zero stamp — leaving the partially-upgraded database adoptable by an older binary, the exact downgrade this slice refuses. SessionStore now stamps before the first schema write instead of after the full chain; the guard still runs (and refuses) before anything is written, and the idempotent chain re-runs to completion on the next open by the same or a newer binary. - P2 'Refused opens still change files': DatabaseManager.initialize() applied pragmas (journal_mode = WAL, a persistent file change) before the SessionStore guard ran. The shared open path now goes through openGuardedSqliteDatabase(), which checks writer compatibility before any pragma, so a refused open leaves a DELETE-mode file as DELETE. - P2 'Tests miss absent stamp writes': under bun test the version encodes to stamp 0, indistinguishable from a stamp-less database, so removing either constructor's stamp call passed every test. The writer-version tests now pin a nonzero version via mock.module, reset the stamp before the SessionSearch case so only its own FTS-DDL write can satisfy the assertion, and add a mid-chain-failure regression test. Removing either stamp call now fails tests (verified by mutation). bun test tests/sqlite/: 253 pass. Refs thedotmack#3609 (slice A) Co-Authored-By: Claude <noreply@anthropic.com>
|
@thedotmack Slice A is ready for review. Greptile's three findings are addressed in 9efd65b — migration-failure stamp semantics, a guarded open path, and mutation-verified tests — and Greptile's re-review of this commit reports no outstanding issues blocking merge. Happy to adjust anything. |
thedotmack
left a comment
There was a problem hiding this comment.
Thanks @akushonkamen, this is careful work. Guarding before the pragmas, stamping before the chain, and the mutation-checked tests are all right, and they should carry over unchanged. Before we land it, though, I'd like to change one thing: what the stamp is compared against.
The concern. The stamp is the plugin semver, so any binary older than the last writer refuses to open the database, even when no schema changed between the two versions. Most claude-mem releases don't touch the schema. That makes these everyday situations full outages, where the worker never becomes ready and every host loses memory:
- rolling back one patch after a bad release;
- two hosts sharing
~/.claude-memwith different bundled versions (Claude Code plugin plus Codex/OpenCode/npx copies); - the plan-16 stale-plugin-dir spawn, which today is mostly harmless.
The destructive case in #3609 is narrower than that: an old binary re-running a rebuild (v7/v9) on a database that already has migrations it doesn't know about.
What I'd suggest:
- Stamp
PRAGMA user_versionwith the highest SessionStore migration version this binary knows (aLATEST_SCHEMA_VERSIONconstant next to the chain), not the semver encoding. assertSchemaWriterCompatiblerefuses only whenuser_version > LATEST_SCHEMA_VERSION. The message can say "this database has schema vN; this claude-mem knows up to vM; upgrade claude-mem". 0 is still adopted.- Add a test that pins
LATEST_SCHEMA_VERSION === MAX(version) FROM schema_versionsafter a fresh open, so a new migration can't forget to bump it. Keep the rest (stamp-before-chain, the guarded shared open, SessionSearch stamping) as is. - Your existing tests map across directly. Swap the version strings for schema numbers.
That keeps exactly the protection #3609 needs, without turning harmless downgrades into outages. Happy to help land it once it's in.
Refs #3609 (slice A — plan-21 step 2, the writer-version stamp only). This is a standalone, self-contained slice per the plan's PR consolidation pass; the other steps are untouched.
Root cause
Nothing records which binary version last wrote the SQLite database, so an older binary can open a newer schema and run its DDL over it. The live case in the #3609 timeline (@husniadil): a 13.4.2 copy inside a Claude Desktop local-agent sandbox reopened a schema-49 DB, ran its v7 rebuild, and dropped
discovery_tokens,ON UPDATE CASCADE, and the threesession_summariesFTS triggers — 15 days of silent summary loss. The plan-16 stale-dir refusal can't see sandbox paths; step 2's writer-version stamp is what catches this one.Demonstrated on current main (
eccb15e): opening a DB stampeduser_version = 99000000runs the full migration chain and rebuilds the schema — no error, no record.Fix
PRAGMA user_versionnow stamps the writing binary's version:major*1_000_000 + minor*1_000 + patch(13.34.2 → 13034002, int32-safe), prerelease suffixes compared by their base — same semantics asparseBaseincompareVersionsDescending. (If you'd rather keepuser_versionfree, the same five functions can read/write a one-row meta table instead — happy to switch.)SessionStoreandSessionSearchconstructors — read the stamp before any DDL, pragmas included, and a stamp newer than the running binary throws a typedSchemaNewerThanBinaryError(reason: 'schema_newer_than_binary') naming both versions and the upgrade action, mirroringassertChromaStoreCompatibleon the chroma data dir. A refused open touches nothing (user_versionandintegrity_checkverified unchanged).user_version = 0) are adopted on first boot and stamped — never refused.SessionStorestamps before the first schema write, not after the chain: the chain's steps commit separately, so a mid-chain failure must still leave the stamp naming the binary that wrote the partial DDL, keeping the database refused to older binaries (fix(sqlite): stamp the last writer's version and refuse older binaries on newer schemas (refs #3609, slice A) #4602 review). The guard runs before any write, so a refused open still touches nothing.SessionSearchstamps only when its FTS DDL actually created anything: a read-only fallback connection (the shippedsearch-readonly-fallbackcontract) writes nothing and records no writer.initPhaseTracker→GET /api/ready); no new wiring.Red → green
bun test tests/sqlite/session-store-writer-version.test.ts→0 pass / 1 fail— no stamp guard exists; on main the "refuses a newer-stamped DB" case opens the DB, runs the migration chain, and rewrites the schema (FK rebuilt withON UPDATE CASCADE,user_versionleft stale).7 pass / 0 fail; full suitebun test tests/sqlite/→ 251 pass / 0 fail / 3.0s (baseline 244/244 + 7 new);bun run typecheckclean.Tests (
tests/sqlite/session-store-writer-version.test.ts)SessionStoreconstructor, file untouched (user_version+integrity_check+ table set equal before/after).SessionSearchconstructor.bun testthe build define is absent so the runtime version is0.0.0-dev).SessionSearchstamps after recreating missing FTS tables (the [plan-21] SQLite Schema Evolution & Queue State Integrity — one DDL path guarded by introspection, immutable session keys, and queue/session states that can actually transition #3609 damage scenario).Merge rubric self-check
eccb15eand reported with a full timeline in [plan-21] SQLite Schema Evolution & Queue State Integrity — one DDL path guarded by introspection, immutable session keys, and queue/session states that can actually transition #3609; the plan master specs this exact behavior as step 2.stamp <= current, "did this connection write DDL") are the correct logic itself.src/services/sqlite/; uses SQLite's built-inPRAGMA user_version. No new processes, state files, or env-var modes.Disclosure
Authored by @akushonkamen with AI assistance (Claude);
Co-Authored-By: Claudetrailer on the commit, matching recent merged history.Diffstat