cm_whatsapp_bot_v1/apps/web/src/test/drizzle-journal-monotonic.test.ts
yiekheng 47d7c53fda feat(db): auto-guard against drizzle journal-skip regression
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>
2026-05-10 21:40:11 +08:00

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);
});
});