// ── Automated Database Migration Runner ────────────────────────────── // Reads .env.local for DATABASE_URL, tracks applied migrations in // a _migrations table, and runs unapplied .sql files via psql. // Runs automatically on npm run dev and npm run setup. // ============================================================================ import { readFileSync, existsSync } from "node:fs" import { execSync } from "node:child_process" import { resolve, dirname } from "node:path" import { fileURLToPath } from "node:url" import { createRequire } from "node:module" const __dirname = dirname(fileURLToPath(import.meta.url)) const ROOT = resolve(__dirname, "..") // ── Load .env.local into process.env ───────────────────────────────── const envPath = resolve(ROOT, ".env.local") if (existsSync(envPath)) { for (const line of readFileSync(envPath, "utf-8").split("\n")) { const trimmed = line.trim() if (!trimmed || trimmed.startsWith("#")) continue const eq = trimmed.indexOf("=") if (eq === -1) continue const key = trimmed.slice(0, eq).trim() let value = trimmed.slice(eq + 1).trim() if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) { value = value.slice(1, -1) } if (!process.env[key]) process.env[key] = value } } const DATABASE_URL = process.env.DATABASE_URL if (!DATABASE_URL) { console.log(" ~ DATABASE_URL not set — skipping migrations") process.exit(0) } // ── Find psql ──────────────────────────────────────────────────────── function findPsql() { try { execSync("psql --version", { stdio: "pipe", timeout: 5000 }) return "psql" } catch {} const candidates = [ "C:\\Program Files\\PostgreSQL\\16\\bin\\psql.exe", "C:\\Program Files\\PostgreSQL\\17\\bin\\psql.exe", "C:\\Program Files\\PostgreSQL\\15\\bin\\psql.exe", "C:\\Program Files\\PostgreSQL\\14\\bin\\psql.exe", ] for (const p of candidates) { if (existsSync(p)) return `"${p}"` } return null } const PSQL = findPsql() if (!PSQL) { console.log(" ~ psql not found — skipping migrations (install PostgreSQL client tools)") process.exit(0) } // ── Database connection (for tracking table) ───────────────────────── const require = createRequire(import.meta.url) const { Pool } = require("pg") let pool try { pool = new Pool({ connectionString: DATABASE_URL, max: 1, connectionTimeoutMillis: 5000, idleTimeoutMillis: 10000, }) await pool.query("SELECT 1") } catch { console.log(" ~ Database not available — skipping migrations") process.exit(0) } // ── Migration logic ────────────────────────────────────────────────── async function runMigrations() { // 1. Create tracking table if not exists await pool.query(` CREATE TABLE IF NOT EXISTS _migrations ( id SERIAL PRIMARY KEY, filename VARCHAR(255) NOT NULL UNIQUE, applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ) `) // 2. Determine file order from run_all.sql (authoritative) const runAllPath = resolve(ROOT, "database", "migrations", "run_all.sql") const runAllContent = readFileSync(runAllPath, "utf-8") const fileOrder = [] for (const line of runAllContent.split("\n")) { const match = line.match(/\\i\s+(\S+\.sql)/) if (match) fileOrder.push(match[1]) } if (fileOrder.length === 0) { console.log(" ~ No migration files found in run_all.sql") return } // 3. Get already-applied files let appliedSet try { const { rows: applied } = await pool.query("SELECT filename FROM _migrations ORDER BY filename") appliedSet = new Set(applied.map((r) => r.filename)) } catch { appliedSet = new Set() } // 4. Build psql command base const psqlCmd = `${PSQL} -d "${DATABASE_URL}" -v ON_ERROR_STOP=1` // 5. Run unapplied files in order. // psql -v ON_ERROR_STOP=1 aborts on the first error with exit code 3. // If the error is "already exists", the file was already applied before // tracking existed — mark it as applied and continue. // Other errors are genuine failures — abort. const ALREADY_APPLIED_PATTERNS = [ "already exists", "duplicate key", "cannot insert multiple commands into a prepared statement", ] let count = 0 for (const file of fileOrder) { if (appliedSet.has(file)) continue const filePath = resolve(ROOT, "database", "migrations", file) if (!existsSync(filePath)) { console.error(` ✗ Migration file not found: ${file}`) process.exit(1) } let stderr = "" try { const result = execSync(`${psqlCmd} -f "${filePath}"`, { timeout: 30000, encoding: "utf-8", maxBuffer: 10 * 1024 * 1024, stdio: ["ignore", "pipe", "pipe"], }) // psql outputs notices/warnings to stderr even on success stderr = result.stderr || "" } catch (err) { const msg = err.stderr || err.stdout || err.message const isAlreadyApplied = ALREADY_APPLIED_PATTERNS.some((p) => msg.includes(p)) if (isAlreadyApplied) { await pool.query("INSERT INTO _migrations (filename) VALUES ($1) ON CONFLICT DO NOTHING", [file]) console.log(` ~ Already applied: ${file}`) count++ continue } console.error(` ✗ Migration failed: ${file}`) console.error(` ${msg.split("\n").slice(0, 5).join("\n ")}`) process.exit(1) } await pool.query("INSERT INTO _migrations (filename) VALUES ($1)", [file]) console.log(` ✓ Applied: ${file}`) count++ } if (count === 0) { console.log(" ~ All migrations up to date") } } // ── Run ────────────────────────────────────────────────────────────── try { await runMigrations() } finally { await pool.end() }