#!/usr/bin/env node // Spielt ausstehende Migrationen ein — und führt Buch darüber. // // ═══ Warum ein eigener Läufer und nicht `supabase db push` ═══ // // Zwei Gründe, beide praktisch: // // 1. **Er braucht nur `pg`.** Das Paket liegt ohnehin im Projekt. Die // Supabase-CLI müsste im Runner-Container bei jedem Lauf heruntergeladen // werden, und ihre Version driftet unabhängig von diesem Repository. // 2. **Er tut genau eine Sache.** `db push` vergleicht Schemata, erzeugt // Diffs und kann bei einer Abweichung mehr tun als nur die fehlenden // Dateien anzuwenden. Beim automatischen Ausrollen ist „nur die fehlenden // Dateien, in Reihenfolge, jede in ihrer eigenen Transaktion" genau das // gewünschte Verhalten und nichts darüber hinaus. // // Die Buchführung liegt bewusst in `supabase_migrations.schema_migrations` — // derselben Tabelle in derselben Form, die die CLI benutzt. Damit bleibt // `supabase db push` weiterhin möglich, etwa von der Arbeitsstation aus: es // sieht dieselben Einträge und überspringt, was hier schon lief. // // ═══ 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 65 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 = "supabase/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; ausserdem zeigte sie bei Supabase auf den Transaktions-Pooler, der // mehrteilige Transaktionen nicht mitmacht. // // `SUPABASE_DB_URL` heisst nach dem Umzug auf einen eigenen Container nicht // mehr, was es ist — der Name bleibt als Rückfallebene, damit bestehende // .env-Dateien und Arbeitsstationen weiterlaufen. const verbindung = process.env.MIGRATE_DATABASE_URL || process.env.SUPABASE_DB_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 { // Dieselbe Form, die die Supabase-CLI anlegt und erwartet. await client.query(`create schema if not exists supabase_migrations`); await client.query(` create table if not exists supabase_migrations.schema_migrations ( version text primary key, statements text[], name text )`); const verbucht = new Set( (await client.query(`select version from supabase_migrations.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 supabase_migrations.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 supabase_migrations.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(); }