Contract — production deploy ships migrations and fails loudly otherwise

Two complementary guards land together:

  1. Build-time (scripts/copy-migrations.mjs + postbuild): npm run build produces dist/db/migrations/*.sql for every .sql file under src/db/migrations/, or fails the build with a non-zero exit code if the source directory is missing or empty.
  2. Runtime (new branch in initDb): a node dist/server.js boot under NODE_ENV=production that nonetheless finds zero migrations throws synchronously with a message naming the directory and the build step, before any DB tables would be expected.

Post-state assertions

Build-time

Assertion Expected Why
npm run build exit code on a clean tree with all 6 migrations on disk 0 Happy path.
ls dist/db/migrations/*.sql \| wc -l after a successful npm run build 6 Every .sql from src/db/migrations/ copied verbatim.
diff -r src/db/migrations dist/db/migrations after build empty (no diff) Byte-identical copy.
npm run build exit code if src/db/migrations/ does not exist non-zero copy-migrations.mjs aborts loudly.
npm run build exit code if src/db/migrations/ exists but contains zero .sql files non-zero copy-migrations.mjs aborts loudly.

Runtime guard

Failure path (production-mode boot with empty migrations):

Step Expected
Set process.env.NODE_ENV = 'production'.
Stub discoverMigrations() (or rename src/db/migrations/ away as the existing test does) so the migration list is empty.
Call initDb(<temp-path>). Throws Error synchronously.
Error message contains the literal substring migrations directory not found. required
Error message contains a hint pointing at the build step (literal substring npm run build is acceptable). required
Error mentions dist/db/migrations so the operator knows where the file should be. required
getDb() after the failed initDb() throws Database not initialized — call initDb() first. required

Non-failure paths (must remain green):

Step Expected
Test runs under jest’s default NODE_ENV=test with a real src/db/migrations/ on disk. Boot succeeds, all existing tests pass.
Existing test 'opens DB with zero migrations when the migrations directory is missing' (db-init.test.ts:428). Stays green — the test runs under NODE_ENV !== 'production', so the new guard does not fire.
tsx-based dev path (e.g. npm run dev). Unaffected. The guard reads process.env.NODE_ENV and the dev path does not set it to production.

Header comment update

Assertion Expected
src/db/index.ts lines 41–44 no longer contain the literal substring Known limitation. true
The same paragraph mentions the resolution and points at scripts/copy-migrations.mjs and the runtime guard. true

Preservation

  • Existing migration discovery semantics (filename regex, prefix collision detection, prefix-zero rejection, non-.sql skip, missing-directory empty-return) unchanged for the non-production case.
  • Existing initDb happy path unchanged: pragmas, integrity check ordering, transaction-per-migration, error wrapping in Migration <file> failed:.
  • All 29 pre-existing db-init.test.ts tests stay green (no expectations are tightened or weakened).
  • src/server.ts 5-stage middleware: untouched per task constraints.
  • src/domains/tasks/state-machine.ts: untouched per task constraints.

Test added

One new test inside the existing describe('initDb(path)', ...) block in src/__tests__/db-init.test.ts:

'throws when migrations directory is missing under NODE_ENV=production'

The test:

  1. Saves process.env.NODE_ENV and sets it to 'production'.
  2. Renames MIGRATIONS_DIR away (mirroring line 434–451’s existing pattern).
  3. Asserts initDb(makeTempDbPath()) throws with a message matching /migrations directory not found.*npm run build/i (or equivalent regex covering the contract substrings).
  4. Restores the directory and NODE_ENV in finally.

Failure modes considered

  • Test pollutes process.env: mitigated by try/finally restoring the original value, plus jest’s per-file process model (each test file is a fresh worker subprocess in modern jest configs).
  • fs.cpSync not in user’s Node: mitigated — engines.node >= 20 already declared in package.json; fs.cpSync ships in Node 16+.
  • postbuild runs even when tsc fails: npm’s postbuild only runs after a successful build. If tsc fails, the copy is skipped — exactly the right behaviour.
  • Operator runs node dist/server.js from a manually-built tree without postbuild: caught by the runtime guard. They see the migrations error instead of an empty database.

Back to top

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

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