Deploy from a push, and start keeping track of migrations
A push to master now builds and restarts the application on the server: a Gitea Actions workflow on a self-hosted runner writes .env from the repository secrets, applies pending migrations, rebuilds the compose stack against the host's Docker daemon, and waits for the container's healthcheck before calling the run green. Without that last step a deploy counts as successful the moment the container *starts*, even if the app inside it dies immediately. Switching migrations on automatically turned up something that had to be fixed first: supabase_migrations.schema_migrations did not exist at all. Every one of the 65 migrations was unrecorded, because they have been applied by hand all along. An automatic `db push` would therefore have replayed all 65 against the live database — initial_schema and the OM cutover included. The database was checked against a spread of migrations first (it is at head), then baselined: all 65 recorded as applied without executing them. The runner is scripts/migrate.mjs rather than the Supabase CLI. It needs only `pg`, which the project already ships, instead of downloading a CLI whose version drifts independently of this repository; and it does one thing — the missing files, in order, each in its own transaction — where `db push` also diffs schemas and may do more than that. Bookkeeping goes in the same table in the same shape the CLI uses, so `supabase db push` from a workstation still works and still skips what already ran. The workflow lives in .github/workflows, not .gitea/. Gitea reads .gitea/workflows and falls back to .github/workflows only when the former is absent — creating .gitea/ would have silently switched off ci.yml, with the run simply never appearing. Verified: both workflow files parse; the secret check names what is missing and refuses; values starting with "-" or containing "=" survive being written to .env; and the runner was exercised against the real database with a throwaway migration — applied once, skipped on a second run, and on a deliberate syntax error rolled back whole, recording nothing. Both probes were removed; the count is back to 65. Not verified: nothing has run on an actual Gitea runner — none is registered yet. DEPLOYMENT.md §5 covers registering one. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
142
.github/workflows/deploy.yml
vendored
Normal file
142
.github/workflows/deploy.yml
vendored
Normal file
@@ -0,0 +1,142 @@
|
||||
name: Deploy
|
||||
|
||||
# Warum diese Datei unter .github/workflows liegt und nicht unter .gitea/:
|
||||
#
|
||||
# Gitea Actions liest `.gitea/workflows`, **und nur wenn dieses Verzeichnis
|
||||
# fehlt**, ersatzweise `.github/workflows`. Ein neu angelegtes `.gitea/`
|
||||
# würde also ci.yml stillschweigend abschalten — der Lauf verschwände
|
||||
# einfach, ohne Fehlermeldung. Solange beide Dateien hier liegen, sieht Gitea
|
||||
# beide. (Auf GitHub bliebe dieser Workflow hängen: es gibt dort keinen
|
||||
# Runner mit dem Label `self-hosted`. Das Repository liegt aber ohnehin auf
|
||||
# git.elycon.solutions.)
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [master]
|
||||
|
||||
# Zwei Pushes kurz hintereinander sollen nicht zwei Deploys übereinander
|
||||
# fahren. Der laufende wird **nicht** abgebrochen — mitten im `docker compose
|
||||
# up` abgeschnitten zu werden ist der eine Zustand, den man nicht will.
|
||||
concurrency:
|
||||
group: deploy-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
name: Migrationen und Container
|
||||
runs-on: self-hosted
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
# Nur was der Migrationsläufer braucht (`pg`), nicht der ganze Baum:
|
||||
# gebaut wird die Anwendung im Container, nicht hier.
|
||||
- name: Abhängigkeiten
|
||||
run: npm ci --omit=dev --ignore-scripts
|
||||
|
||||
# ── .env aus den Gitea-Secrets ──────────────────────────────────
|
||||
#
|
||||
# Nicht die .env vom Server lesen: der Job läuft in einem eigenen
|
||||
# Container, und dessen Dateisystem ist nicht das des Hosts. Die Werte
|
||||
# kommen deshalb aus den Repository-Secrets und werden hier
|
||||
# zusammengesetzt. Sie landen in keinem Abbild — docker compose reicht
|
||||
# sie zur Laufzeit an den Container weiter.
|
||||
- name: .env schreiben
|
||||
env:
|
||||
DATABASE_URL: ${{ secrets.DATABASE_URL }}
|
||||
SUPABASE_DB_URL: ${{ secrets.SUPABASE_DB_URL }}
|
||||
AUTH_SECRET: ${{ secrets.AUTH_SECRET }}
|
||||
AUTH_URL: ${{ secrets.AUTH_URL }}
|
||||
AUTH_MICROSOFT_ENTRA_ID_ID: ${{ secrets.AUTH_MICROSOFT_ENTRA_ID_ID }}
|
||||
AUTH_MICROSOFT_ENTRA_ID_SECRET: ${{ secrets.AUTH_MICROSOFT_ENTRA_ID_SECRET }}
|
||||
AUTH_MICROSOFT_ENTRA_ID_ISSUER: ${{ secrets.AUTH_MICROSOFT_ENTRA_ID_ISSUER }}
|
||||
CRON_SECRET: ${{ secrets.CRON_SECRET }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
fehlend=""
|
||||
for name in DATABASE_URL SUPABASE_DB_URL AUTH_SECRET AUTH_URL \
|
||||
AUTH_MICROSOFT_ENTRA_ID_ID AUTH_MICROSOFT_ENTRA_ID_SECRET \
|
||||
AUTH_MICROSOFT_ENTRA_ID_ISSUER CRON_SECRET; do
|
||||
eval "wert=\${$name:-}"
|
||||
[ -n "$wert" ] || fehlend="$fehlend $name"
|
||||
done
|
||||
if [ -n "$fehlend" ]; then
|
||||
echo "Diese Secrets fehlen im Repository (Settings → Actions → Secrets):$fehlend"
|
||||
exit 1
|
||||
fi
|
||||
# printf statt echo: ein Wert, der mit - beginnt, wäre sonst ein Schalter.
|
||||
{
|
||||
printf 'DATABASE_URL=%s\n' "$DATABASE_URL"
|
||||
printf 'AUTH_SECRET=%s\n' "$AUTH_SECRET"
|
||||
printf 'AUTH_URL=%s\n' "$AUTH_URL"
|
||||
printf 'AUTH_MICROSOFT_ENTRA_ID_ID=%s\n' "$AUTH_MICROSOFT_ENTRA_ID_ID"
|
||||
printf 'AUTH_MICROSOFT_ENTRA_ID_SECRET=%s\n' "$AUTH_MICROSOFT_ENTRA_ID_SECRET"
|
||||
printf 'AUTH_MICROSOFT_ENTRA_ID_ISSUER=%s\n' "$AUTH_MICROSOFT_ENTRA_ID_ISSUER"
|
||||
printf 'CRON_SECRET=%s\n' "$CRON_SECRET"
|
||||
} > .env
|
||||
chmod 600 .env
|
||||
|
||||
# ── Migrationen ─────────────────────────────────────────────────
|
||||
#
|
||||
# Vor dem Neustart, nicht danach: der neue Code erwartet das neue
|
||||
# Schema. Umgekehrt liefe die neue Anwendung kurz gegen das alte und
|
||||
# fiele über fehlende Spalten.
|
||||
#
|
||||
# Erst zeigen, was ansteht — das steht dann im Protokoll des Laufs, auch
|
||||
# wenn danach etwas schiefgeht.
|
||||
- name: Ausstehende Migrationen zeigen
|
||||
env:
|
||||
SUPABASE_DB_URL: ${{ secrets.SUPABASE_DB_URL }}
|
||||
run: node scripts/migrate.mjs --dry-run
|
||||
|
||||
- name: Migrationen anwenden
|
||||
env:
|
||||
SUPABASE_DB_URL: ${{ secrets.SUPABASE_DB_URL }}
|
||||
run: node scripts/migrate.mjs
|
||||
|
||||
# ── Container ───────────────────────────────────────────────────
|
||||
#
|
||||
# Läuft gegen den Docker-Dienst des Hosts (der Runner-Container hat
|
||||
# dessen Socket eingehängt). Der Projektname wird ausdrücklich gesetzt:
|
||||
# sonst leitet ihn Compose vom Verzeichnisnamen ab, und der ist im
|
||||
# Arbeitsverzeichnis des Runners ein anderer als bei der ersten
|
||||
# Installation von Hand — es entstünde ein zweiter Stapel daneben,
|
||||
# während der alte weiterläuft.
|
||||
- name: Bauen und starten
|
||||
env:
|
||||
COMPOSE_PROJECT_NAME: ${{ vars.COMPOSE_PROJECT_NAME || 'alpenwerk-hr' }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
docker compose build
|
||||
docker compose up -d --remove-orphans
|
||||
|
||||
# ── Nachweis ────────────────────────────────────────────────────
|
||||
#
|
||||
# Ohne diesen Schritt gilt ein Deploy als erfolgreich, sobald der
|
||||
# Container *gestartet* ist — auch wenn die Anwendung darin sofort
|
||||
# abstürzt. Gewartet wird auf den Healthcheck aus dem Dockerfile
|
||||
# (GET /login), nicht auf „läuft".
|
||||
- name: Warten, bis die Anwendung antwortet
|
||||
env:
|
||||
COMPOSE_PROJECT_NAME: ${{ vars.COMPOSE_PROJECT_NAME || 'alpenwerk-hr' }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
for versuch in $(seq 1 30); do
|
||||
zustand=$(docker compose ps --format '{{.Health}}' app | head -1)
|
||||
case "$zustand" in
|
||||
healthy) echo "Gesund nach $versuch Versuchen."; exit 0 ;;
|
||||
unhealthy) echo "Container meldet unhealthy."; break ;;
|
||||
esac
|
||||
sleep 4
|
||||
done
|
||||
echo "Anwendung ist nicht gesund geworden. Letzte Ausgaben:"
|
||||
docker compose ps
|
||||
docker compose logs --tail 80 app
|
||||
exit 1
|
||||
|
||||
- name: Aufräumen
|
||||
if: always()
|
||||
run: rm -f .env
|
||||
144
DEPLOYMENT.md
144
DEPLOYMENT.md
@@ -219,10 +219,126 @@ Ohne diesen Wert baut Auth.js seine Rückruf-Adresse aus dem, was der
|
||||
Container sieht — und das ist hinter dem Proxy nicht der Name, den der
|
||||
Browser benutzt hat.
|
||||
|
||||
## 5. Updates ausrollen
|
||||
## 5. Automatisch ausrollen bei einem Push (Gitea Actions)
|
||||
|
||||
Ein Push auf `master` baut und startet die Anwendung auf dem Server neu.
|
||||
Zuständig ist [`.github/workflows/deploy.yml`](.github/workflows/deploy.yml).
|
||||
|
||||
> **Warum `.github/` und nicht `.gitea/`:** Gitea Actions liest
|
||||
> `.gitea/workflows` — und nur wenn dieses Verzeichnis **fehlt**, ersatzweise
|
||||
> `.github/workflows`. Ein neu angelegtes `.gitea/workflows/` würde also
|
||||
> `ci.yml` stillschweigend abschalten. Solange beide Dateien unter `.github/`
|
||||
> liegen, sieht Gitea beide.
|
||||
|
||||
### a) Runner registrieren
|
||||
|
||||
Der Runner läuft als Container **auf demselben Server** wie die Anwendung und
|
||||
bekommt den Docker-Socket des Hosts eingehängt — damit baut und startet er die
|
||||
Container des Hosts, statt welche in sich selbst zu erzeugen.
|
||||
|
||||
Registrierungs-Token holen: Gitea → Repository → *Settings* → *Actions* →
|
||||
*Runners* → *Create new runner*.
|
||||
|
||||
```bash
|
||||
mkdir -p /opt/gitea-runner && cd /opt/gitea-runner
|
||||
|
||||
docker run -d --name gitea-runner --restart unless-stopped \
|
||||
-e GITEA_INSTANCE_URL=https://git.elycon.solutions \
|
||||
-e GITEA_RUNNER_REGISTRATION_TOKEN=<token> \
|
||||
-e GITEA_RUNNER_NAME=alpenwerk-deploy \
|
||||
-e GITEA_RUNNER_LABELS=self-hosted,ubuntu-latest \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
-v /opt/gitea-runner:/data \
|
||||
gitea/act_runner:latest
|
||||
```
|
||||
|
||||
Zu den Labels: `self-hosted` verlangt der Deploy-Workflow, `ubuntu-latest` die
|
||||
bestehende `ci.yml`. Fehlt eines, bleibt der jeweilige Lauf ohne Fehlermeldung
|
||||
in der Warteschlange — **das ist der häufigste Grund, warum „nichts passiert"**.
|
||||
|
||||
> Der eingehängte Docker-Socket gibt dem Runner faktisch Wurzelrechte auf dem
|
||||
> Host. Das ist bei einem Deploy-Runner der Zweck der Übung, aber es heisst
|
||||
> auch: wer in dieses Repository schreiben darf, darf auf diesem Server alles.
|
||||
> Bei zwei Personen vertretbar; bei einem grösseren Kreis gehört der Runner auf
|
||||
> eine eigene Maschine, die sich per SSH verbindet.
|
||||
|
||||
### b) Secrets hinterlegen
|
||||
|
||||
Gitea → Repository → *Settings* → *Actions* → *Secrets*. Dieselben Werte wie in
|
||||
[Abschnitt 1](#1-env-anlegen); der Workflow baut daraus zur Laufzeit die `.env`
|
||||
und löscht sie danach wieder.
|
||||
|
||||
| Secret | Anmerkung |
|
||||
|---|---|
|
||||
| `DATABASE_URL` | Transaktions-Pooler (Supabase: 6543) — den benutzt die Anwendung |
|
||||
| `SUPABASE_DB_URL` | **Direkter** Zugang (Supabase: 5432) — nur für die Migrationen |
|
||||
| `AUTH_SECRET` | |
|
||||
| `AUTH_URL` | z. B. `https://hr.elycon.solutions` |
|
||||
| `AUTH_MICROSOFT_ENTRA_ID_ID` | |
|
||||
| `AUTH_MICROSOFT_ENTRA_ID_SECRET` | |
|
||||
| `AUTH_MICROSOFT_ENTRA_ID_ISSUER` | |
|
||||
| `CRON_SECRET` | |
|
||||
|
||||
Warum zwei Verbindungsstrings: der Transaktions-Pooler bricht bei einer
|
||||
Migration ab, die mehrere Anweisungen in einer Transaktion bündelt. Migrationen
|
||||
gehören über die direkte Verbindung, der laufende Betrieb über den Pooler.
|
||||
|
||||
Zusätzlich unter *Variables* (kein Secret, nur eine Einstellung):
|
||||
|
||||
| Variable | Vorgabe | Wofür |
|
||||
|---|---|---|
|
||||
| `COMPOSE_PROJECT_NAME` | `alpenwerk-hr` | Muss zum bestehenden Stapel passen — sonst entsteht ein zweiter daneben |
|
||||
|
||||
Den bestehenden Namen zeigt auf dem Server:
|
||||
|
||||
```bash
|
||||
docker compose ls
|
||||
```
|
||||
|
||||
### c) Was der Lauf tut
|
||||
|
||||
1. `.env` aus den Secrets schreiben (fehlt eines, bricht er mit Namen ab).
|
||||
2. **Migrationen anwenden** — erst `--dry-run` fürs Protokoll, dann echt.
|
||||
3. `docker compose build` und `up -d` gegen den Docker-Dienst des Hosts.
|
||||
4. Auf `healthy` warten (bis zu zwei Minuten). Wird die Anwendung nicht gesund,
|
||||
schlägt der Lauf fehl und hängt die letzten 80 Logzeilen an.
|
||||
5. `.env` wieder löschen.
|
||||
|
||||
Der Workflow ist **nicht** an `ci.yml` gekoppelt: ein Push auf `master` rollt
|
||||
aus, auch wenn die Tests parallel noch laufen. Soll erst nach grünen Tests
|
||||
ausgerollt werden, gehört der `deploy`-Job mit `needs: [check]` in `ci.yml` —
|
||||
dann allerdings braucht der Runner auch das Label, mit dem `check` läuft.
|
||||
|
||||
## 5a. Migrationen
|
||||
|
||||
Eingespielt werden sie von [`scripts/migrate.mjs`](scripts/migrate.mjs) —
|
||||
demselben Läufer, den auch der Deploy benutzt:
|
||||
|
||||
```bash
|
||||
node --env-file=.env scripts/migrate.mjs --dry-run # was stünde an
|
||||
node --env-file=.env scripts/migrate.mjs # anwenden
|
||||
```
|
||||
|
||||
Buch geführt wird in `supabase_migrations.schema_migrations`, derselben Tabelle
|
||||
in derselben Form, die die Supabase-CLI benutzt — `supabase db push` von der
|
||||
Arbeitsstation bleibt damit möglich und überspringt, was hier schon lief.
|
||||
|
||||
> **Einmalig, im August 2026 bereits erledigt:** Die Migrationen wurden bis
|
||||
> dahin von Hand eingespielt, die Buchführungstabelle existierte gar nicht. Ein
|
||||
> automatischer Lauf hätte deshalb alle 65 Dateien erneut gegen die
|
||||
> produktive Datenbank gespielt — inklusive `initial_schema` und der
|
||||
> OM-Umstellung. Vor dem Einschalten wurde einmal
|
||||
> `node scripts/migrate.mjs --baseline` gefahren: das verbucht alles Vorhandene
|
||||
> als angewendet, **ohne es auszuführen**. Wer eine weitere Umgebung aufsetzt,
|
||||
> deren Datenbank schon steht, braucht denselben Schritt.
|
||||
|
||||
## 5b. Updates von Hand ausrollen
|
||||
|
||||
Falls der Runner nicht läuft oder ein Stand ausser der Reihe gebraucht wird:
|
||||
|
||||
```bash
|
||||
git pull
|
||||
node --env-file=.env scripts/migrate.mjs
|
||||
docker compose build
|
||||
docker compose up -d
|
||||
```
|
||||
@@ -231,13 +347,8 @@ Kurzer Downtime-Moment beim Neustart des `app`-Containers ist bei dieser
|
||||
Single-Instance-Compose-Konfiguration normal. Für Zero-Downtime-Deployments
|
||||
wäre eine zweite Instanz + Load Balancer nötig (siehe Abschnitt 6).
|
||||
|
||||
Datenbank-Migrationen (`supabase/migrations/*.sql`) werden weiterhin über
|
||||
die Supabase CLI gegen das Supabase-Projekt gefahren, unabhängig vom
|
||||
App-Deployment:
|
||||
|
||||
```bash
|
||||
supabase db push
|
||||
```
|
||||
Der Migrationsschritt steht bewusst **vor** dem Neubau: der neue Code erwartet
|
||||
das neue Schema, und umgekehrt liefe die neue Anwendung kurz gegen das alte.
|
||||
|
||||
## 6. Hinweis bei mehreren Replicas
|
||||
|
||||
@@ -257,6 +368,23 @@ Single-Instance-Compose-Konfiguration ist das nicht nötig.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- **Push gemacht, aber kein Lauf startet:** meist die Labels. Der Lauf steht
|
||||
dann in Gitea unter *Actions* als „Waiting" — ohne Fehlermeldung, weil kein
|
||||
Runner das verlangte Label anbietet. `deploy.yml` verlangt `self-hosted`,
|
||||
`ci.yml` verlangt `ubuntu-latest`. Welche Labels registriert sind, zeigt
|
||||
Gitea → *Settings* → *Actions* → *Runners*; ändern lassen sie sich durch
|
||||
erneutes Registrieren.
|
||||
- **Deploy läuft, aber es entsteht ein zweiter Stapel:** `COMPOSE_PROJECT_NAME`
|
||||
passt nicht zum bestehenden. Auf dem Server `docker compose ls` — der dort
|
||||
gelistete Name gehört als Variable ins Repository. Bis dahin läuft die alte
|
||||
Instanz weiter und beansprucht Port 3000; die neue scheitert daran.
|
||||
- **`Cannot connect to the Docker daemon` im Deploy:** dem Runner-Container
|
||||
fehlt der Socket. Er braucht `-v /var/run/docker.sock:/var/run/docker.sock`.
|
||||
- **Migration schlägt im Deploy fehl:** der Lauf bricht ab, bevor Container
|
||||
angefasst werden — die alte Version läuft also weiter. Die fehlgeschlagene
|
||||
Datei wurde vollständig zurückgerollt und **nicht** verbucht; nach der
|
||||
Korrektur reicht ein erneuter Push.
|
||||
|
||||
- **Anmeldung endet auf `/login?error=…`:** die Umleitungs-URI in der
|
||||
Entra-Registrierung muss exakt
|
||||
`https://<host>/api/auth/callback/microsoft-entra-id` lauten. Steht die
|
||||
|
||||
148
scripts/migrate.mjs
Normal file
148
scripts/migrate.mjs
Normal file
@@ -0,0 +1,148 @@
|
||||
#!/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");
|
||||
|
||||
// SUPABASE_DB_URL ist der direkte Zugang (Port 5432, Sitzungsmodus).
|
||||
// DATABASE_URL zeigt in dieser Anwendung auf den Transaktions-Pooler (6543),
|
||||
// und der verträgt kein `create schema` in einer Transaktion mit mehreren
|
||||
// Anweisungen — Migrationen gehören deshalb über die direkte Verbindung.
|
||||
const verbindung = process.env.SUPABASE_DB_URL || process.env.MIGRATE_DATABASE_URL;
|
||||
if (!verbindung) {
|
||||
console.error(
|
||||
"SUPABASE_DB_URL fehlt. Erwartet wird der **direkte** Postgres-Zugang (bei Supabase Port 5432),\n" +
|
||||
"nicht der Transaktions-Pooler 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();
|
||||
}
|
||||
Reference in New Issue
Block a user