experimental-a2 0.3.0 → 0.4.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 (91) hide show
  1. package/CHANGELOG.md +156 -0
  2. package/dist/ai-B4YhEnfw.d.ts +333 -0
  3. package/dist/ai-server.d.ts +48 -9
  4. package/dist/ai-server.js +121 -49
  5. package/dist/ai.d.ts +2 -303
  6. package/dist/ai.js +231 -86
  7. package/dist/cli-B3VuxoDe.js +597 -0
  8. package/dist/cli-bin.d.ts +1 -0
  9. package/dist/cli-bin.js +5 -0
  10. package/dist/cli.d.ts +19 -0
  11. package/dist/cli.js +2 -0
  12. package/dist/client-BrfDXQ8A.d.ts +155 -0
  13. package/dist/client-Bt4tAKi9.js +798 -0
  14. package/dist/client.d.ts +2 -90
  15. package/dist/client.js +1 -409
  16. package/dist/{contract-CG_adnu_.js → contract-48bUMgcL.js} +10 -2
  17. package/dist/{contract-C_3dIIEU.d.ts → contract-jIfaR085.d.ts} +62 -8
  18. package/dist/devtools-J_jZ2vQf.d.ts +151 -0
  19. package/dist/devtools-kJJaORn-.js +338 -0
  20. package/dist/devtools-server.browser.js +1 -1
  21. package/dist/devtools-server.d.ts +2 -2
  22. package/dist/devtools-server.js +224 -43
  23. package/dist/devtools.d.ts +2 -0
  24. package/dist/devtools.js +2 -0
  25. package/dist/{errors-BJRMd-h6.js → errors-BQuJpe82.js} +4 -4
  26. package/dist/{errors-xL_JTXsY.d.ts → errors-W6nwJ-fm.d.ts} +1 -1
  27. package/dist/http.d.ts +71 -13
  28. package/dist/http.js +302 -41
  29. package/dist/{idempotent-replay-BMyHrP0L.js → idempotent-replay-DuqEkYA7.js} +2 -2
  30. package/dist/index.d.ts +5 -5
  31. package/dist/index.js +2 -2
  32. package/dist/{inspection-E7qbD0Xj.js → inspection-DaxB5jM2.js} +2 -1
  33. package/dist/internal-aEotMzu_.js +209 -0
  34. package/dist/otel.d.ts +1 -1
  35. package/dist/platform-B4TnJtWu.js +32 -0
  36. package/dist/react.d.ts +37 -14
  37. package/dist/react.js +26 -15
  38. package/dist/scheduler-qstash.d.ts +78 -0
  39. package/dist/scheduler-qstash.js +499 -0
  40. package/dist/scheduler-task-BpzhPnRS.js +54 -0
  41. package/dist/{recovery-vercel.d.ts → scheduler-vercel.d.ts} +17 -24
  42. package/dist/scheduler-vercel.js +226 -0
  43. package/dist/server-CcNnFnoW.js +1405 -0
  44. package/dist/server-YtPq7hjw.d.ts +260 -0
  45. package/dist/server.d.ts +4 -155
  46. package/dist/server.js +2 -2
  47. package/dist/{log-ldf5g8Cx.d.ts → store-C3sNAaBT.d.ts} +111 -35
  48. package/dist/{log-yJbXUf72.js → store-N8PXxDAS.js} +1 -1
  49. package/dist/store-codec-DTG0Ftek.js +8 -0
  50. package/dist/store-memory.d.ts +11 -0
  51. package/dist/{log-memory.js → store-memory.js} +127 -24
  52. package/dist/{log-polling-DZ1MiKLg.js → store-polling-DgrrAE3d.js} +5 -5
  53. package/dist/{log-postgres.d.ts → store-postgres.d.ts} +6 -6
  54. package/dist/{log-postgres.js → store-postgres.js} +158 -24
  55. package/dist/{log-redis-core-CyJ5L8yR.js → store-redis-core-DWqx3F47.js} +269 -66
  56. package/dist/{log-redis-http.d.ts → store-redis-http.d.ts} +7 -7
  57. package/dist/{log-redis-http.js → store-redis-http.js} +20 -12
  58. package/dist/{log-redis.d.ts → store-redis.d.ts} +6 -6
  59. package/dist/{log-redis.js → store-redis.js} +99 -39
  60. package/dist/{log-sqlite.d.ts → store-sqlite.d.ts} +6 -6
  61. package/dist/{log-sqlite.js → store-sqlite.js} +116 -22
  62. package/dist/{telemetry-Cso0qyHQ.d.ts → telemetry-BjYHTfh2.d.ts} +1 -1
  63. package/dist/testing.browser.d.ts +1 -0
  64. package/dist/testing.browser.js +4 -0
  65. package/dist/testing.d.ts +31 -0
  66. package/dist/testing.js +101 -0
  67. package/dist/wire-DCUZBUlT.js +222 -0
  68. package/docs/01-quickstart.mdx +3 -3
  69. package/docs/concepts/01-contracts.mdx +18 -13
  70. package/docs/concepts/02-handlers.mdx +7 -7
  71. package/docs/concepts/03-durability.mdx +21 -21
  72. package/docs/concepts/04-state.mdx +18 -21
  73. package/docs/guides/01-timers.mdx +154 -49
  74. package/docs/guides/02-cancellation.mdx +30 -4
  75. package/docs/guides/03-react.mdx +20 -21
  76. package/docs/guides/04-local-first.mdx +1 -1
  77. package/docs/guides/05-production.mdx +310 -61
  78. package/docs/guides/06-ai-agents.mdx +249 -49
  79. package/docs/guides/07-devtools.mdx +137 -12
  80. package/docs/guides/08-application-data.mdx +1 -1
  81. package/docs/guides/09-presence.mdx +284 -0
  82. package/docs/guides/10-transports.mdx +131 -0
  83. package/docs/index.mdx +18 -14
  84. package/docs/reference/01-api.mdx +751 -103
  85. package/docs/reference/02-errors.mdx +10 -5
  86. package/package.json +23 -7
  87. package/dist/internal-gCd5qMry.js +0 -44
  88. package/dist/log-memory.d.ts +0 -11
  89. package/dist/recovery-vercel.js +0 -119
  90. package/dist/server-BcLa4RFL.js +0 -877
  91. package/dist/wire-BVsgR8o9.js +0 -62
