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 >= 0 tables; tolerates the empty case)
  • 'opens DB with zero migrations when the migrations directory is missing' — line 428 (renames MIGRATIONS_DIR away, asserts boot succeeds with user_version = 0 and 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 set NODE_ENV=production, so the guard’s process.env.NODE_ENV === 'production' predicate keeps it green.
  • 'bumps PRAGMA user_version to reflect all applied migrations' — line 166, asserts user_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 + new initDb runtime guard.
  • package.json — add postbuild script.
  • 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 .gitignoredist/ is already gitignored at the repo root.

Back to top

Colibri — documentation-first MCP runtime. Apache 2.0 + Commons Clause.

This site uses Just the Docs, a documentation theme for Jekyll.