App machines
A lifecycle: 'session' machine exists once per visitor. A
lifecycle: 'app' machine exists once per server — shared state every
session can see. It’s what powers cross-session views: an admin dashboard, a
live poll tally, an inventory board.
export default defineMachine({ name: 'BoardMachine', lifecycle: 'app', persist: true, subscribes: [{ from: Ping, event: 'pinged', dispatch: 'BUMP' }], // …})How events reach an app machine
Section titled “How events reach an app machine”Two ways, by design:
From sessions, via emits. A session machine emits; the app machine
subscribes. The framework injects sourceSessionId into the dispatched
event so the app machine knows who triggered it. This is the path for
“every order updates the shared board.”
From server code, via dispatchToApp. Webhooks, cron jobs, and anything
else with no HTTP session use the typed server-originated entry point:
import { dispatchToApp } from '@statorjs/stator/server'import Board from './machines/board.ts'
const app = await createApp({ /* … */ })
// e.g. inside a webhook handler or a setInterval:await dispatchToApp(app.store, Board, { type: 'BUMP', by: 5 })dispatchToApp sends the event, persists the machine if it opted in, and
fans the change out to every live SSE connection whose route reads it — a
cron job can move a dashboard in real time.
There is no direct client→app dispatch: browsers talk to their session
machines, and session machines emit upward. That keeps “who can change shared
state” an explicit, reviewable list — and it’s deliberate, because /__events
takes the machine name from the client, and shared state needs an
authorization gate that per-session state doesn’t.
Buttons that drive shared state: the gateway pattern
Section titled “Buttons that drive shared state: the gateway pattern”When a page needs to command an app machine — an admin “restock” button, a
“close poll” control — route it through a gateway session machine. The
gateway’s guards are the authorization boundary (they run against the
session’s own context); the app machine’s subscribes: stays the complete
audit of who can change it:
// machines/admin.ts — lifecycle: 'session'export default defineMachine({ name: 'AdminMachine', lifecycle: 'session', events: {} as { type: 'REQUEST_RESTOCK'; sku: string }, emits: { restockRequested: { payload: (_ctx, ev) => ({ sku: ev.sku }) } }, context: { isAdmin: false }, initial: 'ready', states: { ready: { on: { REQUEST_RESTOCK: { when: (ctx) => ctx.isAdmin, // ← the gate emit: 'restockRequested', }, }, }, }, selectors: {},})// machines/inventory.ts — lifecycle: 'app'subscribes: [{ from: Admin, event: 'restockRequested', dispatch: 'REQUEST_RESTOCK' }],The button sends to the gateway (its own session state — always legal), the
guard authorizes, the emit crosses lifecycles with sourceSessionId
attached, and persistence, effects, and SSE fan-out all behave normally from
there. A more convenient route-gated form is a 1.x candidate; this pattern is
the supported path today and will keep working.
Surviving restarts: persist: true
Section titled “Surviving restarts: persist: true”App machines live in process memory and reset on deploy — often correct (a cache should reset). When state must survive, opt in:
lifecycle: 'app',persist: true,Persisted app machines snapshot through an AppStore — a deliberately
separate interface from the session store (one blob per machine, no TTL).
InMemoryAppStore is the default; pass RedisAppStore for real durability:
import { RedisAppStore } from '@statorjs/stator/server'
const app = await createApp({ // … appStore: new RedisAppStore(process.env.REDIS_URL!),})On boot, a persisted snapshot hydrates the actor before it starts. An unusable snapshot logs loudly and boots fresh — restart-fresh is the safe default. Writes are event-driven: whenever a transition touches the machine, the new snapshot is saved.
Setting persist: true on a session machine is a define-time error —
session machines always persist through the session store.
The single-replica caveat
Section titled “The single-replica caveat”App machines are in-process singletons. Two replicas would each run their own copy and drift; the AppStore assumes a single writer. Multi-replica app state is a 1.x problem with a designed path (leader/backplane) — don’t scale out with persisted app machines until it lands.
Mutual machine relationships
Section titled “Mutual machine relationships”Sometimes two machines relate in both directions: a session cart reads: the
shared inventory (stock-ceiling guards) while inventory subscribes: to the
cart’s orderPlaced. Declaring each half in its own module would make the two
files import each other — a cycle the module loader resolves by handing one
side undefined (Stator diagnoses this at boot with a named error).
The pattern: the importing end owns both halves. The machine that already imports the other attaches the reverse subscription after definition:
// cart.ts — already imports inventory for reads:const CartMachine = defineMachine({ reads: [InventoryMachine], /* … */ })
InventoryMachine.subscribes.push({ from: CartMachine, event: 'orderPlaced', dispatch: 'ORDER_PLACED',})
export default CartMachineThe store still validates the emit name at construction, so the wiring stays checked. First-class support (lazy refs or name-based subscribe) is a 1.x candidate.