---
title: Sessions
description: "A session is one instance of a contract, with its own ordered log, state, and live stream. Collections and queries stay in your database."
---

```ts
// 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](#sessions-and-your-database).

## Clearing a session

```ts server/clear-order.ts
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](#scheduling-a-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](/schedulers/overview) configured, a handler can schedule a
clear. This one clears an idle cart a day after its last activity:

```ts server/carts.ts
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.
