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:

reducer.ts
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:

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