ZenZip logozenzip

State Machines

Persisted, transition-validated state — XState-lite backed by the store, with every transition emitted through the event bus.

Basics#

order-machine.ts
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 returns false, 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#

hooks.ts
// 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.”