PersistentFSM
PersistentFSM ist die event-sourced Variante von
FSM. Sie ist tabellengetrieben: Du
deklarierst eine Übergangstabelle, und jedes Command, das einen Übergang
auslöst, persistiert dessen Event(s). Beim Neustart spielt die FSM
diese Events durch applyEvent ab und setzt im exakten Zustand fort, in
dem sie war.
import { match } from 'ts-pattern';import { PersistentFSM } from 'actor-ts';import type { FsmStateData, FsmTransitionMap } from 'actor-ts';
type State = 'created' | 'submitted' | 'approved' | 'rejected';type Data = { id: string; reviewer?: string; reason?: string };type Command = | { kind: 'submit' } | { kind: 'approve'; reviewer: string } | { kind: 'reject'; reason: string };type Event = | { kind: 'submitted' } | { kind: 'approved'; reviewer: string } | { kind: 'rejected'; reason: string };
class OrderApproval extends PersistentFSM<Command, Event, State, Data> { readonly persistenceId: string;
constructor(private readonly orderId: string) { super(); this.persistenceId = `order-${orderId}`; }
initialFsmState(): State { return 'created'; } initialData(): Data { return { id: this.orderId }; }
transitions: FsmTransitionMap<State, Command, Event, Data> = { created: { submit: { event: { kind: 'submitted' }, next: 'submitted' }, }, submitted: { approve: { event: (command): Event => ({ kind: 'approved', reviewer: command.reviewer }), next: 'approved', }, reject: { event: (command): Event => ({ kind: 'rejected', reason: command.reason }), next: 'rejected', }, }, // `approved` und `rejected` sind terminal — keine Einträge. };
applyEvent(state: State, data: Data, event: Event): FsmStateData<State, Data> { return match(event) .with({ kind: 'submitted' }, () => ({ state: 'submitted' as const, data })) .with({ kind: 'approved' }, (e) => ({ state: 'approved' as const, data: { ...data, reviewer: e.reviewer } })) .with({ kind: 'rejected' }, (e) => ({ state: 'rejected' as const, data: { ...data, reason: e.reason } })) .exhaustive(); }}Die Typparameter sind PersistentFSM<Command, Event, SName, Data> — die
eingehende Command-Union, die persistierte Event-Union, die
Zustandsnamen-Union und die Domain-Daten. Ein Neustart spielt die
persistierten Events durch applyEvent ab, sodass die FSM genau
dort weitermacht, wo die vorherige Inkarnation aufgehört hat —
einschließlich des aktuellen Zustandsnamens und der Daten.
Was du implementierst
Abschnitt betitelt „Was du implementierst“Vier Member treiben die Maschine — kein when / goto / stay-DSL:
initialFsmState()— der Startzustandsname, wenn noch keine Events abgespielt wurden.initialData()— die Startdaten.transitions— die Übergangstabelle (state×command.kind→ Eintrag). Als Klassenfeld deklariert, damit die Command-spezifische Typverengung an jeder Aufrufstelle funktioniert.applyEvent(state, data, event)— der reine Fold, der das kombinierte{ state, data }zurückgibt. Er läuft sowohl zur Persist-Zeit (vorwärts) als auch während der Recovery (Replay), muss also deterministisch und frei von Seiteneffekten sein.
persistenceId (geerbt von
PersistentActor) benennt den
Event-Stream.
Was persistiert wird
Abschnitt betitelt „Was persistiert wird“Es gibt kein Framework-Wrapper-Event — das Journal speichert deine
eigenen Domain-Event-Werte. Bei jedem Command tut die Basisklasse:
- Schlägt
transitions[currentState][command.kind]nach. Gibt es keinen Eintrag, ist es ein ungültiger Übergang — auf warn geloggt, nichts persistiert, keine Zustandsänderung (überschreibeonInvalidTransition, um das anzupassen). - Hat der Eintrag einen
guardund gibt dieserfalsezurück, wird das Command verworfen — auf debug geloggt, nichts persistiert (überschreibeonGuardRejected, um das anzupassen). - Andernfalls wertet sie das
eventdes Eintrags aus, persistiert es viapersistAllund wendet es überapplyEventan.
applyEvent ist die einzige Quelle der Wahrheit für Zustands- +
Daten-Updates.
Recovery
Abschnitt betitelt „Recovery“preStartlädt den letzten Snapshot (falls vorhanden) und liest jedes nachfolgende Domain-Event für diepersistenceIdaus dem Journal.- Spielt jedes Event der Reihe nach durch
applyEventab und baut sowohl den Zustandsnamen als auch die Daten wieder auf. onRecoveryCompletefeuert mit dem finalen abgespielten Zustand, woraufhin die FSM beginnt, Live-Commands zu verarbeiten.
Schlägt die Recovery fehl, stoppt die FSM, bevor ein Übergang läuft oder ein State-Timeout armiert wird — siehe Wenn die Recovery fehlschlägt.
Dieselbe Idiomatik wie
PersistentActor — Replay baut
deterministisch die exakte Position der Zustandsmaschine wieder auf.
transitions und Guards werden nur für Live-Commands herangezogen
(den nächsten Übergang entscheiden); Replay treibt den Zustand rein über
applyEvent.
Die Übergangstabelle
Abschnitt betitelt „Die Übergangstabelle“Jeder Eintrag hat ein event, ein Ziel next und einen optionalen
guard:
transitions: FsmTransitionMap<State, Command, Event, Data> = { submitted: { approve: { // Ein Guard überspringt den Übergang (kein Event, keine // Zustandsänderung), wenn er false zurückgibt — auf debug geloggt. guard: (command) => command.reviewer.length > 0, event: (command): Event => ({ kind: 'approved', reviewer: command.reviewer }), next: 'approved', }, },};event nimmt drei Formen an: ein Literal ({ kind: 'submitted' }),
eine Funktion (command, data) => Event oder — für mehrere Events
pro Command (#66) — ein Array oder eine Funktion, die eines
zurückgibt. Array-Events persistieren atomar in einem einzigen
persistAll, applyEvent läuft einmal pro Event, und nur der finale
Zustand nach dem Replay wird gegen next geprüft. Ein leeres Array ist
ein No-op (nichts persistiert, kein Übergang).
pay: { event: (command): Event[] => [ { kind: 'paid', amount: command.amount }, { kind: 'audit-logged' }, ], next: 'paid',},next ist hauptsächlich informativ — applyEvent ist das, was den
Übergang tatsächlich treibt — aber eine Diskrepanz zwischen den beiden
wird auf warn geloggt, um ein Auseinanderdriften von Tabelle und Fold zu
erkennen.
Zustands-Timeouts (#65)
Abschnitt betitelt „Zustands-Timeouts (#65)“Ein Zustand kann einen _timeout-Eintrag deklarieren, der beim Eintritt
in diesen Zustand einen einmaligen Timer scharf stellt. Verstreicht
afterMs, bevor ein Command herausübergeht, feuert die FSM das
Timeout-Event automatisch durch dieselbe Persist-dann-Apply-Pipeline:
authorized: { capture: { event: { kind: 'captured' }, next: 'captured' }, // Die Autorisierung automatisch ablaufen lassen, wenn niemand // rechtzeitig captured. _timeout: { afterMs: 15 * 60 * 1000, event: { kind: 'expired' }, next: 'expired', // Optionaler Guard — false bricht das Feuern still ab. // guard: (data) => data.amount > 0, },},Der Timer wird bei jedem Übergang neu scharf gestellt — auch bei einem, der im selben Zustand bleibt — und abgebrochen, wenn die FSM den Zustand verlässt (oder stoppt). Genau das lässt die Idle-Session-Form funktionieren:
active: { // Each heartbeat renews the window instead of racing it. heartbeat: { event: { kind: 'touched' }, next: 'active' }, _timeout: { afterMs: 30 * 60 * 1000, event: { kind: 'timedOut' }, next: 'expired', },},Ein gefeuertes Timeout läuft wie jede andere Nachricht durch die Mailbox und kann dort deshalb bereits hinter einem noch nicht ausgeführten Command warten. Das erneute Scharfstellen entwertet ein so eingereihtes Feuern: Das Command läuft zuerst, erneuert das Fenster, und das überholte Feuern wird verworfen, statt die Session trotzdem ablaufen zu lassen (#143).
Bei der Recovery wird der Timer relativ zur Wall-Clock beim
Recovery-Abschluss neu scharf gestellt — eine lange gestoppte FSM
bekommt ein frisches afterMs-Fenster statt eines sofortigen „bereits
abgelaufen”-Feuerns.
Seiteneffekte
Abschnitt betitelt „Seiteneffekte“PersistentFSM hat keine onEnter / onExit / onTransition-Hooks
— die gehören nur zur In-Memory-FSM. Bringe
niemals Seiteneffekte in applyEvent unter: Es ist ein reiner Fold, der
bei jedem Replay läuft, sodass ein Seiteneffekt dort bei jedem Neustart
erneut feuern würde.
Bringe Seiteneffekte dort unter, wo sie nur für Live-Commands laufen
— überschreibe onCommand, delegiere an super.onCommand und handle
dann auf dem neuen Zustand. onCommand läuft während des Replays nie
(die Recovery treibt den Zustand allein über applyEvent), sodass der
Seiteneffekt einmal pro echtem Übergang feuert:
override async onCommand(curr: FsmStateData<State, Data>, command: Command): Promise<void> { await super.onCommand(curr, command); // führt den tabellengetriebenen Übergang aus if (this.currentFsmState === 'submitted') { await this.notifyReviewer(this.currentData); // Seiteneffekt nur vorwärts }}Für Arbeit, die einmal nach dem Wiederhochfahren der FSM laufen muss,
verwende onRecoveryComplete(state).
Konfiguration
Abschnitt betitelt „Konfiguration“Die Persistenz-Maschinerie wird von PersistentActor geerbt —
überschreibe dieselben Hooks:
class OrderApproval extends PersistentFSM<Command, Event, State, Data> { readonly persistenceId = 'order-42';
// Optionale Overrides (gleiche wie PersistentActor): override snapshotPolicy() { return everyNEvents(50); } override eventAdapter() { return undefined; } override tagsFor(event: Event) { return undefined; } // ... initialFsmState / initialData / transitions / applyEvent wie oben}persistenceId— der Event-Stream-Key.snapshotPolicy— periodische Snapshots zur Replay-Begrenzung.eventAdapter/snapshotAdapter— Schema-Migration.tagsFor— Events für Projektions-Konsum taggen.
Aktuellen Zustand lesen
Abschnitt betitelt „Aktuellen Zustand lesen“Zwei protected Getter legen den kombinierten Zustand offen (nach der Recovery verlässlich), praktisch innerhalb überschriebener Handler:
currentFsmState— der aktuelle Zustandsname.currentData— die aktuellen Domain-Daten.
Snapshots für FSMs
Abschnitt betitelt „Snapshots für FSMs“override snapshotPolicy() { return everyNEvents(50); }Für lange laufende FSMs, die viele Übergänge akkumulieren, begrenzen
Snapshots den Replay. Der Snapshot serialisiert das kombinierte
{ state, data } (FsmStateData) — ein kompakter Blob.
Wähle das Intervall basierend auf der Übergangs-Frequenz:
- Wenige Übergänge pro Actor-Lebensdauer (~10) — kein Snapshot nötig.
- Viele Übergänge (100+) — Snapshot alle 50-100 Events.
Siehe Snapshots für die allgemeine Policy-Guidance.
Stolperfallen
Abschnitt betitelt „Stolperfallen“Wie geht’s weiter
Abschnitt betitelt „Wie geht’s weiter“- FSM im Überblick — das größere Bild.
- FSM (In-Memory) — die nicht-persistente Variante.
- PersistentActor — die zugrunde liegende Event-Sourcing-Engine.
- Snapshots — das Replay-Fenster begrenzen.
- Migration im Überblick — zur Weiterentwicklung von State-Daten-Schemas über die Zeit.
Die PersistentFSM-API-Referenz
deckt die vollständige Oberfläche ab.
