/** * Migration Runner — Versioned SQL Migrations for SQLite * * Reads numbered `.sql` files from the migrations directory and applies * them sequentially, tracking applied versions in a `schema_migrations` table. * * Naming convention: `NNN_description.sql` (e.g., `001_initial_schema.sql`) * * All migrations run within a single transaction — all-or-nothing per file. */ import fs from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; import type Database from "better-sqlite3"; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const MIGRATIONS_DIR = path.join(__dirname, "migrations"); /** * Ensure the schema_migrations tracking table exists. */ function ensureMigrationsTable(db: Database.Database): void { db.exec(` CREATE TABLE IF NOT EXISTS _omniroute_migrations ( version TEXT PRIMARY KEY, name TEXT NOT NULL, applied_at TEXT NOT NULL DEFAULT (datetime('now')) ); `); } /** * Get all migration files sorted by version number. */ function getMigrationFiles(): Array<{ version: string; name: string; path: string }> { if (!fs.existsSync(MIGRATIONS_DIR)) return []; return fs .readdirSync(MIGRATIONS_DIR) .filter((f) => f.endsWith(".sql")) .sort() .map((filename) => { const match = filename.match(/^(\d+)_(.+)\.sql$/); if (!match) return null; return { version: match[1], name: match[2], path: path.join(MIGRATIONS_DIR, filename), }; }) .filter(Boolean) as Array<{ version: string; name: string; path: string }>; } /** * Get list of already-applied migration versions. */ function getAppliedVersions(db: Database.Database): Set { const rows = db.prepare("SELECT version FROM _omniroute_migrations").all() as Array<{ version: string; }>; return new Set(rows.map((r) => r.version)); } /** * Run all pending migrations in order. * Returns the number of migrations applied. */ export function runMigrations(db: Database.Database): number { ensureMigrationsTable(db); const files = getMigrationFiles(); const applied = getAppliedVersions(db); let count = 0; for (const migration of files) { if (applied.has(migration.version)) continue; const sql = fs.readFileSync(migration.path, "utf-8"); const applyMigration = db.transaction(() => { db.exec(sql); db.prepare("INSERT INTO _omniroute_migrations (version, name) VALUES (?, ?)").run( migration.version, migration.name ); }); try { applyMigration(); count++; console.log(`[Migration] Applied: ${migration.version}_${migration.name}`); } catch (err: unknown) { const message = err instanceof Error ? err.message : String(err); console.error(`[Migration] FAILED: ${migration.version}_${migration.name} — ${message}`); throw err; // Re-throw to prevent DB from starting in inconsistent state } } if (count > 0) { console.log(`[Migration] ${count} migration(s) applied successfully.`); } return count; } /** * Get migration status for diagnostics. */ export function getMigrationStatus(db: Database.Database): { applied: Array<{ version: string; name: string; applied_at: string }>; pending: Array<{ version: string; name: string }>; } { ensureMigrationsTable(db); const appliedRows = db .prepare("SELECT version, name, applied_at FROM _omniroute_migrations ORDER BY version") .all() as Array<{ version: string; name: string; applied_at: string }>; const appliedVersions = new Set(appliedRows.map((r) => r.version)); const allFiles = getMigrationFiles(); const pending = allFiles.filter((f) => !appliedVersions.has(f.version)); return { applied: appliedRows, pending }; }