experimental-a2 0.2.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 (92) hide show
  1. package/CHANGELOG.md +169 -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-6COoN60V.js → store-polling-DgrrAE3d.js} +7 -6
  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.js → store-redis-core-DWqx3F47.js} +294 -156
  56. package/dist/store-redis-http.d.ts +21 -0
  57. package/dist/store-redis-http.js +70 -0
  58. package/dist/store-redis.d.ts +37 -0
  59. package/dist/store-redis.js +298 -0
  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 +4 -5
  69. package/docs/concepts/01-contracts.mdx +21 -17
  70. package/docs/concepts/02-handlers.mdx +7 -7
  71. package/docs/concepts/03-durability.mdx +26 -29
  72. package/docs/concepts/04-state.mdx +18 -21
  73. package/docs/guides/01-timers.mdx +154 -54
  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 +321 -60
  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 +22 -46
  84. package/docs/reference/01-api.mdx +751 -102
  85. package/docs/reference/02-errors.mdx +10 -5
  86. package/package.json +23 -6
  87. package/dist/internal-D6wNxTck.js +0 -36
  88. package/dist/log-memory.d.ts +0 -11
  89. package/dist/log-redis.d.ts +0 -31
  90. package/dist/recovery-vercel.js +0 -119
  91. package/dist/server-DJgD2YWP.js +0 -877
  92. 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,47 +41,74 @@ 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
- This configures storage for A2's session logs. It does not connect A2 to your
44
+ Prefer Redis? `redis({ url })` from `experimental-a2/store-redis` stores each
45
+ session as a Redis Stream and streams push-natively: writes to watched
46
+ sessions publish a disposable wake-up (sessions nobody watches cost no extra
47
+ command), one shared subscriber connection per process serves every connected
48
+ viewer, and a safety re-read covers a lost wake-up within ten seconds.
49
+ Connections scale with your processes, not with your audience.
50
+ `ioredis` is an optional peer dependency; pass `url`, or inject a client.
51
+ Works on single instances and non-cluster providers such as Upstash, where
52
+ durability is on by default. When only a REST API is available,
53
+ `redisHttp({ url, token })` from `experimental-a2/store-redis-http` speaks the
54
+ same storage over `fetch`, holds no connections at all, and polls on the same
55
+ adaptive cadence as Postgres.
56
+
57
+ This configures storage for A2's session histories. It does not connect A2 to your
44
58
  application tables or make them part of the append transaction. See
45
59
  [A2 and your database](/guides/application-data) for that boundary.
46
60
 
47
- ## 2. Add recovery for handlers
61
+ ## 2. Add a scheduler for handlers and timers
48
62
 
