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.
Files changed (211) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/README.md +1 -1
  3. package/dist/{actor-DBbe5xeE.d.ts → actor-Bjce9_5U.d.ts} +5 -5
  4. package/dist/{actor-DBbe5xeE.d.ts.map → actor-Bjce9_5U.d.ts.map} +1 -1
  5. package/dist/actor-client.d.ts +1 -1
  6. package/dist/actor-react.d.ts +3 -3
  7. package/dist/actor-react.js +1 -1
  8. package/dist/actor.d.ts +1 -1
  9. package/dist/actor.js +2 -2
  10. package/dist/{ai-UrWfKeuv.js → ai-BpOGm-zB.js} +2 -2
  11. package/dist/{ai-UrWfKeuv.js.map → ai-BpOGm-zB.js.map} +1 -1
  12. package/dist/{ai-vpGtcaCB.d.ts → ai-DrUaMyRt.d.ts} +3 -3
  13. package/dist/{ai-vpGtcaCB.d.ts.map → ai-DrUaMyRt.d.ts.map} +1 -1
  14. package/dist/ai-client.d.ts +2 -2
  15. package/dist/ai-client.js +2 -2
  16. package/dist/ai-fx.browser.d.ts +1 -0
  17. package/dist/ai-fx.browser.js +6 -0
  18. package/dist/ai-fx.browser.js.map +1 -0
  19. package/dist/ai-fx.d.ts +10 -0
  20. package/dist/ai-fx.d.ts.map +1 -0
  21. package/dist/ai-fx.js +634 -0
  22. package/dist/ai-fx.js.map +1 -0
  23. package/dist/ai-harness-BuLh_EYi.js +273 -0
  24. package/dist/ai-harness-BuLh_EYi.js.map +1 -0
  25. package/dist/ai-harness-DvsPeJeB.d.ts +26 -0
  26. package/dist/ai-harness-DvsPeJeB.d.ts.map +1 -0
  27. package/dist/ai-pi.browser.d.ts +1 -0
  28. package/dist/ai-pi.browser.js +6 -0
  29. package/dist/ai-pi.browser.js.map +1 -0
  30. package/dist/ai-pi.d.ts +17 -0
  31. package/dist/ai-pi.d.ts.map +1 -0
  32. package/dist/ai-pi.js +904 -0
  33. package/dist/ai-pi.js.map +1 -0
  34. package/dist/ai-react.d.ts +5 -5
  35. package/dist/ai-react.js +2 -2
  36. package/dist/ai-server.d.ts +47 -25
  37. package/dist/ai-server.d.ts.map +1 -1
  38. package/dist/ai-server.js +106 -37
  39. package/dist/ai-server.js.map +1 -1
  40. package/dist/ai.d.ts +1 -1
  41. package/dist/ai.js +1 -1
  42. package/dist/bounded-cache-CPHWF-8h.js +41 -0
  43. package/dist/bounded-cache-CPHWF-8h.js.map +1 -0
  44. package/dist/{client-COaiaDub.js → client-BReKqwZC.js} +3 -3
  45. package/dist/{client-COaiaDub.js.map → client-BReKqwZC.js.map} +1 -1
  46. package/dist/{client-qRnRUzBS.d.ts → client-h_DKFAjj.d.ts} +3 -3
  47. package/dist/{client-qRnRUzBS.d.ts.map → client-h_DKFAjj.d.ts.map} +1 -1
  48. package/dist/client.d.ts +3 -3
  49. package/dist/client.js +1 -1
  50. package/dist/index.d.ts +5 -5
  51. package/dist/{initial-snapshot-BrLso5x_.d.ts → initial-snapshot-Bx-c0pDP.d.ts} +2 -2
  52. package/dist/{initial-snapshot-BrLso5x_.d.ts.map → initial-snapshot-Bx-c0pDP.d.ts.map} +1 -1
  53. package/dist/{internal-CbVvEwad.js → internal-B2T3U4Rq.js} +8 -2
  54. package/dist/internal-B2T3U4Rq.js.map +1 -0
  55. package/dist/otel.d.ts +1 -1
  56. package/dist/{react-BbJ-g6Uu.d.ts → react-BBXgHWFc.d.ts} +4 -4
  57. package/dist/{react-BbJ-g6Uu.d.ts.map → react-BBXgHWFc.d.ts.map} +1 -1
  58. package/dist/react.d.ts +1 -1
  59. package/dist/react.js +1 -1
  60. package/dist/reducer-BAXc8JyF.js.map +1 -1
  61. package/dist/{reducer-BYu6DT7h.d.ts → reducer-CqA59Q6U.d.ts} +2 -2
  62. package/dist/{reducer-BYu6DT7h.d.ts.map → reducer-CqA59Q6U.d.ts.map} +1 -1
  63. package/dist/scheduler-qstash.d.ts +2 -2
  64. package/dist/scheduler-qstash.js +2 -2
  65. package/dist/scheduler-vercel.d.ts +2 -2
  66. package/dist/scheduler-vercel.js +2 -2
  67. package/dist/{server-Rj64fNvX.js → server-Dw_k_7kO.js} +93 -60
  68. package/dist/server-Dw_k_7kO.js.map +1 -0
  69. package/dist/{server-CGM8WzRG.d.ts → server-En7cbi3v.d.ts} +36 -7
  70. package/dist/server-En7cbi3v.d.ts.map +1 -0
  71. package/dist/server.d.ts +4 -4
  72. package/dist/server.js +1 -1
  73. package/dist/{session-clear-Dshpuhff.d.ts → session-clear-Bo-XTFEy.d.ts} +2 -2
  74. package/dist/session-clear-Bo-XTFEy.d.ts.map +1 -0
  75. package/dist/{store-Gpkq7WBo.d.ts → store-Djdv0bnZ.d.ts} +31 -2
  76. package/dist/store-Djdv0bnZ.d.ts.map +1 -0
  77. package/dist/store-N8PXxDAS.js.map +1 -1
  78. package/dist/store-memory.d.ts +1 -1
  79. package/dist/store-memory.js +32 -6
  80. package/dist/store-memory.js.map +1 -1
  81. package/dist/{store-polling-4_y8lETm.js → store-polling-B5FcwJfo.js} +2 -2
  82. package/dist/{store-polling-4_y8lETm.js.map → store-polling-B5FcwJfo.js.map} +1 -1
  83. package/dist/store-postgres.d.ts +1 -1
  84. package/dist/store-postgres.js +54 -3
  85. package/dist/store-postgres.js.map +1 -1
  86. package/dist/{store-presence-polling-Q1C-k2dd.js → store-presence-polling-BAwS8RWn.js} +3 -3
  87. package/dist/{store-presence-polling-Q1C-k2dd.js.map → store-presence-polling-BAwS8RWn.js.map} +1 -1
  88. package/dist/store-redis-http.d.ts +1 -1
  89. package/dist/store-redis-http.js +3 -3
  90. package/dist/{store-redis-notify-tqWNnMRu.js → store-redis-notify-BRITti_o.js} +187 -68
  91. package/dist/store-redis-notify-BRITti_o.js.map +1 -0
  92. package/dist/store-redis.d.ts +1 -1
  93. package/dist/store-redis.js +1 -1
  94. package/dist/store-sqlite.d.ts +1 -1
  95. package/dist/store-sqlite.js +32 -2
  96. package/dist/store-sqlite.js.map +1 -1
  97. package/dist/{telemetry-cZH-cibj.d.ts → telemetry-Cyfqo6Qt.d.ts} +2 -2
  98. package/dist/telemetry-Cyfqo6Qt.d.ts.map +1 -0
  99. package/dist/{wire-DjfHOvdW.js → wire-rSUglYo8.js} +2 -2
  100. package/dist/{wire-DjfHOvdW.js.map → wire-rSUglYo8.js.map} +1 -1
  101. package/docs/01-quickstart.mdx +49 -83
  102. package/docs/{guides/07-examples.mdx → 02-examples.mdx} +10 -20
  103. package/docs/actors/01-introduction.mdx +48 -86
  104. package/docs/actors/02-concurrency.mdx +43 -70
  105. package/docs/actors/03-timers.mdx +28 -42
  106. package/docs/actors/04-routes.mdx +110 -150
  107. package/docs/actors/meta.ts +1 -1
  108. package/docs/agents/01-introduction.mdx +257 -0
  109. package/docs/agents/02-tools.mdx +247 -0
  110. package/docs/agents/03-conversations.mdx +538 -0
  111. package/docs/agents/04-harnesses.mdx +136 -0
  112. package/docs/agents/meta.ts +1 -0
  113. package/docs/client/01-react.mdx +294 -0
  114. package/docs/client/02-transports.mdx +86 -0
  115. package/docs/client/03-presence.mdx +224 -0
  116. package/docs/client/meta.ts +1 -0
  117. package/docs/contract/01-events.mdx +57 -0
  118. package/docs/contract/02-state.mdx +142 -0
  119. package/docs/contract/03-sessions.mdx +175 -0
  120. package/docs/index.mdx +108 -144
  121. package/docs/reference/01-core.mdx +139 -0
  122. package/docs/reference/02-server.mdx +855 -0
  123. package/docs/reference/03-client.mdx +301 -0
  124. package/docs/reference/04-ai.mdx +722 -0
  125. package/docs/reference/05-adapters.mdx +351 -0
  126. package/docs/reference/06-errors.mdx +138 -0
  127. package/docs/reference/meta.ts +1 -1
  128. package/docs/server/01-handlers.mdx +200 -0
  129. package/docs/server/02-timers.mdx +130 -0
  130. package/docs/server/03-cancellation.mdx +141 -0
  131. package/docs/server/04-routes.mdx +243 -0
  132. package/docs/server/05-production.mdx +322 -0
  133. package/docs/server/06-guarantees.mdx +123 -0
  134. package/examples/playground/DEPLOY.md +1 -1
  135. package/examples/playground/app/a0/[appId]/page.tsx +11 -6
  136. package/examples/playground/app/a0/app-list.tsx +9 -2
  137. package/examples/playground/app/a0/app-view.tsx +9 -1
  138. package/examples/playground/app/a0/apps/[appId]/route.ts +10 -5
  139. package/examples/playground/app/a0/apps.test.ts +12 -0
  140. package/examples/playground/app/a0/apps.ts +22 -7
  141. package/examples/playground/app/a0/chat.tsx +47 -1
  142. package/examples/playground/app/a0/fx/events/route.ts +9 -0
  143. package/examples/playground/app/a0/fx/scheduler/route.ts +7 -0
  144. package/examples/playground/app/a0/fx/short/events/route.ts +11 -0
  145. package/examples/playground/app/a0/model.test.ts +18 -1
  146. package/examples/playground/app/a0/model.ts +47 -10
  147. package/examples/playground/app/a0/no-photon.cjs +4 -0
  148. package/examples/playground/app/a0/no-quickjs.cjs +3 -0
  149. package/examples/playground/app/a0/pi/events/route.ts +9 -0
  150. package/examples/playground/app/a0/pi/scheduler/route.ts +7 -0
  151. package/examples/playground/app/a0/pi/short/events/route.ts +11 -0
  152. package/examples/playground/app/a0/sandbox-log.ts +10 -4
  153. package/examples/playground/app/a0/sandbox-session.ts +152 -0
  154. package/examples/playground/app/a0/scheduler/route.ts +2 -2
  155. package/examples/playground/app/a0/server.ts +100 -45
  156. package/examples/playground/app/a0/session.ts +17 -5
  157. package/examples/playground/app/a0/shell.tsx +11 -3
  158. package/examples/playground/app/agent/ingress.test.ts +6 -3
  159. package/examples/playground/app/agent/server.ts +9 -6
  160. package/examples/playground/app/agent/telemetry.test.ts +71 -50
  161. package/examples/playground/app/canvas/[canvasId]/canvas-client.tsx +1 -1
  162. package/examples/playground/app/chat/server.test.ts +30 -26
  163. package/examples/playground/app/chat/server.ts +46 -46
  164. package/examples/playground/next.config.ts +17 -5
  165. package/examples/playground/package.json +4 -1
  166. package/examples/playground/vercel.json +18 -0
  167. package/package.json +27 -3
  168. package/src/ai-agent.ts +261 -87
  169. package/src/ai-fx.browser.ts +5 -0
  170. package/src/ai-fx.ts +833 -0
  171. package/src/ai-harness.ts +518 -0
  172. package/src/ai-model-metadata.ts +11 -6
  173. package/src/ai-pi.browser.ts +5 -0
  174. package/src/ai-pi.ts +1263 -0
  175. package/src/ai-sdk-step.ts +2 -2
  176. package/src/ai-server.ts +3 -1
  177. package/src/internal.ts +7 -0
  178. package/src/libfx.d.ts +34 -0
  179. package/src/reducer.ts +1 -1
  180. package/src/server.ts +177 -19
  181. package/src/store-memory.ts +42 -7
  182. package/src/store-postgres.ts +80 -0
  183. package/src/store-redis-core.ts +210 -79
  184. package/src/store-redis-notify.ts +2 -0
  185. package/src/store-sqlite.ts +60 -0
  186. package/src/store.ts +33 -0
  187. package/src/telemetry.ts +1 -0
  188. package/dist/internal-CbVvEwad.js.map +0 -1
  189. package/dist/server-CGM8WzRG.d.ts.map +0 -1
  190. package/dist/server-Rj64fNvX.js.map +0 -1
  191. package/dist/session-clear-Dshpuhff.d.ts.map +0 -1
  192. package/dist/store-Gpkq7WBo.d.ts.map +0 -1
  193. package/dist/store-redis-notify-tqWNnMRu.js.map +0 -1
  194. package/dist/telemetry-cZH-cibj.d.ts.map +0 -1
  195. package/docs/concepts/01-contracts.mdx +0 -237
  196. package/docs/concepts/02-handlers.mdx +0 -284
  197. package/docs/concepts/03-durability.mdx +0 -314
  198. package/docs/concepts/04-state.mdx +0 -209
  199. package/docs/guides/01-timers.mdx +0 -193
  200. package/docs/guides/02-cancellation.mdx +0 -161
  201. package/docs/guides/03-react.mdx +0 -324
  202. package/docs/guides/04-local-first.mdx +0 -47
  203. package/docs/guides/05-production.mdx +0 -534
  204. package/docs/guides/06-ai-agents.mdx +0 -1408
  205. package/docs/guides/08-application-data.mdx +0 -113
  206. package/docs/guides/09-presence.mdx +0 -262
  207. package/docs/guides/10-transports.mdx +0 -221
  208. package/docs/reference/01-api.mdx +0 -2358
  209. package/docs/reference/02-errors.mdx +0 -150
  210. /package/docs/{concepts → contract}/meta.ts +0 -0
  211. /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 |