Перейти к содержимому
Русский

PersistentFSM

Это содержимое пока не доступно на вашем языке.

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.

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.

There is no framework wrapper event — the journal stores your own domain Event values. On each command the base class:

  1. Looks up transitions[currentState][command.kind]. If there is no entry, it’s an invalid transition — logged at warn, nothing persisted, no state change (override onInvalidTransition to customise).
  2. If the entry has a guard and it returns false, the command is dropped — logged at debug, nothing persisted (override onGuardRejected to customise).
  3. Otherwise it evaluates the entry’s event, persists it via persistAll, and applies it through applyEvent.

applyEvent is the single source of truth for state + data updates.

  1. preStart loads the latest snapshot (if any) and reads every subsequent domain event for the persistenceId from the journal.
  2. Replays each event in order through applyEvent, rebuilding both the state name and the data.
  3. onRecoveryComplete fires 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.

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.

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.

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).

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.

Two protected getters expose the combined state (reliable after recovery), handy inside overridden handlers:

  • currentFsmState — the current state name.
  • currentData — the current domain data.
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.

The PersistentFSM API reference covers the full surface.