smthrs/xstate runs XState v5 state
machines as an optional derived-state layer over rows Smithers already
persists (not a backend, scheduler, or persisted actor): each frame,
useSmithersMachine recomputes state as a pure fold over durable output and
signal rows via XState’s pure initialTransition / transition functions.
Nothing is persisted, so resume, fork, and rewind stay correct automatically.
Use it when control flow outgrows plain conditionals (revision loops,
approval ladders, multi-phase pipelines with re-entry) and you want
state.matches(...) instead of hand-rolled row checks. For linear pipelines,
ctx.outputs conditionals stay simpler.
bunx smthrs signal RUN_ID REVISE --data '{...}'.
There is no send(), no onDone prop, and no actor.
The fold
Each frame,useSmithersMachine(machine, { id, input, events }):
- Collects events by evaluating each declared source against
ctx. - Orders them by (provenance seq, declaration index, mapped-event subindex): causal arrival order with deterministic tiebreaks, a total order that is a pure function of rows on every branch.
- Folds
initialTransition(machine, input), thentransition(machine, snapshot, event)per event. Returned actions are discarded; builtinassignapplies insidetransition. Events not accepted in the current state are discarded, matching live-actor semantics, as are events after the machine reaches a final state. - Returns the final
MachineSnapshot;state.matches(),state.can(),state.context,state.hasTag()work as@xstate/reactusers expect.
useSmithersMachine call a
distinct id.
Event sources
All sources are pure functions of
ctx; deriving a source’s nodeId from
the fold’s own output is circular and unsupported.
Constraints (enforced at mount)
A mount-time lint runs per machine identity (hot reload re-lints) and rejects machine features that can’t exist inside a durable fold, each with a typed error naming the Smithers-native alternative. No escape hatch.
Every callback in the fold (event mappers, context initializers, guards,
assigners, machine
output functions) must be pure, side-effect-free, free
of time/randomness, and non-mutating of inputs; a throwing one fails the
render with a typed SmithersError naming the machine and event/phase,
instead of an anonymous crash deep inside the fold.
Listeners, re-entry, and final states
- The signal table is the event source; listeners are wake-and-park
plumbing. A parked
<WaitForEvent>matters for liveness: an idle machine state with nothing rendered makes the graph quiescent and ends the run, so states awaiting external events should render a listener to keep the run parked and wake a frame on delivery. A missed wake delays the fold by one frame but never loses the event. - Machine re-entry never re-executes a completed Smithers task. A
bare-constant task id in a re-enterable state deadlocks the machine on a
cycle: no new row or event ever arrives, ending the run the same way as
above. The lint requires
meta.smithersTaskIdbe a function of context (see table above) for any state reachable back to itself through the machine’s transition graph, whether by a direct self-transition or a longer cycle. - Final states are derived-only. Smithers can finish while the machine is
non-final (graph quiescent); a machine final state doesn’t cancel
in-flight tasks (stop rendering them instead);
snapshot.outputisn’t the run output; a failure-flavored final state doesn’t fail the run.
Durability and time travel
The fold’s inputs are exactly the rows Smithers restores, so crash/resume recomputes an identical machine state; rewinding to an earlier frame yields the machine state those earlier rows imply; forked runs fold their copied row history and diverge cleanly with their own signals. One carve-out: an in-place payload replacement at the same(nodeId, iteration) (manual retry-task of a completed node, HumanTask
reopen) keeps its seq but changes its payload, so folded history from that
position reinterprets. The in-process cache validates by content hash and
refolds automatically, but workflows feeding machines from replaceable tasks
should prefer fork-and-migrate.
The prefix cache key is (runId, machine id, reducer hash); the reducer hash
content-hashes the machine (config + setup() implementations) plus every
declared event source (kind, target, options, map/schema source). So one
process hosting many runs (or a fork, which inherits its parent’s row history
and machine id verbatim) never serves one run’s snapshot to another. The
cache is bounded (LRU-evicted), so a long-running process doesn’t retain
every historical snapshot. A mid-run machine or event-source edit can’t
silently reuse a fold computed under the old reducer: it rides the
acceptWorkflowChange gate, where a reducer-hash change for an
already-folded (runId, id) logs a machine-history reinterpretation warning
instead of reinterpreting silently.
Performance
Recomputing per frame is cheap: the prefix cache above folds only the new suffix each frame. The CI benchmark (packages/xstate/tests/fold-benchmark.test.js)
folds 10,000 events on a parallel + nested-compound machine in well under a
second of full-refold (~20µs/event), and proves incremental folding touches
only the new suffix by counting actual transitions executed (immune to
machine load), not by timing it; a wall-clock threshold is logged but stays
deliberately generous and non-strict, since it isn’t the real gate. Practical
limit: tens of thousands of events per machine per run; beyond that, summarize
history into machine context via coarser events.
Visualization
Machines are plain XState, so Stately Studio import andxstate/graph static
visualization work unmodified on the machine definition.