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 () => {} },
},
})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.
| 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()carriesconsumer. Pass it toredrivewith the index. See Resolve a dead letter. - Agents take
consumers. Write them withconsumer()against the agent's contract, as in Conversations. A consumer of an AI event type runs beside the agent's own, soagent()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 nameda2.actor,a2.actor.concurrent,a2.ai.metadata,a2.ai.control,a2.ai.model, anda2.ai.tools, which their dead letters name. Their event ids are unchanged. Names starting witha2.are reserved. - Telemetry. Dashboards that filter
a2.append.sourceonhandlerreadconsumerinstead. Eacha2.eventspan also carriesa2.event.consumer; see Telemetry. - Custom stores implement the cursor contract:
cursors(),wakeLog, and the cursor-keyed claim, completion, failure, and redrive methods. SeeA2Store.
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.
-
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 nullIt 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.
-
Apply 0.21's
schema.sqlwhile 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. -
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.
- 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.
- Deploy 0.21. Every event is kept. A consumer starts at the next event
appended, or with
backfillat 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.