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 — a method on the app object (createApp and createDevApp both return it):
import Board from './machines/board.ts'
const app = await createApp({ /* … */ })
// e.g. inside a webhook handler or a setInterval:await app.dispatchToApp(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 design candidate; this pattern is the supported path today and will keep working.
Effects and timers on app machines
Section titled “Effects and timers on app machines”Entry effects and after timers work on app machines exactly as on session machines, with one difference in when: they fire on wall clock, with no session attached — an app machine’s clock is process housekeeping, not work done on behalf of a viewer. That makes app machines the legitimate home for a self-revalidating cache or a circuit breaker:
states: { fresh: { after: [{ delay: CACHE_TTL_MS, send: { type: 'EXPIRE' } }], on: { EXPIRE: { to: 'stale' } }, }, stale: { entry: async (): Promise<Events> => ({ type: 'REFRESHED', data: await fetchUpstream() }), on: { REFRESHED: { to: 'fresh', do: (ctx, ev) => { ctx.data = ev.data } } }, },},Completions dispatch back into the machine and fan out to live routes like any other app-machine change. The dev server’s poll-loop lint deliberately skips app machines — a non-durable server clock belongs here, not on a session machine.
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({ // … persistence: { app: new RedisAppStore(process.env.REDIS_URL!) },})On boot, a persisted snapshot restores 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 deferred 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 design candidate.