experimental-a2 0.17.0 → 0.18.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +21 -0
- package/README.md +1 -1
- package/dist/{actor-DBbe5xeE.d.ts → actor-Bjce9_5U.d.ts} +5 -5
- package/dist/{actor-DBbe5xeE.d.ts.map → actor-Bjce9_5U.d.ts.map} +1 -1
- package/dist/actor-client.d.ts +1 -1
- package/dist/actor-react.d.ts +3 -3
- package/dist/actor-react.js +1 -1
- package/dist/actor.d.ts +1 -1
- package/dist/actor.js +2 -2
- package/dist/{ai-UrWfKeuv.js → ai-BpOGm-zB.js} +2 -2
- package/dist/{ai-UrWfKeuv.js.map → ai-BpOGm-zB.js.map} +1 -1
- package/dist/{ai-vpGtcaCB.d.ts → ai-DrUaMyRt.d.ts} +3 -3
- package/dist/{ai-vpGtcaCB.d.ts.map → ai-DrUaMyRt.d.ts.map} +1 -1
- package/dist/ai-client.d.ts +2 -2
- package/dist/ai-client.js +2 -2
- package/dist/ai-fx.browser.d.ts +1 -0
- package/dist/ai-fx.browser.js +6 -0
- package/dist/ai-fx.browser.js.map +1 -0
- package/dist/ai-fx.d.ts +10 -0
- package/dist/ai-fx.d.ts.map +1 -0
- package/dist/ai-fx.js +634 -0
- package/dist/ai-fx.js.map +1 -0
- package/dist/ai-harness-BuLh_EYi.js +273 -0
- package/dist/ai-harness-BuLh_EYi.js.map +1 -0
- package/dist/ai-harness-DvsPeJeB.d.ts +26 -0
- package/dist/ai-harness-DvsPeJeB.d.ts.map +1 -0
- package/dist/ai-pi.browser.d.ts +1 -0
- package/dist/ai-pi.browser.js +6 -0
- package/dist/ai-pi.browser.js.map +1 -0
- package/dist/ai-pi.d.ts +17 -0
- package/dist/ai-pi.d.ts.map +1 -0
- package/dist/ai-pi.js +904 -0
- package/dist/ai-pi.js.map +1 -0
- package/dist/ai-react.d.ts +5 -5
- package/dist/ai-react.js +2 -2
- package/dist/ai-server.d.ts +47 -25
- package/dist/ai-server.d.ts.map +1 -1
- package/dist/ai-server.js +106 -37
- package/dist/ai-server.js.map +1 -1
- package/dist/ai.d.ts +1 -1
- package/dist/ai.js +1 -1
- package/dist/bounded-cache-CPHWF-8h.js +41 -0
- package/dist/bounded-cache-CPHWF-8h.js.map +1 -0
- package/dist/{client-COaiaDub.js → client-BReKqwZC.js} +3 -3
- package/dist/{client-COaiaDub.js.map → client-BReKqwZC.js.map} +1 -1
- package/dist/{client-qRnRUzBS.d.ts → client-h_DKFAjj.d.ts} +3 -3
- package/dist/{client-qRnRUzBS.d.ts.map → client-h_DKFAjj.d.ts.map} +1 -1
- package/dist/client.d.ts +3 -3
- package/dist/client.js +1 -1
- package/dist/index.d.ts +5 -5
- package/dist/{initial-snapshot-BrLso5x_.d.ts → initial-snapshot-Bx-c0pDP.d.ts} +2 -2
- package/dist/{initial-snapshot-BrLso5x_.d.ts.map → initial-snapshot-Bx-c0pDP.d.ts.map} +1 -1
- package/dist/{internal-CbVvEwad.js → internal-B2T3U4Rq.js} +8 -2
- package/dist/internal-B2T3U4Rq.js.map +1 -0
- package/dist/otel.d.ts +1 -1
- package/dist/{react-BbJ-g6Uu.d.ts → react-BBXgHWFc.d.ts} +4 -4
- package/dist/{react-BbJ-g6Uu.d.ts.map → react-BBXgHWFc.d.ts.map} +1 -1
- package/dist/react.d.ts +1 -1
- package/dist/react.js +1 -1
- package/dist/reducer-BAXc8JyF.js.map +1 -1
- package/dist/{reducer-BYu6DT7h.d.ts → reducer-CqA59Q6U.d.ts} +2 -2
- package/dist/{reducer-BYu6DT7h.d.ts.map → reducer-CqA59Q6U.d.ts.map} +1 -1
- package/dist/scheduler-qstash.d.ts +2 -2
- package/dist/scheduler-qstash.js +2 -2
- package/dist/scheduler-vercel.d.ts +2 -2
- package/dist/scheduler-vercel.js +2 -2
- package/dist/{server-Rj64fNvX.js → server-Dw_k_7kO.js} +93 -60
- package/dist/server-Dw_k_7kO.js.map +1 -0
- package/dist/{server-CGM8WzRG.d.ts → server-En7cbi3v.d.ts} +36 -7
- package/dist/server-En7cbi3v.d.ts.map +1 -0
- package/dist/server.d.ts +4 -4
- package/dist/server.js +1 -1
- package/dist/{session-clear-Dshpuhff.d.ts → session-clear-Bo-XTFEy.d.ts} +2 -2
- package/dist/session-clear-Bo-XTFEy.d.ts.map +1 -0
- package/dist/{store-Gpkq7WBo.d.ts → store-Djdv0bnZ.d.ts} +31 -2
- package/dist/store-Djdv0bnZ.d.ts.map +1 -0
- package/dist/store-N8PXxDAS.js.map +1 -1
- package/dist/store-memory.d.ts +1 -1
- package/dist/store-memory.js +32 -6
- package/dist/store-memory.js.map +1 -1
- package/dist/{store-polling-4_y8lETm.js → store-polling-B5FcwJfo.js} +2 -2
- package/dist/{store-polling-4_y8lETm.js.map → store-polling-B5FcwJfo.js.map} +1 -1
- package/dist/store-postgres.d.ts +1 -1
- package/dist/store-postgres.js +54 -3
- package/dist/store-postgres.js.map +1 -1
- package/dist/{store-presence-polling-Q1C-k2dd.js → store-presence-polling-BAwS8RWn.js} +3 -3
- package/dist/{store-presence-polling-Q1C-k2dd.js.map → store-presence-polling-BAwS8RWn.js.map} +1 -1
- package/dist/store-redis-http.d.ts +1 -1
- package/dist/store-redis-http.js +3 -3
- package/dist/{store-redis-notify-tqWNnMRu.js → store-redis-notify-BRITti_o.js} +187 -68
- package/dist/store-redis-notify-BRITti_o.js.map +1 -0
- package/dist/store-redis.d.ts +1 -1
- package/dist/store-redis.js +1 -1
- package/dist/store-sqlite.d.ts +1 -1
- package/dist/store-sqlite.js +32 -2
- package/dist/store-sqlite.js.map +1 -1
- package/dist/{telemetry-cZH-cibj.d.ts → telemetry-Cyfqo6Qt.d.ts} +2 -2
- package/dist/telemetry-Cyfqo6Qt.d.ts.map +1 -0
- package/dist/{wire-DjfHOvdW.js → wire-rSUglYo8.js} +2 -2
- package/dist/{wire-DjfHOvdW.js.map → wire-rSUglYo8.js.map} +1 -1
- package/docs/01-quickstart.mdx +49 -83
- package/docs/{guides/07-examples.mdx → 02-examples.mdx} +10 -20
- package/docs/actors/01-introduction.mdx +48 -86
- package/docs/actors/02-concurrency.mdx +43 -70
- package/docs/actors/03-timers.mdx +28 -42
- package/docs/actors/04-routes.mdx +110 -150
- package/docs/actors/meta.ts +1 -1
- package/docs/agents/01-introduction.mdx +257 -0
- package/docs/agents/02-tools.mdx +247 -0
- package/docs/agents/03-conversations.mdx +538 -0
- package/docs/agents/04-harnesses.mdx +136 -0
- package/docs/agents/meta.ts +1 -0
- package/docs/client/01-react.mdx +294 -0
- package/docs/client/02-transports.mdx +86 -0
- package/docs/client/03-presence.mdx +224 -0
- package/docs/client/meta.ts +1 -0
- package/docs/contract/01-events.mdx +57 -0
- package/docs/contract/02-state.mdx +142 -0
- package/docs/contract/03-sessions.mdx +175 -0
- package/docs/index.mdx +108 -144
- package/docs/reference/01-core.mdx +139 -0
- package/docs/reference/02-server.mdx +855 -0
- package/docs/reference/03-client.mdx +301 -0
- package/docs/reference/04-ai.mdx +722 -0
- package/docs/reference/05-adapters.mdx +351 -0
- package/docs/reference/06-errors.mdx +138 -0
- package/docs/reference/meta.ts +1 -1
- package/docs/server/01-handlers.mdx +200 -0
- package/docs/server/02-timers.mdx +130 -0
- package/docs/server/03-cancellation.mdx +141 -0
- package/docs/server/04-routes.mdx +243 -0
- package/docs/server/05-production.mdx +322 -0
- package/docs/server/06-guarantees.mdx +123 -0
- package/examples/playground/DEPLOY.md +1 -1
- package/examples/playground/app/a0/[appId]/page.tsx +11 -6
- package/examples/playground/app/a0/app-list.tsx +9 -2
- package/examples/playground/app/a0/app-view.tsx +9 -1
- package/examples/playground/app/a0/apps/[appId]/route.ts +10 -5
- package/examples/playground/app/a0/apps.test.ts +12 -0
- package/examples/playground/app/a0/apps.ts +22 -7
- package/examples/playground/app/a0/chat.tsx +47 -1
- package/examples/playground/app/a0/fx/events/route.ts +9 -0
- package/examples/playground/app/a0/fx/scheduler/route.ts +7 -0
- package/examples/playground/app/a0/fx/short/events/route.ts +11 -0
- package/examples/playground/app/a0/model.test.ts +18 -1
- package/examples/playground/app/a0/model.ts +47 -10
- package/examples/playground/app/a0/no-photon.cjs +4 -0
- package/examples/playground/app/a0/no-quickjs.cjs +3 -0
- package/examples/playground/app/a0/pi/events/route.ts +9 -0
- package/examples/playground/app/a0/pi/scheduler/route.ts +7 -0
- package/examples/playground/app/a0/pi/short/events/route.ts +11 -0
- package/examples/playground/app/a0/sandbox-log.ts +10 -4
- package/examples/playground/app/a0/sandbox-session.ts +152 -0
- package/examples/playground/app/a0/scheduler/route.ts +2 -2
- package/examples/playground/app/a0/server.ts +100 -45
- package/examples/playground/app/a0/session.ts +17 -5
- package/examples/playground/app/a0/shell.tsx +11 -3
- package/examples/playground/app/agent/ingress.test.ts +6 -3
- package/examples/playground/app/agent/server.ts +9 -6
- package/examples/playground/app/agent/telemetry.test.ts +71 -50
- package/examples/playground/app/canvas/[canvasId]/canvas-client.tsx +1 -1
- package/examples/playground/app/chat/server.test.ts +30 -26
- package/examples/playground/app/chat/server.ts +46 -46
- package/examples/playground/next.config.ts +17 -5
- package/examples/playground/package.json +4 -1
- package/examples/playground/vercel.json +18 -0
- package/package.json +27 -3
- package/src/ai-agent.ts +261 -87
- package/src/ai-fx.browser.ts +5 -0
- package/src/ai-fx.ts +833 -0
- package/src/ai-harness.ts +518 -0
- package/src/ai-model-metadata.ts +11 -6
- package/src/ai-pi.browser.ts +5 -0
- package/src/ai-pi.ts +1263 -0
- package/src/ai-sdk-step.ts +2 -2
- package/src/ai-server.ts +3 -1
- package/src/internal.ts +7 -0
- package/src/libfx.d.ts +34 -0
- package/src/reducer.ts +1 -1
- package/src/server.ts +177 -19
- package/src/store-memory.ts +42 -7
- package/src/store-postgres.ts +80 -0
- package/src/store-redis-core.ts +210 -79
- package/src/store-redis-notify.ts +2 -0
- package/src/store-sqlite.ts +60 -0
- package/src/store.ts +33 -0
- package/src/telemetry.ts +1 -0
- package/dist/internal-CbVvEwad.js.map +0 -1
- package/dist/server-CGM8WzRG.d.ts.map +0 -1
- package/dist/server-Rj64fNvX.js.map +0 -1
- package/dist/session-clear-Dshpuhff.d.ts.map +0 -1
- package/dist/store-Gpkq7WBo.d.ts.map +0 -1
- package/dist/store-redis-notify-tqWNnMRu.js.map +0 -1
- package/dist/telemetry-cZH-cibj.d.ts.map +0 -1
- package/docs/concepts/01-contracts.mdx +0 -237
- package/docs/concepts/02-handlers.mdx +0 -284
- package/docs/concepts/03-durability.mdx +0 -314
- package/docs/concepts/04-state.mdx +0 -209
- package/docs/guides/01-timers.mdx +0 -193
- package/docs/guides/02-cancellation.mdx +0 -161
- package/docs/guides/03-react.mdx +0 -324
- package/docs/guides/04-local-first.mdx +0 -47
- package/docs/guides/05-production.mdx +0 -534
- package/docs/guides/06-ai-agents.mdx +0 -1408
- package/docs/guides/08-application-data.mdx +0 -113
- package/docs/guides/09-presence.mdx +0 -262
- package/docs/guides/10-transports.mdx +0 -221
- package/docs/reference/01-api.mdx +0 -2358
- package/docs/reference/02-errors.mdx +0 -150
- /package/docs/{concepts → contract}/meta.ts +0 -0
- /package/docs/{guides → server}/meta.ts +0 -0
|
@@ -1,534 +0,0 @@
|
|
|
1
|
-
---
|
|
2
|
-
title: Going to production
|
|
3
|
-
description: Point the store at durable storage, add a scheduler for recovery and timers, and know what to do when an event dead-letters.
|
|
4
|
-
---
|
|
5
|
-
|
|
6
|
-
## Two pieces
|
|
7
|
-
|
|
8
|
-
Development needs zero setup: SQLite appears under `.a2/`, and tests run
|
|
9
|
-
in memory. Every production server needs a durable store. Servers with handlers
|
|
10
|
-
or scheduled events also need a scheduler. Neither choice changes your event
|
|
11
|
-
contract.
|
|
12
|
-
|
|
13
|
-
## 1. Choose a store
|
|
14
|
-
|
|
15
|
-
Production has no default store, on purpose. A server without one throws
|
|
16
|
-
`STORE_NOT_CONFIGURED` at startup. A failed boot beats events written to a
|
|
17
|
-
filesystem that evaporates.
|
|
18
|
-
|
|
19
|
-
```ts server/orders.ts
|
|
20
|
-
import { createServer } from 'experimental-a2/server'
|
|
21
|
-
import { postgres } from 'experimental-a2/store-postgres'
|
|
22
|
-
import { orders } from '@/contracts'
|
|
23
|
-
|
|
24
|
-
export const ordersServer = createServer({
|
|
25
|
-
contract: orders,
|
|
26
|
-
store: postgres({ connectionString: process.env.DATABASE_URL }),
|
|
27
|
-
handlers: {
|
|
28
|
-
/* ... */
|
|
29
|
-
},
|
|
30
|
-
})
|
|
31
|
-
```
|
|
32
|
-
|
|
33
|
-
The Postgres backend uses real transactions; appends serialize per
|
|
34
|
-
session on an advisory lock, while handler claims remain concurrent. The live
|
|
35
|
-
stream polls the store with an
|
|
36
|
-
activity-adaptive cadence: 25ms while a session is producing events
|
|
37
|
-
(a token stream reads smoothly, not in clumps), backing off to two seconds
|
|
38
|
-
when it goes quiet. Any Postgres works: Neon, Supabase, RDS, your own
|
|
39
|
-
box; transaction-mode poolers included, which is exactly why polling
|
|
40
|
-
is the default. `pg` is an optional peer dependency; pass
|
|
41
|
-
`connectionString`, or inject your own pool as `client`.
|
|
42
|
-
|
|
43
|
-
To use native Postgres notifications, pass `listenConnectionString` alongside
|
|
44
|
-
`connectionString` (or `client`). It must point to the same database through a
|
|
45
|
-
direct connection. Stores with matching connection strings, Postgres environment
|
|
46
|
-
settings, and driver defaults share one query pool and one dedicated listener
|
|
47
|
-
within the same JavaScript runtime, including across separately loaded copies
|
|
48
|
-
of A2. Owned pools also share schema initialization for each schema revision.
|
|
49
|
-
The listener handles every store's event and presence channels.
|
|
50
|
-
|
|
51
|
-
On Vercel, pools created by A2 use the current request's `waitUntil` internally
|
|
52
|
-
to let idle connections close before the instance suspends. Nearby requests
|
|
53
|
-
reuse those connections. This adds no database queries and needs no setup.
|
|
54
|
-
When you supply `client`, you manage its pool's platform lifecycle.
|
|
55
|
-
|
|
56
|
-
Closing a store stops its own subscriptions. The listener closes when its last
|
|
57
|
-
subscriber leaves; the query pool closes when its last store releases it.
|
|
58
|
-
Injected clients are shared by object identity. Each store keeps its own clock,
|
|
59
|
-
ID generator, cursors, and presence state. Different serverless instances still
|
|
60
|
-
have separate pools and listeners, so the direct connection needs database
|
|
61
|
-
connection headroom.
|
|
62
|
-
|
|
63
|
-
Enabling this option installs transactional notification triggers on A2's event
|
|
64
|
-
and presence tables. They also cover writes from instances that use only the
|
|
65
|
-
pooled connection. The triggers remain installed when listeners disconnect.
|
|
66
|
-
Notifications are delivered after commit; a reconnect catches up from the
|
|
67
|
-
cursor. Native event and presence subscriptions also reconcile every ten
|
|
68
|
-
seconds. Without `listenConnectionString`, the store uses adaptive polling.
|
|
69
|
-
|
|
70
|
-
An idle stream stays connected and keeps receiving heartbeats. A new event
|
|
71
|
-
returns polling to its fast cadence. A local `session.push()` refreshes an
|
|
72
|
-
idle SSE stream immediately from its current cursor, even when the write and
|
|
73
|
-
stream routes run in different invocations. WebSocket pushes wake the attached
|
|
74
|
-
stream without reopening the socket. A stream that becomes idle while a push
|
|
75
|
-
is pending, or before observing its acknowledgement, wakes again. An update
|
|
76
|
-
from another client can take up to the idle interval plus query latency to
|
|
77
|
-
arrive after a quiet period.
|
|
78
|
-
|
|
79
|
-
Prefer Redis? `redis({ url })` from `experimental-a2/store-redis` stores each
|
|
80
|
-
session as a Redis Stream and streams push-natively: every write publishes a
|
|
81
|
-
disposable wake-up, one shared subscriber connection per process serves every
|
|
82
|
-
connected viewer, and a safety re-read covers a lost wake-up within ten
|
|
83
|
-
seconds. Connections scale with your processes, not with your audience. On
|
|
84
|
-
providers that bill per command, such as Upstash, each command a script runs
|
|
85
|
-
counts: appending an event with a handler and running it takes 24 commands,
|
|
86
|
-
and an idle live stream takes 6 a minute.
|
|
87
|
-
`ioredis` is an optional peer dependency; pass `url`, or inject a client.
|
|
88
|
-
Works on single instances and non-cluster providers such as Upstash, where
|
|
89
|
-
durability is on by default. When only a REST API is available,
|
|
90
|
-
`redisHttp({ url, token })` from `experimental-a2/store-redis-http` speaks the
|
|
91
|
-
same storage over `fetch`. Live events and presence use Upstash’s HTTP
|
|
92
|
-
`SUBSCRIBE` endpoint (SSE), with the same URL and token. Each active channel
|
|
93
|
-
holds one outbound HTTP subscription per store instance, shared by its local
|
|
94
|
-
viewers and closed when the last viewer leaves. Writes publish wake-ups inside
|
|
95
|
-
the same Redis script as the append, and each wake-up carries the new events so
|
|
96
|
-
live streams deliver them without another read. Live streams trust those
|
|
97
|
-
messages, so a Redis identity that can publish to the `:notify` channels can add
|
|
98
|
-
or hide live events: grant publish access only to identities that can also
|
|
99
|
-
write the store. Presence writes
|
|
100
|
-
also publish their applied fields inside the script. A rejected notification
|
|
101
|
-
does not fail the applied write. Idle event streams
|
|
102
|
-
read once every ten seconds to recover missed notifications. Subscriptions reconnect automatically and catch up from
|
|
103
|
-
the event cursor. Presence reads wait for subscription readiness; after a
|
|
104
|
-
subscription reconnects, A2 re-reads the current presence fields, clears fields
|
|
105
|
-
deleted during the gap, and may repeat their latest values. Updates received
|
|
106
|
-
during that read take precedence. If the subscription endpoint returns 404, 405, or 501, A2
|
|
107
|
-
checks ordinary Redis commands with `PING` and remembers that the store must
|
|
108
|
-
poll instead. Events and presence then use adaptive polling, from 25ms to two
|
|
109
|
-
seconds while idle. Authentication errors, timeouts, and other failures remain
|
|
110
|
-
errors. No Redis client dependency or extra notifier setup is needed.
|
|
111
|
-
|
|
112
|
-
This configures storage for A2's session histories. It does not connect A2 to your
|
|
113
|
-
application tables or make them part of the append transaction. See
|
|
114
|
-
[A2 and your database](/guides/application-data) for that boundary.
|
|
115
|
-
|
|
116
|
-
## 2. Add a scheduler for handlers and timers
|
|
117
|
-
|
|
118
|
-
The scheduler puts a clock on recovery and future appends. Vercel Queues is the
|
|
119
|
-
recommended default on Vercel. QStash is an alternative when you want HTTP
|
|
120
|
-
delivery from separate infrastructure. Both adapters carry A2 recovery
|
|
121
|
-
watchdogs and `session.schedule` tasks through the same route.
|
|
122
|
-
|
|
123
|
-
### Vercel Queues (recommended)
|
|
124
|
-
|
|
125
|
-
`experimental-a2/scheduler-vercel` rides Vercel Queues (`@vercel/queue` is a
|
|
126
|
-
peer dependency). Same `server/orders.ts`, now with `scheduler`:
|
|
127
|
-
|
|
128
|
-
```ts
|
|
129
|
-
// server/orders.ts, now with a scheduler:
|
|
130
|
-
import { createServer } from 'experimental-a2/server'
|
|
131
|
-
import { postgres } from 'experimental-a2/store-postgres'
|
|
132
|
-
import { vercelQueues } from 'experimental-a2/scheduler-vercel'
|
|
133
|
-
import { orders } from '@/contracts'
|
|
134
|
-
|
|
135
|
-
export const scheduler = vercelQueues()
|
|
136
|
-
|
|
137
|
-
export const ordersServer = createServer({
|
|
138
|
-
contract: orders,
|
|
139
|
-
store: postgres({ connectionString: process.env.DATABASE_URL }),
|
|
140
|
-
scheduler,
|
|
141
|
-
handlers: {
|
|
142
|
-
/* ... */
|
|
143
|
-
},
|
|
144
|
-
})
|
|
145
|
-
```
|
|
146
|
-
|
|
147
|
-
One scheduler instance is shared by every server on this route. Mount its
|
|
148
|
-
handler with every server it may deliver to:
|
|
149
|
-
|
|
150
|
-
```ts app/api/a2/scheduler/route.ts
|
|
151
|
-
import { billingServer, ordersServer, scheduler } from '@/server'
|
|
152
|
-
|
|
153
|
-
export const POST = scheduler.handler(ordersServer, billingServer)
|
|
154
|
-
```
|
|
155
|
-
|
|
156
|
-
`scheduler.handler(...)` checks the server list synchronously when this module
|
|
157
|
-
loads. It requires at least one drainable server and unique contract names. A
|
|
158
|
-
bad route fails during startup or a build that evaluates route modules, before
|
|
159
|
-
it can accept a delivery.
|
|
160
|
-
|
|
161
|
-
```json vercel.json
|
|
162
|
-
{
|
|
163
|
-
"functions": {
|
|
164
|
-
"app/api/a2/scheduler/route.ts": {
|
|
165
|
-
"experimentalTriggers": [
|
|
166
|
-
{
|
|
167
|
-
"type": "queue/v2beta",
|
|
168
|
-
"topic": "a2"
|
|
169
|
-
}
|
|
170
|
-
]
|
|
171
|
-
}
|
|
172
|
-
}
|
|
173
|
-
}
|
|
174
|
-
```
|
|
175
|
-
|
|
176
|
-
The trigger is required. It is the Vercel Queues consumer integration and the
|
|
177
|
-
route's protection boundary: Vercel makes the function private, and only queue
|
|
178
|
-
infrastructure can invoke it. Do not expose the callback as an ordinary public
|
|
179
|
-
route or add a second authentication scheme around it.
|
|
180
|
-
|
|
181
|
-
The adapter uses one queue client for recovery watchdogs, scheduled appends,
|
|
182
|
-
and scheduled clears. Its `deploymentId` setting applies to sends, payload
|
|
183
|
-
fetches, acknowledgements, and visibility changes. Omit it to use the publishing
|
|
184
|
-
deployment, pass a deployment ID to target that deployment, or pass `null` for
|
|
185
|
-
deploymentless delivery. Publishing and consuming clients must use the same
|
|
186
|
-
deployment scope.
|
|
187
|
-
|
|
188
|
-
Promoting a new deployment does not move pinned tasks. Older deployments keep
|
|
189
|
-
receiving their own work until it is acknowledged or expires. Upgrading does
|
|
190
|
-
not repair an older deployment's callback handler.
|
|
191
|
-
|
|
192
|
-
With `deploymentId: null`, Vercel chooses an eligible deploymentless consumer;
|
|
193
|
-
this does not select the latest production deployment. Every eligible consumer
|
|
194
|
-
must use `deploymentId: null` and remain compatible with pending tasks and
|
|
195
|
-
session work.
|
|
196
|
-
|
|
197
|
-
Changing the setting does not move already queued tasks. Keep consumers for
|
|
198
|
-
their original deployment scope until those tasks finish. The former
|
|
199
|
-
`scheduledAppends: 'deploymentless'` option affected only timers; its replacement,
|
|
200
|
-
`deploymentId: null`, also makes recovery deploymentless.
|
|
201
|
-
|
|
202
|
-
Vercel Queues defaults to 24-hour message retention. The adapter sets retention
|
|
203
|
-
to 7 days and accepts scheduled appends and clears through 6 days, reserving
|
|
204
|
-
the final day
|
|
205
|
-
for delivery and retries. Recovery watchdogs are measured in seconds.
|
|
206
|
-
|
|
207
|
-
### QStash
|
|
208
|
-
|
|
209
|
-
`experimental-a2/scheduler-qstash` uses QStash HTTP delivery
|
|
210
|
-
(`@upstash/qstash` 2.11.0 or newer is a peer dependency). The conventional
|
|
211
|
-
callback URL is
|
|
212
|
-
inferred in QStash development mode and on Vercel production and preview
|
|
213
|
-
deployments:
|
|
214
|
-
|
|
215
|
-
```ts
|
|
216
|
-
// server/orders.ts, with QStash instead:
|
|
217
|
-
import { createServer } from 'experimental-a2/server'
|
|
218
|
-
import { postgres } from 'experimental-a2/store-postgres'
|
|
219
|
-
import { qstash } from 'experimental-a2/scheduler-qstash'
|
|
220
|
-
import { orders } from '@/contracts'
|
|
221
|
-
|
|
222
|
-
export const scheduler = qstash()
|
|
223
|
-
|
|
224
|
-
export const ordersServer = createServer({
|
|
225
|
-
contract: orders,
|
|
226
|
-
store: postgres({ connectionString: process.env.DATABASE_URL }),
|
|
227
|
-
scheduler,
|
|
228
|
-
handlers: {
|
|
229
|
-
/* ... */
|
|
230
|
-
},
|
|
231
|
-
})
|
|
232
|
-
```
|
|
233
|
-
|
|
234
|
-
Mount `scheduler.handler(ordersServer, billingServer)` once with the same
|
|
235
|
-
`app/api/a2/scheduler/route.ts` module shown above. Local Node.js development
|
|
236
|
-
needs no QStash environment variables. With `@upstash/qstash` 2.11.0 or newer,
|
|
237
|
-
A2 uses `http://localhost:${PORT ?? 3000}/api/a2/scheduler` and enables the
|
|
238
|
-
SDK's development mode. On the first publish or verification, the lazy SDK
|
|
239
|
-
transport downloads and starts its local server. `PORT` selects the application
|
|
240
|
-
port. `QSTASH_DEV_PORT` optionally selects the QStash development server port
|
|
241
|
-
and does not change the callback. This inferred local route sets SDK development
|
|
242
|
-
mode directly; `QSTASH_DEV=false` does not disable it. Pass an explicit `url`
|
|
243
|
-
to leave SDK mode under environment control.
|
|
244
|
-
|
|
245
|
-
A process-less runtime cannot infer local development and needs an explicit
|
|
246
|
-
`url`. An Edge runtime that exposes `process.env` can use the inferred localhost
|
|
247
|
-
callback, but it cannot start the development server itself. Start the server
|
|
248
|
-
from a Node.js instrumentation hook or run it manually. Configure the SDK with
|
|
249
|
-
`QSTASH_DEV` or the manual `QSTASH_URL` and development credentials described
|
|
250
|
-
in the [QStash local-development guide](https://upstash.com/docs/qstash/howto/local-development).
|
|
251
|
-
|
|
252
|
-
A2 disables the SDK's development mode when `NODE_ENV=production`, even if
|
|
253
|
-
`QSTASH_DEV` remains set. Production must use deployed QStash credentials and
|
|
254
|
-
must not rely on the local development server.
|
|
255
|
-
|
|
256
|
-
Production uses `VERCEL_PROJECT_PRODUCTION_URL`; preview uses
|
|
257
|
-
`VERCEL_BRANCH_URL`. Both append the conventional `/api/a2/scheduler` path. An
|
|
258
|
-
explicit `url` always wins. Pass one for another host or path; an application
|
|
259
|
-
port that is not available through `PORT`; Docker networking; a public
|
|
260
|
-
development tunnel; or a non-Vercel production deployment. Outside production,
|
|
261
|
-
an explicit URL leaves SDK development mode under the SDK's normal environment
|
|
262
|
-
configuration. This supports `QSTASH_DEV=true` and manually supplied
|
|
263
|
-
`QSTASH_URL` plus credentials without changing the callback URL.
|
|
264
|
-
|
|
265
|
-
Production and branch URLs are moving aliases. Pending QStash tasks can reach a
|
|
266
|
-
later deployment on that alias, so keep scheduler task envelopes and event
|
|
267
|
-
schemas compatible across deployments.
|
|
268
|
-
|
|
269
|
-
The deployed route must be internet-reachable by QStash after any platform edge
|
|
270
|
-
authentication. The local QStash server calls the inferred localhost route
|
|
271
|
-
directly. A2 reads the raw request body and verifies `Upstash-Signature`, the
|
|
272
|
-
callback URL, and the optional `Upstash-Region` before parsing the message or
|
|
273
|
-
touching the store. Missing or invalid signatures return 401, so ordinary
|
|
274
|
-
unsigned traffic cannot execute scheduler work.
|
|
275
|
-
|
|
276
|
-
When A2 infers a Vercel preview callback, it opportunistically reads the current
|
|
277
|
-
invocation's `x-vercel-oidc-token` on every publish. If the token is a parseable
|
|
278
|
-
JWT that remains valid for at least 60 seconds after the task's first due time,
|
|
279
|
-
the built-in transport forwards it as
|
|
280
|
-
`x-vercel-trusted-oidc-idp-token` and asks QStash to redact that header from its
|
|
281
|
-
dashboard and API. This lets near-term tasks enter a protected preview when its
|
|
282
|
-
[Trusted Sources](https://vercel.com/docs/deployment-protection/methods-to-bypass-deployment-protection/trusted-sources)
|
|
283
|
-
rule accepts the publishing deployment's identity. QStash still receives the
|
|
284
|
-
credential so it can deliver it.
|
|
285
|
-
|
|
286
|
-
This forwarding is best-effort first-delivery admission, not durable
|
|
287
|
-
authentication. A missing, malformed, or near-expiry token leaves the publish
|
|
288
|
-
unchanged so an unprotected preview does not regress. An explicit `url` opts out,
|
|
289
|
-
and inferred production and local callbacks do not forward the token. QStash
|
|
290
|
-
stores the static header and reuses it on retries, so a delayed task or retry can
|
|
291
|
-
outlive the identity even if the token passed the first-due-time check.
|
|
292
|
-
|
|
293
|
-
Callbacks that need durable access through Deployment Protection still require
|
|
294
|
-
an explicit
|
|
295
|
-
[automation bypass](https://vercel.com/docs/deployment-protection/methods-to-bypass-deployment-protection/protection-bypass-automation)
|
|
296
|
-
or a public ingress. Pass a dedicated bypass as a callback header when that is
|
|
297
|
-
the right tradeoff for your project:
|
|
298
|
-
|
|
299
|
-
```ts server/scheduler-qstash-protected.ts
|
|
300
|
-
import { qstash } from 'experimental-a2/scheduler-qstash'
|
|
301
|
-
|
|
302
|
-
const bypass = process.env.VERCEL_AUTOMATION_BYPASS_SECRET
|
|
303
|
-
if (!bypass) throw new Error('Missing VERCEL_AUTOMATION_BYPASS_SECRET')
|
|
304
|
-
|
|
305
|
-
export const scheduler = qstash({
|
|
306
|
-
headers: { 'x-vercel-protection-bypass': bypass },
|
|
307
|
-
})
|
|
308
|
-
```
|
|
309
|
-
|
|
310
|
-
A2 asks QStash to redact every configured callback header from its dashboard
|
|
311
|
-
and API. QStash still receives, stores, and delivers the original value. A
|
|
312
|
-
rotation affects new messages only; already queued messages keep their captured
|
|
313
|
-
header. A custom transport must deliver every `headers` entry and apply every
|
|
314
|
-
`redact.header` name it receives.
|
|
315
|
-
|
|
316
|
-
A separately implemented public Vercel production ingress can instead verify
|
|
317
|
-
the QStash signature and relay the task at delivery time with its current
|
|
318
|
-
Vercel OIDC token. That architecture requires an explicit
|
|
319
|
-
production-to-preview Trusted Sources rule, a strict target allowlist, and a
|
|
320
|
-
versioned routing envelope. The adapter does not implement that relay.
|
|
321
|
-
`Upstash-Signature` remains the authentication boundary for scheduler work
|
|
322
|
-
after any deployment-protection check.
|
|
323
|
-
|
|
324
|
-
Servers configured with different scheduler instances use separate routes,
|
|
325
|
-
even when both instances come from the same adapter. Each QStash instance needs
|
|
326
|
-
its own callback URL, public when cloud QStash delivers to it, so additional
|
|
327
|
-
instances pass `url` explicitly. Each Vercel Queues instance needs its own
|
|
328
|
-
`topic` and a matching `experimentalTriggers` entry for that route. One route
|
|
329
|
-
cannot safely choose a provider after a delivery arrives.
|
|
330
|
-
|
|
331
|
-
By default, the adapter reads `QSTASH_TOKEN`,
|
|
332
|
-
`QSTASH_CURRENT_SIGNING_KEY`, and `QSTASH_NEXT_SIGNING_KEY`. You can instead
|
|
333
|
-
pass `token`, `currentSigningKey`, and `nextSigningKey` directly to `qstash()`.
|
|
334
|
-
Pass a configured `@upstash/qstash` `Client` as `client` when you need to tune
|
|
335
|
-
its API endpoint, publish-request retry policy, or telemetry. A2 continues to
|
|
336
|
-
create the signature `Receiver` from the signing-key options. Use A2's
|
|
337
|
-
`headers` option, not client-global headers, for callback credentials. Use the
|
|
338
|
-
full `transport` override when publishing and verification both need custom
|
|
339
|
-
behavior. An injected client or transport receives raw callback credentials
|
|
340
|
-
and must honor A2's redaction request without logging them.
|
|
341
|
-
The adapter reads at most 1 MiB per callback. Before an initial publish, it
|
|
342
|
-
checks the normalized task with enough reserved room for the largest private
|
|
343
|
-
store-outage lineage it may later need. Every accepted task can therefore carry
|
|
344
|
-
that lineage under the same `maxBodyBytes` limit. Store-outage successors also
|
|
345
|
-
check their actual body before publishing. Set `maxBodyBytes` to a larger plan
|
|
346
|
-
limit when your scheduled event batches legitimately need it. Keep the same
|
|
347
|
-
value on every deployment that can receive the callback URL. Lowering it during
|
|
348
|
-
a rolling deployment can make the new deployment reject a task that the
|
|
349
|
-
previous deployment accepted.
|
|
350
|
-
|
|
351
|
-
QStash delivery is at least once. Stalled work publishes a fresh successor
|
|
352
|
-
watchdog before acknowledging, so QStash's per-message retry budget cannot stop
|
|
353
|
-
recovery before A2 reaches its own failure limit. A retryable store outage also
|
|
354
|
-
hands recovery or timer delivery to a fresh message, but uses a slower outage
|
|
355
|
-
backoff. The first successor waits 30 seconds. Each later generation waits four
|
|
356
|
-
times longer, capped at one day. The generation stays in the signed message
|
|
357
|
-
body and keeps increasing after the delay reaches the cap. Duplicate callbacks
|
|
358
|
-
therefore converge on one successor generation even when they run at different
|
|
359
|
-
times. Separate drain episodes use the first failing task's `dueAt` as part of
|
|
360
|
-
their lineage. Append episodes use the contract, session, and stable task id.
|
|
361
|
-
|
|
362
|
-
If the successor publish fails, the current delivery returns non-2xx and
|
|
363
|
-
QStash retries it. The adapter omits `retries` by default, which lets QStash use
|
|
364
|
-
its provider default. Pass `retries` to set an explicit non-negative value.
|
|
365
|
-
Every delivery attempt is billable. Deterministic publish keys reduce duplicate
|
|
366
|
-
watchdogs during QStash's documented 10-minute deduplication window. That is a
|
|
367
|
-
cost optimization, not a correctness boundary. Durable store claims, attempt
|
|
368
|
-
fencing, and idempotent handler side effects handle duplicate deliveries.
|
|
369
|
-
|
|
370
|
-
No callback can publish a successor when QStash cannot reach the route at all.
|
|
371
|
-
Monitor QStash's DLQ and redrive those messages after a prolonged endpoint or
|
|
372
|
-
deployment outage.
|
|
373
|
-
|
|
374
|
-
QStash currently caps a one-shot delay at 7 days on free plans and 1 year on
|
|
375
|
-
usage-based plans; fixed plans have no fixed maximum. A2 recovery watchdogs
|
|
376
|
-
are seconds apart. Scheduled appends must fit the active plan's limit. Leave at
|
|
377
|
-
least one second of margin at a QStash plan maximum because `notBefore` uses
|
|
378
|
-
whole Unix seconds.
|
|
379
|
-
|
|
380
|
-
Both built-in adapters preserve the original error when a provider rejects a
|
|
381
|
-
send. A recognized definite HTTP 4xx status or error is terminal, except 408,
|
|
382
|
-
409, 425, and 429.
|
|
383
|
-
Those four statuses, every 5xx response, network failures, and errors without a
|
|
384
|
-
recognized status stay retryable because the send may be transient or
|
|
385
|
-
ambiguous. The Vercel adapter also recognizes the SDK's name-only bad request,
|
|
386
|
-
unauthorized, and forbidden errors. Its native duplicate-message error remains
|
|
387
|
-
success. This classification lets durable A2 tools retry transient sends
|
|
388
|
-
without retrying permanent credentials or request errors.
|
|
389
|
-
|
|
390
|
-
One route, two task kinds. A top-level append that leaves or finds pending
|
|
391
|
-
handler work sends a delayed, coalesced "drain this session" watchdog alongside
|
|
392
|
-
its inline handler. The handler does not wait for the queue, while `append`
|
|
393
|
-
joins the initial send for up to two seconds before it returns. An unresponsive
|
|
394
|
-
queue therefore cannot hold the append open indefinitely. Claim renewals send
|
|
395
|
-
another watchdog for just after the current window. Live handlers keep moving
|
|
396
|
-
their per-event claims and the watchdog forward. A killed holder stops
|
|
397
|
-
heartbeating, its claims expire, and the next watchdog retries those pending
|
|
398
|
-
events. With Vercel Queues, A2 caps the final claim window at the platform
|
|
399
|
-
function deadline when Vercel exposes it, so timeout recovery starts promptly.
|
|
400
|
-
A failing handler keeps the current message and redelivers with backoff. Work
|
|
401
|
-
outside its lane continues.
|
|
402
|
-
|
|
403
|
-
Due times are rounded to one-second slots. Top-level sends, claim renewals, and
|
|
404
|
-
racing deliveries targeting the same slot use the same deterministic publish
|
|
405
|
-
key. Provider deduplication can fold them into one message. Busy deliveries
|
|
406
|
-
continue the current message's heartbeat-aligned slot series, so they do not
|
|
407
|
-
create an independent stream of watchdog callbacks.
|
|
408
|
-
Events appended or returned by handlers ride their current execution window
|
|
409
|
-
and add no scheduler operation of their own.
|
|
410
|
-
|
|
411
|
-
Drain tasks carry recovery state and consult the store. Timer tasks carry their
|
|
412
|
-
fixed-id events until delivery, then enter the store through an ordinary
|
|
413
|
-
top-level append.
|
|
414
|
-
|
|
415
|
-
### Dispatch in a fresh invocation
|
|
416
|
-
|
|
417
|
-
Ordinary `append` starts handlers inline and uses the scheduler as the watchdog.
|
|
418
|
-
Use `append.dispatch` when the current request should only persist the input
|
|
419
|
-
and hand pending work directly to the queue:
|
|
420
|
-
|
|
421
|
-
```ts app/api/imports/route.ts
|
|
422
|
-
import { ordersServer } from '@/server'
|
|
423
|
-
|
|
424
|
-
export async function POST(request: Request) {
|
|
425
|
-
const { orderId, shopId, items } = await request.json()
|
|
426
|
-
await ordersServer.session(orderId).append.dispatch({
|
|
427
|
-
id: `created:${orderId}`,
|
|
428
|
-
type: 'created',
|
|
429
|
-
payload: { shopId, items },
|
|
430
|
-
})
|
|
431
|
-
return Response.json({ accepted: true }, { status: 202 })
|
|
432
|
-
}
|
|
433
|
-
```
|
|
434
|
-
|
|
435
|
-
The call awaits the immediate queue send and does not start an inline drain.
|
|
436
|
-
If the send fails, the event may already be durable. Its explicit ID makes the
|
|
437
|
-
retry idempotent. An existing drain for the same session can still claim the
|
|
438
|
-
event first; dispatch controls the wakeup path, not worker affinity.
|
|
439
|
-
|
|
440
|
-
### Schedule a future append
|
|
441
|
-
|
|
442
|
-
`session.schedule(name, { delay: '30s' }, ...events)` uses this same adapter
|
|
443
|
-
and route. The call validates the events, requires JSON payload values, and
|
|
444
|
-
awaits provider acceptance. It does not write an event until delivery. The
|
|
445
|
-
delivered events are ordinary root appends with `cause: null`.
|
|
446
|
-
|
|
447
|
-
Use one stable name, timing, and event batch across retries. A root relative
|
|
448
|
-
delay starts from each call, while a handler relative delay starts from its
|
|
449
|
-
triggering event's durable `createdAt`. Use `{ at: Date }` when a retried root
|
|
450
|
-
call needs one target time. [Timers and delays](/guides/timers) covers
|
|
451
|
-
identity, stale-timer guards, and the full provider limits.
|
|
452
|
-
|
|
453
|
-
:::warning
|
|
454
|
-
Do not roll back to an A2 version without timer delivery while scheduled tasks
|
|
455
|
-
are outstanding. New routes accept old recovery messages, but an old route can
|
|
456
|
-
acknowledge a new append task without writing its events. The same applies to
|
|
457
|
-
scheduled clears and a version without them: an old Vercel Queues route keeps
|
|
458
|
-
failing a clear task until retention ends, and an old QStash route rejects it
|
|
459
|
-
as non-retryable, so QStash moves it to its dead-letter queue. Roll forward, or
|
|
460
|
-
wait for those tasks to deliver or remove them through the provider first.
|
|
461
|
-
:::
|
|
462
|
-
|
|
463
|
-
## 3. When an event dead-letters
|
|
464
|
-
|
|
465
|
-
After ten caught handler failures, A2 stops retrying an event. It blocks later
|
|
466
|
-
events in the same lane; unlaned events and other lanes continue.
|
|
467
|
-
[Durability](/concepts/durability#when-a-handler-keeps-failing) explains this
|
|
468
|
-
boundary. Resolution is manual, and has
|
|
469
|
-
exactly two shapes:
|
|
470
|
-
|
|
471
|
-
- **Fix and retry.** Deploy the handler fix, clear the event's failure
|
|
472
|
-
marker.
|
|
473
|
-
- **Skip.** Mark the event processed, accepting that its effects never
|
|
474
|
-
happened.
|
|
475
|
-
|
|
476
|
-
Both are one-column updates on the events table.
|
|
477
|
-
|
|
478
|
-
## Watch it in traces
|
|
479
|
-
|
|
480
|
-
A2 takes an optional `telemetry`, an instrumentation hook that
|
|
481
|
-
`experimental-a2/otel` implements over OpenTelemetry (`@opentelemetry/api` is a peer
|
|
482
|
-
dependency):
|
|
483
|
-
|
|
484
|
-
```ts
|
|
485
|
-
// server/orders.ts, with telemetry:
|
|
486
|
-
import { createServer } from 'experimental-a2/server'
|
|
487
|
-
import { otel } from 'experimental-a2/otel'
|
|
488
|
-
import { orders } from '@/contracts'
|
|
489
|
-
|
|
490
|
-
export const ordersServer = createServer({
|
|
491
|
-
contract: orders,
|
|
492
|
-
// ...store, scheduler, and handlers as above
|
|
493
|
-
telemetry: otel(),
|
|
494
|
-
})
|
|
495
|
-
```
|
|
496
|
-
|
|
497
|
-
With any OTel setup registered (on Vercel, `@vercel/otel`), the happy
|
|
498
|
-
path lands in a single trace: the request → `a2.append` → the inline
|
|
499
|
-
`a2.drain` → an `a2.event` span per handler → the appends those handlers
|
|
500
|
-
make: the whole causal chain, visually. Handler failures mark their `a2.event`
|
|
501
|
-
span with the exception, each dispatch reports `ctx.attempt` as
|
|
502
|
-
`a2.event.attempt`, and `a2.event.outcome = dead_lettered` is the attribute to
|
|
503
|
-
alert on when an event [needs manual
|
|
504
|
-
resolution](#3-when-an-event-dead-letters). See the
|
|
505
|
-
[API reference](/reference/api#a2otel) for the span catalogue.
|
|
506
|
-
|
|
507
|
-
Agent servers use the same telemetry for `a2.ai.*` spans. Core and AI operations
|
|
508
|
-
can nest and overlap, so their durations do not add up to separate layer totals.
|
|
509
|
-
|
|
510
|
-
## Running without a scheduler
|
|
511
|
-
|
|
512
|
-
If none of a contract's event types have handlers and the app never calls
|
|
513
|
-
`session.schedule`, skip `scheduler`. Those events settle in their append
|
|
514
|
-
transaction, so they create no drains, claims, queue messages, or scheduler
|
|
515
|
-
callbacks. The server is a durable event log for history, reducers, and live
|
|
516
|
-
sync. That is a complete production configuration, not degraded recovery,
|
|
517
|
-
because there is no reaction or timer to deliver.
|
|
518
|
-
|
|
519
|
-
With handlers, skipping `scheduler` means the only wakeups are a top-level
|
|
520
|
-
append or explicit `server.drain()`. Reads never wake the session.
|
|
521
|
-
|
|
522
|
-
That's a real configuration, not a broken one. Fine for internal tools
|
|
523
|
-
and low-stakes apps where "heals on the next write" is acceptable. But
|
|
524
|
-
there's no clock in it: a session nobody wakes stays stuck until someone does.
|
|
525
|
-
For production, configure a scheduler.
|
|
526
|
-
|
|
527
|
-
## Checklist
|
|
528
|
-
|
|
529
|
-
| Piece | Done when |
|
|
530
|
-
| ------------------- | -------------------------------------------------------------------- |
|
|
531
|
-
| Store | `store: postgres(...)` on every server |
|
|
532
|
-
| Scheduler | for servers with handlers or timers: one shared adapter, route, and provider config |
|
|
533
|
-
| Idempotent handlers | external side effects take `event.id` as an idempotency key |
|
|
534
|
-
| Client split | contracts/reducers isomorphic; only `experimental-a2/server` touches backends |
|