Files
stack/packages/db/src/migrate.spec.ts
T
2026-08-24 23:11:50 +00:00

170 lines
7.3 KiB
TypeScript

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<string, number>;
/** 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<string, number>([
['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
}
});
});