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>
60 lines
1.9 KiB
TypeScript
60 lines
1.9 KiB
TypeScript
import { describe, it, expect } from "vitest";
|
|
import { readFileSync } from "node:fs";
|
|
import { join } from "node:path";
|
|
import {
|
|
assertJournalMonotonic,
|
|
formatJournalViolations,
|
|
type JournalEntry,
|
|
} from "@cmbot/db/journal-check";
|
|
|
|
/**
|
|
* CI guard against the recurring drizzle journal-skip bug.
|
|
*
|
|
* Drizzle's migrator orders entries by `when` (not `idx`) and only
|
|
* applies entries whose `when` is greater than the latest applied
|
|
* row's recorded `created_at`. We've shipped two breaking deploys
|
|
* (0010/0011 and 0012/0013) where freshly-generated migrations had
|
|
* `when` values older than a prior manually-bumped entry — `pnpm
|
|
* migrate` printed "Migrations applied." while silently skipping
|
|
* the new SQL, and production 500'd until we hand-fixed the journal.
|
|
*
|
|
* This test reads the committed _journal.json and fails if the
|
|
* entries aren't strictly monotonically increasing by `when` in the
|
|
* same order as `idx`. Catches a bad commit at PR time instead of
|
|
* at the next deploy.
|
|
*/
|
|
describe("drizzle journal monotonicity (regression guard)", () => {
|
|
const journalPath = join(
|
|
__dirname,
|
|
"..",
|
|
"..",
|
|
"..",
|
|
"..",
|
|
"packages",
|
|
"db",
|
|
"migrations",
|
|
"meta",
|
|
"_journal.json",
|
|
);
|
|
|
|
const raw = JSON.parse(readFileSync(journalPath, "utf8")) as {
|
|
entries: JournalEntry[];
|
|
};
|
|
|
|
it("loads at least one journal entry (sanity)", () => {
|
|
expect(raw.entries.length).toBeGreaterThan(0);
|
|
});
|
|
|
|
it("`when` timestamps are strictly increasing in `idx` order", () => {
|
|
const result = assertJournalMonotonic(raw.entries);
|
|
if (!result.ok) {
|
|
// Print the same actionable message migrate.ts prints, so a
|
|
// failed CI run reads exactly like a failed local migrate.
|
|
// eslint-disable-next-line no-console
|
|
console.error(formatJournalViolations(result));
|
|
}
|
|
expect(result.violations).toEqual([]);
|
|
expect(result.ok).toBe(true);
|
|
});
|
|
});
|