Sessions
A session is one instance of a contract, with its own ordered log, state, and live stream. Collections and queries stay in your database.
// anywhere on the server: a route, a handler, a script
const order = ordersServer.session('order-42')A session is one instance of the contract (one order, one chat, one import) with its own ordered log, state, recovery, and live stream. The id is yours. Creating the handle does no I/O; nothing loads until you append, read, or stream.
Pick the boundary
Put events in one session when they form one history, fold, recovery unit, or live view. Split what should be read and recovered independently.
A session is not a table or a general query boundary: one orders contract can
serve millions of order sessions through the same server and handlers.
Collections, joins, search, and constraints across many records belong in
your database.
Clearing a session
import { ordersServer } from './orders'
export async function clearOrder({ orderId, lastIndex }: {
orderId: string
lastIndex: number
}) {
// here's where you'd do auth, or any other checks
return ordersServer.session(orderId).clear({ ifLastIndex: lastIndex })
}session.clear() ends a session's current lifetime: its history is gone, every
reducer is back at its initial state, and the id keeps working for the next
lifetime. It appends a reserved marker, so indexes never restart: clearing a
session whose last event is 42 returns { cleared: true, index: 43 }, and the
next event is 44.
Pass ifLastIndex when newer events must survive. The store checks it in the
same atomic step that appends the marker; if another event landed first, the
clear skips and returns { cleared: false, index } with the current head. A
conditional clear is safe to retry.
The old lifetime's pending events are never dispatched, and a handler still running for one can't append, return events, or read the new lifetime's state. Clearing can't stop code that is already running or undo its side effects.
Connected clients reset when the marker reaches them. After a clear,
session.state() also returns clearIndex: pass the whole result as a client's
initial so a cached client drops its old history too (the automatic snapshot
on subscribe already carries it).
clear() resolves once the old events and cached state are deleted; appends
and reads keep running meanwhile, and the cost grows with the old history. If
deletion fails partway, the session is still cleared and the leftovers are
invisible: retry, or clear again later, to finish. For a very large session,
prefer a scheduled clear, because the provider retries
it and each retry resumes the deletion. Presence isn't cleared; it keeps its
own expiry.
server.fetch has no clear endpoint. Clearing is a server operation, so
authorize your own route before calling it.
Scheduling a clear
With a scheduler configured, a handler can schedule a clear. This one clears an idle cart a day after its last activity:
import * as a2 from 'experimental-a2'
import { createServer } from 'experimental-a2/server'
import { vercelQueues } from 'experimental-a2/scheduler-vercel'
import { z } from 'zod'
export const carts = a2.contract({
name: 'carts',
events: {
'item.added': z.object({ sku: z.string() }),
},
})
export const cartsServer = createServer({
contract: carts,
scheduler: vercelQueues(),
handlers: {
'item.added': async ({ event, session }) => {
await session.schedule.clear('idle', {
delay: '1d',
ifLastIndex: event.index,
})
},
},
})The delay counts from the event's stored time. A later event moves the head
past event.index, so this delivery skips and that event's handler schedules
the next check; only events with a handler extend the policy. A handler's own
writes move the head too, so one that appends should pass the index its last
append returned.
Delivery runs the same conditional clear and finishes the deletion before it acknowledges, so provider retries complete an interrupted delete. No event is appended. The clear happens at delivery, not at an exact deadline, within provider limits: Vercel Queues accepts delays up to seven days.
Sessions and your database
A2 does not replace your application database. It syncs one known session at a time (its events, derived state, and optimistic browser writes), not tables or queries. Give each fact one owner:
- If A2 owns it, append an event and derive state with a reducer.
- If your database owns it, write it with the database's normal transaction.
Never make both paths authoritative for the same fact.
Use A2 when people open something by id and watch it progress: an order in fulfillment, an agent producing a response, an approval waiting for a decision, an import that can fail and resume. Use your database when people list, filter, join, and edit many records, and for constraints that must hold across rows before a request returns.
experimental-a2/store-postgres stores A2's own event logs. It does not watch
your tables, turn their changes into events, or combine an A2 append with your
SQL transaction. Both can share one Postgres database as separate data models
and transaction boundaries.
An issue tracker
Postgres usually owns an issue tracker's core data; A2 sessions own the runs attached to it:
| Part | Owner |
|---|---|
| issues, projects, labels, comments | Postgres |
| permissions, board filters, search | Postgres |
| an AI investigation attached to an issue | one A2 session |
| an import or automation run | one A2 session |
The investigation's session refers to the issue id without becoming a second owner of the issue. A2 can own individual issues when their history and live state are the center of the product, but boards and search then span many sessions and need another query model. Don't put a whole workspace in one session for cross-issue queries: every event joins one growing log that every client follows.
Database writes from handlers
Handlers may call your database like any other side effect. They are
at-least-once, so make the write idempotent with event.id; it runs after the
event is durable and may be retried. That is not one atomic write across A2
and your tables: if a relational change must commit before the request
succeeds, make it through the database's write path.
Cross-session indexes
A handler can maintain a small database index over A2-owned sessions, such as a list of active imports. Treat it as an advanced integration:
- it is eventually consistent with the session logs;
- writes must be idempotent;
- A2 orders events within one session, not across sessions;
- no A2 API scans and replays every session to rebuild it.
Back up and migrate it like application data. If search, joins, and cross-session reporting are central, the database should probably own that domain.