Supabase was only ever the host: the application has talked to PostgreSQL
directly through pg/Kysely for a while. So the move is mostly about
supplying what the platform used to supply.
Proved before building anything. All 65 migrations replay onto an empty
database, and the result matches production exactly — 183 columns, 25
policies, 68 indexes, 84 constraints, identical sets, no diff. The only
function missing from the rebuild turned out to matter, see below.
What the platform supplied, deploy/db-init now does:
- alpenwerk_app, explicitly NOBYPASSRLS. The whole access model is 21
RLS policies; a role that bypasses them would leave everything
working while showing too much, and nobody would notice.
- pgcrypto and pg_trgm. uuid-ossp was available on Supabase but is
used nowhere — no column default, no function calls uuid_generate_*.
- anon, authenticated and service_role as NOLOGIN placeholders. No
policy names them; they only carry grants the platform handed out,
and a data dump referencing them would fail to restore without them.
- A stub `auth` schema. The end state needs none of it — checked: no
foreign key, no policy, no column default refers to it. The June
2026 migrations do, and rewriting those would be falsifying history;
they describe what was true then.
The gap the comparison found: rls_auto_enable() and the ensure_rls event
trigger existed only in the running database, created by hand, in no
migration. That is the net which forces RLS on every newly created
table — the reason a forgotten policy yields an empty table instead of
an open one. A rebuild from migrations would silently not have had it:
everything works, and the next new table is unprotected. Now a migration
(20260819100000), verified by creating a table on the rebuild and
confirming RLS came on by itself.
Data moves separately, via scripts/umzug-von-supabase.sh: schema from
the migrations, then pg_dump --data-only --disable-triggers for the rows.
Without --disable-triggers every foreign key trips over load order. RLS
does not interfere — none of the 19 tables uses FORCE ROW LEVEL
SECURITY, so the owner writes through. The dump is deliberately left on
disk afterwards.
psql and node come from two `tools`-profile services rather than being
installed on the host, so the server needs nothing but Docker. The db
service publishes no port at all — reachable only inside the compose
network.
SUPABASE_DB_URL is renamed MIGRATE_DATABASE_URL, since after this it
describes something else entirely; the old name still works so existing
.env files keep running. Both were exercised, as was the error when
neither is set.
The deploy workflow is set to manual-only. Its preconditions were never
met — no secrets, and whether the job container can reach the host's
Docker daemon is untested — and failing on every push teaches people to
ignore red runs. It also needs updating for the new database service
before it could work at all.
Not verified: none of this has run in an actual container. There is no
Docker daemon on this machine. What is verified is the part that
decides whether it can work — the schema, on a real empty database.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
154 lines
6.0 KiB
JavaScript
154 lines
6.0 KiB
JavaScript
#!/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();
|
|
}
|