#!/usr/bin/env node /** * Apply Postgres migrations from `db/migrations/*.sql` in lexical order. * Wraps each new file in a transaction; tracks applied files in * `_migrations` so re-runs are safe and idempotent. * * The 0001_init.sql (and 0002) are now fully re-runnable (IF NOT EXISTS + * trigger guards) + this script has repair logic for DBs that were * initialized before tracking was introduced. * * Usage: * npm run migrate * npm run migrate:one # same as above (applies all pending) */ require("dotenv").config({ path: ".env.local" }); const fs = require("node:fs"); const path = require("node:path"); const { Client } = require("pg"); const MIGRATIONS_DIR = path.join(__dirname, "..", "db", "migrations"); async function main() { const url = process.env.DATABASE_ADMIN_URL ?? process.env.DATABASE_URL; if (!url) { console.error("❌ DATABASE_URL (or DATABASE_ADMIN_URL) is not set in .env.local"); process.exit(1); } const client = new Client({ connectionString: url }); await client.connect(); try { await client.query(` CREATE TABLE IF NOT EXISTS _migrations ( filename TEXT PRIMARY KEY, applied_at TIMESTAMPTZ NOT NULL DEFAULT now() ) `); const files = fs .readdirSync(MIGRATIONS_DIR) .filter((f) => f.endsWith(".sql")) .sort(); if (files.length === 0) { console.log("No migration files found in db/migrations/"); return; } const { rows: applied } = await client.query( `SELECT filename FROM _migrations`, ); const appliedSet = new Set(applied.map((r) => r.filename)); // Repair for historical DBs (applied before _migrations tracking existed, // or via direct psql / earlier tooling). If the core objects are present // we record the filename so future deploys (and server-side recovery runs) // treat 0001/0002 as done without re-executing the large init script. async function ensureTracked(filename, existenceCheckSql) { if (appliedSet.has(filename)) return; try { const { rows } = await client.query(existenceCheckSql); if (rows.length > 0) { await client.query( `INSERT INTO _migrations (filename) VALUES ($1) ON CONFLICT (filename) DO NOTHING`, [filename] ); console.log(`✓ ${filename} (objects present in DB; repaired tracking)`); appliedSet.add(filename); } } catch (e) { // Non-fatal: the check may fail on a brand-new DB or with limited perms. // We'll let the normal apply path handle it. } } await ensureTracked( "0001_init.sql", "SELECT 1 FROM information_schema.tables WHERE table_name = 'admin_users' LIMIT 1" ); await ensureTracked( "0002_admin_password.sql", "SELECT 1 FROM information_schema.columns WHERE table_name = 'users' AND column_name = 'password_hash' LIMIT 1" ); let appliedNow = 0; for (const file of files) { if (appliedSet.has(file)) { console.log(`✓ ${file} (already applied)`); continue; } const sql = fs.readFileSync(path.join(MIGRATIONS_DIR, file), "utf8"); console.log(`→ Applying ${file}...`); try { await client.query("BEGIN"); await client.query(sql); await client.query(`INSERT INTO _migrations (filename) VALUES ($1)`, [ file, ]); await client.query("COMMIT"); appliedNow += 1; console.log(`✓ ${file}`); } catch (err) { await client.query("ROLLBACK"); console.error(`✗ ${file} failed:`, err.message); throw err; } } console.log( `\n✅ Done. ${appliedNow} new migration(s) applied. ${ files.length - appliedNow } already current.`, ); } finally { await client.end(); } } main().catch((err) => { console.error(err); process.exit(1); });