@@ -1,28 +1,29 @@
1
1
  ---
2
2
  title: Going to production
3
- description: Point the log at durable storage, add recovery for handler work, and know what to do when an event dead-letters.
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
4
  ---
5
5
 
6
6
  ## Two pieces
7
7
 
8
8
  Development needs zero setup: SQLite appears under `.a2/`, and tests run
9
- in memory. Every production server needs a durable log. Servers with handlers
10
- also need recovery. Neither choice changes your event contract.
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.
11
12
 
12
- ## 1. Choose a log
13
+ ## 1. Choose a store
13
14
 
14
- Production has no default log, on purpose. A server without one throws
15
- `LOG_NOT_CONFIGURED` at startup. A failed boot beats events written to a
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
16
17
  filesystem that evaporates.
17
18
 
18
19
  ```ts server/orders.ts
19
20
  import { createServer } from 'experimental-a2/server'
20
- import { postgres } from 'experimental-a2/log-postgres'
21
+ import { postgres } from 'experimental-a2/store-postgres'
21
22
  import { orders } from '@/contracts'
22
23
 
23
24
  export const ordersServer = createServer({
24
25
  contract: orders,
25
- log: postgres({ connectionString: process.env.DATABASE_URL }),
26
+ store: postgres({ connectionString: process.env.DATABASE_URL }),
26
27
  handlers: {
27
28
  /* ... */
28
29
  },
@@ -31,7 +32,7 @@ export const ordersServer = createServer({
31
32
 
32
33
  The Postgres backend uses real transactions; appends serialize per
33
34
  session on an advisory lock, while handler claims remain concurrent. The live
34
- stream polls the log with an
35
+ stream polls the store with an
35
36
  activity-adaptive cadence: 25ms while a session is producing events
36
37
  (a token stream reads smoothly, not in clumps), backing off to 250ms
37
38
  when it goes quiet (a LISTEN/NOTIFY upgrade could still land without
@@ -40,7 +41,7 @@ box; transaction-mode poolers included, which is exactly why polling
40
41
  is the default. `pg` is an optional peer dependency; pass
41
42
  `connectionString`, or inject your own pool as `client`.
42
43
 
43
- Prefer Redis? `redis({ url })` from `experimental-a2/log-redis` stores each
44
+ Prefer Redis? `redis({ url })` from `experimental-a2/store-redis` stores each
44
45
  session as a Redis Stream and streams push-natively: writes to watched
45
46
  sessions publish a disposable wake-up (sessions nobody watches cost no extra
46
47
  command), one shared subscriber connection per process serves every connected
@@ -49,51 +50,65 @@ Connections scale with your processes, not with your audience.
49
50
  `ioredis` is an optional peer dependency; pass `url`, or inject a client.
50
51
  Works on single instances and non-cluster providers such as Upstash, where
51
52
  durability is on by default. When only a REST API is available,
52
- `redisHttp({ url, token })` from `experimental-a2/log-redis-http` speaks the
53
+ `redisHttp({ url, token })` from `experimental-a2/store-redis-http` speaks the
53
54
  same storage over `fetch`, holds no connections at all, and polls on the same
54
55
  adaptive cadence as Postgres.
55
56
 
56
- This configures storage for A2's session logs. It does not connect A2 to your
57
+ This configures storage for A2's session histories. It does not connect A2 to your
57
58
  application tables or make them part of the append transaction. See
58
59
  [A2 and your database](/guides/application-data) for that boundary.
59
60
 
60
- ## 2. Add recovery for handlers
61
+ ## 2. Add a scheduler for handlers and timers
61
62
 
62
- Recovery is what puts a clock on healing. `experimental-a2/recovery-vercel` rides
63
- Vercel Queues (`@vercel/queue` is a peer
64
- dependency). Same `server/orders.ts`, now with `recovery`:
63
+ The scheduler puts a clock on recovery and future appends. Vercel Queues is the
64
+ recommended default on Vercel. QStash is an alternative when you want HTTP
65
+ delivery from separate infrastructure. Both adapters carry A2 recovery
66
+ watchdogs and `session.schedule` tasks through the same route.
67
+
68
+ ### Vercel Queues (recommended)
69
+
70
+ `experimental-a2/scheduler-vercel` rides Vercel Queues (`@vercel/queue` is a
71
+ peer dependency). Same `server/orders.ts`, now with `scheduler`:
65
72
 
66
73
  ```ts
