@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

terminal
pnpm add @yaebal/state-machine

a 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.

bot.ts
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.

guards.ts
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.

hooks.ts
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 then

reading and driving the machine

control.ts
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).

persistence.ts
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 all

api

exportdescription
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 / TransitionDefa 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> }
MachineSnapshotthe persisted shape — what a custom StorageAdapter stores

ctx.machine (ActiveMachine)

memberdescription
state / contextthe 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

fielddefaultdescription
storageMemoryStorageany StorageAdapter<MachineSnapshot> — see @yaebal/sklad
getKeychat.id:from.idper user per chat. undefined means the machine still runs but is never persisted — a fresh initial state every update
ttlms of inactivity before an idle machine resets to initial (lazily, on the next update)
nowDate.nowclock override, mainly for tests
always active, no enter/leave lifecycle. unlike @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.