49
- Recovery is what puts a clock on healing. `experimental-a2/recovery-vercel` rides
50
- Vercel Queues (`@vercel/queue` is a peer
51
- 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`:
52
72
 
53
73
  ```ts
54
- // server/orders.ts, now with recovery:
74
+ // server/orders.ts, now with a scheduler:
55
75
  import { createServer } from 'experimental-a2/server'
56
- import { postgres } from 'experimental-a2/log-postgres'
57
- import { vercelQueues } from 'experimental-a2/recovery-vercel'
76
+ import { postgres } from 'experimental-a2/store-postgres'
77
+ import { vercelQueues } from 'experimental-a2/scheduler-vercel'
58
78
  import { orders } from '@/contracts'
59
79
 
60
- export const recovery = vercelQueues()
80
+ export const scheduler = vercelQueues()
61
81
 
62
82
  export const ordersServer = createServer({
63
83
  contract: orders,
64
- log: postgres({ connectionString: process.env.DATABASE_URL }),
65
- recovery,
84
+ store: postgres({ connectionString: process.env.DATABASE_URL }),
85
+ scheduler,
66
86
  handlers: {
67
87
  /* ... */
68
88
  },
69
89
  })
70
90
  ```
71
91
 
72
- 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:
73
94
 
74
- ```ts app/api/a2/recovery/route.ts
75
- 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'
76
98
 
77
- export const POST = recovery.handler(ordersServer, billingServer)
99
+ export const POST = schedulerHandler(ordersServer, billingServer)
78
100
  ```
79
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
+
80
108
  ```json vercel.json
81
109
  {
82
110
  "functions": {
83
- "app/api/a2/recovery/route.ts": {
111
+ "app/api/a2/scheduler/route.ts": {
84
112
  "experimentalTriggers": [
85
113
  {
86
114
  "type": "queue/v2beta",
@@ -92,35 +120,247 @@ export const POST = recovery.handler(ordersServer, billingServer)
92
120
  }
93
121
  ```
94
122
 
95
- The trigger makes the route private. Only queue infrastructure can invoke
96
- it, so it needs no auth of its own.
97
-
98
- One route, one job. A top-level append that leaves or finds pending handler
99
- work starts a delayed, coalesced "drain this session" arm alongside its inline
100
- handler. The handler does not wait for the queue, while `append` joins the
101
- initial arm for up to two seconds before it returns. An unresponsive queue
102
- therefore cannot hold the append open indefinitely. Claim renewals arm another
103
- watchdog for just after the current window. Live handlers keep moving their
104
- per-event claims and the watchdog forward. A killed holder stops heartbeating,
105
- its claims expire, and the next watchdog retries those pending events. When
106
- Vercel exposes the
107
- function deadline, A2 caps the final claim window there so timeout recovery
108
- starts promptly. A failing handler keeps the current message and redelivers
109
- with backoff. Work outside its lane continues.
110
-
111
- Due times are rounded to one-second slots. Top-level arms, claim renewals, and
112
- racing deliveries targeting the same slot deduplicate into one queue message.
113
- Busy deliveries continue the current message's heartbeat-aligned slot series,
114
- 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.
115
354
  Events appended or returned by handlers ride their current execution window
116
- and add no recovery operation of their own.
355
+ and add no scheduler operation of their own.
117
356
 
118
- No cron, no sweep, no notification bookkeeping. The queue message is
119
- the recovery state, and the log is the only thing it consults.
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.
120
360
 
121
361
  ### Dispatch in a fresh invocation
122
362
 
123
- Ordinary `append` starts handlers inline and uses recovery as the watchdog.
363
+ Ordinary `append` starts handlers inline and uses the scheduler as the watchdog.
124
364
  Use `append.dispatch` when the current request should only persist the input
125
365
  and hand pending work directly to the queue:
126
366
 
@@ -143,6 +383,26 @@ If the send fails, the event may already be durable. Its explicit ID makes the
143
383
  retry idempotent. An existing drain for the same session can still claim the
144
384
  event first; dispatch controls the wakeup path, not worker affinity.
145
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
+
146
406
  ## 3. When an event dead-letters
147
407
 
148
408
  After ten caught handler failures, A2 stops retrying an event. It blocks later
@@ -172,7 +432,7 @@ import { orders } from '@/contracts'
172
432
 
173
433
  export const ordersServer = createServer({
174
434
  contract: orders,
175
- // ...log, recovery, and handlers as above
435
+ // ...store, scheduler, and handlers as above
176
436
  telemetry: otel(),
177
437
  })
178
438
  ```
@@ -187,27 +447,28 @@ alert on when an event [needs manual
187
447
  resolution](#3-when-an-event-dead-letters). See the
188
448
  [API reference](/reference/api#a2otel) for the span catalogue.
189
449
 
190
- ## Running without a queue
450
+ ## Running without a scheduler
191
451
 
192
- If none of a contract's event types have handlers, skip `recovery`. Those
193
- events settle in their append transaction, so they create no drains, claims,
194
- queue messages, or recovery callbacks. The server is a durable event log for
195
- history, reducers, and live sync. That is a complete production configuration,
196
- 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.
197
458
 
198
- 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
199
460
  append or explicit `server.drain()`. Reads never wake the session.
200
461
 
201
462
  That's a real configuration, not a broken one. Fine for internal tools
202
463
  and low-stakes apps where "heals on the next write" is acceptable. But
203
464
  there's no clock in it: a session nobody wakes stays stuck until someone does.
204
- For production, configure recovery.
465
+ For production, configure a scheduler.
205
466
 
206
467
  ## Checklist
207
468
 
208
469
  | Piece | Done when |
209
470
  | ------------------- | -------------------------------------------------------------------- |
210
- | Log | `log: postgres(...)` on every server |
211
- | 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 |
212
473
  | Idempotent handlers | external side effects take `event.id` as an idempotency key |
213
474
  | Client split | contracts/reducers isomorphic; only `experimental-a2/server` touches backends |