67
- // server/orders.ts, now with recovery:
74
+ // server/orders.ts, now with a scheduler:
68
75
  import { createServer } from 'experimental-a2/server'
69
- import { postgres } from 'experimental-a2/log-postgres'
70
- import { vercelQueues } from 'experimental-a2/recovery-vercel'
76
+ import { postgres } from 'experimental-a2/store-postgres'
77
+ import { vercelQueues } from 'experimental-a2/scheduler-vercel'
71
78
  import { orders } from '@/contracts'
72
79
 
73
- export const recovery = vercelQueues()
80
+ export const scheduler = vercelQueues()
74
81
 
75
82
  export const ordersServer = createServer({
76
83
  contract: orders,
77
- log: postgres({ connectionString: process.env.DATABASE_URL }),
78
- recovery,
84
+ store: postgres({ connectionString: process.env.DATABASE_URL }),
85
+ scheduler,
79
86
  handlers: {
80
87
  /* ... */
81
88
  },
82
89
  })
83
90
  ```
84
91
 
85
- One recovery instance is shared by every server, and mounted once:
92
+ One scheduler instance is shared by every server on this route. The route
93
+ derives that scheduler from the servers:
86
94
 
87
- ```ts app/api/a2/recovery/route.ts
88
- import { recovery, ordersServer, billingServer } from '@/server'
95
+ ```ts app/api/a2/scheduler/route.ts
96
+ import { schedulerHandler } from 'experimental-a2/http'
97
+ import { ordersServer, billingServer } from '@/server'
89
98
 
90
- export const POST = recovery.handler(ordersServer, billingServer)
99
+ export const POST = schedulerHandler(ordersServer, billingServer)
91
100
  ```
