0.21

Handlers become consumers, and every store keeps their places in a new layout.

Handlers become consumers

// 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 () => {} },
  },
})
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 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.

Before0.21
createServer({ handlers })createServer({ consumers })
handler(entry){ name, types?, lane?, run } in consumers, or consumer({ contract, ... }).run(fn)
Handler, HandlerEntryremoved: consumer() types run's context
LaneConsumerLane
HandlerContextConsumerContext
HandlerAppendConsumerAppend
handlerContext(), from experimental-a2/aiconsumerContext()
runWithHandlerContext, from experimental-a2/ai/serverrunWithConsumerContext
agent({ handlers })agent({ consumers })
session.redrive(index, { action })session.redrive({ consumer, index }, { action })
a2.append.source = handlera2.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.
  • Agents take consumers. Write them with consumer() against the agent's contract, as in 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.
  • Custom stores implement the cursor contract: cursors(), wakeLog, and the cursor-keyed claim, completion, failure, and redrive methods. See 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):

    select count(*) from a2_events where processed_at is null

    It doesn't count timers. On Vercel Queues, 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). 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.

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