6.1 KiB
6.1 KiB
ADR-0004: Datenbank-Migrationsframework
- Status: accepted
- Datum: 2026-10-25
- Entscheider: CTO
- Betroffene Dokumente:
docs/architecture/v1.1.mdAbschnitt 6.2,db/README.md,services/admin-api/README.md
Kontext und Problemstellung
ProofedMesh wird mehrere PostgreSQL-Schemas betreiben: trust-postgres (Agent Registry, Sink Registry, Data Source Registry, Decisions, per_diem_rates, rate_change-Log, …) und expense-postgres (DB-App als Senke/Datenquelle). Beide müssen über Monate und Releases hinweg schema-sauber versioniert evolvieren — mit nachvollziehbarer Migrationshistorie, deterministischem Rollback und einem CI-Hook, der Breaking Changes vor dem Merge erwischt.
Entscheidungsoptionen
- Option A — golang-migrate: reine SQL-Migrations-Dateien (
NNNN_name.up.sql+NNNN_name.down.sql), CLI + Go-Library, läuft sowohl als init-container als auch programmgesteuert im App-Startup, keine Build-time-Codegen. - Option B — atlas: deklarative Zielschema-Definition in HCL, Migrationen werden auto-generiert, Diff/Lint-Features. Mächtiger, aber vendor-lock-in auf Atlas-DSL.
- Option C — Build-in migrations im Trust-API (eigene Mini-Lösung): rohe
database/sql-Statements in Go-Konstanten, einfacher Zähler inschema_migrations-Tabelle. Null externe Dependencies. - Option D — sqlc + goose: sqlc für typsichere Queries, goose für Migrations.
Entscheidung
Option A — golang-migrate.
Begründung
- Plattform-neutral. SQL-Migrations sind lesbar für jeden Datenbank-Admin ohne Go-Kenntnisse. Rollout kann als init-container ohne Go-Runtime laufen (
migrate/migrate:v4Container). - Reine SQL-Dateien sind in Git-Diffs selbsterklärend und erzeugen keine Build-time-Abhängigkeiten in CI.
- Dual-Modus: Dieselben Migrationen lassen sich manuell via CLI gegen die Prod-DB fahren (für kontrollierte Rollouts) oder beim App-Start automatisch anwenden (für Dev/Test). Keine zusätzlichen Werkzeuge nötig.
- Rollback: jede
up-Migration hat eindown-Pendant. Nicht alle Rollbacks sind sauber lösbar (Datenverlust bei DROP COLUMN), aber die Konvention zwingt uns, bewusst darüber nachzudenken. - Atlas (Option B) ist mächtiger, bringt aber eine zusätzliche DSL und einen Vendor-Lock-in — Overkill für unseren jetzigen Scope.
- Goose/sqlc (Option D) ist eine valide Alternative; wenn wir sqlc wegen typsicherer Queries nachrüsten, bleibt golang-migrate trotzdem der sinnvolle Partner. Sqlc ersetzt es nicht.
- Eigenbau (Option C) hat heute wenig Vorteile und morgen viel Wartung.
Konventionen
- Pfad:
db/<schema-name>/migrations/NNNN_description.{up,down}.sql. Erster Schema-Pfad:db/trust/migrations/. Später z.B.db/expense/migrations/. - Dateinamen: vierstellige Nummer,
_, Snake-Case-Beschreibung,.up.sql/.down.sql. Beispiel:0001_per_diem_rates.up.sql. - Migrationstabelle:
schema_migrations(default von golang-migrate), pro Datenbank. - Nummerierung ist monoton und niemals umgenummeriert. Eine fehlerhafte Migration wird durch eine neue Migration korrigiert, nicht durch Rewrite der History.
- Immutable Datenfelder: wo fachlich nötig, erzwingen Trigger im Schema die Unveränderbarkeit (siehe
per_diem_rates→BEFORE UPDATETrigger, der nurvalid_toundrate_change_attestation_refändern lässt). - Benötigte Postgres-Extensions (
btree_gist,pgcrypto, …) werden am Anfang der jeweiligenup.sqlperCREATE EXTENSION IF NOT EXISTS …eingezogen; nicht in einer separaten "setup"-Datei.
Ausführungsmodell
- Dev/Test: Trust-API führt beim Startup
migrate.Up()aus (Idempotenz garantiert durchschema_migrations-Tabelle). Das ist schnell und fehlerresistent für die interne Entwicklung. - Prod: Rollout via dediziertem Kubernetes-Job (
job-migrate-trust), der VOR dem Rolling-Update der trust-api-Pods läuft. Trust-API startet nur, wennschema_migrations.dirty = falseund die höchste Version mindestens die von der neuen Code-Version erwartete ist. Verhindert, dass neuer Code gegen altes Schema läuft. - Rollback-Prod: separater Job
job-migrate-trust-down, manuell getriggert. CTO-Freigabe als Prozess, nicht als Code-Schranke.
CI-Hook
Eine neue CI-Job-Gruppe db prüft:
- Lint:
sqlfluffoderpg_formatauf alle.sql(Formatierung + Basis-Syntax). - Dry-run: eine wegwerfbare Postgres-Instanz im CI-Job hochziehen,
migrate upausführen,migrate downausführen,migrate uperneut — garantiert, dass Up/Down-Paare konsistent sind. - Keine History-Rewrites: CI prüft, dass vorhandene Migrations-Dateien in einem PR nicht geändert werden (nur neue Nummern hinzukommen).
Konkrete Implementierung dieses CI-Jobs folgt separat — in diesem Heartbeat landet die Framework-Entscheidung + die erste Migration. Der CI-Job kommt parallel zum Go-CI-Job, den wir ohnehin brauchen.
Konsequenzen
Positive
- SQL-first, lesbar für Nicht-Go-Entwickler.
- Dual-Modus (CLI + Library) deckt Dev- und Prod-Workflows mit demselben Werkzeug.
- Keine zusätzliche DSL, keine Codegen-Pipeline.
- Rollback-Pendant pro Up-Migration zwingt zur Reversibilitätsüberlegung.
Negative / Trade-offs
- Keine automatische Diff-Erzeugung (im Gegensatz zu Atlas) — Migrationen schreibt man von Hand.
- Keine Typsicherheit zwischen Code und Schema — mit sqlc nachrüstbar, wenn der Bedarf entsteht.
- Für sehr große Teams könnte Atlas langfristig besser skalieren — rechtfertigt derzeit die Umstellung nicht.
Folgearbeiten / Open Points
- CI-Job
db(sqlfluff+ Up/Down-Dry-Run) als eigener Commit. - Operations-Dokumentation für Prod-Rollouts (Runbook
docs/ops/db-migration.md), sobald der erste Rollout ansteht. - Entscheidung, ob wir Immutability-Trigger per Default im Repo versionieren oder zusätzliche Row-Level-Security einziehen. Im
per_diem_rates-Fall klar: Triggers (siehe Migration 0001).
Links
db/README.mddb/trust/migrations/0001_per_diem_rates.{up,down}.sql- https://github.com/golang-migrate/migrate