experimental-a2 0.18.1 → 0.20.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 +44 -0
- package/dist/actor.js +1 -1
- package/dist/{ai-BMLlI2Ff.d.ts → ai-Cz3hqVhk.d.ts} +33 -16
- package/dist/ai-Cz3hqVhk.d.ts.map +1 -0
- package/dist/ai-client.d.ts +1 -1
- package/dist/ai-client.js +1 -1
- package/dist/ai-control-CvLbW10B.js +46 -0
- package/dist/ai-control-CvLbW10B.js.map +1 -0
- package/dist/ai-fx.d.ts +1 -1
- package/dist/ai-fx.d.ts.map +1 -1
- package/dist/ai-fx.js +63 -13
- package/dist/ai-fx.js.map +1 -1
- package/dist/{ai-harness-B2suP2Qh.d.ts → ai-harness-CV6rXbB3.d.ts} +2 -2
- package/dist/ai-harness-CV6rXbB3.d.ts.map +1 -0
- package/dist/{ai-harness-BuLh_EYi.js → ai-harness-Clbg5ei7.js} +88 -15
- package/dist/ai-harness-Clbg5ei7.js.map +1 -0
- package/dist/{ai-D6mGlO8j.js → ai-mMiWul1T.js} +116 -77
- package/dist/ai-mMiWul1T.js.map +1 -0
- package/dist/ai-pi.d.ts +1 -1
- package/dist/ai-pi.d.ts.map +1 -1
- package/dist/ai-pi.js +56 -11
- package/dist/ai-pi.js.map +1 -1
- package/dist/ai-react.d.ts +1 -1
- package/dist/ai-react.js +1 -1
- package/dist/ai-server.d.ts +2 -2
- package/dist/ai-server.d.ts.map +1 -1
- package/dist/ai-server.js +123 -53
- package/dist/ai-server.js.map +1 -1
- package/dist/ai.d.ts +2 -2
- package/dist/ai.js +1 -1
- package/dist/internal-CscSY7K_.js.map +1 -1
- package/dist/scheduler-qstash.d.ts.map +1 -1
- package/dist/scheduler-qstash.js +5 -5
- package/dist/scheduler-qstash.js.map +1 -1
- package/dist/scheduler-vercel.d.ts.map +1 -1
- package/dist/scheduler-vercel.js +5 -7
- package/dist/scheduler-vercel.js.map +1 -1
- package/dist/server-DYAATFuT.d.ts.map +1 -1
- package/dist/{server-D-Y4pqwu.js → server-qyb5aJJG.js} +39 -25
- package/dist/server-qyb5aJJG.js.map +1 -0
- package/dist/server.js +1 -1
- package/dist/session-clear-DGI4jTQ5.js.map +1 -1
- package/dist/store-postgres.d.ts.map +1 -1
- package/dist/store-postgres.js +38 -136
- package/dist/store-postgres.js.map +1 -1
- package/docs/01-quickstart.mdx +7 -4
- package/docs/actors/01-introduction.mdx +1 -1
- package/docs/actors/meta.ts +1 -1
- package/docs/agents/02-tools.mdx +1 -1
- package/docs/agents/03-conversations.mdx +26 -14
- package/docs/agents/04-harnesses.mdx +51 -5
- package/docs/agents/meta.ts +1 -1
- package/docs/client/meta.ts +1 -1
- package/docs/contract/03-sessions.mdx +2 -2
- package/docs/index.mdx +2 -2
- package/docs/reference/01-core.mdx +8 -8
- package/docs/reference/02-server.mdx +3 -3
- package/docs/reference/04-ai.mdx +36 -19
- package/docs/reference/05-telemetry.mdx +132 -0
- package/docs/reference/06-errors.mdx +1 -1
- package/docs/reference/meta.ts +1 -1
- package/docs/schedulers/01-overview.mdx +112 -0
- package/docs/schedulers/02-vercel-queues.mdx +87 -0
- package/docs/schedulers/03-qstash.mdx +123 -0
- package/docs/schedulers/meta.ts +1 -0
- package/docs/server/01-handlers.mdx +29 -2
- package/docs/server/02-timers.mdx +3 -3
- package/docs/server/06-guarantees.mdx +6 -3
- package/docs/stores/01-overview.mdx +37 -0
- package/docs/stores/02-redis.mdx +49 -0
- package/docs/stores/03-redis-http.mdx +56 -0
- package/docs/stores/04-postgres.mdx +121 -0
- package/docs/stores/05-sqlite.mdx +34 -0
- package/docs/stores/06-memory.mdx +22 -0
- package/docs/stores/meta.ts +1 -0
- package/examples/playground/DEPLOY.md +19 -5
- package/examples/playground/app/a0/a0.css +244 -5
- package/examples/playground/app/a0/app-view.tsx +2 -1
- package/examples/playground/app/a0/apps/[appId]/route.ts +6 -10
- package/examples/playground/app/a0/attachments.ts +133 -0
- package/examples/playground/app/a0/chat.test.tsx +595 -0
- package/examples/playground/app/a0/chat.tsx +623 -63
- package/examples/playground/app/a0/context-menu.tsx +38 -11
- package/examples/playground/app/a0/model.test.ts +28 -0
- package/examples/playground/app/a0/model.ts +30 -0
- package/examples/playground/app/a0/server.ts +5 -0
- package/examples/playground/app/a0/shell.tsx +1 -1
- package/examples/playground/app/a0/uploads/route.test.ts +59 -0
- package/examples/playground/app/a0/uploads/route.ts +29 -0
- package/examples/playground/app/agent/[agentId]/agent-client.tsx +2 -1
- package/examples/playground/app/agent/[agentId]/agent-queue.test.tsx +116 -89
- package/examples/playground/app/agent/[agentId]/compaction-panel.test.tsx +107 -76
- package/examples/playground/app/agent/ingress.test.ts +41 -17
- package/examples/playground/app/agent/model-input.test.tsx +92 -43
- package/examples/playground/app/agent/model.test.ts +12 -3
- package/examples/playground/app/chat/[chatId]/agent-state.test.ts +1 -5
- package/examples/playground/app/chat/[chatId]/agent-stream-drawer.test.tsx +2 -2
- package/examples/playground/app/chat/server.test.ts +38 -65
- package/examples/playground/app/components/activity-history.test.tsx +60 -18
- package/examples/playground/app/components/fast-link.test.tsx +3 -5
- package/examples/playground/app/initial-state.test.tsx +86 -45
- package/examples/playground/next.config.ts +7 -4
- package/examples/playground/package.json +4 -1
- package/examples/playground/test/agent.ts +35 -0
- package/examples/playground/test/model.ts +149 -0
- package/examples/playground/test/serve.ts +184 -0
- package/examples/playground/test/server-only.ts +1 -0
- package/examples/playground/vitest.config.ts +27 -0
- package/package.json +3 -1
- package/sql/postgres/notifications.sql +24 -0
- package/sql/postgres/schema.sql +137 -0
- package/src/ai-agent.ts +9 -4
- package/src/ai-client-state.ts +21 -9
- package/src/ai-context-schema.ts +28 -4
- package/src/ai-context.ts +83 -3
- package/src/ai-control-server.ts +70 -43
- package/src/ai-control-state.ts +3 -4
- package/src/ai-control.ts +59 -10
- package/src/ai-coordinator.ts +7 -6
- package/src/ai-fx.ts +95 -23
- package/src/ai-harness.ts +171 -29
- package/src/ai-message-shape.ts +41 -0
- package/src/ai-pi.ts +88 -32
- package/src/ai-stored-state.ts +1 -3
- package/src/ai.ts +116 -77
- package/src/errors.ts +1 -1
- package/src/internal.ts +2 -1
- package/src/libfx.d.ts +8 -1
- package/src/postgres-notifications.ts +0 -22
- package/src/postgres-resources.ts +6 -9
- package/src/postgres-schema.ts +80 -0
- package/src/scheduler-qstash.ts +12 -5
- package/src/scheduler-vercel.ts +11 -16
- package/src/server.ts +66 -32
- package/src/store-postgres.ts +5 -123
- package/dist/ai-BMLlI2Ff.d.ts.map +0 -1
- package/dist/ai-D6mGlO8j.js.map +0 -1
- package/dist/ai-harness-B2suP2Qh.d.ts.map +0 -1
- package/dist/ai-harness-BuLh_EYi.js.map +0 -1
- package/dist/server-D-Y4pqwu.js.map +0 -1
- package/docs/reference/05-adapters.mdx +0 -351
- package/docs/server/05-production.mdx +0 -323
- package/examples/playground/app/a0/no-photon.cjs +0 -4
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Overview
|
|
3
|
+
description: A clock for handler retries and timers.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
```ts server/orders.ts
|
|
7
|
+
import { createServer } from 'experimental-a2/server'
|
|
8
|
+
import { redis } from 'experimental-a2/store-redis'
|
|
9
|
+
import { vercelQueues } from 'experimental-a2/scheduler-vercel'
|
|
10
|
+
import { orders } from '@/contracts'
|
|
11
|
+
|
|
12
|
+
export const scheduler = vercelQueues()
|
|
13
|
+
|
|
14
|
+
export const ordersServer = createServer({
|
|
15
|
+
contract: orders,
|
|
16
|
+
store: redis({ url: process.env.REDIS_URL }),
|
|
17
|
+
scheduler,
|
|
18
|
+
handlers: {
|
|
19
|
+
/* ... */
|
|
20
|
+
},
|
|
21
|
+
})
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
```ts app/api/a2/scheduler/route.ts
|
|
25
|
+
import { billingServer, ordersServer, scheduler } from '@/server'
|
|
26
|
+
|
|
27
|
+
export const POST = scheduler.handler(ordersServer, billingServer)
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
A scheduler wakes sessions on a clock. It retries a handler that failed or
|
|
31
|
+
whose process died, and it delivers [timers](/server/timers), whose delays must
|
|
32
|
+
fit the [provider's limit](/server/timers#provider-limits). Share one
|
|
33
|
+
scheduler across your servers and mount its route once, with every server it
|
|
34
|
+
delivers to. `scheduler.handler(...)` checks those servers when the module
|
|
35
|
+
loads, so a bad route fails at startup.
|
|
36
|
+
|
|
37
|
+
| Scheduler | Use it for |
|
|
38
|
+
| --- | --- |
|
|
39
|
+
| [Vercel Queues](/schedulers/vercel-queues) | Apps on Vercel (recommended) |
|
|
40
|
+
| [QStash](/schedulers/qstash) | A stable public URL, called from separate infrastructure |
|
|
41
|
+
|
|
42
|
+
## Without a scheduler
|
|
43
|
+
|
|
44
|
+
If no event type has a handler and nothing calls `session.schedule`, skip it.
|
|
45
|
+
Events settle as they're appended, with no queue messages. That's a complete
|
|
46
|
+
production setup for history, reducers, and live sync.
|
|
47
|
+
|
|
48
|
+
With handlers but no scheduler, a session only wakes on a top-level append or
|
|
49
|
+
an explicit `server.drain()`. Reads never wake it, so a session nobody writes
|
|
50
|
+
to stays stuck. That's fine for internal tools that can heal on the next
|
|
51
|
+
write. For production, add a scheduler.
|
|
52
|
+
|
|
53
|
+
## More than one scheduler
|
|
54
|
+
|
|
55
|
+
Servers with different scheduler instances need separate routes, even from the
|
|
56
|
+
same adapter. Give each QStash instance its own `url`, and each Vercel Queues
|
|
57
|
+
instance its own `topic` and trigger.
|
|
58
|
+
|
|
59
|
+
## Dispatch in a fresh invocation
|
|
60
|
+
|
|
61
|
+
`append` starts handlers in the same request and uses the scheduler as a
|
|
62
|
+
watchdog. `append.dispatch` stores the event and hands its handlers to the
|
|
63
|
+
queue instead:
|
|
64
|
+
|
|
65
|
+
```ts app/api/imports/route.ts
|
|
66
|
+
import { ordersServer } from '@/server'
|
|
67
|
+
|
|
68
|
+
export async function POST(request: Request) {
|
|
69
|
+
const { orderId, shopId, items } = await request.json()
|
|
70
|
+
await ordersServer.session(orderId).append.dispatch({
|
|
71
|
+
id: `created:${orderId}`,
|
|
72
|
+
type: 'created',
|
|
73
|
+
payload: { shopId, items },
|
|
74
|
+
})
|
|
75
|
+
return Response.json({ accepted: true }, { status: 202 })
|
|
76
|
+
}
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
It needs a scheduler and waits for the queue to accept the message. If that
|
|
80
|
+
send fails, the event may already be stored; its explicit `id` makes a retry
|
|
81
|
+
safe.
|
|
82
|
+
|
|
83
|
+
## Deploys and rollbacks
|
|
84
|
+
|
|
85
|
+
Queued work can reach a different deployment than the one that sent it. Keep
|
|
86
|
+
event schemas compatible across deployments. Each adapter's page explains
|
|
87
|
+
where its tasks go: [Vercel Queues](/schedulers/vercel-queues#deployments) and
|
|
88
|
+
[QStash](/schedulers/qstash#deployments).
|
|
89
|
+
|
|
90
|
+
:::warning
|
|
91
|
+
Don't roll back to an A2 version without timer delivery while scheduled tasks
|
|
92
|
+
are outstanding. An old route can acknowledge a scheduled append without
|
|
93
|
+
writing its events. Rolling back past scheduled clears fails too: an old
|
|
94
|
+
Vercel Queues route keeps failing the clear until retention ends, and an old
|
|
95
|
+
QStash route moves it to QStash's dead-letter queue. Roll forward, or wait for
|
|
96
|
+
those tasks to deliver first.
|
|
97
|
+
:::
|
|
98
|
+
|
|
99
|
+
## Send errors
|
|
100
|
+
|
|
101
|
+
When a provider rejects a send, both adapters keep the provider's original
|
|
102
|
+
error.
|
|
103
|
+
|
|
104
|
+
- **Terminal:** a definite 4xx status or error, except 408, 409, 425, and 429.
|
|
105
|
+
The Vercel adapter also treats the SDK's `BadRequestError`,
|
|
106
|
+
`UnauthorizedError`, and `ForbiddenError` as terminal.
|
|
107
|
+
- **Retryable:** 408, 409, 425, 429, 5xx, network failures, and errors with no
|
|
108
|
+
recognized status.
|
|
109
|
+
- Vercel Queues' duplicate-message error counts as success.
|
|
110
|
+
|
|
111
|
+
A2's durable AI tools use this classification. Direct `session.schedule()`
|
|
112
|
+
calls still reject with the original error.
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Vercel Queues
|
|
3
|
+
description: The recommended scheduler on Vercel.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
```ts server/orders.ts
|
|
7
|
+
import { createServer } from 'experimental-a2/server'
|
|
8
|
+
import { redis } from 'experimental-a2/store-redis'
|
|
9
|
+
import { vercelQueues } from 'experimental-a2/scheduler-vercel'
|
|
10
|
+
import { orders } from '@/contracts'
|
|
11
|
+
|
|
12
|
+
export const scheduler = vercelQueues()
|
|
13
|
+
|
|
14
|
+
export const ordersServer = createServer({
|
|
15
|
+
contract: orders,
|
|
16
|
+
store: redis({ url: process.env.REDIS_URL }),
|
|
17
|
+
scheduler,
|
|
18
|
+
handlers: {
|
|
19
|
+
/* ... */
|
|
20
|
+
},
|
|
21
|
+
})
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
```ts app/api/a2/scheduler/route.ts
|
|
25
|
+
import { ordersServer, scheduler } from '@/server'
|
|
26
|
+
|
|
27
|
+
export const POST = scheduler.handler(ordersServer)
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
```json vercel.json
|
|
31
|
+
{
|
|
32
|
+
"functions": {
|
|
33
|
+
"app/api/a2/scheduler/route.ts": {
|
|
34
|
+
"experimentalTriggers": [
|
|
35
|
+
{
|
|
36
|
+
"type": "queue/v2beta",
|
|
37
|
+
"topic": "a2"
|
|
38
|
+
}
|
|
39
|
+
]
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
```package-install
|
|
46
|
+
npm i @vercel/queue
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
The trigger is required. It connects the route to Vercel Queues and makes the
|
|
50
|
+
function private, so only the queue can call it. Don't also expose the route
|
|
51
|
+
publicly or wrap it in your own authentication.
|
|
52
|
+
|
|
53
|
+
Messages are kept for seven days (Vercel Queues' default is 24 hours, and
|
|
54
|
+
seven days is its maximum), so timers can be scheduled up to seven days out. A
|
|
55
|
+
longer delay goes out as asked, and Vercel Queues refuses it.
|
|
56
|
+
|
|
57
|
+
A drain delivery is acknowledged once its session settles or a later watchdog
|
|
58
|
+
for it is stored, including when the drain fails after storing that watchdog.
|
|
59
|
+
A drain that fails before then stays unacknowledged, so Vercel Queues
|
|
60
|
+
redelivers it.
|
|
61
|
+
|
|
62
|
+
## Options
|
|
63
|
+
|
|
64
|
+
| Option | Default | |
|
|
65
|
+
| --- | --- | --- |
|
|
66
|
+
| `topic` | `'a2'` | must match the trigger's `topic`, and `[A-Za-z0-9_-]+` |
|
|
67
|
+
| `deploymentId` | `VERCEL_DEPLOYMENT_ID` | which deployment receives tasks; see below |
|
|
68
|
+
| `transport`, `clock` | the `@vercel/queue` client, the system clock | test seams for custom delivery |
|
|
69
|
+
|
|
70
|
+
An injected `transport` receives the topic, the task, and send options, and
|
|
71
|
+
owns its own provider configuration; `deploymentId` only configures the
|
|
72
|
+
built-in client.
|
|
73
|
+
|
|
74
|
+
## Deployments
|
|
75
|
+
|
|
76
|
+
`deploymentId` applies to every task: retries, timers, and scheduled clears.
|
|
77
|
+
Leave it out to deliver to the deployment that sent the task, pass an ID to
|
|
78
|
+
target one deployment, or pass `null` to let Vercel pick. Every deployment that
|
|
79
|
+
sends or receives must use the same setting.
|
|
80
|
+
|
|
81
|
+
- **Tasks stay with their deployment.** Promoting a new deployment doesn't move
|
|
82
|
+
them, so older deployments keep receiving their own work until it's done or
|
|
83
|
+
expires. Upgrading doesn't fix an older deployment's route.
|
|
84
|
+
- **`null` doesn't mean latest.** Vercel picks any eligible deployment, so every
|
|
85
|
+
one of them must use `deploymentId: null` and handle pending tasks.
|
|
86
|
+
- **Changing the setting doesn't move queued tasks.** Keep the old deployments
|
|
87
|
+
running until those tasks finish.
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: QStash
|
|
3
|
+
description: Signed HTTP delivery from Upstash, to a stable public URL.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
```ts server/orders.ts
|
|
7
|
+
import { createServer } from 'experimental-a2/server'
|
|
8
|
+
import { qstash } from 'experimental-a2/scheduler-qstash'
|
|
9
|
+
import { redis } from 'experimental-a2/store-redis'
|
|
10
|
+
import { orders } from '@/contracts'
|
|
11
|
+
|
|
12
|
+
export const scheduler = qstash()
|
|
13
|
+
|
|
14
|
+
export const ordersServer = createServer({
|
|
15
|
+
contract: orders,
|
|
16
|
+
store: redis({ url: process.env.REDIS_URL }),
|
|
17
|
+
scheduler,
|
|
18
|
+
handlers: {
|
|
19
|
+
/* ... */
|
|
20
|
+
},
|
|
21
|
+
})
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
```ts app/api/a2/scheduler/route.ts
|
|
25
|
+
import { ordersServer, scheduler } from '@/server'
|
|
26
|
+
|
|
27
|
+
export const POST = scheduler.handler(ordersServer)
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
```package-install
|
|
31
|
+
npm i @upstash/qstash
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Set `QSTASH_TOKEN`, `QSTASH_CURRENT_SIGNING_KEY`, and
|
|
35
|
+
`QSTASH_NEXT_SIGNING_KEY`. In development you need none of them: A2 starts
|
|
36
|
+
QStash's local server on the first publish. It needs `@upstash/qstash` 2.11.0
|
|
37
|
+
or newer.
|
|
38
|
+
|
|
39
|
+
The route checks `Upstash-Signature` before it touches the store and answers
|
|
40
|
+
unsigned requests with 401. Delivery is at least once, so handler side effects
|
|
41
|
+
need idempotency keys.
|
|
42
|
+
|
|
43
|
+
## Callback URL
|
|
44
|
+
|
|
45
|
+
QStash calls `/api/a2/scheduler`. An explicit `url` always wins; otherwise A2
|
|
46
|
+
infers it:
|
|
47
|
+
|
|
48
|
+
- **Vercel production:** `https://${VERCEL_PROJECT_PRODUCTION_URL}/api/a2/scheduler`.
|
|
49
|
+
- **Vercel preview:** `https://${VERCEL_BRANCH_URL}/api/a2/scheduler`.
|
|
50
|
+
- **Locally:** `http://localhost:${PORT ?? 3000}/api/a2/scheduler`, against
|
|
51
|
+
QStash's development server. `PORT` must be an integer from 1 through 65535.
|
|
52
|
+
|
|
53
|
+
Pass `url` for anything else: production outside Vercel, a custom path or
|
|
54
|
+
host, Docker networking, or a runtime without `process.env`. With an explicit
|
|
55
|
+
`url` outside production, A2 doesn't start the development server: set
|
|
56
|
+
`QSTASH_DEV`, or `QSTASH_URL` and credentials, yourself. An Edge runtime infers
|
|
57
|
+
the local URL but must also start the development server itself.
|
|
58
|
+
|
|
59
|
+
## Options
|
|
60
|
+
|
|
61
|
+
| Option | Default | |
|
|
62
|
+
| --- | --- | --- |
|
|
63
|
+
| `url` | inferred, see above | the callback URL |
|
|
64
|
+
| `token` | `QSTASH_TOKEN` | |
|
|
65
|
+
| `currentSigningKey` | `QSTASH_CURRENT_SIGNING_KEY` | |
|
|
66
|
+
| `nextSigningKey` | `QSTASH_NEXT_SIGNING_KEY` | |
|
|
67
|
+
| `headers` | none | static headers on every callback |
|
|
68
|
+
| `client` | a lazy `@upstash/qstash` client | your own configured client |
|
|
69
|
+
| `retries` | QStash's default | retries per delivery; every attempt is billed |
|
|
70
|
+
| `maxBodyBytes` | `1_048_576` (1 MiB) | the largest task A2 sends or accepts |
|
|
71
|
+
| `transport`, `clock` | the `@upstash/qstash` client and receiver, the system clock | test seams for custom delivery |
|
|
72
|
+
|
|
73
|
+
- **`headers`** are checked and copied when `qstash()` runs. A2 asks QStash to
|
|
74
|
+
redact every value from its dashboard and API, but QStash still stores and
|
|
75
|
+
delivers them. Names are case-insensitive. `Upstash-*`, `Content-Type`, HTTP
|
|
76
|
+
framing headers, and A2's Vercel identity headers are reserved.
|
|
77
|
+
- **`client`** is the `publishJSON(...)` part of a configured `Client`, for its
|
|
78
|
+
endpoint, retry policy, or telemetry. It can't be combined with `token` or
|
|
79
|
+
`transport`. It receives callback credentials unredacted and must apply each
|
|
80
|
+
publish's redaction. Put callback credentials in `headers`, not on the
|
|
81
|
+
client.
|
|
82
|
+
- **`maxBodyBytes`**: A2 reserves room for a task's retry metadata before the
|
|
83
|
+
first publish, so an accepted task never outgrows the limit. Raise it for a
|
|
84
|
+
larger paid-plan limit, and keep one value on every deployment that receives
|
|
85
|
+
the URL.
|
|
86
|
+
- **`transport`** implements `publish(request)` and `verify(request)`. It must
|
|
87
|
+
keep the body, deliver every header, and apply every redaction.
|
|
88
|
+
|
|
89
|
+
## How delivery works
|
|
90
|
+
|
|
91
|
+
- The route returns 200 once a task is done, or once its follow-up is queued,
|
|
92
|
+
so each retry gets a fresh message and retry budget. That includes a drain
|
|
93
|
+
that fails after queueing its follow-up.
|
|
94
|
+
- If the store is down (`STORE_UNAVAILABLE`) or not set up yet
|
|
95
|
+
(`STORE_NOT_CONFIGURED`, such as Postgres before its tables exist) before a
|
|
96
|
+
drain queues its follow-up, or during an append or clear task, the task moves
|
|
97
|
+
to a new message: 30 seconds later, four times longer each time, up to one
|
|
98
|
+
day.
|
|
99
|
+
- A signed but malformed task, or one over `maxBodyBytes`, returns 489 with
|
|
100
|
+
`Upstash-NonRetryable-Error: true`. Unknown contracts, missing
|
|
101
|
+
configuration, and other failures return an error, so QStash retries.
|
|
102
|
+
- If QStash can't reach the route at all, nothing queues a follow-up: watch
|
|
103
|
+
QStash's dead-letter queue and redrive from it.
|
|
104
|
+
- Delays are capped by your plan: 7 days on free plans and 1 year on
|
|
105
|
+
usage-based plans, with no fixed cap on fixed plans. Leave a second of
|
|
106
|
+
margin at the cap.
|
|
107
|
+
|
|
108
|
+
## Protected previews
|
|
109
|
+
|
|
110
|
+
For an inferred preview URL, each publish forwards the invocation's Vercel
|
|
111
|
+
OIDC token as `x-vercel-trusted-oidc-idp-token`, redacted, so a Vercel Trusted
|
|
112
|
+
Sources rule can let the first delivery through. This is best effort: a
|
|
113
|
+
missing or expiring token falls back to a plain publish, and retries reuse the
|
|
114
|
+
stored token, which may have expired. When every protected delivery must get
|
|
115
|
+
through, pass an automation bypass in `headers` instead. Signature checks still
|
|
116
|
+
apply after Vercel's.
|
|
117
|
+
|
|
118
|
+
## Deployments
|
|
119
|
+
|
|
120
|
+
Inferred production and preview URLs are moving aliases, so a pending task can
|
|
121
|
+
reach a later deployment. Keep tasks and event schemas compatible across
|
|
122
|
+
deployments, and keep the same `maxBodyBytes` on all of them: a lower value
|
|
123
|
+
rejects tasks that an earlier deployment accepted.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export default { order: 5 }
|
|
@@ -24,7 +24,7 @@ database, hit an API, or stream a model response. A2 claims the event while it
|
|
|
24
24
|
runs and retries it after a crash.
|
|
25
25
|
|
|
26
26
|
`createServer` from `experimental-a2/server` binds the contract to a
|
|
27
|
-
[store](/
|
|
27
|
+
[store](/stores/overview) and one complete handler table,
|
|
28
28
|
so a handler can't go missing because its module was never imported. Compose
|
|
29
29
|
the table across files by spreading objects into `handlers`. It is the only
|
|
30
30
|
entry point that touches a store, and it fails loudly in a browser bundle.
|
|
@@ -194,7 +194,34 @@ records completion runs the handler again.
|
|
|
194
194
|
Throwing means "retry me." A2 records the failure and retries with backoff.
|
|
195
195
|
The failed event blocks only its lane. After ten failed attempts it
|
|
196
196
|
dead-letters and keeps blocking its lane until
|
|
197
|
-
[you retry or skip it](
|
|
197
|
+
[you retry or skip it](#resolve-a-dead-letter).
|
|
198
198
|
|
|
199
199
|
When someone presses stop, catch the abort and return normally instead. See
|
|
200
200
|
[Cancellation](/server/cancellation).
|
|
201
|
+
|
|
202
|
+
## Resolve a dead letter
|
|
203
|
+
|
|
204
|
+
A [dead-lettered](/server/guarantees#when-a-handler-keeps-failing) event waits
|
|
205
|
+
for you. List a session's dead letters, then retry or skip each one:
|
|
206
|
+
|
|
207
|
+
```ts
|
|
208
|
+
// anywhere on the server, behind your own operator checks:
|
|
209
|
+
import { ordersServer } from '@/server'
|
|
210
|
+
|
|
211
|
+
const order = ordersServer.session(orderId)
|
|
212
|
+
const [dead] = await order.deadLetters()
|
|
213
|
+
if (dead) {
|
|
214
|
+
// dead.lastError says why (null if every attempt timed out or crashed);
|
|
215
|
+
// deploy the handler fix first, then:
|
|
216
|
+
await order.redrive(dead.index, { action: 'retry' })
|
|
217
|
+
}
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
- **Retry.** `{ action: 'retry' }` runs the handler again with a fresh
|
|
221
|
+
ten-failure budget. Once it completes, its lane continues.
|
|
222
|
+
- **Skip.** `{ action: 'skip' }` marks the event processed without running it,
|
|
223
|
+
accepting that its effects never happen. The next event in its lane runs.
|
|
224
|
+
|
|
225
|
+
Each redrive is one atomic store operation, and it wakes the session like an
|
|
226
|
+
append, so the lane resumes without another write. An event that isn't
|
|
227
|
+
dead-lettered anymore is left alone: `redrive` reports `{ redriven: false }`.
|
|
@@ -34,7 +34,7 @@ export const ordersServer = createServer({
|
|
|
34
34
|
|
|
35
35
|
Configure a scheduler, then call `session.schedule(name, timing, ...events)`.
|
|
36
36
|
The scheduler route that handles recovery delivers timers too;
|
|
37
|
-
[mount it once](/
|
|
37
|
+
[mount it once](/schedulers/overview).
|
|
38
38
|
|
|
39
39
|
`timing` is `{ delay }` (units `ms`, `s`, `m`, `h`, `d`, checked by the type)
|
|
40
40
|
or `{ at: Date }`. A delay must be a finite, unsigned base-10 decimal above
|
|
@@ -103,7 +103,7 @@ If the receiving deployment changed or removed the event's schema, delivery
|
|
|
103
103
|
can fail validation and then keeps retrying as a scheduler error. The Vercel
|
|
104
104
|
adapter pins timers to their publishing deployment by default; with
|
|
105
105
|
`deploymentId: null` or a stable URL like QStash's, keep schemas compatible.
|
|
106
|
-
See [
|
|
106
|
+
See [Deploys and rollbacks](/schedulers/overview#deploys-and-rollbacks).
|
|
107
107
|
|
|
108
108
|
## Stale timers
|
|
109
109
|
|
|
@@ -119,7 +119,7 @@ handler schedule the next one.
|
|
|
119
119
|
|
|
120
120
|
| Adapter | Maximum one-shot delay |
|
|
121
121
|
| --- | --- |
|
|
122
|
-
| Vercel Queues |
|
|
122
|
+
| Vercel Queues | 7 days |
|
|
123
123
|
| QStash free | 7 days |
|
|
124
124
|
| QStash usage-based | 1 year |
|
|
125
125
|
| QStash fixed | no fixed maximum |
|
|
@@ -71,7 +71,7 @@ up to two seconds for the watchdog send, never for handlers. A failed arm
|
|
|
71
71
|
records `a2.append.armed = false` on the trace. Handler appends and returned
|
|
72
72
|
events join the running drain and add no arm.
|
|
73
73
|
|
|
74
|
-
[`append.dispatch`](/
|
|
74
|
+
[`append.dispatch`](/schedulers/overview#dispatch-in-a-fresh-invocation) skips
|
|
75
75
|
the inline drain and hands the work to the scheduler instead.
|
|
76
76
|
|
|
77
77
|
An event type without a handler settles in the append transaction. It starts
|
|
@@ -108,8 +108,11 @@ attempt takes its claim over. Ten failed attempts dead-letter the event: later
|
|
|
108
108
|
events in its lane stay blocked, while unlaned events and other lanes continue.
|
|
109
109
|
A crash costs every event it interrupts one attempt, so a session whose work
|
|
110
110
|
keeps outrunning its function dead-letters: bound its fan-out, or put work that
|
|
111
|
-
waits on the same resource in one lane.
|
|
112
|
-
|
|
111
|
+
waits on the same resource in one lane. A `NonRetriableError`, or a returned
|
|
112
|
+
batch the store refuses because it reuses an event id, dead-letters the event
|
|
113
|
+
on that first attempt instead: a retry would fail the same way. The event
|
|
114
|
+
waits until you
|
|
115
|
+
[retry or skip it](/server/handlers#resolve-a-dead-letter) with
|
|
113
116
|
`session.redrive()`.
|
|
114
117
|
|
|
115
118
|
## Limits
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Overview
|
|
3
|
+
description: Where A2 keeps your session histories.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
```ts server/orders.ts
|
|
7
|
+
import { createServer } from 'experimental-a2/server'
|
|
8
|
+
import { redis } from 'experimental-a2/store-redis'
|
|
9
|
+
import { orders } from '@/contracts'
|
|
10
|
+
|
|
11
|
+
export const ordersServer = createServer({
|
|
12
|
+
contract: orders,
|
|
13
|
+
store: redis({ url: process.env.REDIS_URL }),
|
|
14
|
+
handlers: {
|
|
15
|
+
/* ... */
|
|
16
|
+
},
|
|
17
|
+
})
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
| Store | Use it for |
|
|
21
|
+
| --- | --- |
|
|
22
|
+
| [Redis](/stores/redis) | Production (recommended) |
|
|
23
|
+
| [Redis over HTTP](/stores/redis-http) | Production, when your provider only offers a REST API |
|
|
24
|
+
| [Postgres](/stores/postgres) | Production |
|
|
25
|
+
| [SQLite](/stores/sqlite) | Development: the default |
|
|
26
|
+
| [Memory](/stores/memory) | Tests: the default when `NODE_ENV=test` |
|
|
27
|
+
|
|
28
|
+
When you omit `store`, development gets SQLite, so state survives `next dev`
|
|
29
|
+
restarts, and tests get memory. Production has no default, on purpose: the
|
|
30
|
+
server throws `STORE_NOT_CONFIGURED` at startup rather than write events to a
|
|
31
|
+
filesystem that evaporates.
|
|
32
|
+
|
|
33
|
+
A store holds A2's session histories. It doesn't connect A2 to your
|
|
34
|
+
application tables; see
|
|
35
|
+
[Sessions and your database](/contract/sessions#sessions-and-your-database).
|
|
36
|
+
|
|
37
|
+
Servers with handlers or timers also need a [scheduler](/schedulers/overview).
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Redis
|
|
3
|
+
description: The recommended production store. Live streams are pushed, not polled.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
```ts server/orders.ts
|
|
7
|
+
import { createServer } from 'experimental-a2/server'
|
|
8
|
+
import { redis } from 'experimental-a2/store-redis'
|
|
9
|
+
import { orders } from '@/contracts'
|
|
10
|
+
|
|
11
|
+
export const ordersServer = createServer({
|
|
12
|
+
contract: orders,
|
|
13
|
+
store: redis({ url: process.env.REDIS_URL }),
|
|
14
|
+
handlers: {
|
|
15
|
+
/* ... */
|
|
16
|
+
},
|
|
17
|
+
})
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
```package-install
|
|
21
|
+
npm i ioredis
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
Works on a single Redis instance and on non-cluster providers such as Upstash,
|
|
25
|
+
which is durable by default (use its TCP `rediss://` URL). There's nothing to
|
|
26
|
+
set up: A2 creates its keys as it writes.
|
|
27
|
+
|
|
28
|
+
## Options
|
|
29
|
+
|
|
30
|
+
| Option | |
|
|
31
|
+
| --- | --- |
|
|
32
|
+
| `url` | creates an `ioredis` client |
|
|
33
|
+
| `client` | your own `ioredis` client instead |
|
|
34
|
+
| `keyPrefix` | isolates several apps on one Redis. Default `'a2'` |
|
|
35
|
+
| `clock`, `ids` | test seams for timestamps and generated event ids |
|
|
36
|
+
|
|
37
|
+
## How it works
|
|
38
|
+
|
|
39
|
+
- Each session is a Redis Stream.
|
|
40
|
+
- Every write publishes a wake-up, so live streams are push-driven, and
|
|
41
|
+
connections scale with your processes, not with your viewers.
|
|
42
|
+
- A wake-up is disposable: a safety re-read recovers a lost one within ten
|
|
43
|
+
seconds.
|
|
44
|
+
|
|
45
|
+
## Cost per command
|
|
46
|
+
|
|
47
|
+
On providers that bill per command, such as Upstash, appending an event with a
|
|
48
|
+
handler and running it takes 24 commands, and an idle live stream takes 6 a
|
|
49
|
+
minute.
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Redis over HTTP
|
|
3
|
+
description: The same Redis store over fetch, for providers with only a REST API.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
```ts server/orders.ts
|
|
7
|
+
import { createServer } from 'experimental-a2/server'
|
|
8
|
+
import { redisHttp } from 'experimental-a2/store-redis-http'
|
|
9
|
+
import { orders } from '@/contracts'
|
|
10
|
+
|
|
11
|
+
const url = process.env.UPSTASH_REDIS_REST_URL
|
|
12
|
+
const token = process.env.UPSTASH_REDIS_REST_TOKEN
|
|
13
|
+
if (!url || !token) throw new Error('set the Upstash REST URL and token')
|
|
14
|
+
|
|
15
|
+
export const ordersServer = createServer({
|
|
16
|
+
contract: orders,
|
|
17
|
+
store: redisHttp({ url, token }),
|
|
18
|
+
handlers: {
|
|
19
|
+
/* ... */
|
|
20
|
+
},
|
|
21
|
+
})
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
Stores sessions exactly like [Redis](/stores/redis), with no client dependency
|
|
25
|
+
and nothing to set up. Use it when your provider only offers a REST API.
|
|
26
|
+
|
|
27
|
+
:::warning
|
|
28
|
+
Live streams trust the messages on the store's `:notify` channels. Browsers
|
|
29
|
+
never hold Redis credentials, so they can't reach those channels. The risk is a
|
|
30
|
+
credential that can publish but not write, such as a token another service
|
|
31
|
+
uses: it could add or hide events in live views. Grant publish access only to
|
|
32
|
+
credentials that can also write the store.
|
|
33
|
+
:::
|
|
34
|
+
|
|
35
|
+
## Options
|
|
36
|
+
|
|
37
|
+
| Option | |
|
|
38
|
+
| --- | --- |
|
|
39
|
+
| `url`, `token` | your provider's REST endpoint and token |
|
|
40
|
+
| `keyPrefix` | isolates several apps on one Redis. Default `'a2'` |
|
|
41
|
+
| `clock`, `ids` | test seams for timestamps and generated event ids |
|
|
42
|
+
|
|
43
|
+
## Live updates
|
|
44
|
+
|
|
45
|
+
- Live events and presence use Upstash's HTTP `SUBSCRIBE` endpoint, with the
|
|
46
|
+
same URL and token. Each active channel holds one outbound HTTP subscription
|
|
47
|
+
per store instance, shared by its viewers and closed when the last one
|
|
48
|
+
leaves.
|
|
49
|
+
- Writes publish their wake-ups in the same Redis script as the append. A
|
|
50
|
+
rejected notification doesn't fail the write.
|
|
51
|
+
- Idle streams re-read every ten seconds to recover a missed notification.
|
|
52
|
+
Subscriptions reconnect on their own and catch up from the event cursor.
|
|
53
|
+
- After a reconnect, A2 re-reads presence and clears fields deleted during the
|
|
54
|
+
gap, so presence may repeat its latest values.
|
|
55
|
+
- If `SUBSCRIBE` answers 404, 405, or 501, the store polls instead, every 25ms
|
|
56
|
+
while busy and up to every two seconds when idle. Other errors stay errors.
|