import { describe, it, expect } from 'vitest'; import { readFileSync } from 'node:fs'; import { resolve } from 'node:path'; import { applyMigrationsByHash, readJournalTags, type HashLedgerDeps, type MigrationPlanEntry, } from './migrate.js'; /* ------------------------------------------------------------------ */ /* In-memory hash-ledger harness */ /* ------------------------------------------------------------------ */ interface LedgerHarness extends HashLedgerDeps { ledger: Map; /** Recorded statement executions in order: `${hashPrefix}:${stmtIndex}`. */ executed: string[]; /** Optional: statements that should throw when executed. */ failOn?: (migrationHash: string, stmtIdx: number) => boolean; } function makeHarness(plan: MigrationPlanEntry[]): LedgerHarness { const hashToEntry = new Map(plan.map((p) => [p.hash, p])); const h: LedgerHarness = { ledger: new Map(), executed: [], ensureLedger: async () => {}, appliedHashes: async () => [...h.ledger.keys()], recordApplied: async (hash, folderMillis) => { h.ledger.set(hash, folderMillis); }, runStatement: async (statement) => { void statement; // runStatement does not know which migration it belongs to; the // executed log is filled by the wrapper below. }, }; // Wrap runStatement so the executed log records migration context. We // reconstruct context by tracking a cursor the core advances per migration. let cursor = 0; const flat: Array<{ hash: string; idx: number }> = []; for (const m of plan) for (const [i] of m.statements.entries()) flat.push({ hash: m.hash, idx: i }); h.runStatement = async () => { const at = flat[cursor] ?? { hash: '??', idx: -1 }; cursor += 1; if (h.failOn && at.hash !== '??' && h.failOn(at.hash, at.idx)) { throw new Error(`simulated failure in ${at.hash} #${at.idx.toString()}`); } h.executed.push(`${at.hash.slice(0, 6)}:${at.idx.toString()}`); }; void hashToEntry; return h; } /* ------------------------------------------------------------------ */ /* Fixtures */ /* ------------------------------------------------------------------ */ // Reproduces the REAL journal defect shape (#1402 D1): 0009/0010 carry // `when` timestamps BELOW 0008's. Under the old drizzle postgres-js // migrator these were silently skipped on any upgrade whose ledger was // last stamped in the 0008 era. const JOURNAL_FIXTURE: MigrationPlanEntry[] = [ { hash: 'aaaa0000', folderMillis: 1773368153122, statements: ['CREATE TABLE a (id int)'] }, { hash: 'bbbb0008', folderMillis: 1776822435828, statements: ['CREATE TABLE b (id int)'] }, // Backdated entries, exactly as shipped: { hash: 'cccc0009', folderMillis: 1745280000000, statements: ['ALTER TYPE t ADD VALUE', "CREATE TABLE c (s t DEFAULT 'pending')"], }, { hash: 'dddd0010', folderMillis: 1745366400000, statements: ['CREATE TABLE d (id int)'] }, ]; /** A ledger last stamped at the 0008 era: only pre-0009 hashes recorded. */ const LEDGER_AT_0008_ERA = new Map([ ['aaaa0000', 1773368153122], ['bbbb0008', 1776822435828], ]); /* ------------------------------------------------------------------ */ /* The core: apply-by-hash in journal order */ /* ------------------------------------------------------------------ */ describe('applyMigrationsByHash', () => { it('applies backdated journal entries that a timestamp-based migrator would skip (#1402 D1)', async () => { const h = makeHarness(JOURNAL_FIXTURE); h.ledger = new Map(LEDGER_AT_0008_ERA); const result = await applyMigrationsByHash(h, JOURNAL_FIXTURE); // D1 in one sentence: 0009 and 0010 applied despite folderMillis < 0008. expect(result).toEqual({ applied: 2, skipped: 2 }); expect(h.ledger.has('cccc0009')).toBe(true); expect(h.ledger.has('dddd0010')).toBe(true); }); it('executes statements individually (ALTER TYPE visibility, #1402 D2 shape)', async () => { const h = makeHarness(JOURNAL_FIXTURE); await applyMigrationsByHash(h, JOURNAL_FIXTURE); // 0009's two statements recorded as separate executions, in order. expect(h.executed).toContain('cccc00:0'); expect(h.executed).toContain('cccc00:1'); expect(h.executed.indexOf('cccc00:0')).toBeLessThan(h.executed.indexOf('cccc00:1')); }); it('is idempotent: a fully-applied ledger applies nothing', async () => { const h = makeHarness(JOURNAL_FIXTURE); const first = await applyMigrationsByHash(h, JOURNAL_FIXTURE); const second = await applyMigrationsByHash(h, JOURNAL_FIXTURE); expect(first.applied).toBe(4); expect(second).toEqual({ applied: 0, skipped: 4 }); expect(h.executed).toHaveLength(5); // 5 statements; second run executed NONE (not 10) }); it('records no ledger row when a statement fails (crash prefix replays loudly)', async () => { const h = makeHarness(JOURNAL_FIXTURE); h.failOn = (hash, idx) => hash === 'cccc0009' && idx === 1; await expect(applyMigrationsByHash(h, JOURNAL_FIXTURE)).rejects.toThrow( /cccc0009 statement #1 failed: simulated failure/, ); // Statement 0 of 0009 executed, but NO ledger row for 0009: the next run // replays it and fails loudly on "already exists" instead of silently // believing 0009 applied. expect(h.ledger.has('cccc0009')).toBe(false); expect(h.executed).toContain('cccc00:0'); }); it('applies in JOURNAL order, not timestamp order', async () => { const h = makeHarness(JOURNAL_FIXTURE); await applyMigrationsByHash(h, JOURNAL_FIXTURE); // 5 statements total (0009 has two); prefix per migration: journal order, // so 0009's pair sits between 0008 and 0010. const order = h.executed.map((e) => e.slice(0, 4)); expect(order).toEqual(['aaaa', 'bbbb', 'cccc', 'cccc', 'dddd']); }); }); /* ------------------------------------------------------------------ */ /* Journal integrity against the shipped folder */ /* ------------------------------------------------------------------ */ describe('readJournalTags', () => { it('reads the shipped journal in order and sees the known backdated pair', () => { const folder = resolve(__dirname, '../drizzle'); const tags = readJournalTags(folder); expect(tags.length).toBeGreaterThan(0); // The shipped defect (#1402 D1): these two entries carry April-2025 // timestamps below 0008's June-2026 one. If this assertion ever fails // because the journal was FIXED (timestamps corrected or drizzle-kit // regenerated), update #1402 — the hash-ledger core stays correct either // way; this test pins the shipped reality the core was built for. const t9 = tags.find((t) => t.startsWith('0009_')); const t10 = tags.find((t) => t.startsWith('0010_')); const t8 = tags.find((t) => t.startsWith('0008_')); expect([t8, t9, t10]).toBeDefined(); const journal = JSON.parse(readFileSync(resolve(folder, 'meta', '_journal.json'), 'utf8')) as { entries: Array<{ tag: string; when: number }>; }; const when = new Map(journal.entries.map((e) => [e.tag, e.when])); if (t8 && t9 && t10) { expect(when.get(t9)!).toBeLessThan(when.get(t8)!); // backdated below 0008 expect(when.get(t10)!).toBeLessThan(when.get(t8)!); // backdated below 0008 } }); });