diff --git a/package.json b/package.json index 6b996036..c4083257 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,9 @@ "format": "prettier --write \"src/**/*.{ts,tsx,json,css}\"", "db:generate": "drizzle-kit generate", "db:push": "drizzle-kit push", + "db:migrate": "tsx scripts/db-migrate.ts apply", + "db:migrate:status": "tsx scripts/db-migrate.ts status", + "db:migrate:baseline": "tsx scripts/db-migrate.ts baseline", "db:studio": "drizzle-kit studio", "db:seed": "tsx src/lib/db/seed.ts", "db:seed:realistic": "tsx src/lib/db/seed.ts", diff --git a/scripts/db-migrate.ts b/scripts/db-migrate.ts new file mode 100644 index 00000000..a30ea6b6 --- /dev/null +++ b/scripts/db-migrate.ts @@ -0,0 +1,275 @@ +/** + * Production migration runner. + * + * Why this exists (and why `drizzle-kit migrate` isn't enough): + * + * - Drizzle's bundled `migrate()` wraps every migration in a single + * transaction. Postgres forbids `CREATE INDEX CONCURRENTLY` inside + * a transaction (raises 25001) — so any migration containing + * CONCURRENTLY silently aborts or, worse, leaves the migration + * marked applied with the index missing. `0052_audit_critical_fixes.sql` + * ships six CONCURRENTLY composite indexes today and they never + * landed in prod. + * + * - `drizzle-kit push` skips DDL the kit can't infer from the schema — + * e.g. CHECK constraints, partial unique indexes, the berth-pdf + * circular FK. push-only deployments diverge from migration-tracked + * truth. + * + * This script: + * 1. Reads migrations in journal order from `src/lib/db/migrations`. + * 2. Tracks applied state in `drizzle.__drizzle_migrations` (matching + * Drizzle's schema so other tooling sees the same source of truth). + * 3. For each pending migration: splits on `--> statement-breakpoint`, + * classifies each statement as concurrency-safe (CREATE INDEX + * CONCURRENTLY / REINDEX CONCURRENTLY → outside tx) or + * transactional (everything else → batched in one tx per migration). + * 4. Records hash + when-applied so re-runs are no-ops. + * + * Modes: + * `pnpm db:migrate` — apply pending migrations + * `pnpm db:migrate:status` — show pending vs applied without applying + * `pnpm db:migrate:baseline` — mark every migration as applied without + * running it. Use ONCE per environment when + * the schema was bootstrapped via `db:push` + * (dev + the original prod cutover). After + * baseline, all future migrations go through + * `db:migrate` and are tracked in + * `__drizzle_migrations`. + */ + +import 'dotenv/config'; +import { createHash } from 'node:crypto'; +import { readFileSync, readdirSync } from 'node:fs'; +import { join } from 'node:path'; +import postgres from 'postgres'; + +const STATEMENT_BREAKPOINT = '--> statement-breakpoint'; +const MIGRATIONS_DIR = join(process.cwd(), 'src/lib/db/migrations'); +const SCHEMA_NAME = 'drizzle'; +const TABLE_NAME = '__drizzle_migrations'; + +interface JournalEntry { + idx: number; + version: string; + when: number; + tag: string; + breakpoints: boolean; +} + +interface Journal { + version: string; + dialect: string; + entries: JournalEntry[]; +} + +interface MigrationFile { + tag: string; + /** Folder millis from journal `when` — Drizzle uses this as the + * primary key in `__drizzle_migrations`. */ + folderMillis: number; + /** Full file contents. */ + sql: string; + /** SHA-256 hex of the raw file for re-application detection. */ + hash: string; +} + +interface Statement { + /** Raw SQL text (trimmed). */ + sql: string; + /** True when the statement must execute outside a transaction. */ + needsAutocommit: boolean; +} + +function isConcurrencyDDL(sql: string): boolean { + const head = sql + .replace(/^\s*--.*$/gm, '') + .trim() + .toUpperCase(); + return ( + /\bCREATE\s+INDEX\s+CONCURRENTLY\b/.test(head) || + /\bREINDEX\s+\w*\s*CONCURRENTLY\b/.test(head) || + /\bDROP\s+INDEX\s+CONCURRENTLY\b/.test(head) + ); +} + +function readMigrations(): MigrationFile[] { + const journal = JSON.parse( + readFileSync(join(MIGRATIONS_DIR, 'meta', '_journal.json'), 'utf8'), + ) as Journal; + + const files = readdirSync(MIGRATIONS_DIR).filter((f) => f.endsWith('.sql')); + const byTag = new Map(files.map((f) => [f.replace(/\.sql$/, ''), f])); + + return journal.entries.map((entry) => { + const filename = byTag.get(entry.tag); + if (!filename) { + throw new Error(`Migration ${entry.tag} in journal but ${entry.tag}.sql not on disk`); + } + const sql = readFileSync(join(MIGRATIONS_DIR, filename), 'utf8'); + const hash = createHash('sha256').update(sql).digest('hex'); + return { tag: entry.tag, folderMillis: entry.when, sql, hash }; + }); +} + +function splitStatements(sql: string): Statement[] { + // Drizzle inserts `--> statement-breakpoint` between every statement + // when `breakpoints: true` in drizzle.config. We split on those AND + // strip trailing semicolons. Anything before the first breakpoint + // counts too. + const parts = sql.split(STATEMENT_BREAKPOINT); + const out: Statement[] = []; + for (const part of parts) { + const trimmed = part.trim(); + if (!trimmed || trimmed.startsWith('--')) { + // Comment-only chunks (pre-breakpoint header etc.) — skip if + // they have no executable SQL. + const nonComment = trimmed + .split('\n') + .filter((line) => !line.trim().startsWith('--') && line.trim().length > 0); + if (nonComment.length === 0) continue; + } + out.push({ sql: trimmed, needsAutocommit: isConcurrencyDDL(trimmed) }); + } + return out; +} + +async function ensureMigrationsTable(sql: postgres.Sql): Promise { + await sql.unsafe(`CREATE SCHEMA IF NOT EXISTS "${SCHEMA_NAME}"`); + await sql.unsafe(` + CREATE TABLE IF NOT EXISTS "${SCHEMA_NAME}"."${TABLE_NAME}" ( + id SERIAL PRIMARY KEY, + hash text NOT NULL, + created_at bigint + ) + `); +} + +async function getAppliedHashes(sql: postgres.Sql): Promise> { + const rows = await sql.unsafe<{ hash: string }[]>( + `SELECT hash FROM "${SCHEMA_NAME}"."${TABLE_NAME}"`, + ); + return new Set(rows.map((r) => r.hash)); +} + +async function applyMigration(sql: postgres.Sql, migration: MigrationFile): Promise { + const statements = splitStatements(migration.sql); + if (statements.length === 0) { + console.log(` [${migration.tag}] no executable statements, skipping`); + return; + } + + const autocommit = statements.filter((s) => s.needsAutocommit); + const transactional = statements.filter((s) => !s.needsAutocommit); + + // Transactional batch first — schema changes that CONCURRENTLY ops + // depend on (e.g. column adds before CREATE INDEX) need to exist + // before the index build runs. Drizzle migrations are written in + // this order; we preserve it within each phase. + if (transactional.length > 0) { + await sql.begin(async (tx) => { + for (const stmt of transactional) { + await tx.unsafe(stmt.sql); + } + }); + } + + // CONCURRENTLY ops run one at a time, each as its own implicit tx. + // No `BEGIN`/`COMMIT` wrapping — postgres-js's `sql.unsafe` runs + // each call as an independent transaction. + for (const stmt of autocommit) { + await sql.unsafe(stmt.sql); + } + + // Record the migration as applied. created_at mirrors Drizzle's own + // schema so `drizzle-kit migrate` (if ever invoked) sees the same + // state we wrote. + await sql.unsafe( + `INSERT INTO "${SCHEMA_NAME}"."${TABLE_NAME}" (hash, created_at) VALUES ($1, $2)`, + [migration.hash, migration.folderMillis], + ); +} + +async function main(): Promise { + const url = process.env.DATABASE_URL; + if (!url) { + console.error('DATABASE_URL must be set'); + process.exit(1); + } + + const mode = process.argv[2] ?? 'apply'; + if (!['apply', 'status', 'baseline'].includes(mode)) { + console.error(`Unknown mode: ${mode}. Use 'apply' (default), 'status', or 'baseline'.`); + process.exit(1); + } + + const sql = postgres(url, { max: 1, prepare: false }); + + try { + await ensureMigrationsTable(sql); + const applied = await getAppliedHashes(sql); + const migrations = readMigrations(); + const pending = migrations.filter((m) => !applied.has(m.hash)); + + if (mode === 'status') { + console.log(`Applied: ${applied.size}`); + console.log(`Pending: ${pending.length}`); + if (pending.length > 0) { + console.log(''); + console.log('Pending migrations:'); + for (const m of pending) { + const statements = splitStatements(m.sql); + const conc = statements.filter((s) => s.needsAutocommit).length; + const tx = statements.length - conc; + console.log(` ${m.tag} — ${tx} transactional + ${conc} concurrency-safe`); + } + } + return; + } + + if (mode === 'baseline') { + if (pending.length === 0) { + console.log('All migrations already tracked. Nothing to baseline.'); + return; + } + console.log( + `Baselining ${pending.length} migration${ + pending.length === 1 ? '' : 's' + } as applied without running them.`, + ); + console.log( + 'This is correct ONLY when the schema is already in place (e.g. created via db:push).', + ); + for (const m of pending) { + await sql.unsafe( + `INSERT INTO "${SCHEMA_NAME}"."${TABLE_NAME}" (hash, created_at) VALUES ($1, $2)`, + [m.hash, m.folderMillis], + ); + console.log(` → ${m.tag} marked as applied`); + } + console.log(`Done. ${pending.length} baselined.`); + return; + } + + if (pending.length === 0) { + console.log('No pending migrations.'); + return; + } + + console.log(`Applying ${pending.length} migration${pending.length === 1 ? '' : 's'}...`); + for (const m of pending) { + const statements = splitStatements(m.sql); + const conc = statements.filter((s) => s.needsAutocommit).length; + console.log(` → ${m.tag} (${statements.length} statements, ${conc} CONCURRENTLY)`); + await applyMigration(sql, m); + } + console.log(`Done. ${pending.length} applied.`); + } finally { + await sql.end({ timeout: 5 }); + } +} + +main().catch((err) => { + console.error('Migration failed:', err); + process.exit(1); +}); diff --git a/scripts/tsc-staged.mjs b/scripts/tsc-staged.mjs index 65964193..a73ac7b0 100644 --- a/scripts/tsc-staged.mjs +++ b/scripts/tsc-staged.mjs @@ -14,8 +14,7 @@ */ import { spawnSync } from 'node:child_process'; -import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; -import { tmpdir } from 'node:os'; +import { mkdtempSync, rmSync, writeFileSync, mkdirSync } from 'node:fs'; import { join, relative, resolve } from 'node:path'; const cwd = process.cwd(); @@ -26,7 +25,13 @@ if (files.length === 0) { process.exit(0); } -const tmpDir = mkdtempSync(join(tmpdir(), 'tsc-staged-')); +// Temp tsconfig lives inside the project tree (not /tmp) so @types/* +// resolution walks up to node_modules. tsc's "atTypes" auto-discovery +// is anchored to the tsconfig's directory, so a temp config in /tmp +// would miss our @types/node, @types/react, etc. +const baseDir = join(cwd, 'node_modules/.cache/tsc-staged'); +mkdirSync(baseDir, { recursive: true }); +const tmpDir = mkdtempSync(join(baseDir, 'run-')); const tmpConfig = join(tmpDir, 'tsconfig.json'); const relFiles = files.map((f) => relative(tmpDir, resolve(cwd, f))); @@ -36,7 +41,14 @@ writeFileSync( JSON.stringify( { extends: relative(tmpDir, join(cwd, 'tsconfig.json')), - compilerOptions: { noEmit: true, skipLibCheck: true }, + compilerOptions: { + noEmit: true, + skipLibCheck: true, + // Explicitly list `types` so the @types/* auto-discovery + // finds them — without this, the temp-tsconfig location + // anchors discovery to .cache/ and misses node/react/etc. + types: ['node', 'react', 'react-dom'], + }, files: relFiles, include: [], },