Audit — production deploy boots an empty SQLite
Source of finding
Code review finding C2 (critical): a production deploy that runs node dist/server.js finds zero migrations in dist/db/migrations/, so discoverMigrations() returns [], the DB stays at user_version = 0, no tables ever get created, and every β/ζ/ε/η query throws SQLITE_ERROR: no such table: … — but the boot itself looks successful (no error, no log).
The header comment at src/db/index.ts:41-44 already flags this as a “known limitation”. This task closes it.
Repro of the bug at baseline
Worktree: E:\AMS\.worktrees\claude\fix-migrations-in-dist at branch feature/fix-migrations-in-dist (forked from origin/main @ 86e430fb).
$ npm install # 532 packages added
$ npm run build # tsc only; no postbuild step
$ ls dist/db/
index.d.ts index.d.ts.map index.js index.js.map
$ ls dist/db/migrations/
ls: cannot access '…/dist/db/migrations/': No such file or directory
dist/db/migrations/ does not exist. discoverMigrations() would early-return [] via the !existsSync(root) branch at src/db/index.ts:137. The DB stays at user_version = 0.
Inventory
Source files in scope
| Path | Role | Lines |
|---|---|---|
src/db/index.ts |
better-sqlite3 wrapper + migration runner; owns discoverMigrations() (135–173) and initDb() (234–288) |
324 |
src/db/migrations/001_init.sql |
α baseline (comment-only) | — |
src/db/migrations/002_tasks.sql |
β Task Pipeline schema | — |
src/db/migrations/003_thought_records.sql |
ζ Decision Trail schema | — |
src/db/migrations/004_skills.sql |
ε Skill Registry schema | — |
src/db/migrations/005_retention.sql |
η Proof Store retention columns | — |
src/db/migrations/006_eta.sql |
η Proof Store merkle tables | — |
Confirmed all 6 migration files present on disk via ls src/db/migrations/.
Build configuration
| File | Relevant fact |
|---|---|
package.json |
scripts.build = "tsc". No postbuild. files array already lists dist and src/db/schema.sql (npm pack scope). |
tsconfig.json |
outDir: "dist", rootDir: "src", include: ["src/**/*"]. TypeScript by design only emits .js/.d.ts for compiled .ts files; non-TS assets are NOT copied to outDir. |
Existing test surface
src/__tests__/db-init.test.ts — 29 tests, 100% statement / 95% branch coverage on src/db/index.ts. Notable coverage of paths that this task must NOT regress:
'creates DB file if not exists'— line 93'fresh DB reflects tables introduced by applied migrations'— line 137 (asserts>= 0tables; tolerates the empty case)'opens DB with zero migrations when the migrations directory is missing'— line 428 (renamesMIGRATIONS_DIRaway, asserts boot succeeds withuser_version = 0and zero user tables). Critical: this test is the existing hot path that the new prod-mode guard MUST NOT trip on. The test does not setNODE_ENV=production, so the guard’sprocess.env.NODE_ENV === 'production'predicate keeps it green.'bumps PRAGMA user_version to reflect all applied migrations'— line 166, assertsuser_version === countRealMigrations()(drift-tolerant).
Runtime guard surface
The new guard belongs inside initDb, after line 259 (const migrations = discoverMigrations()) and before line 260 (the currentVersion read). Earliest correct position: immediately after discoverMigrations() returns. Latest correct position: before the migration apply loop; if no migrations are present and user_version is already 0, applying nothing is precisely the failure we want to surface.
The predicate is migrations.length === 0 && process.env.NODE_ENV === 'production'. Tests run under NODE_ENV=test (jest default) and dev runs under whatever the user has, neither equal to 'production'.
Drift between header comment and target reality
src/db/index.ts:41-44 reads:
* Known limitation (flagged for P0.2.3 or a later packaging task): `tsc`
* does not copy `.sql` files to `dist/`. For production builds, a step must
* copy `src/db/migrations/` → `dist/db/migrations/`. Tests and `tsx`-based
* dev paths are unaffected because they read from `src/`.
Per acceptance criterion 6, the comment must be reworded post-fix to reflect that the limitation is resolved. Wording will go from “Known limitation” to “Resolved (post-R83 hygiene)”.
Implementation options considered
Option A — copyfiles devDep + postbuild script. Single-line npm install, single-line package.json patch. Pros: well-known. Cons: adds a transitive dep tree (≈10 packages), an extra moving part to keep secured.
Option B — cpx devDep + postbuild script. Same shape as Option A. Same pros/cons.
Option C — Custom Node script (scripts/copy-migrations.mjs) using fs.cpSync. Zero new devDeps. Node 20+ ships fs.cpSync (we already require >=20 in engines.node). The script is ~20 lines, fully reviewable, no supply-chain risk. Repository already has a scripts/r77-frontmatter.mjs precedent — same .mjs convention, same node scripts/... invocation pattern.
Decision: Option C — fewest moving parts (zero new deps), no supply-chain footprint, mirrors an existing in-repo convention. The script will fail loudly if the source directory is missing or contains zero .sql files (build-time guard, complementary to the runtime guard).
Scope
src/db/index.ts— header comment update + newinitDbruntime guard.package.json— addpostbuildscript.scripts/copy-migrations.mjs— new file.src/__tests__/db-init.test.ts— new test for the prod-mode-empty-migrations failure.- 5-step chain artefacts (this audit + contract + packet + verification).
Non-scope
- Changes to migration files themselves.
- Changes to the FSM in
src/domains/tasks/state-machine.ts(per task constraints). - Changes to the 5-stage middleware in
src/server.ts(per task constraints). - Bundler/packaging strategy beyond the simple copy (e.g. esbuild bundling, single-file deploy artifact) — out of scope.
- Adding
dist/db/migrations/to.gitignore—dist/is already gitignored at the repo root.