Zum Inhalt springen

State Machine

Status: Offen

Bereits als r.stateMachine() in features/planned.md erwaehnt. Hier die konkrete Ausarbeitung mit 2 Umsetzungswegen.

Bedarf aus den Samples

SampleEntityStatesKomplexitaet
beammycarorder10 StatesBerechnet aus Sub-Entity (driverOrder) States
beammycardriverOrder9 StatesLinearer Flow mit Validierung pro Transition
beammycarinvoice4 StatesEinfach linear (draft → sent → paid → cancelled)
mietnomadebillingPeriod5 StatesLinear mit Ruecksprung (review → in_progress)
mietnomadeprotocol4 StatesLinear, Transition braucht Validierung (sign → Pflichtfelder)

Muster

  1. Einfache lineare Kette: invoice, protocol — A → B → C, kein Zurueck
  2. Linear mit Ruecksprung: billingPeriod — review → in_progress (Korrekturen)
  3. Komplexe Berechnung: order.state wird aus driverOrder-States berechnet, nicht direkt gesetzt
  4. Validierung bei Transition: protocol.sign braucht Pflichtfelder, driverOrder.completePickup braucht Protokoll
  5. 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 driverOrder
r.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 erlaubt

Vorteile

  • 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

KriteriumWeg A (r.stateMachine)Weg B (WriteHandler Pattern)
Implementierungsaufwand FrameworkHoch (neues Sub-System)Niedrig (2 Utility-Funktionen)
Boilerplate pro FeatureNiedrig (deklarativ)Mittel (Handler pro Transition)
FlexibilitaetMittel (Guards/Hooks)Hoch (voller Handler-Zugriff)
Boot-ValidierungJa (Dead State, Unreachable)Nein
UI-IntegrationJa (erlaubte Transitions bekannt)Nein (manuell)
Computed StatesEigenes Sub-SystempostSave Hook (existiert)
LernkurveNeue APIBestehende API
TestbarkeitTransitions testbar als ConfigHandler 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:

  1. Weg B ist in 1h implementiert — 2 Funktionen, fertig
  2. Computed States brauchen bei Weg A ein eigenes Sub-System, bei Weg B ist es ein postSave Hook
  3. Guards mit Daten-Aenderungen (completePickup setzt timestamp UND State) passen besser in Handler
  4. Wenn sich Patterns stabilisieren → r.stateMachine() als Sugar ueber dem Pattern bauen (Weg A wird Weg B unter der Haube)