Transactions + Two-Phase Hooks
Kernentscheidung
Jeder /api/write und /api/batch laeuft in einer echten DB-Transaktion. Hooks sind in zwei Phasen unterteilt: inTransaction (rollt mit zurueck) und afterCommit (feuert erst nach Commit).
Warum zwei Phasen
Das Dual-Write-Problem: Nicht alle Nebeneffekte sind gleichwertig.
| Side-Effect | Phase | Warum |
|---|---|---|
| Audit-Row in DB | inTransaction | Muss atomar mit der Aenderung sein — kein Audit ohne Daten, keine Daten ohne Audit |
| Counter/Inventar-Update | inTransaction | DB-Write, soll konsistent rollen |
| Abhaengige Entity-Writes | inTransaction | Gleiche Atomicitaets-Garantie |
| SSE-Broadcast | afterCommit | Externes System, nicht zurueckrollbar |
| Search-Index (Meilisearch) | afterCommit | Externes System, Inkonsistenz sonst |
| Email-Versand | afterCommit | Email ist weg wenn gesendet |
| BullMQ Job-Trigger | afterCommit | Job darf nicht fuer rolled-back Entity laufen |
| Event-Log (Redis Stream) | afterCommit | Stream-Event soll committed State reflektieren |
Vorbild: Rails (after_save vs after_commit), Django (post_save vs transaction.on_commit).
API
// Default: afterCommit (externes System, best-effort, Fehler werden geloggt)r.hook("postSave", handler, async (result, ctx) => { /* ... */ });
// Opt-in: inTransaction (DB-Writes, atomar, Fehler rollen zurueck)r.hook("postSave", handler, async (result, ctx) => { await ctx.db.insert(auditTable).values({...});}, { phase: HookPhases.inTransaction });preDelete hat keine Phase-Option — es laeuft immer inTransaction (sonst waere der Pre-Hook sinnlos).
Fehler-Semantik
| Phase | Fehler in Hook | Effekt |
|---|---|---|
inTransaction | wird rethrown | Transaktion rollt zurueck, Batch failed, afterCommit feuert nicht |
afterCommit | wird geloggt | Nachfolgende Hooks laufen weiter, Batch bleibt erfolgreich |
Batch-Endpoint
POST /api/batch{ "commands": [ { "type": "user:write:user:create", "payload": { ... } }, { "type": "order:write:order:create", "payload": { ... } } ], "requestId": "uuid-for-retry-idempotency"}Alle Commands laufen in einer Transaktion. Schlaegt Command 3 fehl, rollen 1+2 zurueck. Bei Erfolg feuern alle afterCommit-Hooks aller Commands nacheinander.
Idempotency: Gleiche requestId im Retry → gecachtes BatchResult kommt zurueck, Commands laufen nicht erneut. Sowohl Erfolge als auch Failures werden gecached (TTL 300s).
Konsequenzen fuer Handler-Dev
ctx.dbist innerhalb von Handlern und inTransaction-Hooks immer die tx-scoped TenantDb. Keine Sonderfaelle.- Handler duerfen throw — das rollt die Transaktion korrekt zurueck.
- Nested Writes (Handler A ruft
dispatcher.write()intern) → Postgres Savepoints via Drizzle’stx.transaction(). Funktioniert, aber sparsam nutzen. afterCommit-Hooks sehen zwarctx.db, sollten aber keine DB-Writes darueber machen — das wuerde ausserhalb der Transaktion laufen und widerspricht dem Design.
Performance-Tradeoff
Jeder Write = BEGIN + COMMIT extra. Bei Postgres sind das ~0.1ms pro Write (lokal). Fuer die Atomic-Audit-Garantie ist das akzeptabel. Wer massenhaft writes braucht, nutzt POST /api/batch mit mehreren Commands in einer Transaktion.
Implementation
| Was | Wo |
|---|---|
HookPhase Type + Defaults | engine/types/hooks.ts |
| Phase-filtering in Registry | engine/registry.ts (filterByPhase) |
| Phase-Routing in Lifecycle-Pipeline | pipeline/lifecycle-pipeline.ts |
BatchResult, BatchRollback, runBatch | pipeline/dispatcher.ts |
| Route | api/routes.ts (POST /api/batch) |
| Tests | api/__tests__/batch.integration.ts, engine/__tests__/hook-phases.test.ts, pipeline/__tests__/lifecycle-pipeline.test.ts |
Referenz-Saetze (nicht verwechseln)
- Hook-Phase (
HookPhase): “Wann feuert dieser Hook — drin in der Transaktion oder danach?” - Handler-Typ (
write/query/command): “Was ist die Intent der Operation?” - Lifecycle-Event (
preSave/postSave/…): “An welchem Punkt im Handler-Lifecycle?”
Die drei sind orthogonal.