experimental-a2 0.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/CHANGELOG.md +128 -0
  2. package/dist/ai-server.browser.d.ts +1 -0
  3. package/dist/ai-server.browser.js +4 -0
  4. package/dist/ai-server.d.ts +65 -0
  5. package/dist/ai-server.js +494 -0
  6. package/dist/ai.d.ts +282 -0
  7. package/dist/ai.js +922 -0
  8. package/dist/cache-indexeddb.d.ts +1 -0
  9. package/dist/cache-indexeddb.js +0 -0
  10. package/dist/client.d.ts +90 -0
  11. package/dist/client.js +410 -0
  12. package/dist/contract-B0kAXoaL.js +60 -0
  13. package/dist/contract-DL8btVd9.d.ts +161 -0
  14. package/dist/devtools-server.browser.d.ts +1 -0
  15. package/dist/devtools-server.browser.js +4 -0
  16. package/dist/devtools-server.d.ts +22 -0
  17. package/dist/devtools-server.js +1087 -0
  18. package/dist/errors-BJRMd-h6.js +23 -0
  19. package/dist/errors-xL_JTXsY.d.ts +20 -0
  20. package/dist/http.d.ts +44 -0
  21. package/dist/http.js +119 -0
  22. package/dist/index.d.ts +5 -0
  23. package/dist/index.js +3 -0
  24. package/dist/inspection-E7qbD0Xj.js +10 -0
  25. package/dist/internal-Dm8Ejnud.js +36 -0
  26. package/dist/log-Dg1I8NRr.d.ts +245 -0
  27. package/dist/log-memory.d.ts +11 -0
  28. package/dist/log-memory.js +345 -0
  29. package/dist/log-polling-RO7kclzR.js +83 -0
  30. package/dist/log-postgres.d.ts +40 -0
  31. package/dist/log-postgres.js +628 -0
  32. package/dist/log-redis.d.ts +31 -0
  33. package/dist/log-redis.js +711 -0
  34. package/dist/log-sqlite.d.ts +17 -0
  35. package/dist/log-sqlite.js +450 -0
  36. package/dist/log-yJbXUf72.js +5 -0
  37. package/dist/otel.d.ts +12 -0
  38. package/dist/otel.js +41 -0
  39. package/dist/react.d.ts +54 -0
  40. package/dist/react.js +85 -0
  41. package/dist/recovery-vercel.d.ts +60 -0
  42. package/dist/recovery-vercel.js +120 -0
  43. package/dist/retryable-lazy-DZWmHpii.js +19 -0
  44. package/dist/server-DYsnKTTy.js +780 -0
  45. package/dist/server.browser.d.ts +1 -0
  46. package/dist/server.browser.js +11 -0
  47. package/dist/server.d.ts +136 -0
  48. package/dist/server.js +2 -0
  49. package/dist/telemetry-C78al20p.d.ts +32 -0
  50. package/dist/validate-XKT4FSNn.js +28 -0
  51. package/dist/wire-2QpU1EtJ.js +62 -0
  52. package/docs/01-quickstart.mdx +214 -0
  53. package/docs/concepts/01-contracts.mdx +138 -0
  54. package/docs/concepts/02-handlers.mdx +146 -0
  55. package/docs/concepts/03-durability.mdx +230 -0
  56. package/docs/concepts/04-state.mdx +133 -0
  57. package/docs/guides/01-timers.mdx +85 -0
  58. package/docs/guides/02-cancellation.mdx +107 -0
  59. package/docs/guides/03-react.mdx +234 -0
  60. package/docs/guides/04-local-first.mdx +88 -0
  61. package/docs/guides/05-production.mdx +179 -0
  62. package/docs/guides/06-ai-agents.mdx +659 -0
  63. package/docs/guides/07-devtools.mdx +101 -0
  64. package/docs/guides/08-application-data.mdx +114 -0
  65. package/docs/index.mdx +282 -0
  66. package/docs/reference/01-api.mdx +637 -0
  67. package/docs/reference/02-errors.mdx +77 -0
  68. package/package.json +111 -0
