Twice now we've shipped a deploy that 500'd in production because
drizzle silently skipped freshly-generated migrations whose `when`
timestamps were older than a prior manually-bumped entry (0010/0011
in 1b7f553, then 0012/0013 in 2731888). Both times pnpm migrate
printed "Migrations applied." while the live DB schema lagged the
code's expectations.
Three layers of defence:
1. packages/db/src/journal-check.ts — pure helpers
- assertJournalMonotonic(entries): walks idx-sorted entries and
returns each one whose `when` <= the previous entry's `when`,
plus a suggested `when` value to bump it to.
- formatJournalViolations(result): renders an actionable
multi-line message that points at the offending file path.
2. packages/db/src/migrate.ts — pre-flight
Reads _journal.json BEFORE handing it to drizzle.migrate(). If
the journal is non-monotonic, it prints the violations + bump
instructions and exits with code 2. No more "Migrations applied."
while silently skipping.
3. apps/web/src/test/drizzle-journal-monotonic.test.ts — CI guard
Reads the committed _journal.json at test time. CI fails on the
PR before the bad commit can ship. Imports the helper through a
new "./journal-check" subpath export on @cmbot/db so the test
doesn't rely on a deep path into the package.
Together: a bad commit fails CI; if it somehow got through, migrate
itself refuses to run; if migrate is bypassed, the previous deploy's
schema stays intact (drizzle wouldn't have skipped anything in any
case where the journal is monotonic).
Web suite 480 → 482 tests, all green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
44 lines
1.6 KiB
TypeScript
44 lines
1.6 KiB
TypeScript
import { migrate } from "drizzle-orm/node-postgres/migrator";
|
|
import { readFileSync } from "node:fs";
|
|
import { join, dirname } from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
import { createClient } from "./index.js";
|
|
import {
|
|
assertJournalMonotonic,
|
|
formatJournalViolations,
|
|
type JournalEntry,
|
|
} from "./journal-check.js";
|
|
|
|
const databaseUrl = process.env.DATABASE_URL;
|
|
if (!databaseUrl) {
|
|
console.error("DATABASE_URL not set");
|
|
process.exit(1);
|
|
}
|
|
|
|
// --- Pre-flight: refuse to run if the journal is non-monotonic. -----------
|
|
// Drizzle silently skips entries whose `when` is older than the previous
|
|
// entry's `when`. We've hit this twice now (0010/0011, then 0012/0013),
|
|
// each time the symptom was "Migrations applied." with no schema change
|
|
// and a 500 in production for the missing column. Catch it before we
|
|
// hand the journal to drizzle.
|
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
const journalPath = join(__dirname, "..", "migrations", "meta", "_journal.json");
|
|
const journal = JSON.parse(readFileSync(journalPath, "utf8")) as {
|
|
entries: JournalEntry[];
|
|
};
|
|
const check = assertJournalMonotonic(journal.entries);
|
|
if (!check.ok) {
|
|
console.error(formatJournalViolations(check));
|
|
console.error(
|
|
"\nRefusing to run drizzle migrate. Bump the offending `when` values in\n" +
|
|
"_journal.json so they're strictly increasing in the same order as `idx`.",
|
|
);
|
|
process.exit(2);
|
|
}
|
|
|
|
const { db, pool } = createClient(databaseUrl);
|
|
console.log("Applying migrations...");
|
|
await migrate(db, { migrationsFolder: "./migrations" });
|
|
console.log("Migrations applied.");
|
|
await pool.end();
|