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 cpSync recursive copy of the whole directory. Any non-.sql files would be carried too — but discoverMigrations() ignores them in the runtime, and migrations dir today contains only .sql plus .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 db handle 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 that getDb() throws after a failed initDb.
  • 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:

  • regex flags is: i for case-insensitivity, s for . matching newlines (the message wraps).
  • The expectation regex covers all three required substrings in order.
  • process.env.NODE_ENV save/restore handles the case where it was unset.

Order of edits

  1. E1 — write scripts/copy-migrations.mjs.
  2. E2 — patch package.json.
  3. E3 — add runtime guard to initDb.
  4. E4 — update the module header comment.
  5. E5 — add the new test.
  6. Run npm run build && npm run lint && npm test and capture output.
  7. ls dist/db/migrations/ to confirm the 6 .sql files landed.
  8. diff -r src/db/migrations dist/db/migrations to confirm byte-identical copy.

Risk register

  • Migration test renames MIGRATIONS_DIR away. If a test crash leaves the directory renamed, subsequent runs see zero migrations. Mitigation: the existing test at line 428 already uses this pattern with try/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 same describe because they unwind in finally.
  • copy-migrations.mjs runs after every npm run build. Cost is ≤ 6 file copies on a known-small list; negligible. No watcher mode, no caching needed.

Back to top

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

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