import { createHash } from "node:crypto"; import { readdir, readFile } from "node:fs/promises"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { config as loadEnv } from "dotenv"; import { Pool } from "pg"; const LOCK_CLASS_ID = 7813; const LOCK_OBJECT_ID = 4312; const parseBoolean = (value, fallback = false) => { if (value === undefined || value === null || String(value).trim().length === 0) { return fallback; } const normalized = String(value).trim().toLowerCase(); return normalized === "true" || normalized === "1" || normalized === "yes" || normalized === "on"; }; const sha256 = (content) => createHash("sha256").update(content).digest("hex"); const listMigrationFiles = async (migrationsDir) => { const entries = await readdir(migrationsDir, { withFileTypes: true }); return entries .filter((entry) => entry.isFile() && /^\d+.*\.sql$/i.test(entry.name)) .map((entry) => entry.name) .sort((a, b) => a.localeCompare(b)); }; const loadMigrations = async (migrationsDir) => { const files = await listMigrationFiles(migrationsDir); if (files.length === 0) { throw new Error(`[migrate] no SQL migration files found in ${migrationsDir}`); } const migrations = []; for (const fileName of files) { const filePath = path.join(migrationsDir, fileName); const sql = await readFile(filePath, "utf8"); migrations.push({ fileName, sql, checksum: sha256(sql), }); } return migrations; }; const ensureMigrationTable = async (client) => { await client.query(` CREATE TABLE IF NOT EXISTS schema_migrations ( version TEXT PRIMARY KEY, checksum TEXT NOT NULL, applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); `); }; const applyMigrations = async (client, migrations) => { const applied = await client.query("SELECT version, checksum FROM schema_migrations ORDER BY version ASC"); const appliedByVersion = new Map(applied.rows.map((row) => [String(row.version), String(row.checksum)])); for (const migration of migrations) { const existingChecksum = appliedByVersion.get(migration.fileName); if (existingChecksum) { if (existingChecksum !== migration.checksum) { throw new Error( `[migrate] checksum mismatch for ${migration.fileName}. expected ${existingChecksum}, got ${migration.checksum}`, ); } continue; } await client.query("BEGIN"); try { await client.query(migration.sql); await client.query( "INSERT INTO schema_migrations (version, checksum) VALUES ($1, $2)", [migration.fileName, migration.checksum], ); await client.query("COMMIT"); console.log(`[migrate] applied ${migration.fileName}`); } catch (error) { await client.query("ROLLBACK").catch(() => undefined); throw new Error(`[migrate] failed while applying ${migration.fileName}`, { cause: error }); } } }; const main = async () => { loadEnv(); if (!process.env.DATABASE_URL) { throw new Error("[migrate] DATABASE_URL is required"); } const scriptDir = path.dirname(fileURLToPath(import.meta.url)); const rootDir = path.resolve(scriptDir, "../../.."); const migrationsDir = path.join(rootDir, "database", "migrations"); const pool = new Pool({ connectionString: process.env.DATABASE_URL, ssl: parseBoolean(process.env.DATABASE_SSL, false) ? { rejectUnauthorized: true, } : undefined, }); const client = await pool.connect(); try { const migrations = await loadMigrations(migrationsDir); await client.query("SELECT pg_advisory_lock($1, $2)", [LOCK_CLASS_ID, LOCK_OBJECT_ID]); await ensureMigrationTable(client); await applyMigrations(client, migrations); console.log("[migrate] completed"); } finally { await client.query("SELECT pg_advisory_unlock($1, $2)", [LOCK_CLASS_ID, LOCK_OBJECT_ID]).catch(() => undefined); client.release(); await pool.end(); } }; main().catch((error) => { console.error("[migrate] fatal", error); process.exit(1); });