Contract — production deploy ships migrations and fails loudly otherwise
Two complementary guards land together:
- Build-time (
scripts/copy-migrations.mjs+postbuild):npm run buildproducesdist/db/migrations/*.sqlfor every.sqlfile undersrc/db/migrations/, or fails the build with a non-zero exit code if the source directory is missing or empty. - Runtime (new branch in
initDb): anode dist/server.jsboot underNODE_ENV=productionthat 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-
.sqlskip, missing-directory empty-return) unchanged for the non-production case. - Existing
initDbhappy path unchanged: pragmas, integrity check ordering, transaction-per-migration, error wrapping inMigration <file> failed:. - All 29 pre-existing
db-init.test.tstests stay green (no expectations are tightened or weakened). src/server.ts5-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:
- Saves
process.env.NODE_ENVand sets it to'production'. - Renames
MIGRATIONS_DIRaway (mirroring line 434–451’s existing pattern). - Asserts
initDb(makeTempDbPath())throws with a message matching/migrations directory not found.*npm run build/i(or equivalent regex covering the contract substrings). - Restores the directory and
NODE_ENVinfinally.
Failure modes considered
- Test pollutes
process.env: mitigated bytry/finallyrestoring the original value, plus jest’s per-file process model (each test file is a fresh worker subprocess in modern jest configs). fs.cpSyncnot in user’s Node: mitigated —engines.node >= 20already declared inpackage.json;fs.cpSyncships in Node 16+.postbuildruns even whentscfails: npm’spostbuildonly runs after a successfulbuild. Iftscfails, the copy is skipped — exactly the right behaviour.- Operator runs
node dist/server.jsfrom a manually-built tree withoutpostbuild: caught by the runtime guard. They see the migrations error instead of an empty database.