Migrations-Strategie
Entscheidung (2026-04-12, refined 2026-04-27)
Per-App drizzle-kit, hart validiert beim Boot, mit Auto-Rebuild für Projections.
Drei zusammenhängende Mechaniken:
- drizzle-kit pro App-Workspace — jede App hat ihren eigenen
drizzle.config.ts+drizzle/migrations/. Migrations werden committed wie SQL-Files in Rails/Django. - Hard Boot-Gate —
runProdAppvalidiert beim Start dass alle Migrations applied sind und alle erwarteten Tabellen existieren. Drift = Boot-Error (kein Auto-Heal). - Auto-Rebuild für Projection-Schema-Changes —
migrate generateerkennt welche Tabellen Projections sind, schreibt einen Rebuild- Marker,migrate applyruftrebuildProjectionfür die betroffenen Projections nach drizzle-kit migrate.
Warum kein Runtime Auto-Migrate?
- Concurrency: 10 Nodes booten gleichzeitig → Race Conditions bei ALTER TABLE
- Kein Review: Entwickler sieht nicht was passiert bevor es passiert
- Risiko: Production-Deploys sollten reviewbar sein
- Standard: Prisma, Drizzle-kit, Django, Rails — alle nutzen CLI-Steps
Vergleich mit anderen Frameworks
| Framework | Dev-Workflow | Prod-Workflow | Migration-Files? |
|---|---|---|---|
| Prisma | prisma db push — direkt | prisma migrate deploy | Nur Prod |
| Drizzle-kit | drizzle-kit push — direkt | drizzle-kit migrate | Nur Prod |
| Django | makemigrations + migrate | Gleich | Immer |
| Rails | rails db:migrate | Gleich | Immer |
| Kumiko | kumiko migrate generate | kumiko migrate apply | Immer (committed) |
Dasselbe Drizzle-Modell: lokale Iteration via committed SQL-Files, Prod- Deploys reviewbar im PR.
Per-App-Setup
Jede App-Workspace bringt mit:
samples/showcases/<app>/ drizzle.config.ts — drizzle-kit config (hand-maintained, ~10 Zeilen) drizzle/ schema.ts — Re-Export-Barrel (statisch) schema.custom.ts — Framework-Infra + Bundle-Custom-Tables (hand-maintained) schema.generated.ts — Auto-gen aus run-config (regeneriert per kumiko migrate generate) generate.ts — Bun-Script das schema.generated.ts schreibt migration-hooks.ts — Bun-Script: write-rebuild-marker + run-rebuilds migrations/ — drizzle-kit generate-Output (committed) 0000_*.sql 0001_*.sql 0042__rebuild.json — Rebuild-Marker (wenn Projections betroffen) meta/_journal.json meta/0000_snapshot.jsonDie App-Konvention src/run-config.ts exportiert APP_FEATURES und
HAS_AUTH — beide Bootstrap-Wrappers (bin/main.ts, bin/server.ts)
und der Schema-Generator nutzen genau diese eine Source-of-Truth.
CLI-Commands
Alle Commands laufen im App-Workspace-CWD (yarn workspace <app> kumiko ...
oder cd samples/showcases/<app> && yarn kumiko ...).
yarn kumiko migrate generate-schema # nur drizzle/schema.generated.ts regenyarn kumiko migrate generate # generate-schema + drizzle-kit generate + rebuild-markeryarn kumiko migrate apply # drizzle-kit migrate + auto-rebuild für Projection-Changesyarn kumiko migrate validate # Schema-Drift-Check (DB vs. Journal/Snapshot)yarn kumiko migrate status # drizzle-kit check (Migration-Files konsistent?)yarn kumiko migrate drop # latest Migration löschen (Dev)Boot-Verhalten
runProdApp führt kein SQL aus. Beim Start läuft:
- Schema-Drift-Check (default; opt-out via
migrations: false):- Lade
__drizzle_migrations-Tabelle aus DB - Vergleiche mit
drizzle/migrations/meta/_journal.json - Pending Migrations? → Boot-Error mit Liste der pending Tags
- Lade letztes Snapshot, prüfe
tableExistsfür jede gelistete Tabelle - Fehlende Tabellen? → Boot-Error
- Lade
- Auf Erfolg: ApiEntrypoint wird gestartet, App nimmt Requests an
Boot-Error-Message:
[runProdApp] BOOT ABORTED — Schema drift detected: 1 unapplied migration(s): - 0042_brave_taskmaster Run 'yarn kumiko migrate apply' to bring the DB up-to-date.Pre-Deploy-Step (Container-Orchestrator)
migrate apply läuft im Pre-Deploy-Step des Container-Orchestrators —
nicht in CI (CI hat keine Prod-DB-Credentials, das wäre falsch). Der
Pre-Deploy-Step läuft im selben Trust-Boundary wie der App-Container,
hat dieselben Secrets, läuft sequenziell genau einmal pro Deploy.
Coolify (PublicStatus)
# in der Coolify-App-Configpre_deployment_command: "bun run /app/node_modules/.bin/kumiko migrate apply"Coolify spawned vor jedem Service-Rollout einen ephemeral Container mit
den App-Env-Vars (DATABASE_URL etc.), fährt migrate apply, dann erst
rollt der echte Service-Container.
Docker-Compose mit Init-Job
services: migrate: image: <app>:latest command: bun run /app/node_modules/.bin/kumiko migrate apply environment: DATABASE_URL: ${DATABASE_URL} restart: "no" app: depends_on: migrate: condition: service_completed_successfullyKubernetes
Init-Container oder vorgelagerter Job mit denselben Secrets.
Projection-Rebuild-Flow
Bei einer Schema-Änderung an einer Projection-Tabelle (incl. r.entity-
Tabellen, die als Implicit-Projection registriert sind) lässt
migrate generate einen Side-Marker entstehen:
drizzle/migrations/0042_brave_taskmaster.sql ← drizzle-kit generatedrizzle/migrations/0042_brave_taskmaster__rebuild.json ← migration-hooks.tsMarker-Inhalt:
{ "schemaVersion": 1, "migrationTag": "0042_brave_taskmaster", "projections": ["publicstatus:projection:component-entity"]}Beim migrate apply:
- drizzle-kit migrate fährt SQL-Migrations und tracked sie in
__drizzle_migrations - CLI berechnet welche Migrations neu applied wurden (Diff Journal vs.
__drizzle_migrationsCount) - Für jede neu applied Migration: lese
<tag>__rebuild.json - Sammle Projection-Namen, rufe
rebuildProjection(name, deps)für jede - Loggt eventsProcessed + durationMs pro Projection
Apps können den Auto-Rebuild deaktivieren indem sie keine
drizzle/migration-hooks.ts mitliefern. Dann muss kumiko project rebuild
manuell gefahren werden.
Implicit-Projection (Sprint G)
Jede r.entity(...)-Registration legt automatisch eine ImplicitProjection
mit Name <feature>:projection:<entity>-entity an. Das macht Entity-
Tabellen rebaubar (TRUNCATE + Replay über die Auto-Verb-Events
<entity>.created/.updated/.deleted/.restored) ohne dass Apps explizit
r.projection(...) schreiben müssen.
Garantie: Live==Rebuild-Equivalence by-construction. Beide Pfade
(EventStoreExecutor live, rebuildProjection im Replay) rufen dieselbe
applyEntityEvent-Funktion. Integration-Test
(db/__tests__/implicit-projection-equivalence.integration.ts) beweist
für eine repräsentative Sequenz von create/update/delete/restore + pinst
die bekannte Sensitive-Drift (siehe Backlog) als test.
Bekannte Drift bei sensitive-Feldern: das Event-Log strippt sensitive-Felder vor dem Append (GDPR-Annahme), die Live-Read-Tabelle bekommt sie über den unstripped flatData, der Rebuild aber nur den stripped event.payload. Bei Schema-Rebuild gehen sensitive Daten verloren (NULL in der rebuilt Row). Ist im Test pinst, Welle-3-Roadmap für separate sensitive-Spalten oder verschlüsseltes Event-Payload.
ImplicitProjections sind im kumiko project list standardmäßig
ausgeblendet (includeImplicit: true zum Anzeigen) — sie sind aber per
Name als Rebuild-Ziel adressierbar.
Backlog (Welle 3+)
- Versioned Projections (Marten-Style) — bei sehr großen Read-Modellen
Zero-Downtime-Rebuild via parallelen
<table>_v2-Tabellen + atomic switch. Nicht jetzt nötig (PublicStatus-Scale OK), aber als Sprint G+1 vermerkt. - information_schema-Diff im Boot-Gate — heute checken wir Journal-
Applied + tableExists. Spalten-Drift (manuelles ALTER TABLE) wird nicht
detektiert. Drizzle’s eigener
migratemacht das auch nicht; wer es braucht, kann periodischdrizzle-kit pullgegen die Prod-DB fahren und das Snapshot-Diff prüfen. extendSchema-Auto-Migration für Registrar-Extensions (JSONB-Add- Column zur Boot-Zeit) — bisher nicht implementiert.- Sensitive-Field-Persistenz im Rebuild — sensitive Felder gehen heute
beim Schema-Rebuild verloren (Event-Log gestrippt für GDPR). Optionen:
(a) verschlüsseltes Event-Payload mit per-Tenant-Keys, (b) separate
Sensitive-Spalten die NICHT durch rebuildProjection touched werden,
(c) Re-Issue-Pfad bei Rebuild (post-replay-Hook der die fehlenden
sensitive-Werte aus einer extra Quelle nachzieht). Pinst durch
implicit-projection-equivalence.integration.tsals load-bearing test. tenantContextinapplyEntityEvent-Signatur erzwingen — heute verlässt sich die Tenant-Isolation darauf dass der Caller (EventStore- Executor) vorherloadById(tenant-scoped) gemacht hat. Wenn eine zukünftige Erweiterung applyEntityEvent direkt aus einem neuen Pfad ruft ohne Pre-Check, ist Tenant-Bypass möglich. Sauber: expliziterexpectedTenantId-Parameter, wirft bei Mismatch. Heute nur durch Header-Comment dokumentiert.
Test-Workflows (Verifikation)
Standard-Szenarien die geprüft sind:
- Frische DB:
migrate applylegt alle Tabellen an, Boot-Gate grün - Schema-Diff entity: Field hinzu →
migrate generateschreibt Migration + Rebuild-Marker,migrate applyfährt ALTER TABLE + automatischrebuildProjection, Boot-Gate grün - Drift-Detection: Manuell DROP TABLE →
migrate validateexit 1 - Live==Rebuild: Integration-Test in
db/__tests__/ - Idempotente Re-Runs: zweiter
migrate applyno-op - Boot-Gate: App ohne
migrate apply→ SchemaDriftError im Log
Implementation
packages/framework/src/migrations/— Schema-Drift-Detection, Snapshot-Diff, Rebuild-Marker-IOpackages/framework/src/db/apply-entity-event.ts— Pure Schreib-Logik für Auto-Verben (live + rebuild gemeinsam)packages/framework/src/engine/registry.ts— Implicit-Projection- Auto-Registration pror.entitypackages/framework/src/pipeline/projections-runner.ts— Filter ImplicitProjections im Live-Apply-Pfad (sonst doppelter write)packages/dev-server/src/compose-features.ts— Single Source of Truth für Bootstrap+Generatorbin/kumiko.ts migrate— CLI-Orchestrierung (per-app cwd, bun-mode drizzle-kit)