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.
- package/CHANGELOG.md +169 -0
- package/dist/ai-B4YhEnfw.d.ts +333 -0
- package/dist/ai-server.d.ts +48 -9
- package/dist/ai-server.js +121 -49
- package/dist/ai.d.ts +2 -303
- package/dist/ai.js +231 -86
- package/dist/cli-B3VuxoDe.js +597 -0
- package/dist/cli-bin.d.ts +1 -0
- package/dist/cli-bin.js +5 -0
- package/dist/cli.d.ts +19 -0
- package/dist/cli.js +2 -0
- package/dist/client-BrfDXQ8A.d.ts +155 -0
- package/dist/client-Bt4tAKi9.js +798 -0
- package/dist/client.d.ts +2 -90
- package/dist/client.js +1 -409
- package/dist/{contract-CG_adnu_.js → contract-48bUMgcL.js} +10 -2
- package/dist/{contract-C_3dIIEU.d.ts → contract-jIfaR085.d.ts} +62 -8
- package/dist/devtools-J_jZ2vQf.d.ts +151 -0
- package/dist/devtools-kJJaORn-.js +338 -0
- package/dist/devtools-server.browser.js +1 -1
- package/dist/devtools-server.d.ts +2 -2
- package/dist/devtools-server.js +224 -43
- package/dist/devtools.d.ts +2 -0
- package/dist/devtools.js +2 -0
- package/dist/{errors-BJRMd-h6.js → errors-BQuJpe82.js} +4 -4
- package/dist/{errors-xL_JTXsY.d.ts → errors-W6nwJ-fm.d.ts} +1 -1
- package/dist/http.d.ts +71 -13
- package/dist/http.js +302 -41
- package/dist/{idempotent-replay-BMyHrP0L.js → idempotent-replay-DuqEkYA7.js} +2 -2
- package/dist/index.d.ts +5 -5
- package/dist/index.js +2 -2
- package/dist/{inspection-E7qbD0Xj.js → inspection-DaxB5jM2.js} +2 -1
- package/dist/internal-aEotMzu_.js +209 -0
- package/dist/otel.d.ts +1 -1
- package/dist/platform-B4TnJtWu.js +32 -0
- package/dist/react.d.ts +37 -14
- package/dist/react.js +26 -15
- package/dist/scheduler-qstash.d.ts +78 -0
- package/dist/scheduler-qstash.js +499 -0
- package/dist/scheduler-task-BpzhPnRS.js +54 -0
- package/dist/{recovery-vercel.d.ts → scheduler-vercel.d.ts} +17 -24
- package/dist/scheduler-vercel.js +226 -0
- package/dist/server-CcNnFnoW.js +1405 -0
- package/dist/server-YtPq7hjw.d.ts +260 -0
- package/dist/server.d.ts +4 -155
- package/dist/server.js +2 -2
- package/dist/{log-ldf5g8Cx.d.ts → store-C3sNAaBT.d.ts} +111 -35
- package/dist/{log-yJbXUf72.js → store-N8PXxDAS.js} +1 -1
- package/dist/store-codec-DTG0Ftek.js +8 -0
- package/dist/store-memory.d.ts +11 -0
- package/dist/{log-memory.js → store-memory.js} +127 -24
- package/dist/{log-polling-6COoN60V.js → store-polling-DgrrAE3d.js} +7 -6
- package/dist/{log-postgres.d.ts → store-postgres.d.ts} +6 -6
- package/dist/{log-postgres.js → store-postgres.js} +158 -24
- package/dist/{log-redis.js → store-redis-core-DWqx3F47.js} +294 -156
- package/dist/store-redis-http.d.ts +21 -0
- package/dist/store-redis-http.js +70 -0
- package/dist/store-redis.d.ts +37 -0
- package/dist/store-redis.js +298 -0
- package/dist/{log-sqlite.d.ts → store-sqlite.d.ts} +6 -6
- package/dist/{log-sqlite.js → store-sqlite.js} +116 -22
- package/dist/{telemetry-Cso0qyHQ.d.ts → telemetry-BjYHTfh2.d.ts} +1 -1
- package/dist/testing.browser.d.ts +1 -0
- package/dist/testing.browser.js +4 -0
- package/dist/testing.d.ts +31 -0
- package/dist/testing.js +101 -0
- package/dist/wire-DCUZBUlT.js +222 -0
- package/docs/01-quickstart.mdx +4 -5
- package/docs/concepts/01-contracts.mdx +21 -17
- package/docs/concepts/02-handlers.mdx +7 -7
- package/docs/concepts/03-durability.mdx +26 -29
- package/docs/concepts/04-state.mdx +18 -21
- package/docs/guides/01-timers.mdx +154 -54
- package/docs/guides/02-cancellation.mdx +30 -4
- package/docs/guides/03-react.mdx +20 -21
- package/docs/guides/04-local-first.mdx +1 -1
- package/docs/guides/05-production.mdx +321 -60
- package/docs/guides/06-ai-agents.mdx +249 -49
- package/docs/guides/07-devtools.mdx +137 -12
- package/docs/guides/08-application-data.mdx +1 -1
- package/docs/guides/09-presence.mdx +284 -0
- package/docs/guides/10-transports.mdx +131 -0
- package/docs/index.mdx +22 -46
- package/docs/reference/01-api.mdx +751 -102
- package/docs/reference/02-errors.mdx +10 -5
- package/package.json +23 -6
- package/dist/internal-D6wNxTck.js +0 -36
- package/dist/log-memory.d.ts +0 -11
- package/dist/log-redis.d.ts +0 -31
- package/dist/recovery-vercel.js +0 -119
- package/dist/server-DJgD2YWP.js +0 -877
- package/dist/wire-BVsgR8o9.js +0 -62
|
@@ -1,28 +1,29 @@
|
|
|
1
1
|
---
|
|
2
2
|
title: Going to production
|
|
3
|
-
description: Point the
|
|
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
|
|
10
|
-
also need
|
|
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
|
|
13
|
+
## 1. Choose a store
|
|
13
14
|
|
|
14
|
-
Production has no default
|
|
15
|
-
`
|
|
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/
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
61
|
+
## 2. Add a scheduler for handlers and timers
|
|
48
62
|
|
|
49
|
-
|
|
50
|
-
Vercel
|
|
51
|
-
|
|
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
|
|
74
|
+
// server/orders.ts, now with a scheduler:
|
|
55
75
|
import { createServer } from 'experimental-a2/server'
|
|
56
|
-
import { postgres } from 'experimental-a2/
|
|
57
|
-
import { vercelQueues } from 'experimental-a2/
|
|
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
|
|
80
|
+
export const scheduler = vercelQueues()
|
|
61
81
|
|
|
62
82
|
export const ordersServer = createServer({
|
|
63
83
|
contract: orders,
|
|
64
|
-
|
|
65
|
-
|
|
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
|
|
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/
|
|
75
|
-
import {
|
|
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 =
|
|
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/
|
|
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
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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
|
|
355
|
+
and add no scheduler operation of their own.
|
|
117
356
|
|
|
118
|
-
|
|
119
|
-
|
|
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
|
|
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
|
-
// ...
|
|
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
|
|
450
|
+
## Running without a scheduler
|
|
191
451
|
|
|
192
|
-
If none of a contract's event types have handlers
|
|
193
|
-
events settle in their append
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
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 `
|
|
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
|
|
465
|
+
For production, configure a scheduler.
|
|
205
466
|
|
|
206
467
|
## Checklist
|
|
207
468
|
|
|
208
469
|
| Piece | Done when |
|
|
209
470
|
| ------------------- | -------------------------------------------------------------------- |
|
|
210
|
-
|
|
|
211
|
-
|
|
|
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 |
|