Quickstart

Define a contract, react to its events, and make your first durable append in five minutes.

What you'll build

An order that notifies a shop when it arrives: one contract, one handler, two routes, and a log you can read back.

1. Install

npm install experimental-a2 zod

2. Define a contract

A contract is a name plus the events it understands, each with a schema:

server/orders.ts
import { z } from 'zod'
import * as a2 from 'experimental-a2'

export const orders = a2.contract({
  name: 'orders',
  events: {
    'created': z.object({ shopId: z.string(), items: z.array(z.string()) }),
    'shop.notified': z.object({}),
  },
})

3. Implement it

createServer binds the contract to storage and handlers. Same file, below the contract:

server/orders.ts
import { createServer } from 'experimental-a2/server'

export const ordersServer = createServer({
  contract: orders,
  handlers: {
    created: async ({ event }) => {
      // your side effect, e.g. email the shop, idempotent via event.id:
      // await sendEmailToShop(event.payload, { idempotencyKey: event.id })
      return { type: 'shop.notified', payload: {} }
    },
  },
})
  • Handlers can run more than once (at-least-once delivery), so make external calls safe to repeat. event.id is stable across re-runs: use it as the idempotency key.
  • The returned event commits atomically with handler success. If the handler throws or the process dies first, shop.notified doesn't appear. Return one event or an array.

Development needs no database setup: events go to SQLite at .a2/dev.db and survive restarts. Tests get an in-memory store. Production needs an explicit store; see Stores.

4. Append the first event

app/api/orders/route.ts
import { ordersServer } from '@/server/orders'

export async function POST(req: Request) {
  const { orderId, shopId, items } = await req.json()

  await ordersServer.session(orderId).append({
    type: 'created',
    payload: { shopId, items },
  })

  return Response.json({ ok: true })
}

session(orderId) is one session, one instance of the contract. The id is yours, and creating the handle does no I/O.

curl -X POST localhost:3000/api/orders \
  -H 'content-type: application/json' \
  -d '{ "orderId": "order-42", "shopId": "shop-7", "items": ["flat white"] }'

The payload is validated before anything is written; a bad one fails with INVALID_PAYLOAD and the issues.

When append returns, the event is durably in the log and A2 has started the created handler inline. In production, a queue-backed scheduler wakes pending work after a failure or process death. See Guarantees.

5. Read the log back

app/api/orders/[orderId]/route.ts
import { ordersServer } from '@/server/orders'

export async function GET(
  _req: Request,
  { params }: { params: Promise<{ orderId: string }> },
) {
  const { orderId } = await params
  const events = await ordersServer.session(orderId).history()
  return Response.json(events)
}
curl localhost:3000/api/orders/order-42
[
  { "index": 1, "type": "created", "payload": { "shopId": "shop-7", "items": ["flat white"] } },
  { "index": 2, "type": "shop.notified", "payload": {} }
]

Each event also carries id, sessionId, and createdAt (trimmed here). You appended one event and the handler returned the second. Debugging in A2 is reading the log.

6. Fold state out of the log

UIs usually want a view, not raw history. Define a reducer once:

reducer.ts
import { reducer } from 'experimental-a2'
import { orders } from '@/server/orders'

export const ordersReducer = reducer({
  contract: orders,
  name: 'order-status',
  initialState: { status: 'new' },
}).fold((state, event) => {
  switch (event.type) {
    case 'created':
      return { ...state, status: 'created' }
    case 'shop.notified':
      return { ...state, status: 'notified' }
    default:
      return state
  }
})

Then fold wherever you need the current picture:

// anywhere on the server:
import { ordersServer } from '@/server/orders'
import { ordersReducer } from '@/reducer'

const { state } = await ordersServer.session('order-42').state(ordersReducer)
// state → { status: 'notified' }

State isn't a second record to update. It's a fold over the log; see State.

That's it

You have a validated write path, a handler, a log you can read, and a view folded from it. The rest of these docs apply the same two moves, append an event and react to it, to timers, cancellation, live UIs, and production.