State Machine
Status: Offen
Bereits als r.stateMachine() in features/planned.md erwaehnt. Hier die konkrete Ausarbeitung mit 2 Umsetzungswegen.
Bedarf aus den Samples
| Sample | Entity | States | Komplexitaet |
|---|---|---|---|
| beammycar | order | 10 States | Berechnet aus Sub-Entity (driverOrder) States |
| beammycar | driverOrder | 9 States | Linearer Flow mit Validierung pro Transition |
| beammycar | invoice | 4 States | Einfach linear (draft → sent → paid → cancelled) |
| mietnomade | billingPeriod | 5 States | Linear mit Ruecksprung (review → in_progress) |
| mietnomade | protocol | 4 States | Linear, Transition braucht Validierung (sign → Pflichtfelder) |
Muster
- Einfache lineare Kette: invoice, protocol — A → B → C, kein Zurueck
- Linear mit Ruecksprung: billingPeriod — review → in_progress (Korrekturen)
- Komplexe Berechnung: order.state wird aus driverOrder-States berechnet, nicht direkt gesetzt
- Validierung bei Transition: protocol.sign braucht Pflichtfelder, driverOrder.completePickup braucht Protokoll
- Side Effects: State-Wechsel triggert Hooks (SSE Broadcast, Search Update, Audit)
Was eine State Machine koennen muss
- Erlaubte Transitions definieren
- Validierung pro Transition (Guard-Funktion)
- Side Effects nach Transition (Hook-Integration)
- State-Feld ist read-only (nur via Transition aenderbar)
- Berechnete States (order aus driverOrders) — Sonderfall
Weg A: r.stateMachine() als Registrar-Methode
State Machine als First-Class Feature des Registrars. Definiert Transitions deklarativ, generiert WriteHandler automatisch.
API
r.entity("driverOrder", { fields: { state: { type: "select", options: DRIVER_ORDER_STATES, default: "pending" }, // ... andere Felder },});
r.stateMachine("driverOrder", { field: "state", transitions: { pending: ["accepted", "rejected"], accepted: ["pickup_started"], pickup_started: ["pickup_completed"], pickup_completed: ["on_the_way"], on_the_way: ["arrived"], arrived: ["delivery_started"], delivery_started: ["delivery_completed"], }, guards: { pickup_completed: async (ctx) => { // Protokoll muss existieren const protocol = await ctx.db.query("handoverProtocol", { where: { driverOrderId: ctx.id, type: "pickup" }, }); if (!protocol?.completedAt) throw new ValidationError("protocol_required"); }, }, hooks: { delivery_completed: async (ctx) => { // Order-State neu berechnen await recalculateOrderState(ctx); }, },});Was der Registrar generiert
// Auto-generierte WriteHandler pro Transition:// driverOrder.accept → state: pending → accepted// driverOrder.reject → state: pending → rejected// driverOrder.startPickup → state: accepted → pickup_started// ...
// Jeder Handler:// 1. Prueft ob aktuelle State in den erlaubten From-States ist// 2. Ruft Guard auf (wenn definiert)// 3. Setzt neuen State// 4. Ruft Hook auf (wenn definiert)// 5. Standard Lifecycle Pipeline (postSave → SSE, Search, Audit)Berechneter State (order)
r.stateMachine("order", { field: "state", computed: { // Kein transitions-Objekt — State wird berechnet, nicht direkt gesetzt calculate: async (ctx) => { const driverOrders = await ctx.db.query("driverOrder", { where: { orderId: ctx.id }, }); return calculateOrderState(driverOrders); }, // Wird getriggert wenn sich ein driverOrder aendert triggerOn: ["driverOrder.postSave"], },});Vorteile
- Deklarativ: Transitions auf einen Blick sichtbar
- Auto-generierte Handler: Kein Boilerplate fuer einfache Transitions
- Guards: Validierung sauber getrennt von Transition-Logik
- Boot-Validierung: Framework kann pruefen ob alle States erreichbar sind (Dead State Detection)
- Computed States: Sonderfall explizit modelliert
- UI-Integration: Framework kennt erlaubte Transitions → UI kann Buttons automatisch rendern
Nachteile
- Neues Konzept: Noch eine Registrar-Methode, mehr API-Surface
- Rigide: Wenn eine Transition Daten aendern muss (nicht nur State), braucht man trotzdem einen Handler
- Komplexitaet: Computed States + triggerOn ist ein eigenes Sub-System
- Handler-Naming: Auto-generierte Handler-Namen (driverOrder.accept) koennten mit manuellen kollidieren
Weg B: Transition als WriteHandler-Pattern (Convention)
Keine neue Registrar-Methode. State Machines werden als Pattern mit bestehenden WriteHandlern gebaut. Ein Helper validiert Transitions.
API
import { defineTransitions, guardTransition } from "@kumiko/framework";
const DRIVER_ORDER_TRANSITIONS = defineTransitions({ pending: ["accepted", "rejected"], accepted: ["pickup_started"], pickup_started: ["pickup_completed"], pickup_completed: ["on_the_way"], on_the_way: ["arrived"], arrived: ["delivery_started"], delivery_started: ["delivery_completed"],});
// In der Feature-Definition:r.writeHandler("driverOrder.accept", { input: z.object({ id: z.string() }), handler: async (ctx) => { const current = await ctx.db.findById("driverOrder", ctx.input.id); guardTransition(DRIVER_ORDER_TRANSITIONS, current.state, "accepted"); return ctx.db.update("driverOrder", ctx.input.id, { state: "accepted" }); },});
r.writeHandler("driverOrder.completePickup", { input: z.object({ id: z.string() }), handler: async (ctx) => { const current = await ctx.db.findById("driverOrder", ctx.input.id); guardTransition(DRIVER_ORDER_TRANSITIONS, current.state, "pickup_completed");
// Custom Guard const protocol = await ctx.db.query("handoverProtocol", { ... }); if (!protocol?.completedAt) throw new ValidationError("protocol_required");
return ctx.db.update("driverOrder", ctx.input.id, { state: "pickup_completed" }); },});Berechneter State (order)
// postSave Hook auf driverOrderr.hook("postSave", "driverOrder", async (ctx) => { const orderId = ctx.data.orderId; const driverOrders = await ctx.db.query("driverOrder", { where: { orderId } }); const newState = calculateOrderState(driverOrders); await ctx.db.update("order", orderId, { state: newState });});Was das Framework liefert
// Nur 2 Utility-Funktionen:
function defineTransitions(map: Record<string, string[]>): TransitionMap;// → Typisiert, validiert bei Boot dass alle States konsistent sind
function guardTransition(map: TransitionMap, from: string, to: string): void;// → Wirft TransitionError("invalid_transition", { from, to, allowed }) wenn nicht erlaubtVorteile
- Kein neues Konzept: Nutzt bestehende WriteHandler + Hooks
- Flexibel: Jeder Handler kann beliebige Logik haben (Daten aendern, Guards, Side Effects)
- Keine Kollisionen: Handler-Namen sind explizit, nicht auto-generiert
- Einfach zu implementieren: 2 Utility-Funktionen, kein neues Sub-System
- Schrittweise adoptierbar: Kann ohne Migration von bestehenden Handlern genutzt werden
Nachteile
- Boilerplate: Jede Transition braucht eigenen Handler mit guardTransition-Aufruf
- Kein Auto-UI: Framework weiss nicht welche Transitions erlaubt sind → UI muss manuell gebaut werden
- Boot-Validierung: Framework kann nicht automatisch pruefen ob alle States erreichbar sind
- Convention over Configuration: Entwickler muss Pattern kennen und einhalten
Vergleich
| Kriterium | Weg A (r.stateMachine) | Weg B (WriteHandler Pattern) |
|---|---|---|
| Implementierungsaufwand Framework | Hoch (neues Sub-System) | Niedrig (2 Utility-Funktionen) |
| Boilerplate pro Feature | Niedrig (deklarativ) | Mittel (Handler pro Transition) |
| Flexibilitaet | Mittel (Guards/Hooks) | Hoch (voller Handler-Zugriff) |
| Boot-Validierung | Ja (Dead State, Unreachable) | Nein |
| UI-Integration | Ja (erlaubte Transitions bekannt) | Nein (manuell) |
| Computed States | Eigenes Sub-System | postSave Hook (existiert) |
| Lernkurve | Neue API | Bestehende API |
| Testbarkeit | Transitions testbar als Config | Handler einzeln testbar |
Anforderungen aus den Samples
- Lineare Transitions (invoice: draft → sent → paid)
- Ruecksprung (billingPeriod: review → in_progress)
- Guard-Validierung (protocol.sign braucht Pflichtfelder)
- Berechneter State (order aus driverOrders)
- Side Effects nach Transition (SSE, Search, Audit — existiert via Pipeline)
- State ist read-only (nicht direkt via update aenderbar)
- Erlaubte Transitions abfragbar (fuer UI-Buttons)
Empfehlung
Weg B (Pattern) fuer den Start, mit Option auf Weg A spaeter. Gruende:
- Weg B ist in 1h implementiert — 2 Funktionen, fertig
- Computed States brauchen bei Weg A ein eigenes Sub-System, bei Weg B ist es ein postSave Hook
- Guards mit Daten-Aenderungen (completePickup setzt timestamp UND State) passen besser in Handler
- Wenn sich Patterns stabilisieren →
r.stateMachine()als Sugar ueber dem Pattern bauen (Weg A wird Weg B unter der Haube)