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.
Files changed (221) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/README.md +1 -1
  3. package/dist/{actor-DBbe5xeE.d.ts → actor-C7b-1arn.d.ts} +5 -5
  4. package/dist/{actor-DBbe5xeE.d.ts.map → actor-C7b-1arn.d.ts.map} +1 -1
  5. package/dist/actor-client.d.ts +1 -1
  6. package/dist/actor-react.d.ts +3 -3
  7. package/dist/actor-react.js +1 -1
  8. package/dist/actor.d.ts +1 -1
  9. package/dist/actor.js +2 -2
  10. package/dist/{ai-vpGtcaCB.d.ts → ai-BMLlI2Ff.d.ts} +3 -3
  11. package/dist/{ai-vpGtcaCB.d.ts.map → ai-BMLlI2Ff.d.ts.map} +1 -1
  12. package/dist/{ai-UrWfKeuv.js → ai-D6mGlO8j.js} +2 -2
  13. package/dist/{ai-UrWfKeuv.js.map → ai-D6mGlO8j.js.map} +1 -1
  14. package/dist/ai-client.d.ts +2 -2
  15. package/dist/ai-client.js +2 -2
  16. package/dist/ai-fx.browser.d.ts +1 -0
  17. package/dist/ai-fx.browser.js +6 -0
  18. package/dist/ai-fx.browser.js.map +1 -0
  19. package/dist/ai-fx.d.ts +10 -0
  20. package/dist/ai-fx.d.ts.map +1 -0
  21. package/dist/ai-fx.js +692 -0
  22. package/dist/ai-fx.js.map +1 -0
  23. package/dist/ai-harness-B2suP2Qh.d.ts +26 -0
  24. package/dist/ai-harness-B2suP2Qh.d.ts.map +1 -0
  25. package/dist/ai-harness-BuLh_EYi.js +273 -0
  26. package/dist/ai-harness-BuLh_EYi.js.map +1 -0
  27. package/dist/ai-pi.browser.d.ts +1 -0
  28. package/dist/ai-pi.browser.js +6 -0
  29. package/dist/ai-pi.browser.js.map +1 -0
  30. package/dist/ai-pi.d.ts +17 -0
  31. package/dist/ai-pi.d.ts.map +1 -0
  32. package/dist/ai-pi.js +950 -0
  33. package/dist/ai-pi.js.map +1 -0
  34. package/dist/ai-react.d.ts +5 -5
  35. package/dist/ai-react.js +2 -2
  36. package/dist/ai-server.d.ts +47 -25
  37. package/dist/ai-server.d.ts.map +1 -1
  38. package/dist/ai-server.js +113 -40
  39. package/dist/ai-server.js.map +1 -1
  40. package/dist/ai-tool-input-DmSqMUQi.js +40 -0
  41. package/dist/ai-tool-input-DmSqMUQi.js.map +1 -0
  42. package/dist/ai.d.ts +1 -1
  43. package/dist/ai.js +1 -1
  44. package/dist/bounded-cache-CPHWF-8h.js +41 -0
  45. package/dist/bounded-cache-CPHWF-8h.js.map +1 -0
  46. package/dist/{client-COaiaDub.js → client-BhHLoNKF.js} +3 -3
  47. package/dist/{client-COaiaDub.js.map → client-BhHLoNKF.js.map} +1 -1
  48. package/dist/{client-qRnRUzBS.d.ts → client-h_DKFAjj.d.ts} +3 -3
  49. package/dist/{client-qRnRUzBS.d.ts.map → client-h_DKFAjj.d.ts.map} +1 -1
  50. package/dist/client.d.ts +3 -3
  51. package/dist/client.js +1 -1
  52. package/dist/index.d.ts +5 -5
  53. package/dist/{initial-snapshot-BrLso5x_.d.ts → initial-snapshot-Bx-c0pDP.d.ts} +2 -2
  54. package/dist/{initial-snapshot-BrLso5x_.d.ts.map → initial-snapshot-Bx-c0pDP.d.ts.map} +1 -1
  55. package/dist/{internal-CbVvEwad.js → internal-CscSY7K_.js} +11 -3
  56. package/dist/internal-CscSY7K_.js.map +1 -0
  57. package/dist/otel.d.ts +1 -1
  58. package/dist/{react-BbJ-g6Uu.d.ts → react-BBXgHWFc.d.ts} +4 -4
  59. package/dist/{react-BbJ-g6Uu.d.ts.map → react-BBXgHWFc.d.ts.map} +1 -1
  60. package/dist/react.d.ts +1 -1
  61. package/dist/react.js +1 -1
  62. package/dist/reducer-BAXc8JyF.js.map +1 -1
  63. package/dist/{reducer-BYu6DT7h.d.ts → reducer-CqA59Q6U.d.ts} +2 -2
  64. package/dist/{reducer-BYu6DT7h.d.ts.map → reducer-CqA59Q6U.d.ts.map} +1 -1
  65. package/dist/scheduler-qstash.d.ts +2 -2
  66. package/dist/scheduler-qstash.js +3 -3
  67. package/dist/scheduler-qstash.js.map +1 -1
  68. package/dist/scheduler-vercel.d.ts +7 -7
  69. package/dist/scheduler-vercel.d.ts.map +1 -1
  70. package/dist/scheduler-vercel.js +9 -10
  71. package/dist/scheduler-vercel.js.map +1 -1
  72. package/dist/{server-Rj64fNvX.js → server-D-Y4pqwu.js} +126 -67
  73. package/dist/server-D-Y4pqwu.js.map +1 -0
  74. package/dist/{server-CGM8WzRG.d.ts → server-DYAATFuT.d.ts} +42 -7
  75. package/dist/server-DYAATFuT.d.ts.map +1 -0
  76. package/dist/server.d.ts +4 -4
  77. package/dist/server.js +1 -1
  78. package/dist/{session-clear-Dshpuhff.d.ts → session-clear-C-QwTXV1.d.ts} +2 -2
  79. package/dist/session-clear-C-QwTXV1.d.ts.map +1 -0
  80. package/dist/{store-Gpkq7WBo.d.ts → store-CyLUy86v.d.ts} +53 -5
  81. package/dist/store-CyLUy86v.d.ts.map +1 -0
  82. package/dist/store-N8PXxDAS.js.map +1 -1
  83. package/dist/store-memory.d.ts +1 -1
  84. package/dist/store-memory.js +56 -11
  85. package/dist/store-memory.js.map +1 -1
  86. package/dist/{store-polling-4_y8lETm.js → store-polling-7WJV0YWP.js} +2 -2
  87. package/dist/{store-polling-4_y8lETm.js.map → store-polling-7WJV0YWP.js.map} +1 -1
  88. package/dist/store-postgres.d.ts +1 -1
  89. package/dist/store-postgres.d.ts.map +1 -1
  90. package/dist/store-postgres.js +106 -25
  91. package/dist/store-postgres.js.map +1 -1
  92. package/dist/{store-presence-polling-Q1C-k2dd.js → store-presence-polling-B1ixeqIS.js} +3 -3
  93. package/dist/{store-presence-polling-Q1C-k2dd.js.map → store-presence-polling-B1ixeqIS.js.map} +1 -1
  94. package/dist/store-redis-http.d.ts +1 -1
  95. package/dist/store-redis-http.js +3 -3
  96. package/dist/{store-redis-notify-tqWNnMRu.js → store-redis-notify-CM0AfLY0.js} +271 -101
  97. package/dist/store-redis-notify-CM0AfLY0.js.map +1 -0
  98. package/dist/store-redis.d.ts +1 -1
  99. package/dist/store-redis.js +1 -1
  100. package/dist/store-sqlite.d.ts +1 -1
  101. package/dist/store-sqlite.js +66 -21
  102. package/dist/store-sqlite.js.map +1 -1
  103. package/dist/{telemetry-cZH-cibj.d.ts → telemetry-Cyfqo6Qt.d.ts} +2 -2
  104. package/dist/telemetry-Cyfqo6Qt.d.ts.map +1 -0
  105. package/dist/{wire-DjfHOvdW.js → wire-BKxH1vbP.js} +2 -2
  106. package/dist/{wire-DjfHOvdW.js.map → wire-BKxH1vbP.js.map} +1 -1
  107. package/docs/01-quickstart.mdx +49 -83
  108. package/docs/{guides/07-examples.mdx → 02-examples.mdx} +10 -20
  109. package/docs/actors/01-introduction.mdx +48 -86
  110. package/docs/actors/02-concurrency.mdx +43 -70
  111. package/docs/actors/03-timers.mdx +28 -42
  112. package/docs/actors/04-routes.mdx +110 -150
  113. package/docs/actors/meta.ts +1 -1
  114. package/docs/agents/01-introduction.mdx +257 -0
  115. package/docs/agents/02-tools.mdx +247 -0
  116. package/docs/agents/03-conversations.mdx +538 -0
  117. package/docs/agents/04-harnesses.mdx +136 -0
  118. package/docs/agents/meta.ts +1 -0
  119. package/docs/client/01-react.mdx +294 -0
  120. package/docs/client/02-transports.mdx +86 -0
  121. package/docs/client/03-presence.mdx +224 -0
  122. package/docs/client/meta.ts +1 -0
  123. package/docs/contract/01-events.mdx +57 -0
  124. package/docs/contract/02-state.mdx +142 -0
  125. package/docs/contract/03-sessions.mdx +175 -0
  126. package/docs/index.mdx +109 -145
  127. package/docs/reference/01-core.mdx +139 -0
  128. package/docs/reference/02-server.mdx +860 -0
  129. package/docs/reference/03-client.mdx +301 -0
  130. package/docs/reference/04-ai.mdx +731 -0
  131. package/docs/reference/05-adapters.mdx +351 -0
  132. package/docs/reference/06-errors.mdx +138 -0
  133. package/docs/reference/meta.ts +1 -1
  134. package/docs/server/01-handlers.mdx +200 -0
  135. package/docs/server/02-timers.mdx +130 -0
  136. package/docs/server/03-cancellation.mdx +142 -0
  137. package/docs/server/04-routes.mdx +243 -0
  138. package/docs/server/05-production.mdx +323 -0
  139. package/docs/server/06-guarantees.mdx +132 -0
  140. package/examples/playground/DEPLOY.md +3 -5
  141. package/examples/playground/app/a0/[appId]/page.tsx +12 -13
  142. package/examples/playground/app/a0/app-list.tsx +9 -2
  143. package/examples/playground/app/a0/app-view.tsx +9 -1
  144. package/examples/playground/app/a0/apps/[appId]/route.ts +10 -5
  145. package/examples/playground/app/a0/apps.test.ts +12 -0
  146. package/examples/playground/app/a0/apps.ts +22 -7
  147. package/examples/playground/app/a0/chat.tsx +47 -1
  148. package/examples/playground/app/a0/devtools.tsx +2 -8
  149. package/examples/playground/app/a0/fx/events/route.ts +9 -0
  150. package/examples/playground/app/a0/fx/scheduler/route.ts +7 -0
  151. package/examples/playground/app/a0/fx/short/events/route.ts +11 -0
  152. package/examples/playground/app/a0/model.test.ts +18 -1
  153. package/examples/playground/app/a0/model.ts +47 -10
  154. package/examples/playground/app/a0/no-photon.cjs +4 -0
  155. package/examples/playground/app/a0/no-quickjs.cjs +3 -0
  156. package/examples/playground/app/a0/pi/events/route.ts +9 -0
  157. package/examples/playground/app/a0/pi/scheduler/route.ts +7 -0
  158. package/examples/playground/app/a0/pi/short/events/route.ts +11 -0
  159. package/examples/playground/app/a0/sandbox-log.ts +10 -4
  160. package/examples/playground/app/a0/sandbox-session.ts +152 -0
  161. package/examples/playground/app/a0/scheduler/route.ts +2 -2
  162. package/examples/playground/app/a0/server.ts +98 -54
  163. package/examples/playground/app/a0/session.ts +17 -5
  164. package/examples/playground/app/a0/shell.tsx +11 -3
  165. package/examples/playground/app/agent/ingress.test.ts +6 -3
  166. package/examples/playground/app/agent/server.ts +9 -6
  167. package/examples/playground/app/agent/telemetry.test.ts +71 -50
  168. package/examples/playground/app/canvas/[canvasId]/canvas-client.tsx +1 -1
  169. package/examples/playground/app/chat/server.test.ts +30 -26
  170. package/examples/playground/app/chat/server.ts +46 -46
  171. package/examples/playground/next.config.ts +17 -5
  172. package/examples/playground/package.json +4 -1
  173. package/examples/playground/vercel.json +18 -0
  174. package/package.json +27 -3
  175. package/src/ai-agent.ts +271 -90
  176. package/src/ai-fx.browser.ts +5 -0
  177. package/src/ai-fx.ts +912 -0
  178. package/src/ai-harness.ts +518 -0
  179. package/src/ai-model-metadata.ts +11 -6
  180. package/src/ai-pi.browser.ts +5 -0
  181. package/src/ai-pi.ts +1322 -0
  182. package/src/ai-sdk-step.ts +2 -2
  183. package/src/ai-server.ts +3 -1
  184. package/src/ai-tool-input.ts +45 -0
  185. package/src/internal.ts +13 -0
  186. package/src/libfx.d.ts +34 -0
  187. package/src/reducer.ts +1 -1
  188. package/src/scheduler-qstash.ts +5 -5
  189. package/src/scheduler-vercel.ts +14 -15
  190. package/src/server.ts +233 -29
  191. package/src/store-memory.ts +92 -16
  192. package/src/store-postgres.ts +156 -25
  193. package/src/store-redis-core.ts +322 -110
  194. package/src/store-redis-notify.ts +2 -0
  195. package/src/store-sqlite.ts +121 -28
  196. package/src/store.ts +55 -3
  197. package/src/telemetry.ts +1 -0
  198. package/dist/internal-CbVvEwad.js.map +0 -1
  199. package/dist/server-CGM8WzRG.d.ts.map +0 -1
  200. package/dist/server-Rj64fNvX.js.map +0 -1
  201. package/dist/session-clear-Dshpuhff.d.ts.map +0 -1
  202. package/dist/store-Gpkq7WBo.d.ts.map +0 -1
  203. package/dist/store-redis-notify-tqWNnMRu.js.map +0 -1
  204. package/dist/telemetry-cZH-cibj.d.ts.map +0 -1
  205. package/docs/concepts/01-contracts.mdx +0 -237
  206. package/docs/concepts/02-handlers.mdx +0 -284
  207. package/docs/concepts/03-durability.mdx +0 -314
  208. package/docs/concepts/04-state.mdx +0 -209
  209. package/docs/guides/01-timers.mdx +0 -193
  210. package/docs/guides/02-cancellation.mdx +0 -161
  211. package/docs/guides/03-react.mdx +0 -324
  212. package/docs/guides/04-local-first.mdx +0 -47
  213. package/docs/guides/05-production.mdx +0 -534
  214. package/docs/guides/06-ai-agents.mdx +0 -1408
  215. package/docs/guides/08-application-data.mdx +0 -113
  216. package/docs/guides/09-presence.mdx +0 -262
  217. package/docs/guides/10-transports.mdx +0 -221
  218. package/docs/reference/01-api.mdx +0 -2358
  219. package/docs/reference/02-errors.mdx +0 -150
  220. /package/docs/{concepts → contract}/meta.ts +0 -0
  221. /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).