State
State is a fold over the log: raw history for facts, a reducer for the current view.
History
// anywhere on the server:
const events = await ordersServer.session(orderId).history()Every event in the session, oldest first: always the raw log, never a summary
or snapshot. Use it for audits, debugging, and handler questions like "did the
shop already start?" Pass inclusive gte or lte indexes to read a slice.
Reducers
Derive a reducer from the orders contract and fold:
import { reducer } from 'experimental-a2'
import { z } from 'zod'
import { orders } from './contracts'
export const ordersReducer = reducer({
contract: orders,
name: 'order-status',
initialState: { status: 'new' },
stateSchema: z.object({
status: z.enum(['new', 'created', 'notified', 'expired']),
shopId: z.string().optional(),
}),
}).fold((state, event) => {
switch (event.type) {
case 'created':
return { status: 'created', shopId: event.payload.shopId }
case 'shop.notified':
return { ...state, status: 'notified' }
case 'expired':
return { ...state, status: 'expired' }
default:
return state
}
})// anywhere on the server, e.g. a server component before first paint:
const { state, index } = await ordersServer.session(orderId).state(ordersReducer)A reducer is a name (its cache identity), an
initialState, an optional stateSchema, and a pure fold,
(state, event) => state. The two-step shape fixes the state and event types
before .fold(), so you write no type arguments and literal unions survive.
state() returns state and index: the fold includes every event through
that index and none after. The browser resumes its live stream from there; see
React.
Without stateSchema, the state type is inferred from initialState, fine
while every fold arm returns the same shape. With it, the schema is the type
(the seed may lean on its defaults), initialState is validated at
definition, and a cached fold that fails the schema, in a server snapshot or
the browser cache, is discarded and refolded. That catches
shape drift a stale name can't.
Contract and reducer modules import only schemas and experimental-a2, so the
browser runs the same reducer; see React. A
server-only app can keep the contract next to createServer instead.
Read boundaries
A root session reads the latest committed frontier. A handler's session stops at its triggering event by default, stable across concurrent appends and retries:
import { createServer } from 'experimental-a2/server'
import { orders } from './contracts'
import { ordersReducer } from './reducer'
export const ordersServer = createServer({
contract: orders,
handlers: {
created: async ({ event, session }) => {
const atTrigger = await session.state(ordersReducer)
// atTrigger.index === event.index
const latest = await session.state(ordersReducer, { through: 'latest' })
// latest also folds events appended after this one
},
},
})Use { through: 'latest' } for joins or other logic that intentionally
observes later events. A numeric { through: index } reads any inclusive log
boundary; through: 0 returns initialState. state() is observational: it
never runs handlers or waits for pending work, and its index marks committed
history, not handler completion.
A latest read and a following ctx.session.append(name, ...events) are
separate operations, and concurrent appends and retries can move the frontier
between them. For joins, read with { through: 'latest' }, use a monotone
readiness check (once ready, always ready), and give the output event a stable
explicit id. Repeated attempts then converge on the same append.
Snapshots are a cache
The store backend caches folded state as snapshots keyed by the reducer's
name, so two reducers over one session never share an entry. Changed the
fold's logic? Change the name (a -v2 suffix works). The next read ignores
the old snapshots, refolds from raw events, and caches under the new name.
A missing snapshot, or one stateSchema rejects, rebuilds from the log. A
storage read failure propagates with its backend diagnostics instead: existing
A2 errors pass through, and others become STORE_UNAVAILABLE with the
original error as their cause.
Snapshot write-back runs in platform waitUntil after the state is ready. A
failed write doesn't fail the read: A2 reports it through the a2.snapshot
telemetry span (or console.error by default) without snapshot contents, and
the next read folds the missing tail again.
Each session and reducer has one latest snapshot. Older ones exist only while handler events that read at their boundary are unfinished (not yet completed or dead-lettered). This is automatic: there is no TTL, history limit, or retention setting.
Deleting every snapshot is always safe. The log rebuilds them.
Events don't change meaning
The log is append-only, and created means forever what it meant when it was
written. When a payload needs a new shape, add a new event type (created.v2)
instead of redefining the old one.
Reducers accumulate cases over time and never migrate anything: old sessions keep their old events, new sessions write new ones, and the same fold handles both.