Skip to content

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:

  1. drizzle-kit pro App-Workspace — jede App hat ihren eigenen drizzle.config.ts + drizzle/migrations/. Migrations werden committed wie SQL-Files in Rails/Django.
  2. Hard Boot-GaterunProdApp validiert beim Start dass alle Migrations applied sind und alle erwarteten Tabellen existieren. Drift = Boot-Error (kein Auto-Heal).
  3. Auto-Rebuild für Projection-Schema-Changesmigrate generate erkennt welche Tabellen Projections sind, schreibt einen Rebuild- Marker, migrate apply ruft rebuildProjection fü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

FrameworkDev-WorkflowProd-WorkflowMigration-Files?
Prismaprisma db push — direktprisma migrate deployNur Prod
Drizzle-kitdrizzle-kit push — direktdrizzle-kit migrateNur Prod
Djangomakemigrations + migrateGleichImmer
Railsrails db:migrateGleichImmer
Kumikokumiko migrate generatekumiko migrate applyImmer (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.json

Die 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 ...).

Terminal window
yarn kumiko migrate generate-schema # nur drizzle/schema.generated.ts regen
yarn kumiko migrate generate # generate-schema + drizzle-kit generate + rebuild-marker
yarn kumiko migrate apply # drizzle-kit migrate + auto-rebuild für Projection-Changes
yarn 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:

  1. 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 tableExists für jede gelistete Tabelle
    • Fehlende Tabellen? → Boot-Error
  2. 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-Config
pre_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_successfully

Kubernetes

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 generate
drizzle/migrations/0042_brave_taskmaster__rebuild.json ← migration-hooks.ts

Marker-Inhalt:

{
"schemaVersion": 1,
"migrationTag": "0042_brave_taskmaster",
"projections": ["publicstatus:projection:component-entity"]
}

Beim migrate apply:

  1. drizzle-kit migrate fährt SQL-Migrations und tracked sie in __drizzle_migrations
  2. CLI berechnet welche Migrations neu applied wurden (Diff Journal vs. __drizzle_migrations Count)
  3. Für jede neu applied Migration: lese <tag>__rebuild.json
  4. Sammle Projection-Namen, rufe rebuildProjection(name, deps) für jede
  5. 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 migrate macht das auch nicht; wer es braucht, kann periodisch drizzle-kit pull gegen 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.ts als load-bearing test.
  • tenantContext in applyEntityEvent-Signatur erzwingen — heute verlässt sich die Tenant-Isolation darauf dass der Caller (EventStore- Executor) vorher loadById (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: expliziter expectedTenantId-Parameter, wirft bei Mismatch. Heute nur durch Header-Comment dokumentiert.

Test-Workflows (Verifikation)

Standard-Szenarien die geprüft sind:

  1. Frische DB: migrate apply legt alle Tabellen an, Boot-Gate grün
  2. Schema-Diff entity: Field hinzu → migrate generate schreibt Migration + Rebuild-Marker, migrate apply fährt ALTER TABLE + automatisch rebuildProjection, Boot-Gate grün
  3. Drift-Detection: Manuell DROP TABLE → migrate validate exit 1
  4. Live==Rebuild: Integration-Test in db/__tests__/
  5. Idempotente Re-Runs: zweiter migrate apply no-op
  6. Boot-Gate: App ohne migrate apply → SchemaDriftError im Log

Implementation

  • packages/framework/src/migrations/ — Schema-Drift-Detection, Snapshot-Diff, Rebuild-Marker-IO
  • packages/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 pro r.entity
  • packages/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+Generator
  • bin/kumiko.ts migrate — CLI-Orchestrierung (per-app cwd, bun-mode drizzle-kit)