// Migration runner — applies plugin-declared migrations once each, tracked in // public.schema_migrations. The plugin manager only *surfaces* migrations() // (file URLs); this is what actually runs them, at boot. // // Design for a lean / DB-optional core: // * No declared migrations (e.g. kgva: no plugins) → returns immediately, never // opens a DB connection and never imports `pg`. // * `exec` is injectable — an async (text, params?) => rows function — so the // logic unit-tests without a real database. Omit it and the runner connects // via `pg` to `databaseUrl` (lazy import; clear error if `pg` is missing). // * Each migration runs once (id = "/") inside its own // transaction; the file SQL is itself idempotent (see 001_dialog.sql), so a // pre-existing DB (openbureau) is baselined cleanly on first run. import { readFile } from 'node:fs/promises'; import { fileURLToPath } from 'node:url'; export async function runMigrations(manager, { databaseUrl, exec, log = console } = {}) { const migs = manager.migrations(); if (!migs.length) return { applied: [], skipped: [], pending: false }; let close = async () => {}; if (!exec) { if (!databaseUrl) { log.warn?.(`migrate: ${migs.length} Migration(en) deklariert, aber DATABASE_URL fehlt — übersprungen.`); return { applied: [], skipped: migs.map((m) => `${m.plugin}/${m.file}`), pending: true }; } ({ exec, close } = await pgExecutor(databaseUrl)); } try { await exec(`CREATE TABLE IF NOT EXISTS public.schema_migrations ( id text PRIMARY KEY, plugin text NOT NULL, applied_at timestamptz NOT NULL DEFAULT now() )`); const applied = [], skipped = []; for (const m of migs) { const id = `${m.plugin}/${m.file}`; const done = await exec('SELECT 1 FROM public.schema_migrations WHERE id = $1', [id]); if (done?.length) { skipped.push(id); continue; } try { const sql = await readFile(fileURLToPath(m.url), 'utf8'); await exec('BEGIN'); try { await exec(sql); await exec('INSERT INTO public.schema_migrations (id, plugin) VALUES ($1, $2)', [id, m.plugin]); await exec('COMMIT'); } catch (e) { await exec('ROLLBACK'); throw e; } } catch (e) { throw new Error(`Migration ${id} fehlgeschlagen: ${e.message}`); } applied.push(id); log.log?.(`migrate: angewandt ${id}`); } return { applied, skipped, pending: false }; } finally { await close(); } } // Default executor: one pg client over databaseUrl. `pg` is imported lazily so a // DB-less instance never pulls it in at runtime. async function pgExecutor(databaseUrl) { let pg; try { pg = (await import('pg')).default; } catch { throw new Error("migrate: Paket 'pg' fehlt (npm i pg) — für Plugin-Migrationen nötig."); } const client = new pg.Client({ connectionString: databaseUrl }); await client.connect(); const exec = async (text, params) => (await client.query(text, params)).rows; return { exec, close: () => client.end() }; }