@@ -0,0 +1,107 @@
1
+ ---
2
+ title: Cancellation
3
+ description: There is no abort() API. Cancelling is appending, and the running handler finds out through a signal.
4
+ ---
5
+
6
+ ## Cancelling is appending
7
+
8
+ A user presses stop on a streaming AI response. In A2 that's not a special
9
+ operation with its own machinery. It's an event:
10
+
11
+ ```ts
12
+ // in the browser: push from useSession (see Live UI)
13
+ push({ type: 'cancelled', payload: { lastSeenIndex: index } })
14
+ ```
15
+
16
+ Same route, same auth, same log as every other event. Two things then
17
+ happen, at two different speeds.
18
+
19
+ ## The running handler stops
20
+
21
+ A handler doing abortable work opts in by naming the events that should
22
+ interrupt it:
23
+
24
+ ```ts server/chat.ts
25
+ import { createServer } from 'experimental-a2/server'
26
+ import { chat } from '@/contracts'
27
+
28
+ export const chatServer = createServer({
29
+ contract: chat,
30
+ handlers: {
31
+ generate: {
32
+ abortOn: ['cancelled'],
33
+ handler: async (ctx) => {
34
+ // your abortable work; hand it ctx.signal:
35
+ // await streamText({ signal: ctx.signal, ... })
36
+ },
37
+ },
38
+ },
39
+ })
40
+ ```
41
+
42
+ While the handler runs, A2 watches the session's log. The moment a
43
+ `cancelled` event lands (or one already landed before the handler
44
+ started), `ctx.signal` fires. Handlers without `abortOn` pay nothing.
45
+
46
+ One rule: an aborted handler should catch and return normally. Throwing
47
+ means "retry me", exactly the wrong response to someone pressing stop.
48
+
49
+ When one session multiplexes work (several generations over its
50
+ lifetime), match by *instance*, not just type, with a predicate:
51
+
52
+ ```ts
53
+ // server/chat.ts, the generate entry, targeted by instance:
54
+ generate: {
55
+ abortOn: {
56
+ // only the cancel that names this run fires the signal
57
+ cancelled: (event, trigger) => event.payload.of === trigger.id,
58
+ },
59
+ handler: async (ctx) => {
60
+ // ...
61
+ },
62
+ }
63
+ ```
64
+
65
+ The pushing side already knows the id it's cancelling; it's in the
66
+ `events` feed the hook exposes.
67
+
68
+ Because sessions process serially, the `cancelled` event's own handler runs
69
+ *after* the interrupted one settles. That makes it the natural place for
70
+ cleanup. The signal is the preemption channel; the event is the durable
71
+ record.
72
+
73
+ ## The view cuts
74
+
75
+ Chunks in flight during the cancel round-trip still land in the log after
76
+ `cancelled`. That's correct. They're true history; the model really did
77
+ generate them. Keeping them out of what the user sees is the reducer's job:
78
+
79
+ ```ts reducer.ts
80
+ case 'chunk':
81
+ return state.cancelled
82
+ ? state
83
+ : { ...state, chunks: [...state.chunks, { index: event.index, delta: event.payload.delta }] }
84
+ case 'cancelled':
85
+ return {
86
+ ...state,
87
+ cancelled: true,
88
+ chunks: state.chunks.filter((c) => c.index <= event.payload.lastSeenIndex),
89
+ }
90
+ ```
91
+
92
+ `lastSeenIndex` is the client's stream frontier, a number it already
93
+ tracks to resume subscriptions. Cutting on it makes the trim exact and
94
+ clock-free: chunks the log ordered before `cancelled` but the user never
95
+ saw are removed retroactively, so the optimistic view and the
96
+ authoritative refold agree on the same result. No wall clocks, no skew.
97
+
98
+ And because the client pushes `cancelled`
99
+ [optimistically](/guides/react#the-client-component), its own fold cuts
100
+ instantly. Stragglers still arriving over the stream hit `state.cancelled`
101
+ and vanish from the view.
102
+
103
+ :::tip
104
+ Keep chunks at per-chunk granularity in state until the session reaches a
105
+ terminal event, then compact them into one string. That's reducer design,
106
+ fully yours.
107
+ :::
@@ -0,0 +1,234 @@
1
+ ---
2
+ title: Live UI with React
3
+ description: "Synchronize one session from server to browser: render its state, stream new events, and push writes optimistically through one reducer."
4
+ ---
5
+
6
+ ## The model
7
+
8
+ A session can be read live from the browser. The server renders the first
9
+ paint, a stream keeps it fresh, and writes apply instantly. One reducer
10
+ produces every one of those views; server and client fold the same log
11
+ with the same function, so they can't disagree.
12
+
13
+ That's the trick, really. The client doesn't sync state. It syncs events,
14
+ and folds them locally. State sync is a hard problem. An append-only log
15
+ with positions is not.
16
+
17
+ This is session sync. The client follows one known session id; it does not
18
+ subscribe to database tables or queries across many sessions. Keep those
19
+ collection views in your [application database](/guides/application-data).
20
+
21
+ The pieces: two route handlers (read and write), a factory that binds your
22
+ reducer, and the provider + hook it returns.
23
+
24
+ ## The API route
25
+
26
+ One file exposes a session over HTTP: `GET` streams events, `POST` appends.
27
+ These are ordinary route handlers; put whatever checks you like in front.
28
+
29
+ ```ts app/api/order-events/route.ts
30
+ import { A2Error } from 'experimental-a2'
31
+ import { ordersServer } from '@/server/orders'
32
+ import { errorResponse, parsePushBody, sseResponse } from 'experimental-a2/http'
33
+
34
+ export async function GET(req: Request) {
35
+ const { searchParams } = new URL(req.url)
36
+ const sessionId = searchParams.get('sessionId')
37
+ if (!sessionId) {
38
+ return errorResponse(new A2Error('INVALID_PAYLOAD', 'missing sessionId'))
39
+ }
40
+ const startAt = Number(searchParams.get('index')) || 0
41
+
42
+ // here's where you'd do auth, or any other checks
43
+
44
+ return sseResponse(ordersServer.session(sessionId).stream({ startAt }))
45
+ }
46
+
47
+ export async function POST(req: Request) {
48
+ try {
49
+ const { sessionId, events } = await parsePushBody(req)
50
+
51
+ // here's where you'd do auth, or any other checks
52
+
53
+ const result = await ordersServer.session(sessionId).append(...events)
54
+ return Response.json(result)
55
+ } catch (err) {
56
+ return errorResponse(err)
57
+ }
58
+ }
59
+ ```
60
+
61
+ `GET` is the read path. `stream({ startAt })` is a live `AsyncIterable` of
62
+ one session's events starting after a given position, and `sseResponse`
63
+ pipes it into a server-sent events response. Clients pass `index` to resume
64
+ exactly where they left off, after a first paint or a dropped
65
+ connection.
66
+
67
+ `POST` is the write path. `parsePushBody` validates the envelope (and
68
+ throws `INVALID_PAYLOAD` on garbage), then `append` does the rest. The
69
+ response is the appended events: an ack, not a stream. `errorResponse`
70
+ serializes any thrown [`A2Error`](/reference/errors#over-the-wire) so the
71
+ client can branch on the same codes.
72
+
73
+ ## The session module
74
+
75
+ Bind your reducer once, in a `'use client'` file, and export the pair:
76
+
77
+ ```tsx app/orders/[orderId]/session.ts
78
+ 'use client'
79
+ import { createClient } from 'experimental-a2/client'
80
+ import { createReact } from 'experimental-a2/react'
81
+ import { ordersReducer } from '@/reducer'
82
+
83
+ export const ordersClient = createClient({
84
+ reducer: ordersReducer,
85
+ api: '/api/order-events',
86
+ })
87
+ export const { SessionProvider, useSession } = createReact({ client: ordersClient })
88
+ ```
89
+
90
+ This is the whole client/server seam. `createReact` is a factory (like
91
+ `createContext`): everything it returns is typed by the reducer value;
92
+ the state from its fold, the pushable events from its vocabulary. No type
93
+ arguments anywhere, and nothing in this file touches the server module.
94
+ The client also gives every session a stable in-memory identity. Calling
95
+ `ordersClient.session(orderId)` before navigation and mounting the provider
96
+ after navigation reaches the same optimistic state, with no handoff store.
97
+
98
+ ## The server component
99
+
100
+ The first paint costs no client JavaScript: fold on the server, pass the
101
+ result down.
102
+
103
+ ```tsx app/orders/[orderId]/page.tsx
104
+ import { ordersServer } from '@/server/orders'
105
+ import { ordersReducer } from '@/reducer'
106
+ import { SessionProvider } from './session'
107
+ import { OrderClient } from './order-client'
108
+
109
+ export default async function OrderPage({
110
+ params,
111
+ }: {
112
+ params: Promise<{ orderId: string }>
113
+ }) {
114
+ const { orderId } = await params
115
+ const session = ordersServer.session(orderId)
116
+ const { state, index } = await session.state(ordersReducer)
117
+ const initialEvents = (await session.history()).filter(
118
+ (event) => event.index <= index,
119
+ )
120
+
121
+ return (
122
+ <SessionProvider
123
+ sessionId={orderId}
124
+ initialState={state}
125
+ initialIndex={index}
126
+ initialEvents={initialEvents}
127
+ >
128
+ <OrderClient />
129
+ </SessionProvider>
130
+ )
131
+ }
132
+ ```
133
+
134
+ Three props do the heavy lifting. `initialState` is the fold; `initialIndex`
135
+ is its frontier, the log position the fold reflects; `initialEvents` makes
136
+ the raw feed available in the server render too. Reading state before history
137
+ and filtering at the state frontier gives both values one consistent boundary.
138
+ The client opens its stream at exactly that position. Nothing missed, nothing
139
+ folded twice.
140
+ (No `reducer` or `api` prop: the provider got both from the shared client.)
141
+
142
+ ## The client component
143
+
144
+ ```tsx app/orders/[orderId]/order-client.tsx
145
+ 'use client'
146
+ import { useSession } from './session'
147
+
148
+ export function OrderClient() {
149
+ const { state, push, events, index } = useSession()
150
+
151
+ return (
152
+ <button onClick={() => push({ type: 'shop.started', payload: {} })}>
153
+ Start
154
+ </button>
155
+ )
156
+ }
157
+ ```
158
+
159
+ What the hook gives you:
160
+
161
+ - **`state`**: the live view, folded through the shared reducer. It
162
+ updates three ways: optimistically when you push, corrected when the
163
+ server acks, and continuously as the stream delivers events, yours and
164
+ everyone else's.
165
+ - **`push`**: typed against the contract's vocabulary (via the reducer). Payloads are
166
+ validated locally before anything leaves the browser, apply instantly,
167
+ swap in the real event when the `POST` acks, and refold without it if
168
+ the server says no. Rejections carry the same
169
+ [`A2Error` codes](/reference/errors) the server throws, so both sides
170
+ branch on `code`. Awaiting `push(...)` gives the server ack; the
171
+ result also carries **`.confirmed`**, a promise for the moment the
172
+ live stream delivers the batch back and the view shows server truth.
173
+ `await push(...).confirmed` is "continue once this is real"; two
174
+ `performance.now()` calls around the two awaits are a complete
175
+ push→ack→stream latency meter.
176
+ - **`events`**: the raw feed `state` is folded from, for UI that wants
177
+ the log itself: an activity feed, a debug panel.
178
+ - **`index`**: the stream frontier, the last server-confirmed log
179
+ position. This is the `lastSeenIndex` that makes
180
+ [cancellation](/guides/cancellation) exact.
181
+ - **`connection`**: a discriminated union of `{ status: 'idle' }`,
182
+ `{ status: 'connecting', reconnects, error }`,
183
+ `{ status: 'live', reconnects }`, or `{ status: 'closed' }`. A status
184
+ dot is one ternary away; "reconnecting…" is
185
+ `status === 'connecting' && reconnects > 0`; `error` (why the last
186
+ connection ended) only exists while disconnected, so impossible
187
+ states don't compile. It's honest about silence, too: the server
188
+ heartbeats the stream (`: ping` every 15s), and a client that hears
189
+ nothing for two beats treats the connection as dead and reconnects, so
190
+ `live` means bytes are flowing, not "the socket hasn't errored yet".
191
+
192
+ The provider opens the stream on mount, closes it on unmount, and
193
+ reconnects with backoff, resuming from `index`, when the connection
194
+ drops. Components that only write don't need the hook; `POST` to the
195
+ route directly, or call `ordersClient.session(id).push(...)` so a later
196
+ provider sees the same optimistic session.
197
+
198
+ That identity is an in-memory L1, not another source of truth. Repeated
199
+ `session(id)` calls reuse it while active and for five idle minutes by
200
+ default. A newer server fold advances it, pending pushes stay overlaid,
201
+ and a stale server render cannot rewind it. The optional IndexedDB cache
202
+ is the L2: it survives reloads; the memory runtime does not.
203
+
204
+ One detail worth knowing: every push carries a client-generated event id.
205
+ That id is how the ack finds its optimistic entry, and it makes retrying
206
+ a failed `POST` idempotent for free (`push` auto-retries only
207
+ `LOG_UNAVAILABLE`).
208
+
209
+ Optimistic pushes are also what make [cancellation](/guides/cancellation)
210
+ feel instant: the `cancelled` event folds locally before the server ever
211
+ sees it.
212
+
213
+ Sessions can outlive the tab, too: hand `createReact` a `cache` and
214
+ revisits paint from the local copy, the stream resumes from the cached
215
+ frontier, and offline pushes queue and replay. See
216
+ [Local-first](/guides/local-first).
217
+
218
+ ## Keep the backend out of the bundle
219
+
220
+ The split is structural, not disciplinary. Core `experimental-a2` (the contract,
221
+ reducers, errors) imports no backend, ever; only `experimental-a2/server` can reach
222
+ one, and its exports map resolves to a loud error under the browser
223
+ condition. The layout above rides that:
224
+
225
+ 1. `contracts.ts` and `reducer.ts` import nothing but schemas and `experimental-a2`,
226
+ isomorphic by construction.
227
+ 2. The session module imports the reducer, never `experimental-a2/server`. Client
228
+ components don't need the server's *type*, even: the factory carries
229
+ all the typing.
230
+
231
+ Belt and suspenders: put `import 'server-only'` at the top of your
232
+ server module and enable `verbatimModuleSyntax` in your tsconfig; an
233
+ accidental value import then fails your build too, with your own error
234
+ message.
@@ -0,0 +1,88 @@
1
+ ---
2
+ title: Local-first
3
+ description: "Cache one A2 session in the browser: instant revisits, stream resumption, and offline writes."
4
+ ---
5
+
6
+ ## One option
7
+
8
+ ```tsx app/orders/[orderId]/session.ts
9
+ 'use client'
10
+ import { createReact } from 'experimental-a2/react'
11
+ import { indexedDb } from 'experimental-a2/cache-indexeddb'
12
+ import { ordersReducer } from '@/reducer'
13
+
14
+ export const { SessionProvider, useSession } = createReact({
15
+ reducer: ordersReducer,
16
+ cache: indexedDb(),
17
+ })
18
+ ```
19
+
20
+ This is the [session module](/guides/react#the-session-module) from Live
21
+ UI with one addition. The cache is created here, in client code, where
22
+ live objects belong; server components keep importing `SessionProvider`
23
+ exactly as before; nothing crosses the server boundary.
24
+
25
+ With `cache` set:
26
+
27
+ - **Revisits paint instantly.** State folds from the local copy before the
28
+ network says a word. Works offline too.
29
+ - **The stream resumes from the cached frontier.** Reconnects fetch only
30
+ what this browser hasn't seen, no matter how long it's been.
31
+ - **Pushes queue while offline**, and replay, in order, when the
32
+ connection returns.
33
+
34
+ Without it, nothing changes; the provider behaves exactly as before.
35
+
36
+ ## Why this is safe
37
+
38
+ Local-first is usually hard because it means syncing state, and state
39
+ needs merging. A2 caches an append-only log with server-assigned positions
40
+ instead. A cached event can never be *wrong*; this browser can only be
41
+ *behind*. Catching up is fetching events after an index, which is what
42
+ `stream({ startAt })` does anyway. There's no merge function because
43
+ there's nothing to merge.
44
+
45
+ ## What gets stored
46
+
47
+ Three things, in IndexedDB:
48
+
49
+ | Stored | Invalidated by |
50
+ | --------------------------------------------- | ------------------------------------------------------- |
51
+ | the session's events | nothing; they're immutable |
52
+ | a folded snapshot, keyed by reducer name | a reducer rename (or a `stateSchema` mismatch, when the reducer declares one): discarded, refolded from cached events |
53
+ | pending pushes | the server's ack |
54
+
55
+ Note the middle row: the reducer's `name` does the same job here that it
56
+ does for [server snapshots](/concepts/state#snapshots-are-a-cache). One
57
+ string, both sides of the wire.
58
+
59
+ Deleting the cache is always safe. It's a replica of what this browser
60
+ already saw; the log on the server stays the only authority.
61
+
62
+ ## Offline writes
63
+
64
+ `push` while offline persists the events, client ids and all, and
65
+ applies them optimistically, same as always. On reconnect they replay in
66
+ order. The replay is idempotent by construction: an identical batch is an
67
+ idempotent success, so a flaky reconnect, or a second tab replaying the
68
+ same queue, causes no duplicates. Rejections refold without the event,
69
+ exactly like an online push the server refused.
70
+
71
+ What this is *not*: local execution. Handlers run on the server, when the
72
+ events arrive. An offline push is a queued intent, not a completed
73
+ action. Design the UI accordingly ("sending…" is honest; "sent" is not).
74
+
75
+ ## Logout
76
+
77
+ Cached events outlive the session cookie. If your app authenticates,
78
+ clear the cache when the user leaves:
79
+
80
+ ```ts
81
+ // in the browser, wherever your logout flow runs:
82
+ await cache.clear() // everything, or cache.clear(sessionId) for one session
83
+ ```
84
+
85
+ That's the one obligation `cache` puts on you. The cache never *grants*
86
+ access (your routes still authorize every read and write) but it
87
+ *retains* what was legitimately seen, and retained data belongs to the
88
+ person who saw it.
@@ -0,0 +1,179 @@
1
+ ---
2
+ title: Going to production
3
+ description: Point the log at Postgres, add queue-backed recovery, and know what to do when an event dead-letters.
4
+ ---
5
+
6
+ ## Two pieces
7
+
8
+ Development needs zero setup: SQLite appears under `.a2/`, and tests run
9
+ in memory. Production needs two deliberate pieces. Neither changes your
10
+ handlers.
11
+
12
+ ## 1. Choose a log
13
+
14
+ Production has no default log, on purpose. A server without one throws
15
+ `LOG_NOT_CONFIGURED` at startup. A failed boot beats events written to a
16
+ filesystem that evaporates.
17
+
18
+ ```ts server/orders.ts
19
+ import { createServer } from 'experimental-a2/server'
20
+ import { postgres } from 'experimental-a2/log-postgres'
21
+ import { orders } from '@/contracts'
22
+
23
+ export const ordersServer = createServer({
24
+ contract: orders,
25
+ log: postgres({ connectionString: process.env.DATABASE_URL }),
26
+ handlers: {
27
+ /* ... */
28
+ },
29
+ })
30
+ ```
31
+
32
+ The Postgres backend uses real transactions; appends serialize per
33
+ session on an advisory lock, and the live stream polls the log with an
34
+ activity-adaptive cadence: 25ms while a session is producing events
35
+ (a token stream reads smoothly, not in clumps), backing off to 250ms
36
+ when it goes quiet (a LISTEN/NOTIFY upgrade could still land without
37
+ any API change). Any Postgres works: Neon, Supabase, RDS, your own
38
+ box; transaction-mode poolers included, which is exactly why polling
39
+ is the default. `pg` is an optional peer dependency; pass
40
+ `connectionString`, or inject your own pool as `client`.
41
+
42
+ This configures storage for A2's session logs. It does not connect A2 to your
43
+ application tables or make them part of the append transaction. See
44
+ [A2 and your database](/guides/application-data) for that boundary.
45
+
46
+ ## 2. Add recovery
47
+
48
+ Recovery is what puts a clock on healing. `experimental-a2/recovery-vercel` rides
49
+ Vercel Queues (`@vercel/queue` is a peer
50
+ dependency). Same `server/orders.ts`, now with `recovery`:
51
+
52
+ ```ts
53
+ // server/orders.ts, now with recovery:
54
+ import { createServer } from 'experimental-a2/server'
55
+ import { postgres } from 'experimental-a2/log-postgres'
56
+ import { vercelQueues } from 'experimental-a2/recovery-vercel'
57
+ import { orders } from '@/contracts'
58
+
59
+ export const recovery = vercelQueues()
60
+
61
+ export const ordersServer = createServer({
62
+ contract: orders,
63
+ log: postgres({ connectionString: process.env.DATABASE_URL }),
64
+ recovery,
65
+ handlers: {
66
+ /* ... */
67
+ },
68
+ })
69
+ ```
70
+
71
+ One recovery instance is shared by every server, and mounted once:
72
+
73
+ ```ts app/api/a2/recovery/route.ts
74
+ import { recovery, ordersServer, billingServer } from '@/server'
75
+
76
+ export const POST = recovery.handler(ordersServer, billingServer)
77
+ ```
78
+
79
+ ```json vercel.json
80
+ {
81
+ "functions": {
82
+ "app/api/a2/recovery/route.ts": {
83
+ "experimentalTriggers": [
84
+ {
85
+ "type": "queue/v2beta",
86
+ "topic": "a2"
87
+ }
88
+ ]
89
+ }
90
+ }
91
+ }
92
+ ```
93
+
94
+ The trigger makes the route private. Only queue infrastructure can invoke
95
+ it, so it needs no auth of its own.
96
+
97
+ One route, one job. Every top-level append starts a delayed, coalesced
98
+ "drain this session" arm alongside its inline handler. The handler does not
99
+ wait for the queue, while `append` joins the initial arm for up to two seconds
100
+ before it returns. An unresponsive queue therefore cannot hold the append open
101
+ indefinitely. Every lease renewal arms another watchdog for just after that
102
+ lease window. A live holder keeps moving the watchdog forward. A killed holder
103
+ stops heartbeating, its lease expires, and the next watchdog retries the
104
+ pending event. When Vercel exposes the function deadline, A2 caps the final
105
+ lease window there so timeout recovery starts promptly. A failing handler
106
+ keeps the current message and redelivers with backoff until the session
107
+ settles.
108
+
109
+ Due times are rounded to one-second slots. Top-level arms, lease renewals, and
110
+ racing deliveries targeting the same slot deduplicate into one queue message.
111
+ Busy deliveries continue the current message's heartbeat-aligned slot series,
112
+ so they do not create an independent stream of watchdog callbacks.
113
+ Events appended by handlers ride their current execution window and add no
114
+ recovery operation of their own.
115
+
116
+ No cron, no sweep, no notification bookkeeping. The queue message is
117
+ the recovery state, and the log is the only thing it consults.
118
+
119
+ ## 3. When an event dead-letters
120
+
121
+ After ten caught handler failures, A2 stops retrying an event and the session
122
+ stalls at it; [Durability](/concepts/durability#when-a-handler-keeps-failing)
123
+ explains why stalling is the honest choice. Resolution is manual, and has
124
+ exactly two shapes:
125
+
126
+ - **Fix and retry.** Deploy the handler fix, clear the event's failure
127
+ marker.
128
+ - **Skip.** Mark the event processed, accepting that its effects never
129
+ happened.
130
+
131
+ Both are one-column updates on the events table.
132
+
133
+ ## Watch it in traces
134
+
135
+ A2 takes an optional `telemetry`, an instrumentation hook that
136
+ `experimental-a2/otel` implements over OpenTelemetry (`@opentelemetry/api` is a peer
137
+ dependency):
138
+
139
+ ```ts
140
+ // server/orders.ts, with telemetry:
141
+ import { createServer } from 'experimental-a2/server'
142
+ import { otel } from 'experimental-a2/otel'
143
+ import { orders } from '@/contracts'
144
+
145
+ export const ordersServer = createServer({
146
+ contract: orders,
147
+ // ...log, recovery, and handlers as above
148
+ telemetry: otel(),
149
+ })
150
+ ```
151
+
152
+ With any OTel setup registered (on Vercel, `@vercel/otel`), the happy
153
+ path lands in a single trace: the request → `a2.append` → the inline
154
+ `a2.drain` → an `a2.event` span per handler → the appends those handlers
155
+ make: the whole causal chain, visually. Handler failures mark their `a2.event`
156
+ span with the exception, each dispatch reports `ctx.attempt` as
157
+ `a2.event.attempt`, and `a2.event.outcome = dead_lettered` is the attribute to
158
+ alert on when a session [stalls for manual
159
+ resolution](#3-when-an-event-dead-letters). See the
160
+ [API reference](/reference/api#a2otel) for the span catalogue.
161
+
162
+ ## Running without a queue
163
+
164
+ Skip `recovery`, and the only wakeups are a top-level append or explicit
165
+ `server.drain()`. Reads never wake the session.
166
+
167
+ That's a real configuration, not a broken one. Fine for internal tools
168
+ and low-stakes apps where "heals on the next write" is acceptable. But
169
+ there's no clock in it: a session nobody wakes stays stuck until someone does.
170
+ For production, configure recovery.
171
+
172
+ ## Checklist
173
+
174
+ | Piece | Done when |
175
+ | ------------------- | -------------------------------------------------------------------- |
176
+ | Log | `log: postgres(...)` on every server |
177
+ | Recovery | one shared `vercelQueues()`, route mounted, trigger in `vercel.json` |
178
+ | Idempotent handlers | external side effects take `event.id` as an idempotency key |
179
+ | Client split | contracts/reducers isomorphic; only `experimental-a2/server` touches backends |