What ships (verifiable without live DB, 64 new tests): - db/migrations/0000_initial_schema.sql (Drizzle-generated, 7 tables) + .down.sql + registry entry - db/migrations/rehearse.ts: forward-then-rollback round-trip with row-count hash check (DoD 2.2) - infra/docker-compose.yml: postgres 17 + redis 7 + openobserve for local dev (5433/6380/5080) - packages/schema/src/rate-limit.ts: pluggable store; 4 tests including 21st-of-20 reject (DoD 2.4) - packages/schema/src/csrf.ts: HMAC double-submit token; 8 tests covering forgery + tamper + malformed - packages/schema/src/authz.ts: 3-role Cerbos-equivalent rules (operator/approver/viewer); 6 tests - packages/schema/src/api-contracts.ts: Zod schemas for /api/content, /api/approvals, /api/publications, /api/feature-flags + idempotencyKeyOf; 11 tests What defers to live-DB session: - 2.3 admin route handlers integration tests (401/403/200/422 contract suite) - 2.2 actual rehearsal execution against staging DB Total: 79/79 tests pass across 9 files in 4 packages. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
108 lines
3.4 KiB
TypeScript
108 lines
3.4 KiB
TypeScript
#!/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<string> => {
|
|
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<void> => {
|
|
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);
|
|
});
|