PersistentFSM
Este conteúdo não está disponível em sua língua ainda.
PersistentFSM is the event-sourced variant of
FSM. It is table-driven: you declare a
transition table, and every command that fires a transition persists
its event(s). On restart, the FSM replays those events through
applyEvent and resumes in the exact state it was in.
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` and `rejected` are terminal — no entries. };
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(); }}The type parameters are PersistentFSM<Command, Event, SName, Data> —
the incoming command union, the persisted event union, the state-name
union, and the domain data. A restart replays the persisted events
through applyEvent, so the FSM picks up exactly where the previous
incarnation left off — current state name and data included.
What you implement
Section titled “What you implement”Four members drive the machine — no when / goto / stay DSL:
initialFsmState()— the starting state name when no events have been replayed.initialData()— the starting data.transitions— the transition table (state×command.kind→ entry). Declared as a class field so the per-command type narrowing works at each call site.applyEvent(state, data, event)— the pure fold that returns the combined{ state, data }. It runs both at persist time (forward) and during recovery (replay), so it must be deterministic and free of side effects.
persistenceId (inherited from
PersistentActor) names the event
stream.
What gets persisted
Section titled “What gets persisted”There is no framework wrapper event — the journal stores your own
domain Event values. On each command the base class:
- Looks up
transitions[currentState][command.kind]. If there is no entry, it’s an invalid transition — logged at warn, nothing persisted, no state change (overrideonInvalidTransitionto customise). - If the entry has a
guardand it returnsfalse, the command is dropped — logged at debug, nothing persisted (overrideonGuardRejectedto customise). - Otherwise it evaluates the entry’s
event, persists it viapersistAll, and applies it throughapplyEvent.
applyEvent is the single source of truth for state + data updates.
Recovery
Section titled “Recovery”preStartloads the latest snapshot (if any) and reads every subsequent domain event for thepersistenceIdfrom the journal.- Replays each event in order through
applyEvent, rebuilding both the state name and the data. onRecoveryCompletefires with the final replayed state, after which the FSM starts processing live commands.
If recovery fails, the FSM stops before any transition runs or any state timeout is armed — see When recovery fails.
Same idiom as
PersistentActor —
replay deterministically rebuilds the state machine’s exact position.
transitions and guards are only consulted for live commands
(deciding the next transition); replay drives state purely through
applyEvent.
The transition table
Section titled “The transition table”Each entry has an event, a target next, and an optional guard:
transitions: FsmTransitionMap<State, Command, Event, Data> = { submitted: { approve: { // A guard skips the transition (no event, no state change) when // it returns false — logged at debug. guard: (command) => command.reviewer.length > 0, event: (command): Event => ({ kind: 'approved', reviewer: command.reviewer }), next: 'approved', }, },};event takes three shapes: a literal ({ kind: 'submitted' }), a
function (command, data) => Event, or — for multiple events per
command (#66) — an array or a function returning one. Array events
persist atomically in a single persistAll, applyEvent runs once per
event, and only the final post-replay state is checked against
next. An empty array is a no-op (nothing persisted, no transition).
pay: { event: (command): Event[] => [ { kind: 'paid', amount: command.amount }, { kind: 'audit-logged' }, ], next: 'paid',},next is mainly informational — applyEvent is what actually drives
the transition — but a mismatch between the two is logged at warn to
catch table/fold drift.
State timeouts (#65)
Section titled “State timeouts (#65)”A state may declare a _timeout entry that arms a one-shot timer when
the FSM enters that state. If afterMs elapses before a command
transitions out, the FSM auto-fires the timeout event through the same
persist-then-apply pipeline:
authorized: { capture: { event: { kind: 'captured' }, next: 'captured' }, // Auto-expire the authorization if nobody captures in time. _timeout: { afterMs: 15 * 60 * 1000, event: { kind: 'expired' }, next: 'expired', // Optional guard — returning false cancels the fire silently. // guard: (data) => data.amount > 0, },},The timer is re-armed on every transition — including one that stays in the same state — and cancelled when the FSM leaves the state (or stops). That is what makes the idle-session shape work:
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', },},A fired timeout travels through the mailbox like any other message, so it can already be queued behind a command that has not run yet. Re-arming invalidates such a queued fire: the command is processed first, renews the window, and the superseded fire is dropped instead of expiring the session anyway (#143).
On recovery the timer is re-armed relative to the wall-clock at
recovery completion — a long-stopped FSM gets a fresh afterMs window
rather than an immediate “already expired” fire.
Side effects
Section titled “Side effects”PersistentFSM has no onEnter / onExit / onTransition hooks —
those belong only to the in-memory FSM. Never
put side effects in applyEvent: it is a pure fold that runs on every
replay, so a side effect there would fire again on each restart.
Put side effects where they run only for live commands — override
onCommand, delegate to super.onCommand, then act on the new state.
onCommand never runs during replay (recovery drives state through
applyEvent alone), so the side effect fires once per real transition:
override async onCommand(curr: FsmStateData<State, Data>, command: Command): Promise<void> { await super.onCommand(curr, command); // runs the table-driven transition if (this.currentFsmState === 'submitted') { await this.notifyReviewer(this.currentData); // forward-only side effect }}For work that must run once after the FSM comes back up, use
onRecoveryComplete(state).
Configuration
Section titled “Configuration”The persistence machinery is inherited from PersistentActor — override
the same hooks:
class OrderApproval extends PersistentFSM<Command, Event, State, Data> { readonly persistenceId = 'order-42';
// Optional overrides (same as PersistentActor): override snapshotPolicy() { return everyNEvents(50); } override eventAdapter() { return undefined; } override tagsFor(event: Event) { return undefined; } // ... initialFsmState / initialData / transitions / applyEvent as above}persistenceId— the event-stream key.snapshotPolicy— periodic snapshots to bound replay.eventAdapter/snapshotAdapter— schema migration.tagsFor— tagging events for projection consumption.
Reading current state
Section titled “Reading current state”Two protected getters expose the combined state (reliable after recovery), handy inside overridden handlers:
currentFsmState— the current state name.currentData— the current domain data.
Snapshots for FSMs
Section titled “Snapshots for FSMs”override snapshotPolicy() { return everyNEvents(50); }For long-running FSMs accumulating many transitions, snapshots bound
replay. The snapshot serializes the combined { state, data }
(FsmStateData) — a compact blob.
Pick the interval based on transition frequency:
- Few transitions per actor lifetime (~10) — no snapshot needed.
- Many transitions (100+) — snapshot every 50-100 events.
See Snapshots for the general policy guidance.
Pitfalls
Section titled “Pitfalls”Where to next
Section titled “Where to next”- FSM overview — the bigger picture.
- FSM (in-memory) — the non-persistent variant.
- PersistentActor — the underlying event-sourcing engine.
- Snapshots — bounding the replay window.
- Migration overview — for evolving state-data schemas over time.
The PersistentFSM API
reference covers the full surface.
