@yaebal/state-machine
a declarative finite-state machine backed by @yaebal/sklad storage: typed events,
guarded transitions, onEnter/onLeave hooks. unlike @yaebal/scenes, there are no steps and no explicit enter() — a key's machine is always active, starting at initial the
first time it's seen, and moves only when a typed event you send matches a transition declared
for the current state.
install
pnpm add @yaebal/state-machinea first machine
declare a machine with defineMachine (pinning the context, the typed event union
and the extended-state bag), register it with stateMachine(def), and dispatch
typed events from any handler with ctx.machine.send(event) — event types the
current state doesn't declare are a compile error inside on.
import { createBot, type Context } from "yaebal";
import { defineMachine, stateMachine } from "@yaebal/state-machine";
type OrderEvent = { type: "PAY" } | { type: "SHIP" } | { type: "CANCEL" };
const order = defineMachine<Context, OrderEvent>({
initial: "created",
states: {
created: {
on: {
PAY: { target: "paid" },
CANCEL: { target: "cancelled" },
},
},
paid: {
onEnter: (ctx) => ctx.send("payment received — shipping soon"),
on: {
SHIP: { target: "shipped" },
CANCEL: { target: "cancelled" },
},
},
shipped: {
onEnter: (ctx) => ctx.send("your order shipped 📦"),
},
cancelled: {
onEnter: (ctx) => ctx.send("order cancelled"),
},
},
});
const bot = createBot(process.env.BOT_TOKEN!)
.install(stateMachine(order));
bot.command("pay", (ctx) => ctx.machine.send({ type: "PAY" }));
bot.command("ship", (ctx) => ctx.machine.send({ type: "SHIP" }));
bot.command("status", (ctx) => ctx.reply(`order is ${ctx.machine.state}`));
bot.start();guarded transitions
a state can declare several transitions for the same event as an array. they are tried in
order; a guard returning false skips to the next candidate. send() resolves true only if a transition actually fired — otherwise
the state is unchanged. actions run after onLeave, before the
target's onEnter, and can mutate ctx.machine.context freely.
type OrderEvent = { type: "PAY" } | { type: "SHIP" } | { type: "CANCEL" };
const order = defineMachine<Context, OrderEvent, { paidAt?: number }>({
initial: "created",
states: {
created: {
on: {
PAY: {
target: "paid",
actions: (ctx) => { ctx.machine.context.paidAt = Date.now(); },
},
CANCEL: { target: "cancelled" },
},
},
paid: {
on: {
// a guard rejecting skips to the next candidate for the same event
SHIP: { target: "shipped", guard: (ctx) => ctx.machine.context.paidAt !== undefined },
CANCEL: { target: "cancelled" },
},
},
shipped: {},
cancelled: {},
},
});hooks
onEnter/onLeave are declared per state and fire on every
activation/exit of that state — including the machine's very first activation, where onEnter runs with info.from === undefined.
const order = defineMachine<Context, OrderEvent>({
initial: "created",
states: {
created: { on: { PAY: { target: "paid" } } },
paid: {
onEnter: (ctx, info) => ctx.send(`payment received (from ${info.from})`),
onLeave: (ctx, info) => console.log("leaving paid for", info.to, "via", info.event.type),
on: { SHIP: { target: "shipped" } },
},
shipped: {
onEnter: (ctx) => ctx.send("your order shipped 📦"),
},
},
});
// onEnter fires on the machine's very first activation too — info.from is undefined thenreading and driving the machine
bot.command("status", (ctx) => ctx.reply(ctx.machine.state));
bot.command("ship", async (ctx) => {
if (!ctx.machine.can("SHIP")) return ctx.reply("nothing to ship yet");
const moved = await ctx.machine.send({ type: "SHIP" });
return ctx.reply(moved ? `now ${ctx.machine.state}` : "can't ship from here");
});
bot.command("reset", (ctx) => ctx.machine.reset());persistence and ttl
the current state and extended-state bag are one json snapshot in a StorageAdapter<MachineSnapshot>. the default is in-memory; pass any @yaebal/sklad adapter (redis, sqlite, cloudflare kv, json file) and restarts
resume a key in the same state. ttl resets an inactive machine to initial lazily, on the key's next update — no onLeave fires for the
expired state since no event drove the reset, only the initial state's onEnter (with info.from set to the expired state's name).
import { stateMachine } from "@yaebal/state-machine";
import { redisStorage } from "@yaebal/sklad";
import Redis from "ioredis";
bot.install(stateMachine(order, {
storage: redisStorage(new Redis()), // any StorageAdapter<MachineSnapshot>
ttl: 60 * 60_000, // reset an inactive machine after an hour
}));
// a restart now resumes a key in the same state, extended-state bag and allapi
| export | description |
|---|---|
stateMachine(def, options?) | the plugin. adds ctx.machine, typed from def |
defineMachine<C, Event, MCtx>(def) | identity helper that pins a def's context, event union and extended-state bag types |
MachineDef / StateNodeDef / TransitionDef | a def: initial, context, states. a state node is { onEnter?, onLeave?, on? }; a transition is { target, guard?, actions? } |
MachineContext<C, Event, MCtx> | the context hooks/guards/actions receive: C & { machine: ActiveMachine<Event, MCtx> } |
MachineSnapshot | the persisted shape — what a custom StorageAdapter stores |
ctx.machine (ActiveMachine)
| member | description |
|---|---|
state / context | the current state's name, and the typed extended-state bag (mutate freely — persisted automatically) |
matches(state) | is the current state exactly state? |
can(type) | would send with an event of this type find a declared transition? guards are not evaluated |
send(event) | dispatch a typed event; resolves true if a transition fired |
reset() | back to initial, rebuilding the context bag — fires onEnter like the first activation |
StateMachineOptions
| field | default | description |
|---|---|---|
storage | MemoryStorage | any StorageAdapter<MachineSnapshot> — see @yaebal/sklad |
getKey | chat.id:from.id | per user per chat. undefined means the machine still runs but is never
persisted — a fresh initial state every update |
ttl | — | ms of inactivity before an idle machine resets to initial (lazily, on the next update) |
now | Date.now | clock override, mainly for tests |
@yaebal/scenes,
a key doesn't opt into the machine — it starts in initial the moment it's first
seen. there's no enter/leave to call; just send events. self-healing snapshots. a snapshot pointing at a state a deploy removed is discarded on the next update and the key resets to
initial, instead of shadowing
it forever. extended state must stay json-serializable.
ctx.machine.context round-trips through the storage adapter — keep it data, not class instances. concurrency. snapshots are read-modify-write per update, the same caveat as
@yaebal/scenes — safe under the built-in sequential poll loop and @yaebal/runner's default per-chat lanes; on webhooks, serialize updates per key
yourself or two simultaneous transitions can race.testing
machines test end-to-end with @yaebal/test actors: send the command that dispatches
an event, and assert on ctx.machine.state, env.callsTo("sendMessage") and the storage contents. packages/state-machine/src/index.test.ts covers every
behavior on this page.
related
@yaebal/scenes — durable step-by-step wizards, for flows that
ask questions and navigate rather than react to typed events · @yaebal/conversation — the coroutine alternative for
flows that don't need durable snapshots · @yaebal/session — long-lived per-chat state a machine's onEnter/onLeave hooks can write into.