State Machines
Persisted, transition-validated state — XState-lite backed by the store, with every transition emitted through the event bus.
Basics#
const order = app.machine("order", {
initial: "created",
states: {
created: { on: { PAY: "paid", CANCEL: "cancelled" } },
paid: { on: { PACK: "packed", REFUND: "refunded" } },
packed: { on: { SHIP: "shipped" } },
shipped: { on: { DELIVER: "delivered" } },
delivered: {}, // terminal: no outgoing transitions
cancelled: {},
refunded: {},
},
});
await app.start();
await order.create("ord_1"); // → true (false if it existed)
const t = await order.send("ord_1", "PAY"); // → { from: "created", to: "paid" }
await order.state("ord_1"); // → "paid"- State names are inferred —
order.state(id)is typed as the union of your state keys. create()is idempotent: an existing instance returnsfalse, never resets.- Instances persist in the store — restart-safe like everything else.
Transitions#
// Transitions are validated against the definition and applied
// atomically (optimistic UPDATE … WHERE state = expected):
await order.send("ord_1", "PAY");
// → throws: invalid transition: machine 'order' instance 'ord_1'
// cannot handle 'PAY' in state 'paid'
// Concurrent senders race safely: the loser re-reads and re-validates;
// a still-valid transition retries, an invalidated one throws.Validation happens in Rust against the registered definition; the apply is a guarded UPDATE … WHERE state = expectedplus a history append in one transaction. Two processes can't both win the same transition.
Transition events#
// Every transition emits "<machine>.<toState>" through the event bus:
app.on("order.*", ({ event, payload }) => {
// payload: { machine: "order", id: "ord_1", from, event: "PAY", to }
console.log(event); // "order.paid"
});
// …including DURABLE workflow triggers:
app.workflow("fulfil", { on: "order.paid" }, async ({ step, input }) => {
await step.run("allocate", () => warehouse.allocate(input.payload.id));
});This is the integration point: machines turn imperative state changes into events, and the event bus turns events into durable workflows. Order fulfilment, notification fan-out, audit trails — all hang off transitions without coupling to the caller.
Atomic with the event
The state change, history entry, emitted event, and any durably-triggered runs commit in one store transaction — a crash can't produce a transition whose event (or whose triggered workflows) went missing.
Queries & history#
const log = await order.history("ord_1");
// newest first:
// [ { fromState: "paid", event: "PACK", toState: "packed", at: 1717… },
// { fromState: "created", event: "PAY", toState: "paid", at: 1717… } ]Design scope#
Deliberately XState-lite: flat states, event-keyed transitions, persistence, history, bus integration. Not included (by design, for now): nested/parallel states, guards, actions, actors. Workflows cover the “do things over time” half — machines only answer “what state is this entity in, and what's allowed next.”