/** * Migration runner * * How it works: * 1. Creates a `_migrations` table on first run. * 2. Loads all migration modules from ./migrations/ in filename order. * 3. Skips any migration whose `id` is already recorded in `_migrations`. * 4. Executes pending migrations inside individual transactions. * 5. Records each successful migration with a timestamp. * * Adding a new migration: * - Create `apps/server/src/db/migrations/NNN_description.ts` * - Export `id` (string, matches filename) and `up` (SQL string). * - Optionally export `down` (SQL string) for rollback support. * - The runner picks it up automatically on next startup. */ import db from './db'; import path from 'path'; import fs from 'fs'; interface Migration { id: string; up: string; down?: string; } function bootstrap() { db.exec(` CREATE TABLE IF NOT EXISTS _migrations ( id TEXT PRIMARY KEY, applied_at TEXT NOT NULL DEFAULT (datetime('now')) ); `); } function loadMigrations(): Migration[] { const dir = path.join(__dirname, 'migrations'); if (!fs.existsSync(dir)) return []; return fs .readdirSync(dir) .filter((f) => f.endsWith('.ts') || f.endsWith('.js')) .sort() .map((file) => { // eslint-disable-next-line @typescript-eslint/no-var-requires const mod = require(path.join(dir, file)) as Migration; if (!mod.id || !mod.up) { throw new Error(`Migration ${file} must export 'id' and 'up'`); } return mod; }); } function getApplied(): Set { const rows = db.prepare('SELECT id FROM _migrations').all() as { id: string }[]; return new Set(rows.map((r) => r.id)); } export function runMigrations() { bootstrap(); const migrations = loadMigrations(); const applied = getApplied(); const pending = migrations.filter((m) => !applied.has(m.id)); if (pending.length === 0) { console.log('[db] All migrations up to date.'); return; } console.log(`[db] Running ${pending.length} pending migration(s)...`); for (const migration of pending) { const apply = db.transaction(() => { db.exec(migration.up); db.prepare('INSERT INTO _migrations (id) VALUES (?)').run(migration.id); }); try { apply(); console.log(`[db] ✓ Applied: ${migration.id}`); } catch (err) { console.error(`[db] ✗ Failed: ${migration.id}`, err); throw err; // Abort startup on migration failure } } console.log('[db] Migrations complete.'); } export function rollback(targetId: string) { const migrations = loadMigrations(); const applied = getApplied(); // Find all applied migrations after targetId in reverse order const toRollback = migrations .filter((m) => applied.has(m.id) && m.id > targetId) .reverse(); if (toRollback.length === 0) { console.log('[db] Nothing to roll back.'); return; } for (const migration of toRollback) { if (!migration.down) { console.warn(`[db] ⚠ No down migration for: ${migration.id} — skipping`); continue; } const revert = db.transaction(() => { db.exec(migration.down!); db.prepare('DELETE FROM _migrations WHERE id = ?').run(migration.id); }); try { revert(); console.log(`[db] ✓ Rolled back: ${migration.id}`); } catch (err) { console.error(`[db] ✗ Rollback failed: ${migration.id}`, err); throw err; } } }