92
101
 
102
+ `schedulerHandler(...)` checks the wiring synchronously when this module loads.
103
+ Every argument must be an A2 server with a scheduler, every contract name must
104
+ be unique, and every server must hold the exact same scheduler instance. A bad
105
+ route fails during startup or a build that evaluates route modules, before it
106
+ can accept a delivery.
107
+
93
108
  ```json vercel.json
94
109
  {
95
110
  "functions": {
96
- "app/api/a2/recovery/route.ts": {
111
+ "app/api/a2/scheduler/route.ts": {
97
112
  "experimentalTriggers": [
98
113
  {
99
114
  "type": "queue/v2beta",
@@ -105,34 +120,247 @@ export const POST = recovery.handler(ordersServer, billingServer)
105
120
  }
106
121
  ```
107
122
 
108
- The trigger makes the route private. Only queue infrastructure can invoke
109
- it, so it needs no auth of its own.
110
-
111
- One route, one job. A top-level append that leaves or finds pending handler
112
- work starts a delayed, coalesced "drain this session" arm alongside its inline
113
- handler. The handler does not wait for the queue, while `append` joins the
114
- initial arm for up to two seconds before it returns. An unresponsive queue
115
- therefore cannot hold the append open indefinitely. Claim renewals arm another
116
- watchdog for just after the current window. Live handlers keep moving their
117
- per-event claims and the watchdog forward. A killed holder stops heartbeating,
118
- its claims expire, and the next watchdog retries those pending events. When
119
- Vercel exposes the
120
- function deadline, A2 caps the final claim window there so timeout recovery
121
- starts promptly. A failing handler keeps the current message and redelivers
122
- with backoff. Work outside its lane continues.
123
-
124
- Due times are rounded to one-second slots. Top-level arms, claim renewals, and
125
- racing deliveries targeting the same slot deduplicate into one queue message.
126
- Busy deliveries continue the current message's heartbeat-aligned slot series,
127
- so they do not create an independent stream of watchdog callbacks.
123
+ The trigger is required. It is the Vercel Queues consumer integration and the
124
+ route's protection boundary: Vercel makes the function private, and only queue
125
+ infrastructure can invoke it. Do not expose the callback as an ordinary public
126
+ route or add a second authentication scheme around it.
127
+
128
+ The adapter pins recovery watchdogs and scheduled appends to the deployment
129
+ that published them. This is the safe default. A recovery task always returns
130
+ to code that understands the session work it armed.
131
+
132
+ You can remove deployment affinity from scheduled appends only:
133
+
134
+ ```ts
135
+ // server/orders.ts, when every consumer is compatible with pending timers:
136
+ import { vercelQueues } from 'experimental-a2/scheduler-vercel'
137
+
138
+ export const scheduler = vercelQueues({
139
+ scheduledAppends: 'deploymentless',
140
+ })
141
+ ```
142
+
143
+ `deploymentless` does not mean "latest production." It lets Vercel Queues
144
+ choose an eligible deploymentless consumer for the topic. Keep task envelopes
145
+ and event schemas compatible across every such consumer. Recovery watchdogs
146
+ remain deployment-scoped in both modes. Scheduler task envelopes are
147
+ versioned.
148
+
149
+ Vercel Queues defaults to 24-hour message retention. The adapter sets retention
150
+ to 7 days and accepts scheduled appends through 6 days, reserving the final day
151
+ for delivery and retries. Recovery watchdogs are measured in seconds.
152
+
153
+ ### QStash
154
+
155
+ `experimental-a2/scheduler-qstash` uses QStash HTTP delivery
156
+ (`@upstash/qstash` 2.11.0 or newer is a peer dependency). The conventional
157
+ callback URL is
158
+ inferred in QStash development mode and on Vercel production and preview
159
+ deployments:
160
+
161
+ ```ts
162
+ // server/orders.ts, with QStash instead:
163
+ import { createServer } from 'experimental-a2/server'
164
+ import { postgres } from 'experimental-a2/store-postgres'
165
+ import { qstash } from 'experimental-a2/scheduler-qstash'
166
+ import { orders } from '@/contracts'
167
+
168
+ export const scheduler = qstash()
169
+
170
+ export const ordersServer = createServer({
171
+ contract: orders,
172
+ store: postgres({ connectionString: process.env.DATABASE_URL }),
173
+ scheduler,
174
+ handlers: {
175
+ /* ... */
176
+ },
177
+ })
178
+ ```
179
+
180
+ Mount `schedulerHandler(ordersServer, billingServer)` once with the same
181
+ `app/api/a2/scheduler/route.ts` module shown above. Local Node.js development
182
+ needs no QStash environment variables. With `@upstash/qstash` 2.11.0 or newer,
183
+ A2 uses `http://localhost:${PORT ?? 3000}/api/a2/scheduler` and enables the
184
+ SDK's development mode. On the first publish or verification, the lazy SDK
185
+ transport downloads and starts its local server. `PORT` selects the application
186
+ port. `QSTASH_DEV_PORT` optionally selects the QStash development server port
187
+ and does not change the callback. This inferred local route sets SDK development
188
+ mode directly; `QSTASH_DEV=false` does not disable it. Pass an explicit `url`
189
+ to leave SDK mode under environment control.
190
+
191
+ A process-less runtime cannot infer local development and needs an explicit
192
+ `url`. An Edge runtime that exposes `process.env` can use the inferred localhost
193
+ callback, but it cannot start the development server itself. Start the server
194
+ from a Node.js instrumentation hook or run it manually. Configure the SDK with
195
+ `QSTASH_DEV` or the manual `QSTASH_URL` and development credentials described
196
+ in the [QStash local-development guide](https://upstash.com/docs/qstash/howto/local-development).
197
+
198
+ A2 disables the SDK's development mode when `NODE_ENV=production`, even if
199
+ `QSTASH_DEV` remains set. Production must use deployed QStash credentials and
200
+ must not rely on the local development server.
201
+
202
+ Production uses `VERCEL_PROJECT_PRODUCTION_URL`; preview uses
203
+ `VERCEL_BRANCH_URL`. Both append the conventional `/api/a2/scheduler` path. An
204
+ explicit `url` always wins. Pass one for another host or path; an application
205
+ port that is not available through `PORT`; Docker networking; a public
206
+ development tunnel; or a non-Vercel production deployment. Outside production,
207
+ an explicit URL leaves SDK development mode under the SDK's normal environment
208
+ configuration. This supports `QSTASH_DEV=true` and manually supplied
209
+ `QSTASH_URL` plus credentials without changing the callback URL.
210
+
211
+ Production and branch URLs are moving aliases. Pending QStash tasks can reach a
212
+ later deployment on that alias, so keep scheduler task envelopes and event
213
+ schemas compatible across deployments.
214
+
215
+ The deployed route must be internet-reachable by QStash after any platform edge
216
+ authentication. The local QStash server calls the inferred localhost route
217
+ directly. A2 reads the raw request body and verifies `Upstash-Signature`, the
218
+ callback URL, and the optional `Upstash-Region` before parsing the message or
219
+ touching the store. Missing or invalid signatures return 401, so ordinary
220
+ unsigned traffic cannot execute scheduler work.
221
+
222
+ When A2 infers a Vercel preview callback, it opportunistically reads the current
223
+ invocation's `x-vercel-oidc-token` on every publish. If the token is a parseable
224
+ JWT that remains valid for at least 60 seconds after the task's first due time,
225
+ the built-in transport forwards it as
226
+ `x-vercel-trusted-oidc-idp-token` and asks QStash to redact that header from its
227
+ dashboard and API. This lets near-term tasks enter a protected preview when its
228
+ [Trusted Sources](https://vercel.com/docs/deployment-protection/methods-to-bypass-deployment-protection/trusted-sources)
229
+ rule accepts the publishing deployment's identity. QStash still receives the
230
+ credential so it can deliver it.
231
+
232
+ This forwarding is best-effort first-delivery admission, not durable
233
+ authentication. A missing, malformed, or near-expiry token leaves the publish
234
+ unchanged so an unprotected preview does not regress. An explicit `url` opts out,
235
+ and inferred production and local callbacks do not forward the token. QStash
236
+ stores the static header and reuses it on retries, so a delayed task or retry can
237
+ outlive the identity even if the token passed the first-due-time check.
238
+
239
+ Callbacks that need durable access through Deployment Protection still require
240
+ an explicit
241
+ [automation bypass](https://vercel.com/docs/deployment-protection/methods-to-bypass-deployment-protection/protection-bypass-automation)
242
+ or a public ingress. Pass a dedicated bypass as a callback header when that is
243
+ the right tradeoff for your project:
244
+
245
+ ```ts server/scheduler-qstash-protected.ts
246
+ import { qstash } from 'experimental-a2/scheduler-qstash'
247
+
248
+ const bypass = process.env.VERCEL_AUTOMATION_BYPASS_SECRET
249
+ if (!bypass) throw new Error('Missing VERCEL_AUTOMATION_BYPASS_SECRET')
250
+
251
+ export const scheduler = qstash({
252
+ headers: { 'x-vercel-protection-bypass': bypass },
253
+ })
254
+ ```
255
+
256
+ A2 asks QStash to redact every configured callback header from its dashboard
257
+ and API. QStash still receives, stores, and delivers the original value. A
258
+ rotation affects new messages only; already queued messages keep their captured
259
+ header. A custom transport must deliver every `headers` entry and apply every
260
+ `redact.header` name it receives.
261
+
262
+ A separately implemented public Vercel production ingress can instead verify
263
+ the QStash signature and relay the task at delivery time with its current
264
+ Vercel OIDC token. That architecture requires an explicit
265
+ production-to-preview Trusted Sources rule, a strict target allowlist, and a
266
+ versioned routing envelope. The adapter does not implement that relay.
267
+ `Upstash-Signature` remains the authentication boundary for scheduler work
268
+ after any deployment-protection check.
269
+
270
+ Servers configured with different scheduler instances use separate routes,
271
+ even when both instances come from the same adapter. Each QStash instance needs
272
+ its own callback URL, public when cloud QStash delivers to it, so additional
273
+ instances pass `url` explicitly. Each Vercel Queues instance needs its own
274
+ `topic` and a matching `experimentalTriggers` entry for that route. One route
275
+ cannot safely choose a provider after a delivery arrives.
276
+
277
+ By default, the adapter reads `QSTASH_TOKEN`,
278
+ `QSTASH_CURRENT_SIGNING_KEY`, and `QSTASH_NEXT_SIGNING_KEY`. You can instead
279
+ pass `token`, `currentSigningKey`, and `nextSigningKey` directly to `qstash()`.
280
+ Pass a configured `@upstash/qstash` `Client` as `client` when you need to tune
281
+ its API endpoint, publish-request retry policy, or telemetry. A2 continues to
282
+ create the signature `Receiver` from the signing-key options. Use A2's
283
+ `headers` option, not client-global headers, for callback credentials. Use the
284
+ full `transport` override when publishing and verification both need custom
285
+ behavior. An injected client or transport receives raw callback credentials
286
+ and must honor A2's redaction request without logging them.
287
+ The adapter reads at most 1 MiB per callback. Before an initial publish, it
288
+ checks the normalized task with enough reserved room for the largest private
289
+ store-outage lineage it may later need. Every accepted task can therefore carry
290
+ that lineage under the same `maxBodyBytes` limit. Store-outage successors also
291
+ check their actual body before publishing. Set `maxBodyBytes` to a larger plan
292
+ limit when your scheduled event batches legitimately need it. Keep the same
293
+ value on every deployment that can receive the callback URL. Lowering it during
294
+ a rolling deployment can make the new deployment reject a task that the
295
+ previous deployment accepted.
296
+
297
+ QStash delivery is at least once. Stalled work publishes a fresh successor
298
+ watchdog before acknowledging, so QStash's per-message retry budget cannot stop
299
+ recovery before A2 reaches its own failure limit. A retryable store outage also
300
+ hands recovery or timer delivery to a fresh message, but uses a slower outage
301
+ backoff. The first successor waits 30 seconds. Each later generation waits four
302
+ times longer, capped at one day. The generation stays in the signed message
303
+ body and keeps increasing after the delay reaches the cap. Duplicate callbacks
304
+ therefore converge on one successor generation even when they run at different
305
+ times. Separate drain episodes use the first failing task's `dueAt` as part of
306
+ their lineage. Append episodes use the contract, session, and stable task id.
307
+
308
+ If the successor publish fails, the current delivery returns non-2xx and
309
+ QStash retries it. The adapter omits `retries` by default, which lets QStash use
310
+ its provider default. Pass `retries` to set an explicit non-negative value.
311
+ Every delivery attempt is billable. Deterministic publish keys reduce duplicate
312
+ watchdogs during QStash's documented 10-minute deduplication window. That is a
313
+ cost optimization, not a correctness boundary. Durable store claims, attempt
314
+ fencing, and idempotent handler side effects handle duplicate deliveries.
315
+
316
+ No callback can publish a successor when QStash cannot reach the route at all.
317
+ Monitor QStash's DLQ and redrive those messages after a prolonged endpoint or
318
+ deployment outage.
319
+
320
+ QStash currently caps a one-shot delay at 7 days on free plans and 1 year on
321
+ usage-based plans; fixed plans have no fixed maximum. A2 recovery watchdogs
322
+ are seconds apart. Scheduled appends must fit the active plan's limit. Leave at
323
+ least one second of margin at a QStash plan maximum because `notBefore` uses
324
+ whole Unix seconds.
325
+
326
+ Both built-in adapters preserve the original error when a provider rejects a
327
+ send. A recognized definite HTTP 4xx status or error is terminal, except 408,
328
+ 409, 425, and 429.
329
+ Those four statuses, every 5xx response, network failures, and errors without a
330
+ recognized status stay retryable because the send may be transient or
331
+ ambiguous. The Vercel adapter also recognizes the SDK's name-only bad request,
332
+ unauthorized, and forbidden errors. Its native duplicate-message error remains
333
+ success. This classification lets durable A2 tools retry transient sends
334
+ without retrying permanent credentials or request errors.
335
+
336
+ One route, two task kinds. A top-level append that leaves or finds pending
337
+ handler work sends a delayed, coalesced "drain this session" watchdog alongside
338
+ its inline handler. The handler does not wait for the queue, while `append`
339
+ joins the initial send for up to two seconds before it returns. An unresponsive
340
+ queue therefore cannot hold the append open indefinitely. Claim renewals send
341
+ another watchdog for just after the current window. Live handlers keep moving
342
+ their per-event claims and the watchdog forward. A killed holder stops
343
+ heartbeating, its claims expire, and the next watchdog retries those pending
344
+ events. With Vercel Queues, A2 caps the final claim window at the platform
345
+ function deadline when Vercel exposes it, so timeout recovery starts promptly.
346
+ A failing handler keeps the current message and redelivers with backoff. Work
347
+ outside its lane continues.
348
+
349
+ Due times are rounded to one-second slots. Top-level sends, claim renewals, and
350
+ racing deliveries targeting the same slot use the same deterministic publish
351
+ key. Provider deduplication can fold them into one message. Busy deliveries
352
+ continue the current message's heartbeat-aligned slot series, so they do not
353
+ create an independent stream of watchdog callbacks.
128
354
  Events appended or returned by handlers ride their current execution window
129
- and add no recovery operation of their own.
355
+ and add no scheduler operation of their own.
130
356
 
131
- The queue message carries the recovery state, and recovery consults the log.
357
+ Drain tasks carry recovery state and consult the store. Timer tasks carry their
358
+ fixed-id events until delivery, then enter the store through an ordinary
359
+ top-level append.
132
360
 
133
361
  ### Dispatch in a fresh invocation
134
362
 
135
- Ordinary `append` starts handlers inline and uses recovery as the watchdog.
363
+ Ordinary `append` starts handlers inline and uses the scheduler as the watchdog.
136
364
  Use `append.dispatch` when the current request should only persist the input
137
365
  and hand pending work directly to the queue:
138
366
 
@@ -155,6 +383,26 @@ If the send fails, the event may already be durable. Its explicit ID makes the
155
383
  retry idempotent. An existing drain for the same session can still claim the
156
384
  event first; dispatch controls the wakeup path, not worker affinity.
157
385
 
386
+ ### Schedule a future append
387
+
388
+ `session.schedule(name, { delay: '30s' }, ...events)` uses this same adapter
389
+ and route. The call validates the events, requires JSON payload values, and
390
+ awaits provider acceptance. It does not write an event until delivery. The
391
+ delivered events are ordinary root appends with `cause: null`.
392
+
393
+ Use one stable name, timing, and event batch across retries. A root relative
394
+ delay starts from each call, while a handler relative delay starts from its
395
+ triggering event's durable `createdAt`. Use `{ at: Date }` when a retried root
396
+ call needs one target time. [Timers and delays](/guides/timers) covers
397
+ identity, stale-timer guards, and the full provider limits.
398
+
399
+ :::warning
400
+ Do not roll back to an A2 version without timer delivery while scheduled tasks
401
+ are outstanding. New routes accept old recovery messages, but an old route can
402
+ acknowledge a new append task without writing its events. Roll forward, or wait
403
+ for those tasks to deliver or remove them through the provider first.
404
+ :::
405
+
158
406
  ## 3. When an event dead-letters
159
407
 
160
408
  After ten caught handler failures, A2 stops retrying an event. It blocks later
@@ -184,7 +432,7 @@ import { orders } from '@/contracts'
184
432
 
185
433
  export const ordersServer = createServer({
186
434
  contract: orders,
187
- // ...log, recovery, and handlers as above
435
+ // ...store, scheduler, and handlers as above
188
436
  telemetry: otel(),
189
437
  })
190
438
  ```
@@ -199,27 +447,28 @@ alert on when an event [needs manual
199
447
  resolution](#3-when-an-event-dead-letters). See the
200
448
  [API reference](/reference/api#a2otel) for the span catalogue.
201
449
 
202
- ## Running without a queue
450
+ ## Running without a scheduler
203
451
 
204
- If none of a contract's event types have handlers, skip `recovery`. Those
205
- events settle in their append transaction, so they create no drains, claims,
206
- queue messages, or recovery callbacks. The server is a durable event log for
207
- history, reducers, and live sync. That is a complete production configuration,
208
- not degraded recovery, because there is no reaction to recover.
452
+ If none of a contract's event types have handlers and the app never calls
453
+ `session.schedule`, skip `scheduler`. Those events settle in their append
454
+ transaction, so they create no drains, claims, queue messages, or scheduler
455
+ callbacks. The server is a durable event log for history, reducers, and live
456
+ sync. That is a complete production configuration, not degraded recovery,
457
+ because there is no reaction or timer to deliver.
209
458
 
210
- With handlers, skipping `recovery` means the only wakeups are a top-level
459
+ With handlers, skipping `scheduler` means the only wakeups are a top-level
211
460
  append or explicit `server.drain()`. Reads never wake the session.
212
461
 
213
462
  That's a real configuration, not a broken one. Fine for internal tools
214
463
  and low-stakes apps where "heals on the next write" is acceptable. But
215
464
  there's no clock in it: a session nobody wakes stays stuck until someone does.
216
- For production, configure recovery.
465
+ For production, configure a scheduler.
217
466
 
218
467
  ## Checklist
219
468
 
220
469
  | Piece | Done when |
221
470
  | ------------------- | -------------------------------------------------------------------- |
222
- | Log | `log: postgres(...)` on every server |
223
- | Recovery | for servers with handlers: one shared `vercelQueues()`, route, and trigger |
471
+ | Store | `store: postgres(...)` on every server |
472
+ | Scheduler | for servers with handlers or timers: one shared adapter, route, and provider config |
224
473
  | Idempotent handlers | external side effects take `event.id` as an idempotency key |
225
474
  | Client split | contracts/reducers isomorphic; only `experimental-a2/server` touches backends |