We ship FSM (`src/fsm/FSM.ts`) and PersistentActor separately. The combination — a state machine whose transitions are persisted as events and replayed on recovery — is its own well-defined pattern (Akka has `PersistentFSM`).
Why it earns its own actor: order workflows, reservation systems, payment flows, claim adjudication. The natural model is "current state + transition events"; today users either roll their own (mixing FSM message dispatch with manual `persist` calls) or pick one or the other.
API sketch:
```ts
type State = 'pending' | 'paid' | 'shipped' | 'cancelled';
type Event = { kind: 'paid' } | { kind: 'shipped' } | { kind: 'cancelled' };
type Cmd = { kind: 'pay' } | { kind: 'ship' } | { kind: 'cancel' };
class OrderFsm extends PersistentFSM<Cmd, Event, State, OrderData> {
readonly persistenceId = 'order-1';
initialState(): State { return 'pending'; }
initialData(): OrderData { return { items: [], total: 0 }; }
override transitions = {
pending: {
pay: { event: { kind: 'paid' }, next: 'paid' },
cancel: { event: { kind: 'cancelled' }, next: 'cancelled' },
},
paid: {
ship: { event: { kind: 'shipped' }, next: 'shipped' },
},
};
applyEvent(state: State, data: OrderData, e: Event): { state: State; data: OrderData } {
/* user-defined: how each event mutates state + data */
}
}
```
The base class:
- Routes commands through the user-declared transition table.
- On a valid transition, calls `persist(event)` then `applyEvent(...)` to update both state and domain data.
- On recovery, replays events through `applyEvent` to rebuild state + data.
- Honours the snapshot policy from `PersistentActor`.
Components:
| File |
Task |
| `src/fsm/PersistentFSM.ts` (new) |
Base class + transition-table validation. |
| `tests/unit/fsm/PersistentFSM.test.ts` (new) |
Round-trip, recovery from journal, invalid-transition rejection. |
| `examples/fsm/order-workflow.ts` (new) |
End-to-end demo of an order pipeline. |
Estimate: 3-4 days.
Verification:
- Round-trip: drive an order through pending → paid → shipped, restart actor, recovered state is `'shipped'` with the right data.
- Invalid transition: `ship` from `'pending'` is rejected; no event is persisted.
Out of scope:
- Time-based transitions (`stateTimeout` from Akka) — separate issue if requested.
We ship FSM (`src/fsm/FSM.ts`) and PersistentActor separately. The combination — a state machine whose transitions are persisted as events and replayed on recovery — is its own well-defined pattern (Akka has `PersistentFSM`).
Why it earns its own actor: order workflows, reservation systems, payment flows, claim adjudication. The natural model is "current state + transition events"; today users either roll their own (mixing FSM message dispatch with manual `persist` calls) or pick one or the other.
API sketch:
```ts
type State = 'pending' | 'paid' | 'shipped' | 'cancelled';
type Event = { kind: 'paid' } | { kind: 'shipped' } | { kind: 'cancelled' };
type Cmd = { kind: 'pay' } | { kind: 'ship' } | { kind: 'cancel' };
class OrderFsm extends PersistentFSM<Cmd, Event, State, OrderData> {
readonly persistenceId = 'order-1';
initialState(): State { return 'pending'; }
initialData(): OrderData { return { items: [], total: 0 }; }
override transitions = {
pending: {
pay: { event: { kind: 'paid' }, next: 'paid' },
cancel: { event: { kind: 'cancelled' }, next: 'cancelled' },
},
paid: {
ship: { event: { kind: 'shipped' }, next: 'shipped' },
},
};
applyEvent(state: State, data: OrderData, e: Event): { state: State; data: OrderData } {
/* user-defined: how each event mutates state + data */
}
}
```
The base class:
Components:
Estimate: 3-4 days.
Verification:
Out of scope: