---
title: '0.21'
description: Handlers become consumers, and every store keeps their places in a new layout.
---

## Handlers become consumers

```ts
// before: server/orders.ts on 0.20
export const ordersServer = createServer({
  contract: orders,
  handlers: {
    created: async ({ event }) => {
      // await sendEmailToShop(event.payload.shopId, { idempotencyKey: event.id })
      return { type: 'shop.notified', payload: {} }
    },
    'shop.started': { lane: 'shop', handler: async () => {} },
    'shop.notified': { lane: 'shop', handler: async () => {} },
  },
})
```

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

export const ordersServer = createServer({
  contract: orders,
  consumers: [
    {
      name: 'notify-shop',
      types: ['created'],
      run: async ({ event }) => {
        // await sendEmailToShop(event.payload.shopId, { idempotencyKey: event.id })
        return { type: 'shop.notified', payload: {} }
      },
    },
    // the types that shared the 'shop' lane, as one consumer:
    {
      name: 'shop-work',
      types: ['shop.started', 'shop.notified'],
      lane: () => 'shop',
      run: async ({ event }) => {
        // your work for event.type
      },
    },
  ],
})
```

`handlers` are gone: [consumers](/server/consumers) are the one way to react
to events. Each entry of a handler table becomes a consumer that takes its
type, with the same function as its `run`, and its `abortOn` if it had one. A
lane belongs to one consumer, so entries that shared a lane become one consumer
that takes all their types, with a lane function returning that lane. A
consumer's name is its identity: its cursors, failures, and dead letters key
on it, so pick names you'll keep.

| Before | 0.21 |
| --- | --- |
| `createServer({ handlers })` | `createServer({ consumers })` |
| `handler(entry)` | `{ name, types?, lane?, run }` in `consumers`, or `consumer({ contract, ... }).run(fn)` |
| `Handler`, `HandlerEntry` | removed: `consumer()` types `run`'s context |
| `Lane` | `ConsumerLane` |
| `HandlerContext` | `ConsumerContext` |
| `HandlerAppend` | `ConsumerAppend` |
| `handlerContext()`, from `experimental-a2/ai` | `consumerContext()` |
| `runWithHandlerContext`, from `experimental-a2/ai/server` | `runWithConsumerContext` |
| `agent({ handlers })` | `agent({ consumers })` |
| `session.redrive(index, { action })` | `session.redrive({ consumer, index }, { action })` |
| `a2.append.source` = `handler` | `a2.append.source` = `consumer` |

- **Dead letters name their consumer.** An event dead-letters for one
  consumer, so each entry from `session.deadLetters()` carries `consumer`.
  Pass it to `redrive` with the index. See
  [Resolve a dead letter](/server/consumers#resolve-a-dead-letter).
- **Agents take `consumers`.** Write them with `consumer()` against the agent's
  contract, as in [Conversations](/agents/conversations). A consumer of an AI
  event type runs beside the agent's own, so `agent()` no longer rejects one.
- **Actors keep `actor({ handlers })`.** Nothing changes in an actor's code.
- **A2's own consumers start with `a2.`** Actors and agents run on consumers
  named `a2.actor`, `a2.actor.concurrent`, `a2.ai.metadata`, `a2.ai.control`,
  `a2.ai.model`, and `a2.ai.tools`, which their dead letters name. Their event
  ids are unchanged. Names starting with `a2.` are reserved.
- **Telemetry.** Dashboards that filter `a2.append.source` on `handler` read
  `consumer` instead. Each `a2.event` span also carries `a2.event.consumer`;
  see [Telemetry](/reference/telemetry).
- **Custom stores** implement the cursor contract: `cursors()`, `wakeLog`, and
  the cursor-keyed claim, completion, failure, and redrive methods. See
  [`A2Store`](/reference/server#a2store).

## Postgres

0.21 keeps each consumer's place in new tables, `a2_cursors` and
`a2_lane_events`. Its `schema.sql` creates them, then drops the columns earlier
releases dispatched by, and the `a2_schema` table 0.20 reads on a cold start.
Earlier deployments can't run against the new tables, and 0.21 needs them.

1. **Let earlier deployments finish their work.** Applying the schema drops
   any work their handlers haven't finished, since 0.21 runs no handlers to
   finish it. This query counts that work, dead letters included (retry or
   skip those first, or accept that they're dropped):

   ```sql
   select count(*) from a2_events where processed_at is null
   ```

   It doesn't count timers. On [Vercel Queues](/schedulers/vercel-queues#deployments),
   each one goes to the deployment that scheduled it, which can't write once
   the schema is applied, so a timer still waiting then is lost. Let them fire,
   or schedule the ones you need again on 0.21.

2. **Apply 0.21's `schema.sql` while none of them is running**, as the role
   your app connects with:
   `node_modules/experimental-a2/sql/postgres/schema.sql`. Every event is
   kept. There's no separate migration: the file is the whole schema, and
   each statement creates what's missing or removes what an earlier release
   used, so it brings a database from any release to 0.21 and changes nothing
   when you apply it again.
3. **Deploy 0.21.** Deployed before the schema, its queries that reach a
   missing table throw `STORE_NOT_CONFIGURED`, naming the file, and the next
   request after you apply it works, with no redeploy.

0.21's `schema.sql` also creates a function, `a2_batch`, that each append,
claim, completion, and `consume` poll runs through in one round trip. Applying
the file creates it; with Drizzle, add it as a custom migration (see
[With Drizzle](/stores/postgres#with-drizzle)). Without it, 0.21's writes throw
`STORE_NOT_CONFIGURED`, naming `schema.sql`.

0.21 also checks nothing up front: no schema version read before the first
query. A query that finds a table or column missing, or that its role can't
use, throws `STORE_NOT_CONFIGURED`, naming the file to apply or the grant to
make. With Drizzle, `drizzle-schema.ts` shows the new tables; on a database
that already has A2's tables, use `schema.sql` as the migration for them. See
[With Drizzle](/stores/postgres#with-drizzle).

## SQLite

A file an earlier release wrote throws `STORE_NOT_CONFIGURED` when the store
opens it. Delete it, `.a2/dev.db` by default, or pass another `path`. Its
sessions don't carry over.

## Redis

0.21 keeps each consumer's place in keys of its own, and reads the log,
snapshots, and clears that earlier releases wrote as they are. Keep your
`keyPrefix`.

1. **Let earlier deployments finish their work, then stop them.** 0.21 runs
   no handlers, so the work theirs haven't finished, dead letters included, is
   dropped. An earlier deployment still running beside 0.21 keeps its own lanes
   on the same prefix, so one lane's events can run on both at once. On
   [Vercel Queues](/schedulers/vercel-queues#deployments) it also keeps
   receiving the timers it scheduled, until they fire: let them, or remove the
   deployment and schedule the ones you need again on 0.21.
2. **Deploy 0.21.** Every event is kept. A consumer starts at the next event
   appended, or with `backfill` at the beginning of the log.

The keys earlier releases dispatched by (`meta`, `meta:{n}`, `meta-split`,
`pending`, `ready`, `snapshot-pins:{n}`, `snapshot-pin-counts`, under each
session) stay until you delete them; 0.21 never reads them.

## Memory

Nothing to do: the memory store starts empty in every process.
