#!/usr/bin/env node // Spielt ausstehende Migrationen ein — und führt Buch darüber. // // ═══ Warum ein eigener Läufer ═══ // // Zwei Gründe, beide praktisch: // // 1. **Er braucht nur `pg`.** Das Paket liegt ohnehin im Projekt. Jedes // zusätzliche Werkzeug müsste im Runner-Container bei jedem Lauf // heruntergeladen werden, und seine Version driftet unabhängig von // diesem Repository. // 2. **Er tut genau eine Sache.** Nur die fehlenden Dateien, in Reihenfolge, // jede in ihrer eigenen Transaktion. Beim automatischen Ausrollen ist das // genau das gewünschte Verhalten und nichts darüber hinaus. // // Die Buchführung liegt in `migrationen.schema_migrations`. // // ═══ Aufrufe ═══ // // node scripts/migrate.mjs ausstehende anwenden // node scripts/migrate.mjs --dry-run nur zeigen, was anstünde // node scripts/migrate.mjs --baseline alles als angewendet verbuchen, // **ohne** es auszuführen // // `--baseline` ist für den einen Fall gedacht, in dem eine Datenbank schon auf // dem Stand ist, die Buchführung aber fehlt — genau die Lage dieses Projekts // im August 2026, weil die Migrationen bis dahin von Hand eingespielt wurden. // Ohne diesen Schritt hielte der Läufer alle 67 für ausstehend und würde sie // gegen eine bereits migrierte Datenbank laufen lassen. import { readdirSync, readFileSync } from "node:fs"; import { join } from "node:path"; import pg from "pg"; const VERZEICHNIS = "db/migrations"; const argumente = new Set(process.argv.slice(2)); const nurZeigen = argumente.has("--dry-run"); const baseline = argumente.has("--baseline"); // Der **direkte** Zugang als Verwalter — nicht DATABASE_URL. // // DATABASE_URL gehört der Anwendungsrolle, und die darf bewusst kein Schema // ändern. const verbindung = process.env.MIGRATE_DATABASE_URL; if (!verbindung) { console.error( "MIGRATE_DATABASE_URL fehlt. Erwartet wird der **direkte** Zugang als Verwalter,\n" + "nicht die Verbindung der Anwendung aus DATABASE_URL." ); process.exit(1); } /** Dateiname → { version, name }; „20260818100000_offboarding.sql". */ function zerlegen(datei) { const punkt = datei.indexOf("_"); return punkt === -1 ? { version: datei.replace(/\.sql$/, ""), name: "" } : { version: datei.slice(0, punkt), name: datei.slice(punkt + 1).replace(/\.sql$/, "") }; } const dateien = readdirSync(VERZEICHNIS) .filter((f) => f.endsWith(".sql")) .sort(); const client = new pg.Client({ connectionString: verbindung, ssl: process.env.DATABASE_SSL === "false" ? undefined : { rejectUnauthorized: false }, }); await client.connect(); try { await client.query(`create schema if not exists migrationen`); await client.query(` create table if not exists migrationen.schema_migrations ( version text primary key, statements text[], name text )`); // ── Einmalige Übernahme der alten Buchführung ────────────────────── // // Bis zum Umzug lagen die Einträge in `supabase_migrations.schema_migrations`. // Ohne diese Übernahme fände der Läufer eine leere Tabelle vor, hielte alle // 67 Migrationen für ausstehend und spielte sie gegen eine Datenbank ein, // die längst auf dem Stand ist — mit einem Fehler beim ersten `create table`, // das es schon gibt. // // Übernommen wird nur in eine noch **leere** Tabelle: ein zweiter Lauf tut // nichts mehr. Auf einer frischen Datenbank gibt es die alte Tabelle nicht, // dann fällt die Abfrage ganz weg. const alteTabelle = ( await client.query(`select to_regclass('supabase_migrations.schema_migrations') is not null as da`) ).rows[0]?.da; if (alteTabelle) { const { rowCount } = await client.query(` insert into migrationen.schema_migrations (version, statements, name) select version, statements, name from supabase_migrations.schema_migrations where not exists (select 1 from migrationen.schema_migrations) on conflict (version) do nothing`); if (rowCount > 0) console.log(`${rowCount} Einträge aus der alten Buchführung übernommen.`); } const verbucht = new Set( (await client.query(`select version from migrationen.schema_migrations`)).rows.map((r) => r.version) ); const ausstehend = dateien.filter((f) => !verbucht.has(zerlegen(f).version)); if (ausstehend.length === 0) { console.log(`Nichts anzuwenden — ${verbucht.size} Migrationen verbucht, ${dateien.length} Dateien vorhanden.`); process.exit(0); } if (nurZeigen) { console.log(`${ausstehend.length} ausstehend:`); for (const f of ausstehend) console.log(" " + f); process.exit(0); } if (baseline) { // Nur verbuchen. Der Inhalt wird trotzdem mitgeschrieben, damit später // nachvollziehbar ist, welcher Text als angewendet galt. for (const datei of ausstehend) { const { version, name } = zerlegen(datei); const inhalt = readFileSync(join(VERZEICHNIS, datei), "utf8"); await client.query( `insert into migrationen.schema_migrations (version, statements, name) values ($1, $2, $3) on conflict (version) do nothing`, [version, [inhalt], name] ); } console.log(`${ausstehend.length} Migrationen als angewendet verbucht, ohne sie auszuführen.`); process.exit(0); } console.log(`${ausstehend.length} ausstehend, werden angewendet:`); for (const datei of ausstehend) { const { version, name } = zerlegen(datei); const inhalt = readFileSync(join(VERZEICHNIS, datei), "utf8"); const start = Date.now(); // Jede Datei in ihrer eigenen Transaktion: eine, die scheitert, hinterlässt // nichts Halbes, und die davor bleiben angewendet und verbucht. Der Lauf // bricht danach ab — weiterzumachen hiesse, auf einem Stand aufzubauen, // den es nicht gibt. await client.query("begin"); try { await client.query(inhalt); await client.query( `insert into migrationen.schema_migrations (version, statements, name) values ($1, $2, $3)`, [version, [inhalt], name] ); await client.query("commit"); console.log(` ok ${datei} (${Date.now() - start} ms)`); } catch (fehler) { await client.query("rollback"); console.error(` FEHLGESCHLAGEN ${datei}`); console.error(" " + (fehler instanceof Error ? fehler.message : String(fehler))); process.exit(1); } } console.log("Fertig."); } finally { await client.end(); }