Packet — execution plan
Five surgical edits + one new file. No re-architecting; the migration runner internals are untouched.
E1 — scripts/copy-migrations.mjs (new file)
Create a small Node script that copies .sql files from src/db/migrations/ to dist/db/migrations/ with an explicit count check.
#!/usr/bin/env node
/**
* Colibri build helper — copy SQL migration files from `src/db/migrations/`
* to `dist/db/migrations/` so a `node dist/server.js` boot can find them.
*
* Invoked from `package.json` as `postbuild`. Runs after `tsc` emits the
* compiled JS into `dist/`. Without this step, `dist/db/migrations/` does
* not exist and the runtime migration runner discovers zero migrations,
* leaving the production DB at `user_version = 0` with no tables.
*
* Fails the build (non-zero exit) if the source directory is missing or
* contains zero `.sql` files — both are silent corruptions of a deploy.
*/
import { cpSync, existsSync, mkdirSync, readdirSync } from 'node:fs';
import { dirname, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
const here = dirname(fileURLToPath(import.meta.url));
const repoRoot = resolve(here, '..');
const srcDir = join(repoRoot, 'src', 'db', 'migrations');
const destDir = join(repoRoot, 'dist', 'db', 'migrations');
if (!existsSync(srcDir)) {
console.error(`copy-migrations: source directory not found: ${srcDir}`);
process.exit(1);
}
const sqlFiles = readdirSync(srcDir).filter((f) => f.endsWith('.sql'));
if (sqlFiles.length === 0) {
console.error(`copy-migrations: no .sql files found in ${srcDir}`);
process.exit(1);
}
mkdirSync(destDir, { recursive: true });
cpSync(srcDir, destDir, { recursive: true });
console.log(
`copy-migrations: copied ${sqlFiles.length} migration(s) ${srcDir} → ${destDir}`,
);
Notes:
- Uses
cpSyncrecursive copy of the whole directory. Any non-.sqlfiles would be carried too — butdiscoverMigrations()ignores them in the runtime, and migrations dir today contains only.sqlplus.gitkeep-style files which are also harmless. - Logs to stdout on success (build-time, not runtime — no MCP wire impact).
- Logs to stderr + exits non-zero on failure modes.
E2 — package.json postbuild script
"build": "tsc",
"postbuild": "node scripts/copy-migrations.mjs",
Insert "postbuild" immediately after "build". npm runs postbuild automatically after a successful build.
E3 — src/db/index.ts runtime guard
Inside initDb, between the existing line const migrations = discoverMigrations(); (currently 259) and the loop. Pseudo-diff:
// 6. Apply pending migrations.
const migrations = discoverMigrations();
+
+ // Build-output sanity. If we are running in production but have zero
+ // migrations on disk, the build step that copies `src/db/migrations/` →
+ // `dist/db/migrations/` did not run. Failing loudly here prevents a silent
+ // boot at user_version=0 with no tables (every β/ζ/ε/η query would then
+ // throw "no such table"). Tests and dev (`tsx`) paths are unaffected
+ // because they do not set NODE_ENV=production.
+ if (migrations.length === 0 && process.env.NODE_ENV === 'production') {
+ db.close();
+ throw new Error(
+ 'Database boot aborted: migrations directory not found or empty ' +
+ '(expected at dist/db/migrations/). Run `npm run build` to copy ' +
+ 'SQL migrations from src/db/migrations/ into dist/.',
+ );
+ }
+
const currentVersion = db.pragma('user_version', { simple: true }) as number;
Notes:
- Closes the freshly-opened
dbhandle before throwing, mirroring the integrity_check failure path at lines 254–256. - Singleton stays
null(set at line 286 only on the success path), preserving the contract thatgetDb()throws after a failedinitDb. - Message contains all three required substrings:
migrations directory not found,dist/db/migrations,npm run build.
E4 — src/db/index.ts header comment update
Replace the “Known limitation” paragraph (lines 41–44) with a “Resolved” note. Old:
* 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/`.
*/
New:
* Build-time copy (resolved post-R83): `tsc` does not emit non-TS assets,
* so `scripts/copy-migrations.mjs` runs as `npm postbuild` and copies
* `src/db/migrations/` → `dist/db/migrations/`. A `node dist/server.js` boot
* under `NODE_ENV=production` that nonetheless finds zero migrations throws
* synchronously inside `initDb` (search "migrations directory not found")
* rather than silently leaving `user_version = 0`. Tests and `tsx`-based
* dev paths are unaffected.
*/
E5 — src/__tests__/db-init.test.ts new test
Add inside the existing describe('initDb(path)', ...) block, after the existing 'opens DB with zero migrations when the migrations directory is missing' test (around line 452). New test:
it('throws under NODE_ENV=production when migrations directory is missing', async () => {
// Mirrors the existing "directory missing" test but flips NODE_ENV to
// production. The runtime guard added for the dist-migrations fix MUST
// fire here. Restore both the directory and NODE_ENV in `finally`.
const hidden = `${MIGRATIONS_DIR}.hidden-prod-test`;
const previousNodeEnv = process.env.NODE_ENV;
fs.renameSync(MIGRATIONS_DIR, hidden);
process.env.NODE_ENV = 'production';
try {
const p = makeTempDbPath();
expect(() => initDb(p)).toThrow(
/migrations directory not found.*dist\/db\/migrations.*npm run build/is,
);
// Singleton stays null after the failed boot.
expect(() => getDb()).toThrow(/Database not initialized/);
} finally {
if (previousNodeEnv === undefined) {
delete process.env.NODE_ENV;
} else {
process.env.NODE_ENV = previousNodeEnv;
}
fs.renameSync(hidden, MIGRATIONS_DIR);
}
});
Notes:
regexflagsis:ifor case-insensitivity,sfor.matching newlines (the message wraps).- The expectation regex covers all three required substrings in order.
process.env.NODE_ENVsave/restore handles the case where it was unset.
Order of edits
- E1 — write
scripts/copy-migrations.mjs. - E2 — patch
package.json. - E3 — add runtime guard to
initDb. - E4 — update the module header comment.
- E5 — add the new test.
- Run
npm run build && npm run lint && npm testand capture output. ls dist/db/migrations/to confirm the 6.sqlfiles landed.diff -r src/db/migrations dist/db/migrationsto confirm byte-identical copy.
Risk register
- Migration test renames
MIGRATIONS_DIRaway. If a test crash leaves the directory renamed, subsequent runs see zero migrations. Mitigation: the existing test at line 428 already uses this pattern withtry/finally; the new test follows the same pattern. - Two tests in the same file mutate
MIGRATIONS_DIR. Jest runs files sequentially within a worker; the renames inside one test cannot leak into a sibling test in the samedescribebecause they unwind infinally. copy-migrations.mjsruns after everynpm run build. Cost is ≤ 6 file copies on a known-small list; negligible. No watcher mode, no caching needed.