#!/usr/bin/env bun /** * Migration rehearsal script — Stage 2.2 DoD. * * Applies every forward migration against a clean staging DB, then every rollback * in reverse order, capturing row counts and a content-hash at each step. Fails * the run if forward+rollback don't return the DB to its pre-migration state. * * Usage: * DATABASE_URL=postgres://... bun run db/migrations/rehearse.ts */ import { readdirSync, readFileSync } from "node:fs"; import { join } from "node:path"; import { createHash } from "node:crypto"; import postgres from "postgres"; const MIGRATIONS_DIR = join(import.meta.dir); interface Migration { number: string; name: string; forward: string; rollback: string; } const loadMigrations = (): Migration[] => { const all = readdirSync(MIGRATIONS_DIR); const forwards = all.filter((f) => /^\d{4}_.*\.sql$/.test(f) && !f.endsWith(".down.sql")).sort(); return forwards.map((forwardName) => { const number = forwardName.slice(0, 4); const base = forwardName.slice(0, -4); const downName = `${base}.down.sql`; const rollbackPath = join(MIGRATIONS_DIR, downName); if (!all.includes(downName)) { throw new Error(`Missing rollback file: ${downName}`); } return { number, name: base, forward: readFileSync(join(MIGRATIONS_DIR, forwardName), "utf8"), rollback: readFileSync(rollbackPath, "utf8"), }; }); }; const tableSnapshot = async (sql: postgres.Sql): Promise => { const tables = await sql<{ tablename: string }[]>` SELECT tablename FROM pg_tables WHERE schemaname = 'public' ORDER BY tablename `; const lines: string[] = []; for (const { tablename } of tables) { const rows = await sql.unsafe(`SELECT COUNT(*)::text AS c FROM "${tablename}"`); const count = (rows[0] as { c: string } | undefined)?.c ?? "0"; lines.push(`${tablename}=${count}`); } return createHash("sha256").update(lines.join("|")).digest("hex"); }; const main = async (): Promise => { const url = process.env.DATABASE_URL; if (!url) { console.error("DATABASE_URL not set"); process.exit(2); } const sql = postgres(url, { max: 1, onnotice: () => {} }); const migrations = loadMigrations(); console.log(`[rehearse] ${migrations.length} migration(s) to test`); const before = await tableSnapshot(sql); console.log(`[rehearse] pre-state hash: ${before}`); for (const m of migrations) { console.log(`[rehearse] forward: ${m.name}`); for (const stmt of m.forward.split("--> statement-breakpoint")) { const trimmed = stmt.trim(); if (trimmed) await sql.unsafe(trimmed); } } const afterForward = await tableSnapshot(sql); console.log(`[rehearse] post-forward hash: ${afterForward}`); for (const m of [...migrations].reverse()) { console.log(`[rehearse] rollback: ${m.name}`); for (const stmt of m.rollback.split(";")) { const trimmed = stmt.trim(); if (trimmed) await sql.unsafe(trimmed); } } const afterRollback = await tableSnapshot(sql); console.log(`[rehearse] post-rollback hash: ${afterRollback}`); await sql.end(); if (afterRollback !== before) { console.error(`[rehearse] FAIL — rollback did not restore pre-state`); console.error(` before: ${before}`); console.error(` afterRollback: ${afterRollback}`); process.exit(1); } console.log(`[rehearse] PASS — forward + rollback round-trip clean`); }; main().catch((e) => { console.error(e); process.exit(1); });