experimental-a2 0.17.0 → 0.18.1
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 +32 -0
- package/README.md +1 -1
- package/dist/{actor-DBbe5xeE.d.ts → actor-C7b-1arn.d.ts} +5 -5
- package/dist/{actor-DBbe5xeE.d.ts.map → actor-C7b-1arn.d.ts.map} +1 -1
- package/dist/actor-client.d.ts +1 -1
- package/dist/actor-react.d.ts +3 -3
- package/dist/actor-react.js +1 -1
- package/dist/actor.d.ts +1 -1
- package/dist/actor.js +2 -2
- package/dist/{ai-vpGtcaCB.d.ts → ai-BMLlI2Ff.d.ts} +3 -3
- package/dist/{ai-vpGtcaCB.d.ts.map → ai-BMLlI2Ff.d.ts.map} +1 -1
- package/dist/{ai-UrWfKeuv.js → ai-D6mGlO8j.js} +2 -2
- package/dist/{ai-UrWfKeuv.js.map → ai-D6mGlO8j.js.map} +1 -1
- package/dist/ai-client.d.ts +2 -2
- package/dist/ai-client.js +2 -2
- package/dist/ai-fx.browser.d.ts +1 -0
- package/dist/ai-fx.browser.js +6 -0
- package/dist/ai-fx.browser.js.map +1 -0
- package/dist/ai-fx.d.ts +10 -0
- package/dist/ai-fx.d.ts.map +1 -0
- package/dist/ai-fx.js +692 -0
- package/dist/ai-fx.js.map +1 -0
- package/dist/ai-harness-B2suP2Qh.d.ts +26 -0
- package/dist/ai-harness-B2suP2Qh.d.ts.map +1 -0
- package/dist/ai-harness-BuLh_EYi.js +273 -0
- package/dist/ai-harness-BuLh_EYi.js.map +1 -0
- package/dist/ai-pi.browser.d.ts +1 -0
- package/dist/ai-pi.browser.js +6 -0
- package/dist/ai-pi.browser.js.map +1 -0
- package/dist/ai-pi.d.ts +17 -0
- package/dist/ai-pi.d.ts.map +1 -0
- package/dist/ai-pi.js +950 -0
- package/dist/ai-pi.js.map +1 -0
- package/dist/ai-react.d.ts +5 -5
- package/dist/ai-react.js +2 -2
- package/dist/ai-server.d.ts +47 -25
- package/dist/ai-server.d.ts.map +1 -1
- package/dist/ai-server.js +113 -40
- package/dist/ai-server.js.map +1 -1
- package/dist/ai-tool-input-DmSqMUQi.js +40 -0
- package/dist/ai-tool-input-DmSqMUQi.js.map +1 -0
- package/dist/ai.d.ts +1 -1
- package/dist/ai.js +1 -1
- package/dist/bounded-cache-CPHWF-8h.js +41 -0
- package/dist/bounded-cache-CPHWF-8h.js.map +1 -0
- package/dist/{client-COaiaDub.js → client-BhHLoNKF.js} +3 -3
- package/dist/{client-COaiaDub.js.map → client-BhHLoNKF.js.map} +1 -1
- package/dist/{client-qRnRUzBS.d.ts → client-h_DKFAjj.d.ts} +3 -3
- package/dist/{client-qRnRUzBS.d.ts.map → client-h_DKFAjj.d.ts.map} +1 -1
- package/dist/client.d.ts +3 -3
- package/dist/client.js +1 -1
- package/dist/index.d.ts +5 -5
- package/dist/{initial-snapshot-BrLso5x_.d.ts → initial-snapshot-Bx-c0pDP.d.ts} +2 -2
- package/dist/{initial-snapshot-BrLso5x_.d.ts.map → initial-snapshot-Bx-c0pDP.d.ts.map} +1 -1
- package/dist/{internal-CbVvEwad.js → internal-CscSY7K_.js} +11 -3
- package/dist/internal-CscSY7K_.js.map +1 -0
- package/dist/otel.d.ts +1 -1
- package/dist/{react-BbJ-g6Uu.d.ts → react-BBXgHWFc.d.ts} +4 -4
- package/dist/{react-BbJ-g6Uu.d.ts.map → react-BBXgHWFc.d.ts.map} +1 -1
- package/dist/react.d.ts +1 -1
- package/dist/react.js +1 -1
- package/dist/reducer-BAXc8JyF.js.map +1 -1
- package/dist/{reducer-BYu6DT7h.d.ts → reducer-CqA59Q6U.d.ts} +2 -2
- package/dist/{reducer-BYu6DT7h.d.ts.map → reducer-CqA59Q6U.d.ts.map} +1 -1
- package/dist/scheduler-qstash.d.ts +2 -2
- package/dist/scheduler-qstash.js +3 -3
- package/dist/scheduler-qstash.js.map +1 -1
- package/dist/scheduler-vercel.d.ts +7 -7
- package/dist/scheduler-vercel.d.ts.map +1 -1
- package/dist/scheduler-vercel.js +9 -10
- package/dist/scheduler-vercel.js.map +1 -1
- package/dist/{server-Rj64fNvX.js → server-D-Y4pqwu.js} +126 -67
- package/dist/server-D-Y4pqwu.js.map +1 -0
- package/dist/{server-CGM8WzRG.d.ts → server-DYAATFuT.d.ts} +42 -7
- package/dist/server-DYAATFuT.d.ts.map +1 -0
- package/dist/server.d.ts +4 -4
- package/dist/server.js +1 -1
- package/dist/{session-clear-Dshpuhff.d.ts → session-clear-C-QwTXV1.d.ts} +2 -2
- package/dist/session-clear-C-QwTXV1.d.ts.map +1 -0
- package/dist/{store-Gpkq7WBo.d.ts → store-CyLUy86v.d.ts} +53 -5
- package/dist/store-CyLUy86v.d.ts.map +1 -0
- package/dist/store-N8PXxDAS.js.map +1 -1
- package/dist/store-memory.d.ts +1 -1
- package/dist/store-memory.js +56 -11
- package/dist/store-memory.js.map +1 -1
- package/dist/{store-polling-4_y8lETm.js → store-polling-7WJV0YWP.js} +2 -2
- package/dist/{store-polling-4_y8lETm.js.map → store-polling-7WJV0YWP.js.map} +1 -1
- package/dist/store-postgres.d.ts +1 -1
- package/dist/store-postgres.d.ts.map +1 -1
- package/dist/store-postgres.js +106 -25
- package/dist/store-postgres.js.map +1 -1
- package/dist/{store-presence-polling-Q1C-k2dd.js → store-presence-polling-B1ixeqIS.js} +3 -3
- package/dist/{store-presence-polling-Q1C-k2dd.js.map → store-presence-polling-B1ixeqIS.js.map} +1 -1
- package/dist/store-redis-http.d.ts +1 -1
- package/dist/store-redis-http.js +3 -3
- package/dist/{store-redis-notify-tqWNnMRu.js → store-redis-notify-CM0AfLY0.js} +271 -101
- package/dist/store-redis-notify-CM0AfLY0.js.map +1 -0
- package/dist/store-redis.d.ts +1 -1
- package/dist/store-redis.js +1 -1
- package/dist/store-sqlite.d.ts +1 -1
- package/dist/store-sqlite.js +66 -21
- package/dist/store-sqlite.js.map +1 -1
- package/dist/{telemetry-cZH-cibj.d.ts → telemetry-Cyfqo6Qt.d.ts} +2 -2
- package/dist/telemetry-Cyfqo6Qt.d.ts.map +1 -0
- package/dist/{wire-DjfHOvdW.js → wire-BKxH1vbP.js} +2 -2
- package/dist/{wire-DjfHOvdW.js.map → wire-BKxH1vbP.js.map} +1 -1
- package/docs/01-quickstart.mdx +49 -83
- package/docs/{guides/07-examples.mdx → 02-examples.mdx} +10 -20
- package/docs/actors/01-introduction.mdx +48 -86
- package/docs/actors/02-concurrency.mdx +43 -70
- package/docs/actors/03-timers.mdx +28 -42
- package/docs/actors/04-routes.mdx +110 -150
- package/docs/actors/meta.ts +1 -1
- package/docs/agents/01-introduction.mdx +257 -0
- package/docs/agents/02-tools.mdx +247 -0
- package/docs/agents/03-conversations.mdx +538 -0
- package/docs/agents/04-harnesses.mdx +136 -0
- package/docs/agents/meta.ts +1 -0
- package/docs/client/01-react.mdx +294 -0
- package/docs/client/02-transports.mdx +86 -0
- package/docs/client/03-presence.mdx +224 -0
- package/docs/client/meta.ts +1 -0
- package/docs/contract/01-events.mdx +57 -0
- package/docs/contract/02-state.mdx +142 -0
- package/docs/contract/03-sessions.mdx +175 -0
- package/docs/index.mdx +109 -145
- package/docs/reference/01-core.mdx +139 -0
- package/docs/reference/02-server.mdx +860 -0
- package/docs/reference/03-client.mdx +301 -0
- package/docs/reference/04-ai.mdx +731 -0
- package/docs/reference/05-adapters.mdx +351 -0
- package/docs/reference/06-errors.mdx +138 -0
- package/docs/reference/meta.ts +1 -1
- package/docs/server/01-handlers.mdx +200 -0
- package/docs/server/02-timers.mdx +130 -0
- package/docs/server/03-cancellation.mdx +142 -0
- package/docs/server/04-routes.mdx +243 -0
- package/docs/server/05-production.mdx +323 -0
- package/docs/server/06-guarantees.mdx +132 -0
- package/examples/playground/DEPLOY.md +3 -5
- package/examples/playground/app/a0/[appId]/page.tsx +12 -13
- package/examples/playground/app/a0/app-list.tsx +9 -2
- package/examples/playground/app/a0/app-view.tsx +9 -1
- package/examples/playground/app/a0/apps/[appId]/route.ts +10 -5
- package/examples/playground/app/a0/apps.test.ts +12 -0
- package/examples/playground/app/a0/apps.ts +22 -7
- package/examples/playground/app/a0/chat.tsx +47 -1
- package/examples/playground/app/a0/devtools.tsx +2 -8
- package/examples/playground/app/a0/fx/events/route.ts +9 -0
- package/examples/playground/app/a0/fx/scheduler/route.ts +7 -0
- package/examples/playground/app/a0/fx/short/events/route.ts +11 -0
- package/examples/playground/app/a0/model.test.ts +18 -1
- package/examples/playground/app/a0/model.ts +47 -10
- package/examples/playground/app/a0/no-photon.cjs +4 -0
- package/examples/playground/app/a0/no-quickjs.cjs +3 -0
- package/examples/playground/app/a0/pi/events/route.ts +9 -0
- package/examples/playground/app/a0/pi/scheduler/route.ts +7 -0
- package/examples/playground/app/a0/pi/short/events/route.ts +11 -0
- package/examples/playground/app/a0/sandbox-log.ts +10 -4
- package/examples/playground/app/a0/sandbox-session.ts +152 -0
- package/examples/playground/app/a0/scheduler/route.ts +2 -2
- package/examples/playground/app/a0/server.ts +98 -54
- package/examples/playground/app/a0/session.ts +17 -5
- package/examples/playground/app/a0/shell.tsx +11 -3
- package/examples/playground/app/agent/ingress.test.ts +6 -3
- package/examples/playground/app/agent/server.ts +9 -6
- package/examples/playground/app/agent/telemetry.test.ts +71 -50
- package/examples/playground/app/canvas/[canvasId]/canvas-client.tsx +1 -1
- package/examples/playground/app/chat/server.test.ts +30 -26
- package/examples/playground/app/chat/server.ts +46 -46
- package/examples/playground/next.config.ts +17 -5
- package/examples/playground/package.json +4 -1
- package/examples/playground/vercel.json +18 -0
- package/package.json +27 -3
- package/src/ai-agent.ts +271 -90
- package/src/ai-fx.browser.ts +5 -0
- package/src/ai-fx.ts +912 -0
- package/src/ai-harness.ts +518 -0
- package/src/ai-model-metadata.ts +11 -6
- package/src/ai-pi.browser.ts +5 -0
- package/src/ai-pi.ts +1322 -0
- package/src/ai-sdk-step.ts +2 -2
- package/src/ai-server.ts +3 -1
- package/src/ai-tool-input.ts +45 -0
- package/src/internal.ts +13 -0
- package/src/libfx.d.ts +34 -0
- package/src/reducer.ts +1 -1
- package/src/scheduler-qstash.ts +5 -5
- package/src/scheduler-vercel.ts +14 -15
- package/src/server.ts +233 -29
- package/src/store-memory.ts +92 -16
- package/src/store-postgres.ts +156 -25
- package/src/store-redis-core.ts +322 -110
- package/src/store-redis-notify.ts +2 -0
- package/src/store-sqlite.ts +121 -28
- package/src/store.ts +55 -3
- package/src/telemetry.ts +1 -0
- package/dist/internal-CbVvEwad.js.map +0 -1
- package/dist/server-CGM8WzRG.d.ts.map +0 -1
- package/dist/server-Rj64fNvX.js.map +0 -1
- package/dist/session-clear-Dshpuhff.d.ts.map +0 -1
- package/dist/store-Gpkq7WBo.d.ts.map +0 -1
- package/dist/store-redis-notify-tqWNnMRu.js.map +0 -1
- package/dist/telemetry-cZH-cibj.d.ts.map +0 -1
- package/docs/concepts/01-contracts.mdx +0 -237
- package/docs/concepts/02-handlers.mdx +0 -284
- package/docs/concepts/03-durability.mdx +0 -314
- package/docs/concepts/04-state.mdx +0 -209
- package/docs/guides/01-timers.mdx +0 -193
- package/docs/guides/02-cancellation.mdx +0 -161
- package/docs/guides/03-react.mdx +0 -324
- package/docs/guides/04-local-first.mdx +0 -47
- package/docs/guides/05-production.mdx +0 -534
- package/docs/guides/06-ai-agents.mdx +0 -1408
- package/docs/guides/08-application-data.mdx +0 -113
- package/docs/guides/09-presence.mdx +0 -262
- package/docs/guides/10-transports.mdx +0 -221
- package/docs/reference/01-api.mdx +0 -2358
- package/docs/reference/02-errors.mdx +0 -150
- /package/docs/{concepts → contract}/meta.ts +0 -0
- /package/docs/{guides → server}/meta.ts +0 -0
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Routes
|
|
3
|
+
description: Serve every session with `server.fetch` over HTTP or a WebSocket, or build your own route from session methods.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
## The HTTP route
|
|
7
|
+
|
|
8
|
+
```ts app/api/public-order-events/route.ts
|
|
9
|
+
import { ordersServer } from '@/server/orders'
|
|
10
|
+
|
|
11
|
+
export const GET = ordersServer.fetch
|
|
12
|
+
export const POST = ordersServer.fetch
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
`server.fetch` is bound, so an unprotected route is two exports. A plain GET
|
|
16
|
+
streams one session over SSE, a GET with `gte` and `lte` returns a history
|
|
17
|
+
slice, and POST appends the pushed events. If the contract declares presence,
|
|
18
|
+
the same route streams and accepts it.
|
|
19
|
+
|
|
20
|
+
Wrap the call when the route needs authentication or authorization:
|
|
21
|
+
|
|
22
|
+
```ts app/api/protected-order-events/route.ts
|
|
23
|
+
import { ordersServer } from '@/server/orders'
|
|
24
|
+
|
|
25
|
+
async function fetchOrders(request: Request): Promise<Response> {
|
|
26
|
+
// your authentication:
|
|
27
|
+
const user = { id: 'user-1' }
|
|
28
|
+
if (!user) return new Response(null, { status: 401 })
|
|
29
|
+
|
|
30
|
+
return ordersServer.fetch(request, {
|
|
31
|
+
authorize(operation) {
|
|
32
|
+
// your per-session and per-operation authorization:
|
|
33
|
+
// return canAccess(user, operation)
|
|
34
|
+
return true
|
|
35
|
+
},
|
|
36
|
+
})
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export const GET = fetchOrders
|
|
40
|
+
export const POST = fetchOrders
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Authenticate in the route, where headers, cookies, and framework context
|
|
44
|
+
live. `authorize` then checks each typed operation (`stream`, `history`, or
|
|
45
|
+
`push` with its parsed events and presence patch) after A2 parses it and
|
|
46
|
+
before schema validation or durable I/O. Return `false` to answer
|
|
47
|
+
`FORBIDDEN` (403). A throw is an infrastructure failure and answers
|
|
48
|
+
`STORE_UNAVAILABLE`.
|
|
49
|
+
|
|
50
|
+
If a URL or connection token is scoped to one session, `authorize` must
|
|
51
|
+
compare that scope to `operation.sessionId`. Keep URL tokens short-lived:
|
|
52
|
+
URLs can appear in infrastructure logs.
|
|
53
|
+
|
|
54
|
+
## The WebSocket route
|
|
55
|
+
|
|
56
|
+
```ts app/api/socket-order-events/route.ts
|
|
57
|
+
import { experimental_upgradeWebSocket } from '@vercel/functions'
|
|
58
|
+
import { ordersServer } from '@/server/orders'
|
|
59
|
+
|
|
60
|
+
export const POST = ordersServer.fetch
|
|
61
|
+
|
|
62
|
+
export function GET(request: Request): Promise<Response> {
|
|
63
|
+
return ordersServer.fetch(request, {
|
|
64
|
+
upgradeWebSocket: (attach) =>
|
|
65
|
+
experimental_upgradeWebSocket(attach, {
|
|
66
|
+
maxPayload: 4 * 1024 * 1024,
|
|
67
|
+
}),
|
|
68
|
+
})
|
|
69
|
+
}
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
Pass the platform's upgrade capability on GET; POST can stay a direct export.
|
|
73
|
+
The socket is bound to the `sessionId` in its upgrade URL: A2 authorizes that
|
|
74
|
+
session before upgrading, and frames can't switch sessions. Each connected
|
|
75
|
+
session has its own socket.
|
|
76
|
+
|
|
77
|
+
Authenticate the upgrade request before calling `server.fetch`. For browser
|
|
78
|
+
sockets authenticated by cookies, validate the request's `Origin` too: a
|
|
79
|
+
WebSocket handshake has no CORS preflight. Then `authorize` checks the session
|
|
80
|
+
stream and every push or presence frame. There is no separate upgrade
|
|
81
|
+
operation.
|
|
82
|
+
|
|
83
|
+
To share one socket across sessions, opt in on the route and set
|
|
84
|
+
`multiplex: true` on the [client](/client/transports):
|
|
85
|
+
|
|
86
|
+
```ts app/api/multiplexed-order-events/route.ts
|
|
87
|
+
import { experimental_upgradeWebSocket } from '@vercel/functions'
|
|
88
|
+
import { ordersServer } from '@/server/orders'
|
|
89
|
+
|
|
90
|
+
export function GET(request: Request): Promise<Response> {
|
|
91
|
+
return ordersServer.fetch(request, {
|
|
92
|
+
multiplexWebSocket: true,
|
|
93
|
+
upgradeWebSocket: (attach) =>
|
|
94
|
+
experimental_upgradeWebSocket(attach, {
|
|
95
|
+
maxPayload: 4 * 1024 * 1024,
|
|
96
|
+
}),
|
|
97
|
+
})
|
|
98
|
+
}
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
Enable multiplexing only when the request's authentication context may open
|
|
102
|
+
more than one session and `authorize` checks each session id. Subscribe frames
|
|
103
|
+
open sessions after the upgrade.
|
|
104
|
+
|
|
105
|
+
Denials stay scoped:
|
|
106
|
+
|
|
107
|
+
- A denied subscribe on a multiplexed socket gets an `unsubscribed` notice
|
|
108
|
+
with `FORBIDDEN` and leaves other sessions connected. A failed stream or a
|
|
109
|
+
thrown authorization check carries `STORE_UNAVAILABLE`. The client hands
|
|
110
|
+
these to its reconnection policy as `A2ClientError`s.
|
|
111
|
+
- A denied event push gets a non-retryable `FORBIDDEN` ack.
|
|
112
|
+
- Denied presence is dropped silently; presence repaints on the next update.
|
|
113
|
+
|
|
114
|
+
A2 reads the platform's invocation deadline and closes the socket cleanly
|
|
115
|
+
before it.
|
|
116
|
+
|
|
117
|
+
## Build your own
|
|
118
|
+
|
|
119
|
+
`server.fetch` speaks the wire A2's client expects. For anything else, such as
|
|
120
|
+
another framework, your own framing, or a consumer that isn't A2's client,
|
|
121
|
+
build the route from session methods. It's just an event log: read a snapshot,
|
|
122
|
+
follow the stream, append.
|
|
123
|
+
|
|
124
|
+
```ts app/api/orders/[orderId]/live/route.ts
|
|
125
|
+
import { orders } from '@/contracts'
|
|
126
|
+
import { ordersServer } from '@/server/orders'
|
|
127
|
+
|
|
128
|
+
type Params = { params: Promise<{ orderId: string }> }
|
|
129
|
+
|
|
130
|
+
const encoder = new TextEncoder()
|
|
131
|
+
const frame = (name: string, data: unknown): Uint8Array =>
|
|
132
|
+
encoder.encode(`event: ${name}\ndata: ${JSON.stringify(data)}\n\n`)
|
|
133
|
+
|
|
134
|
+
export async function GET(
|
|
135
|
+
_request: Request,
|
|
136
|
+
{ params }: Params,
|
|
137
|
+
): Promise<Response> {
|
|
138
|
+
const { orderId } = await params
|
|
139
|
+
// here's where you'd do auth, or any other checks
|
|
140
|
+
const session = ordersServer.session(orderId)
|
|
141
|
+
const snapshot = await session.state(orders.reducer)
|
|
142
|
+
const events = session
|
|
143
|
+
.stream({ startAfter: snapshot.index })
|
|
144
|
+
[Symbol.asyncIterator]()
|
|
145
|
+
|
|
146
|
+
const body = new ReadableStream<Uint8Array>({
|
|
147
|
+
start(controller) {
|
|
148
|
+
controller.enqueue(frame('snapshot', snapshot))
|
|
149
|
+
},
|
|
150
|
+
async pull(controller) {
|
|
151
|
+
const next = await events.next()
|
|
152
|
+
if (next.done) return controller.close()
|
|
153
|
+
const event = next.value
|
|
154
|
+
const name = event.type === 'a2.cleared' ? 'cleared' : 'event'
|
|
155
|
+
controller.enqueue(frame(name, event))
|
|
156
|
+
},
|
|
157
|
+
// the browser left: end the subscription
|
|
158
|
+
async cancel() {
|
|
159
|
+
await events.return?.()
|
|
160
|
+
},
|
|
161
|
+
})
|
|
162
|
+
return new Response(body, {
|
|
163
|
+
headers: { 'content-type': 'text/event-stream' },
|
|
164
|
+
})
|
|
165
|
+
}
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
`session.state()` returns the snapshot's `index`, so the stream picks up
|
|
169
|
+
exactly where the snapshot ends. A clear arrives as an `a2.cleared` marker in
|
|
170
|
+
the same stream. Writes go through `session.append`, which validates against
|
|
171
|
+
the contract:
|
|
172
|
+
|
|
173
|
+
```ts app/api/orders/[orderId]/live/route.ts
|
|
174
|
+
import { A2Error } from 'experimental-a2'
|
|
175
|
+
|
|
176
|
+
export async function POST(
|
|
177
|
+
request: Request,
|
|
178
|
+
{ params }: Params,
|
|
179
|
+
): Promise<Response> {
|
|
180
|
+
const { orderId } = await params
|
|
181
|
+
// here's where you'd do auth, or any other checks
|
|
182
|
+
const { shopId, items } = await request.json()
|
|
183
|
+
try {
|
|
184
|
+
const [event] = await ordersServer.session(orderId).append({
|
|
185
|
+
type: 'created',
|
|
186
|
+
payload: { shopId, items },
|
|
187
|
+
})
|
|
188
|
+
return Response.json(event)
|
|
189
|
+
} catch (error) {
|
|
190
|
+
if (error instanceof A2Error && error.code === 'INVALID_PAYLOAD') {
|
|
191
|
+
return Response.json({ issues: error.details }, { status: 400 })
|
|
192
|
+
}
|
|
193
|
+
throw error
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
The browser follows it with `EventSource` and folds with the contract's own
|
|
199
|
+
reducer, the same fold the server runs:
|
|
200
|
+
|
|
201
|
+
```ts app/orders/[orderId]/follow-order.ts
|
|
202
|
+
'use client'
|
|
203
|
+
import { orders } from '@/contracts'
|
|
204
|
+
|
|
205
|
+
type OrderState = typeof orders.reducer.initialState
|
|
206
|
+
|
|
207
|
+
export function followOrder(
|
|
208
|
+
orderId: string,
|
|
209
|
+
onChange: (state: OrderState) => void,
|
|
210
|
+
): () => void {
|
|
211
|
+
const url = `/api/orders/${encodeURIComponent(orderId)}/live`
|
|
212
|
+
const source = new EventSource(url)
|
|
213
|
+
let state = orders.reducer.initialState
|
|
214
|
+
const update = (next: OrderState): void => {
|
|
215
|
+
state = next
|
|
216
|
+
onChange(state)
|
|
217
|
+
}
|
|
218
|
+
source.addEventListener('snapshot', (message) => {
|
|
219
|
+
update(JSON.parse(message.data).state)
|
|
220
|
+
})
|
|
221
|
+
source.addEventListener('event', (message) => {
|
|
222
|
+
update(orders.reducer.fold(state, JSON.parse(message.data)))
|
|
223
|
+
})
|
|
224
|
+
source.addEventListener('cleared', () => {
|
|
225
|
+
update(orders.reducer.initialState)
|
|
226
|
+
})
|
|
227
|
+
return () => source.close()
|
|
228
|
+
}
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
`EventSource` reconnects on its own, and each connection starts from a fresh
|
|
232
|
+
snapshot. What `server.fetch` adds on top, and a custom route takes on itself:
|
|
233
|
+
|
|
234
|
+
- resuming a stream from the client's last `index` instead of a new snapshot
|
|
235
|
+
- bounded history reads (`gte` and `lte`)
|
|
236
|
+
- the push envelope with acks and `.confirmed`, and presence
|
|
237
|
+
- WebSocket upgrades
|
|
238
|
+
- `authorize` on every operation; direct `session.append` bypasses it, so the
|
|
239
|
+
route is where your checks go
|
|
240
|
+
- closing streams before the platform's invocation deadline
|
|
241
|
+
|
|
242
|
+
A2's client, `createClient` and `useSession`, talks to `server.fetch`. Pair a
|
|
243
|
+
custom route with your own client.
|
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Production
|
|
3
|
+
description: Choose a durable store, add a scheduler for recovery and timers, and know what to do when an event dead-letters.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
## 1. Choose a store
|
|
7
|
+
|
|
8
|
+
```ts server/orders.ts
|
|
9
|
+
import { createServer } from 'experimental-a2/server'
|
|
10
|
+
import { redis } from 'experimental-a2/store-redis'
|
|
11
|
+
import { orders } from '@/contracts'
|
|
12
|
+
|
|
13
|
+
export const ordersServer = createServer({
|
|
14
|
+
contract: orders,
|
|
15
|
+
store: redis({ url: process.env.REDIS_URL }),
|
|
16
|
+
handlers: {
|
|
17
|
+
/* ... */
|
|
18
|
+
},
|
|
19
|
+
})
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
| Entry point | Backend | Use it for |
|
|
23
|
+
| --- | --- | --- |
|
|
24
|
+
| `experimental-a2/store-redis` | Redis | Production (recommended) |
|
|
25
|
+
| `experimental-a2/store-redis-http` | Redis over a REST API | Production |
|
|
26
|
+
| `experimental-a2/store-postgres` | Postgres | Production |
|
|
27
|
+
| `experimental-a2/store-sqlite` | SQLite | Development: the default, at `.a2/dev.db` |
|
|
28
|
+
| `experimental-a2/store-memory` | In-memory | Tests: the default when `NODE_ENV=test` |
|
|
29
|
+
|
|
30
|
+
When you omit `store`, development gets SQLite (state survives `next dev`
|
|
31
|
+
restarts) and tests get memory. Production has no default, on purpose: the
|
|
32
|
+
server throws `STORE_NOT_CONFIGURED` at startup rather than write events to a
|
|
33
|
+
filesystem that evaporates.
|
|
34
|
+
|
|
35
|
+
The store holds A2's session histories. It does not connect A2 to your
|
|
36
|
+
application tables; see [Sessions and your database](/contract/sessions#sessions-and-your-database).
|
|
37
|
+
|
|
38
|
+
### Redis (recommended)
|
|
39
|
+
|
|
40
|
+
`redis({ url })` from `experimental-a2/store-redis` stores each session as a
|
|
41
|
+
Redis Stream and streams push-natively, so connections scale with your
|
|
42
|
+
processes, not your audience. `ioredis` is an optional peer dependency; pass
|
|
43
|
+
`url` or inject a client. It works on single instances and non-cluster
|
|
44
|
+
providers such as Upstash, where durability is on by default.
|
|
45
|
+
|
|
46
|
+
On providers that bill per command, such as Upstash, appending an event with a
|
|
47
|
+
handler and running it takes 24 commands, and an idle live stream takes 6 a
|
|
48
|
+
minute. See
|
|
49
|
+
[`experimental-a2/store-redis`](/reference/adapters#experimental-a2store-redis)
|
|
50
|
+
for how live streams recover a lost wake-up.
|
|
51
|
+
|
|
52
|
+
When only a REST API is available, `redisHttp({ url, token })` from
|
|
53
|
+
`experimental-a2/store-redis-http` speaks the same storage over `fetch`, with
|
|
54
|
+
no client dependency or extra setup. Live events and presence use Upstash's
|
|
55
|
+
HTTP `SUBSCRIBE` endpoint with the same URL and token. If that endpoint answers
|
|
56
|
+
404, 405, or 501, the store polls instead; other errors stay errors. See
|
|
57
|
+
[`experimental-a2/store-redis-http`](/reference/adapters#experimental-a2store-redis-http)
|
|
58
|
+
for its subscriptions, reconnects, and fallback.
|
|
59
|
+
|
|
60
|
+
:::warning
|
|
61
|
+
`redisHttp` live streams trust the messages on the store's `:notify`
|
|
62
|
+
channels. Browsers never hold Redis credentials, so they can't reach those
|
|
63
|
+
channels. The risk is a Redis credential that can publish but not write, such
|
|
64
|
+
as a token another service uses: it could add or hide events in live views.
|
|
65
|
+
Grant publish access only to credentials that can also write the store.
|
|
66
|
+
:::
|
|
67
|
+
|
|
68
|
+
### Postgres
|
|
69
|
+
|
|
70
|
+
Any Postgres works: Neon, Supabase, RDS, or your own box, including
|
|
71
|
+
transaction-mode poolers. `pg` is an optional peer dependency. Pass
|
|
72
|
+
`connectionString`, or inject your own pool as `client`; an injected pool's
|
|
73
|
+
platform lifecycle is yours to manage.
|
|
74
|
+
|
|
75
|
+
Live streams poll by default, backing off when a session goes quiet. For
|
|
76
|
+
native notifications, also pass `listenConnectionString`: a direct connection
|
|
77
|
+
to the same database. It installs notification triggers on A2's event and
|
|
78
|
+
presence tables, which stay installed and also cover writes from instances
|
|
79
|
+
without the option. Stores in one runtime share a listener, but every
|
|
80
|
+
serverless instance opens its own direct connection, so leave connection
|
|
81
|
+
headroom. See
|
|
82
|
+
[`experimental-a2/store-postgres`](/reference/adapters#experimental-a2store-postgres)
|
|
83
|
+
for live-stream latency and recovery.
|
|
84
|
+
|
|
85
|
+
## 2. Add a scheduler for handlers and timers
|
|
86
|
+
|
|
87
|
+
The scheduler puts a clock on recovery and on [timers](/server/timers).
|
|
88
|
+
Vercel Queues is the recommended default on Vercel. QStash delivers over HTTP
|
|
89
|
+
from separate infrastructure. Either adapter carries recovery and
|
|
90
|
+
`session.schedule` tasks through one route. Scheduled delays must fit the
|
|
91
|
+
[provider's limit](/server/timers#provider-limits).
|
|
92
|
+
|
|
93
|
+
### Vercel Queues (recommended)
|
|
94
|
+
|
|
95
|
+
```ts
|
|
96
|
+
// server/orders.ts, now with a scheduler:
|
|
97
|
+
import { createServer } from 'experimental-a2/server'
|
|
98
|
+
import { redis } from 'experimental-a2/store-redis'
|
|
99
|
+
import { vercelQueues } from 'experimental-a2/scheduler-vercel'
|
|
100
|
+
import { orders } from '@/contracts'
|
|
101
|
+
|
|
102
|
+
export const scheduler = vercelQueues()
|
|
103
|
+
|
|
104
|
+
export const ordersServer = createServer({
|
|
105
|
+
contract: orders,
|
|
106
|
+
store: redis({ url: process.env.REDIS_URL }),
|
|
107
|
+
scheduler,
|
|
108
|
+
handlers: {
|
|
109
|
+
/* ... */
|
|
110
|
+
},
|
|
111
|
+
})
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
`@vercel/queue` is a peer dependency. Share one scheduler instance across
|
|
115
|
+
servers, and mount its handler once with every server it may deliver to:
|
|
116
|
+
|
|
117
|
+
```ts app/api/a2/scheduler/route.ts
|
|
118
|
+
import { billingServer, ordersServer, scheduler } from '@/server'
|
|
119
|
+
|
|
120
|
+
export const POST = scheduler.handler(ordersServer, billingServer)
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
`scheduler.handler(...)` checks its servers when the module loads: at least
|
|
124
|
+
one drainable server, with unique contract names. A bad route fails at
|
|
125
|
+
startup, or in a build that evaluates route modules.
|
|
126
|
+
|
|
127
|
+
```json vercel.json
|
|
128
|
+
{
|
|
129
|
+
"functions": {
|
|
130
|
+
"app/api/a2/scheduler/route.ts": {
|
|
131
|
+
"experimentalTriggers": [
|
|
132
|
+
{
|
|
133
|
+
"type": "queue/v2beta",
|
|
134
|
+
"topic": "a2"
|
|
135
|
+
}
|
|
136
|
+
]
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
The trigger is required. It connects the route to Vercel Queues and makes the
|
|
143
|
+
function private, so only queue infrastructure can invoke it. Don't expose the
|
|
144
|
+
callback as a public route or wrap it in a second authentication scheme.
|
|
145
|
+
|
|
146
|
+
The adapter keeps messages for 7 days (Vercel Queues' default is 24 hours) and
|
|
147
|
+
accepts scheduled appends and clears up to 6 days out.
|
|
148
|
+
|
|
149
|
+
### QStash
|
|
150
|
+
|
|
151
|
+
```ts
|
|
152
|
+
// server/orders.ts, with QStash instead:
|
|
153
|
+
import { createServer } from 'experimental-a2/server'
|
|
154
|
+
import { qstash } from 'experimental-a2/scheduler-qstash'
|
|
155
|
+
import { redis } from 'experimental-a2/store-redis'
|
|
156
|
+
import { orders } from '@/contracts'
|
|
157
|
+
|
|
158
|
+
export const scheduler = qstash()
|
|
159
|
+
|
|
160
|
+
export const ordersServer = createServer({
|
|
161
|
+
contract: orders,
|
|
162
|
+
store: redis({ url: process.env.REDIS_URL }),
|
|
163
|
+
scheduler,
|
|
164
|
+
handlers: {
|
|
165
|
+
/* ... */
|
|
166
|
+
},
|
|
167
|
+
})
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
`@upstash/qstash` 2.11.0 or newer is a peer dependency. Mount
|
|
171
|
+
`scheduler.handler(...)` in the same `app/api/a2/scheduler/route.ts`, and set
|
|
172
|
+
`QSTASH_TOKEN`, `QSTASH_CURRENT_SIGNING_KEY`, and `QSTASH_NEXT_SIGNING_KEY`.
|
|
173
|
+
|
|
174
|
+
- **Callback URL:** inferred from `PORT` locally, and from
|
|
175
|
+
`VERCEL_PROJECT_PRODUCTION_URL` or `VERCEL_BRANCH_URL` on Vercel. Pass `url`
|
|
176
|
+
anywhere else.
|
|
177
|
+
- **Local development:** A2 starts QStash's development server on the first
|
|
178
|
+
publish or verification, with no QStash variables needed.
|
|
179
|
+
- **Security:** the route verifies `Upstash-Signature` before touching the
|
|
180
|
+
store and answers unsigned traffic with 401. Behind Deployment Protection,
|
|
181
|
+
pass an automation bypass as a callback header.
|
|
182
|
+
- **Outages:** delivery is at least once and A2 keeps recovery going, but when
|
|
183
|
+
QStash can't reach the route at all, redrive its DLQ.
|
|
184
|
+
|
|
185
|
+
The [adapter reference](/reference/adapters#experimental-a2scheduler-qstash)
|
|
186
|
+
covers the options, protected previews, Edge runtimes, and body limits.
|
|
187
|
+
|
|
188
|
+
### More than one scheduler
|
|
189
|
+
|
|
190
|
+
Servers with different scheduler instances need separate routes, even from the
|
|
191
|
+
same adapter. Each extra QStash instance passes its own `url`. Each Vercel
|
|
192
|
+
Queues instance needs its own `topic` and a matching `experimentalTriggers`
|
|
193
|
+
entry.
|
|
194
|
+
|
|
195
|
+
### Dispatch in a fresh invocation
|
|
196
|
+
|
|
197
|
+
Ordinary `append` starts handlers inline and uses the scheduler as a watchdog.
|
|
198
|
+
`append.dispatch` persists the input and hands the pending work straight to the
|
|
199
|
+
queue instead:
|
|
200
|
+
|
|
201
|
+
```ts app/api/imports/route.ts
|
|
202
|
+
import { ordersServer } from '@/server'
|
|
203
|
+
|
|
204
|
+
export async function POST(request: Request) {
|
|
205
|
+
const { orderId, shopId, items } = await request.json()
|
|
206
|
+
await ordersServer.session(orderId).append.dispatch({
|
|
207
|
+
id: `created:${orderId}`,
|
|
208
|
+
type: 'created',
|
|
209
|
+
payload: { shopId, items },
|
|
210
|
+
})
|
|
211
|
+
return Response.json({ accepted: true }, { status: 202 })
|
|
212
|
+
}
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
It requires a scheduler. The call awaits the queue send and starts no inline
|
|
216
|
+
drain. If the send fails, the event may already be durable; its explicit id
|
|
217
|
+
makes the retry idempotent. Dispatch controls the wakeup, not which worker
|
|
218
|
+
claims the event.
|
|
219
|
+
|
|
220
|
+
### Running without a scheduler
|
|
221
|
+
|
|
222
|
+
If no event type has a handler and nothing calls `session.schedule`, skip
|
|
223
|
+
`scheduler`. Events settle in their append transaction, with no drains, claims,
|
|
224
|
+
or queue messages. That's a complete production configuration: a durable event
|
|
225
|
+
log for history, reducers, and live sync.
|
|
226
|
+
|
|
227
|
+
With handlers, skipping it means the only wakeups are a top-level append or an
|
|
228
|
+
explicit `server.drain()`. Reads never wake a session, so one nobody wakes
|
|
229
|
+
stays stuck. That's fine for internal tools where "heals on the next write" is
|
|
230
|
+
acceptable. For production, configure a scheduler.
|
|
231
|
+
|
|
232
|
+
## 3. When an event dead-letters
|
|
233
|
+
|
|
234
|
+
A [dead-lettered](/server/guarantees#when-a-handler-keeps-failing) event waits
|
|
235
|
+
for you. List a session's dead letters, then resolve each one:
|
|
236
|
+
|
|
237
|
+
```ts
|
|
238
|
+
// anywhere on the server, behind your own operator checks:
|
|
239
|
+
import { ordersServer } from '@/server'
|
|
240
|
+
|
|
241
|
+
const order = ordersServer.session(orderId)
|
|
242
|
+
const [dead] = await order.deadLetters()
|
|
243
|
+
if (dead) {
|
|
244
|
+
// dead.lastError says why (null if every attempt timed out or crashed);
|
|
245
|
+
// deploy the handler fix first, then:
|
|
246
|
+
await order.redrive(dead.index, { action: 'retry' })
|
|
247
|
+
}
|
|
248
|
+
```
|
|
249
|
+
|
|
250
|
+
- **Fix and retry.** `{ action: 'retry' }` runs the handler again with a fresh
|
|
251
|
+
ten-failure budget. Once it completes, its lane continues.
|
|
252
|
+
- **Skip.** `{ action: 'skip' }` marks the event processed without running it,
|
|
253
|
+
accepting that its effects never happen. The next event in its lane runs.
|
|
254
|
+
|
|
255
|
+
Each redrive is one atomic store operation, and it wakes the session the way an
|
|
256
|
+
append does, so the lane resumes without another write. An event that isn't
|
|
257
|
+
dead-lettered anymore is left alone: `redrive` reports `{ redriven: false }`.
|
|
258
|
+
|
|
259
|
+
## 4. Deploy safely
|
|
260
|
+
|
|
261
|
+
The Vercel adapter's `deploymentId` applies to every task: recovery, scheduled
|
|
262
|
+
appends, and scheduled clears. Omit it to use the publishing deployment, pass
|
|
263
|
+
a deployment ID to target one, or pass `null` for deploymentless delivery.
|
|
264
|
+
Publishing and consuming clients must use the same scope.
|
|
265
|
+
|
|
266
|
+
- **Pinned tasks stay put.** Promoting a new deployment doesn't move them;
|
|
267
|
+
older deployments keep receiving their own work until it is acknowledged or
|
|
268
|
+
expires. Upgrading doesn't repair an older deployment's callback handler.
|
|
269
|
+
- **Deploymentless doesn't mean latest.** Vercel picks an eligible consumer,
|
|
270
|
+
not the latest production deployment. Every eligible consumer must use
|
|
271
|
+
`deploymentId: null` and stay compatible with pending tasks and session work.
|
|
272
|
+
- **Changing the setting doesn't move queued tasks.** Keep consumers for the
|
|
273
|
+
original scope until those tasks finish.
|
|
274
|
+
|
|
275
|
+
QStash's inferred production and branch URLs are moving aliases, so pending
|
|
276
|
+
tasks can reach a later deployment. Keep task envelopes and event schemas
|
|
277
|
+
compatible across deployments, and keep the same `maxBodyBytes` on every
|
|
278
|
+
deployment that can receive the callback: lowering it mid-rollout can reject
|
|
279
|
+
tasks the previous deployment accepted.
|
|
280
|
+
|
|
281
|
+
:::warning
|
|
282
|
+
Don't roll back to an A2 version without timer delivery while scheduled tasks
|
|
283
|
+
are outstanding.
|
|
284
|
+
An old route can acknowledge a new append task as a recovery message without
|
|
285
|
+
writing its events. Rolling back past scheduled clears fails too: an old
|
|
286
|
+
Vercel Queues route keeps failing a clear task until retention ends, and an
|
|
287
|
+
old QStash route rejects it as non-retryable, moving it to QStash's
|
|
288
|
+
dead-letter queue. Roll forward, or wait for those tasks to deliver or remove
|
|
289
|
+
them through the provider first.
|
|
290
|
+
:::
|
|
291
|
+
|
|
292
|
+
## Watch it in traces
|
|
293
|
+
|
|
294
|
+
```ts
|
|
295
|
+
// server/orders.ts, with telemetry:
|
|
296
|
+
import { createServer } from 'experimental-a2/server'
|
|
297
|
+
import { otel } from 'experimental-a2/otel'
|
|
298
|
+
import { orders } from '@/contracts'
|
|
299
|
+
|
|
300
|
+
export const ordersServer = createServer({
|
|
301
|
+
contract: orders,
|
|
302
|
+
// ...store, scheduler, and handlers as above
|
|
303
|
+
telemetry: otel(),
|
|
304
|
+
})
|
|
305
|
+
```
|
|
306
|
+
|
|
307
|
+
`telemetry` takes an instrumentation hook; `experimental-a2/otel` implements
|
|
308
|
+
it over OpenTelemetry (`@opentelemetry/api` is a peer dependency). With any
|
|
309
|
+
OTel setup registered (on Vercel, `@vercel/otel`), one trace shows the request,
|
|
310
|
+
`a2.append`, the inline `a2.drain`, an `a2.event` span per handler, and the
|
|
311
|
+
appends those handlers make. Failures mark their `a2.event` span,
|
|
312
|
+
`a2.event.attempt` reports `ctx.attempt`, and `a2.event.outcome =
|
|
313
|
+
dead_lettered` is the attribute to alert on. Agent servers add `a2.ai.*`
|
|
314
|
+
spans. See the [span catalogue](/reference/adapters#the-span-catalogue).
|
|
315
|
+
|
|
316
|
+
## Checklist
|
|
317
|
+
|
|
318
|
+
| Piece | Done when |
|
|
319
|
+
| ------------------- | -------------------------------------------------------------------- |
|
|
320
|
+
| Store | `store: redis(...)` (or `postgres(...)`) on every server |
|
|
321
|
+
| Scheduler | for servers with handlers or timers: one shared adapter, route, and provider config |
|
|
322
|
+
| Idempotent handlers | external side effects take `event.id` as an idempotency key |
|
|
323
|
+
| Client split | contracts/reducers isomorphic; only `experimental-a2/server` touches backends |
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Guarantees
|
|
3
|
+
description: Appends are effectively-once, handlers are at-least-once, and retries keep event identity across crashes.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
## In short
|
|
7
|
+
|
|
8
|
+
- **Appends are effectively-once.** Event ids deduplicate writes. A batch commits
|
|
9
|
+
before its handlers start; they may start before `append` resolves.
|
|
10
|
+
- **Handlers are at-least-once.** They may run again after a crash, so
|
|
11
|
+
[side effects use idempotency keys](/server/handlers#handlers-can-run-twice).
|
|
12
|
+
- **The log stays ordered.** Every session has one immutable append order.
|
|
13
|
+
- **Handlers are concurrent by default.** Log order does not serialize them.
|
|
14
|
+
Only events in the same lane run one at a time in log order.
|
|
15
|
+
|
|
16
|
+
## One event, many attempts
|
|
17
|
+
|
|
18
|
+
An event is pending until A2 marks it processed. Every wakeup (an append, a
|
|
19
|
+
scheduler watchdog, or `server.drain()`) claims the session's eligible events,
|
|
20
|
+
and the log decides what remains. Reads never dispatch handlers.
|
|
21
|
+
|
|
22
|
+
Each claim expires. If its holder dies, the claim lapses and the next wakeup
|
|
23
|
+
claims the event again with a larger `ctx.attempt`. The retry gets the same
|
|
24
|
+
event id, index, type, and payload. A kill after the claim can use up an
|
|
25
|
+
attempt before your code runs, and that attempt counts toward the event's
|
|
26
|
+
[failure budget](#when-a-handler-keeps-failing). An older attempt can never
|
|
27
|
+
overwrite a newer one's completion or failure.
|
|
28
|
+
|
|
29
|
+
:::note[Recovery is not an event]
|
|
30
|
+
A2 does not append `recovered` or `continued`. Recovery retries the same event.
|
|
31
|
+
Your log contains only application facts.
|
|
32
|
+
:::
|
|
33
|
+
|
|
34
|
+
## Immediate and dependent events
|
|
35
|
+
|
|
36
|
+
[`ctx.session.append(name, ...events)`](/server/handlers#append-what-should-happen-now)
|
|
37
|
+
commits immediately: its events stay in the log if the parent later crashes,
|
|
38
|
+
and run concurrently with it unless both share a lane.
|
|
39
|
+
[Returned events](/server/handlers#return-what-happens-after-success) commit
|
|
40
|
+
with the parent's completion, in one transaction.
|
|
41
|
+
|
|
42
|
+
If the handler throws or dies before that transaction, neither the completion
|
|
43
|
+
nor the returned events exist, and the retry produces the same deterministic
|
|
44
|
+
ids. If the transaction commits but its acknowledgement is lost, the retry gets
|
|
45
|
+
the committed rows back. A partial or changed batch is rejected.
|
|
46
|
+
|
|
47
|
+
The transaction covers only A2's log. An HTTP call, database write, or other
|
|
48
|
+
external effect can't join it, so a crash after the effect but before
|
|
49
|
+
completion repeats it. Use `event.id` as an idempotency key or add fencing in
|
|
50
|
+
the external system.
|
|
51
|
+
|
|
52
|
+
## Lanes
|
|
53
|
+
|
|
54
|
+
[Lanes](/server/handlers#lanes) are session-scoped serial groups. Only the
|
|
55
|
+
lowest-index unfinished event in a lane is eligible, so a failure blocks later
|
|
56
|
+
events in that lane, not the whole session. Unlaned events and different lanes
|
|
57
|
+
run concurrently. A2 stores the lane at append, so a changed lane resolver
|
|
58
|
+
affects only new events.
|
|
59
|
+
|
|
60
|
+
A lane controls A2 dispatch, not the outside world. A2 claims every eligible
|
|
61
|
+
event, with no global concurrency limit or priority queue. Put capacity limits
|
|
62
|
+
near the constrained resource, such as an API client, connection pool, or
|
|
63
|
+
runtime.
|
|
64
|
+
|
|
65
|
+
## Append and scheduling
|
|
66
|
+
|
|
67
|
+
A top-level append commits before any wakeup. A2 then starts the handlers
|
|
68
|
+
inline and, with a scheduler, arms a watchdog in parallel. Handlers never wait
|
|
69
|
+
for the queue, and a handler may start before `append` returns. `append` waits
|
|
70
|
+
up to two seconds for the watchdog send, never for handlers. A failed arm
|
|
71
|
+
records `a2.append.armed = false` on the trace. Handler appends and returned
|
|
72
|
+
events join the running drain and add no arm.
|
|
73
|
+
|
|
74
|
+
[`append.dispatch`](/server/production#dispatch-in-a-fresh-invocation) skips
|
|
75
|
+
the inline drain and hands the work to the scheduler instead.
|
|
76
|
+
|
|
77
|
+
An event type without a handler settles in the append transaction. It starts
|
|
78
|
+
no drain or arm unless older handled work in the session is still pending.
|
|
79
|
+
|
|
80
|
+
## Causal trees are durable
|
|
81
|
+
|
|
82
|
+
Every event a handler appends or returns stores a `cause`: the triggering
|
|
83
|
+
event's index and dispatch attempt. Top-level appends store `null`. Children
|
|
84
|
+
of one handler are siblings, nested handlers add levels, and a retried child
|
|
85
|
+
keeps its original cause.
|
|
86
|
+
|
|
87
|
+
## A chain that crosses the function timeout
|
|
88
|
+
|
|
89
|
+
A function killed mid-handler restarts that handler from its entry point in a
|
|
90
|
+
fresh invocation: same event, new attempt. Events already processed stay
|
|
91
|
+
processed, so a chain of handlers can outlast one invocation, but each handler
|
|
92
|
+
must fit in a fresh one: an attempt that runs out of time is a failed attempt,
|
|
93
|
+
and a handler that never fits dead-letters after ten. Split longer work into
|
|
94
|
+
smaller events or increase the function duration. On Vercel, A2 caps the claim window
|
|
95
|
+
at the function deadline when the platform exposes it, so a retry starts
|
|
96
|
+
promptly after a timeout. Once the deadline passes, A2 claims nothing more in
|
|
97
|
+
that invocation, so a timeout costs the event one attempt.
|
|
98
|
+
|
|
99
|
+
## When a handler keeps failing
|
|
100
|
+
|
|
101
|
+
A caught failure counts against that event, releases its claim, and leaves
|
|
102
|
+
other eligible events running. A2 holds it back for a delay that starts at
|
|
103
|
+
one second and doubles with each failed attempt up to 30 seconds, then retries
|
|
104
|
+
it on the session's next wake. With a scheduler, that's a few seconds at first,
|
|
105
|
+
then the delay itself. An attempt that ends before it finishes, because its
|
|
106
|
+
process timed out, crashed, or lost its claim, counts too, once the next
|
|
107
|
+
attempt takes its claim over. Ten failed attempts dead-letter the event: later
|
|
108
|
+
events in its lane stay blocked, while unlaned events and other lanes continue.
|
|
109
|
+
A crash costs every event it interrupts one attempt, so a session whose work
|
|
110
|
+
keeps outrunning its function dead-letters: bound its fan-out, or put work that
|
|
111
|
+
waits on the same resource in one lane. The event waits until you
|
|
112
|
+
[retry or skip it](/server/production#3-when-an-event-dead-letters) with
|
|
113
|
+
`session.redrive()`.
|
|
114
|
+
|
|
115
|
+
## Limits
|
|
116
|
+
|
|
117
|
+
| Situation | Result |
|
|
118
|
+
| --- | --- |
|
|
119
|
+
| Process dies before the first arm is durable | Retry with the same ids, append again later, or call `drain()`. |
|
|
120
|
+
| No scheduler configured | A later top-level append or explicit `drain()` wakes the session. Reads never do. |
|
|
121
|
+
| Scheduler invocation dies after claiming but before arming | Its unacknowledged queue delivery is the slower fallback. |
|
|
122
|
+
| Store backend unavailable | Safe progress stops until it returns. |
|
|
123
|
+
| External effect succeeds before A2 completion | The handler may repeat it. Use idempotency or external fencing. |
|
|
124
|
+
|
|
125
|
+
## What append never throws for
|
|
126
|
+
|
|
127
|
+
A failing handler never makes `append` throw. Handler failures are retried and,
|
|
128
|
+
if persistent, [dead-letter the event](#when-a-handler-keeps-failing).
|
|
129
|
+
|
|
130
|
+
Append success means the event is durable, not that its handlers finished. An
|
|
131
|
+
identical batch retry returns rows with the same ids. A mixed batch throws
|
|
132
|
+
[`PARTIAL_DUPLICATE_BATCH`](/reference/errors).
|