ci/woodpecker/push/publish Pipeline failed
Co-authored-by: ops-deploy-01 <[email protected]>
170 lines
7.3 KiB
TypeScript
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
|
|
}
|
|
});
|
|
});
|