cm_whatsapp_bot_v1/packages/db/src/journal-check.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

91 lines
3.1 KiB
TypeScript

/**
* Drizzle journal monotonicity guard.
*
* Background — twice already we hit this regression: a `pnpm migrate`
* silently skipped a freshly-generated migration because its `when`
* timestamp was older than the previous migration's `when`. Drizzle's
* migrator orders the entries by `when` (not by `idx`) and only
* applies entries whose `when` is strictly greater than the latest
* row's `created_at` in `pgboss... drizzle.__drizzle_migrations`.
*
* Symptom: migrate prints "Migrations applied." while the schema in
* the live DB is missing whatever 0012 / 0013 were supposed to add.
* Web 500's on every authenticated request because the code expects
* the new columns.
*
* This module is the first line of defence:
* - `assertJournalMonotonic(entries)` is a pure check the test
* suite runs against the committed journal file. CI fails on a
* bad commit before it can ship.
* - migrate.ts calls it on boot. If the live journal in source
* control has slipped out of monotonic order, migrate refuses
* to run and prints the offending entries with the smallest
* bump that would unbreak each one.
*/
export interface JournalEntry {
idx: number;
tag: string;
when: number;
}
export interface JournalCheckResult {
ok: boolean;
/** Entries whose `when` is <= the previous entry's `when`. */
violations: Array<{
idx: number;
tag: string;
when: number;
/** The previous entry's when — the new bound that this one must beat. */
previousWhen: number;
previousTag: string;
/** A `when` value that would make THIS entry monotonic again. */
suggestedWhen: number;
}>;
}
/**
* Walk the journal entries in idx order and report any whose `when`
* is not strictly greater than the previous entry's `when`. The
* journal can have any starting timestamp; we only care about the
* relative ordering matching idx. Equal timestamps are also a
* violation — drizzle requires strictly greater.
*/
export function assertJournalMonotonic(entries: JournalEntry[]): JournalCheckResult {
const sorted = [...entries].sort((a, b) => a.idx - b.idx);
const violations: JournalCheckResult["violations"] = [];
for (let i = 1; i < sorted.length; i++) {
const prev = sorted[i - 1]!;
const cur = sorted[i]!;
if (cur.when <= prev.when) {
violations.push({
idx: cur.idx,
tag: cur.tag,
when: cur.when,
previousWhen: prev.when,
previousTag: prev.tag,
suggestedWhen: prev.when + 1000,
});
}
}
return { ok: violations.length === 0, violations };
}
/** Format the check result into a multi-line human message. */
export function formatJournalViolations(result: JournalCheckResult): string {
if (result.ok) return "";
const lines: string[] = [
"Drizzle journal is not monotonic — migrate would silently skip these entries:",
];
for (const v of result.violations) {
lines.push(
` ${v.tag} (idx ${v.idx}) when=${v.when} <= ${v.previousTag}.when=${v.previousWhen}`,
);
lines.push(
` fix: set ${v.tag}.when to >= ${v.suggestedWhen} in ` +
`packages/db/migrations/meta/_journal.json`,
);
}
return lines.join("\n");
}