experimental-a2 0.0.0 → 0.1.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 (55) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/dist/ai-server.browser.js +2 -2
  3. package/dist/ai-server.d.ts +19 -7
  4. package/dist/ai-server.js +730 -96
  5. package/dist/ai.d.ts +32 -11
  6. package/dist/ai.js +253 -75
  7. package/dist/client.d.ts +1 -1
  8. package/dist/client.js +4 -4
  9. package/dist/{contract-B0kAXoaL.js → contract-CG_adnu_.js} +2 -1
  10. package/dist/{contract-DL8btVd9.d.ts → contract-C_3dIIEU.d.ts} +4 -1
  11. package/dist/devtools-server.browser.js +2 -2
  12. package/dist/devtools-server.js +1 -1
  13. package/dist/http.d.ts +1 -1
  14. package/dist/http.js +4 -3
  15. package/dist/idempotent-replay-BMyHrP0L.js +19 -0
  16. package/dist/index.d.ts +4 -4
  17. package/dist/index.js +1 -1
  18. package/dist/{internal-Dm8Ejnud.js → internal-D6wNxTck.js} +3 -3
  19. package/dist/{log-Dg1I8NRr.d.ts → log-ldf5g8Cx.d.ts} +74 -56
  20. package/dist/log-memory.d.ts +1 -1
  21. package/dist/log-memory.js +173 -96
  22. package/dist/{log-polling-RO7kclzR.js → log-polling-6COoN60V.js} +1 -1
  23. package/dist/log-postgres.d.ts +1 -1
  24. package/dist/log-postgres.js +235 -192
  25. package/dist/log-redis.d.ts +1 -1
  26. package/dist/log-redis.js +453 -263
  27. package/dist/log-sqlite.d.ts +1 -1
  28. package/dist/log-sqlite.js +216 -127
  29. package/dist/otel.d.ts +1 -1
  30. package/dist/otel.js +1 -1
  31. package/dist/react.d.ts +1 -1
  32. package/dist/react.js +1 -1
  33. package/dist/recovery-vercel.d.ts +2 -2
  34. package/dist/recovery-vercel.js +9 -10
  35. package/dist/server-BWffWe5A.js +867 -0
  36. package/dist/server.browser.js +4 -4
  37. package/dist/server.d.ts +42 -27
  38. package/dist/server.js +1 -1
  39. package/dist/{telemetry-C78al20p.d.ts → telemetry-Cso0qyHQ.d.ts} +1 -1
  40. package/dist/{wire-2QpU1EtJ.js → wire-BVsgR8o9.js} +1 -1
  41. package/docs/01-quickstart.mdx +7 -7
  42. package/docs/concepts/01-contracts.mdx +22 -22
  43. package/docs/concepts/02-handlers.mdx +223 -89
  44. package/docs/concepts/03-durability.mdx +191 -112
  45. package/docs/concepts/04-state.mdx +27 -1
  46. package/docs/guides/01-timers.mdx +4 -4
  47. package/docs/guides/02-cancellation.mdx +32 -4
  48. package/docs/guides/05-production.mdx +36 -27
  49. package/docs/guides/06-ai-agents.mdx +151 -70
  50. package/docs/guides/07-devtools.mdx +6 -3
  51. package/docs/guides/08-application-data.mdx +5 -6
  52. package/docs/index.mdx +30 -14
  53. package/docs/reference/01-api.mdx +280 -70
  54. package/package.json +31 -31
  55. package/dist/server-DYsnKTTy.js +0 -780
@@ -9,47 +9,44 @@ description: Appends are effectively-once, handlers are at-least-once, and retri
9
9
  `append` returns, the event is in the log.
10
10
  - **Handlers are at-least-once.** They may run again after a crash, so
11
11
  [side effects use idempotency keys](/concepts/handlers#handlers-can-run-twice).
12
- - **Order holds per session.** Events are processed in log order. A2 never
13
- dispatches past a pending event.
12
+ - **The log stays ordered.** Every session has one immutable event order.
13
+ - **Handlers are concurrent by default.** Independent events can run at the
14
+ same time. Events in the same lane run one at a time in log order.
14
15
 
15
16
  ## The correctness model
16
17
 
17
- The log says what needs work. The lease says who may work now. The processed
18
- marker says what finished. The watchdog says when to look again.
18
+ The log says what needs work. A per-event claim says who may work now. The
19
+ processed marker says what finished. The watchdog says when to look again.
19
20
 
20
21
  ```text
21
- +-----------------+
22
- | Wakeups |
23
- | |
24
- | append |
25
- | watchdog |
26
- | state / stream |
27
- +--------+--------+
22
+ +------------------+
23
+ | Wakeups |
24
+ | append, watchdog |
25
+ +--------+---------+
28
26
  |
29
27
  v
30
- +-----------------+
31
- | drain(session) |
32
- +--------+--------+
28
+ +------------------+
29
+ | drain(session) |
30
+ +--------+---------+
33
31
  |
34
32
  v
35
- +-----------------+
36
- | claimNext |
37
- +--------+--------+
33
+ +------------------+
34
+ | claimAvailable |
35
+ +--------+---------+
38
36
  |
39
- +----+----+
40
- | |
41
- v v
42
- +----------+ +-----------------+
43
- | return | | run handler |
44
- | busy or | | claimed event |
45
- | settled | +-----------------+
46
- +----------+
37
+ +----+----------------+
38
+ | |
39
+ v v
40
+ +-------------+ +-------------------+
41
+ | busy or | | run every claimed |
42
+ | settled | | event concurrently|
43
+ +-------------+ +-------------------+
47
44
  ```
48
45
 
49
46
  A queue message names a session. It carries no event index or continuation
50
47
  state. Every wakeup runs the same drain, and the log decides what remains.
51
- Append follows its causal tree; recovery and explicit `server.drain()` inspect
52
- the full session. Reads never dispatch handlers.
48
+ Recovery and explicit `server.drain()` inspect the full session. Reads never
49
+ dispatch handlers.
53
50
 
54
51
  :::note[Recovery is not an event]
55
52
  A2 does not append `recovered` or `continued`. Recovery retries the same event.
@@ -59,72 +56,149 @@ Your log contains only application facts.
59
56
  ## One event, many attempts
60
57
 
61
58
  An event is pending until it has a processed marker. Attempt tracking is part
62
- of the operations that already claim and advance that event:
59
+ of the operations that already claim and complete that event:
63
60
 
64
61
  ```text
65
62
  +----------------------------+ one backend operation
66
- | claimNext |
67
- | head + lease + attempt |
63
+ | claimAvailable |
64
+ | eligible events + attempts |
68
65
  +-------------+--------------+
69
66
  |
70
- v
71
- +----------------------------+
72
- | handler 1 |
73
- +-------------+--------------+
74
- |
75
- v
76
- +----------------------------+ one backend operation
77
- | completeAndClaimNext |
78
- | complete 1 + claim 2 |
79
- +-------------+--------------+
80
- |
81
- v
82
- +----------------------------+
83
- | handler 2 |
84
- +----------------------------+
67
+ +------+------+
68
+ | |
69
+ v v
70
+ +-------------+ +-------------+
71
+ | handler A | | handler B |
72
+ +------+------+ +------+------+
73
+ | |
74
+ v v
75
+ +-------------+ +-------------+ one operation per event
76
+ | complete A | | complete B |
77
+ +-------------+ +-------------+
85
78
  ```
86
79
 
87
80
  `ctx.attempt` is the durable, 1-based claim ordinal. A kill after the claim can
88
81
  consume an ordinal before user code sees it, but adds no caught failure. A
89
82
  retry gets the same event id, index, type, and payload with a larger attempt.
90
83
 
91
- Use `event.id` to deduplicate external effects. `ctx.append` derives stable
92
- child ids when call order is stable. Otherwise, pass
93
- [explicit child ids](/concepts/handlers#handlers-can-run-twice).
84
+ Use `event.id` to deduplicate external effects. `ctx.session.append` derives
85
+ stable child ids from its handler-local name. See
86
+ [Handlers can run twice](/concepts/handlers#handlers-can-run-twice).
87
+
88
+ Each claim stores its holder and expiry on the event. Claiming an expired event
89
+ increments its attempt before a new handler starts. Completion and failure are
90
+ fenced by that ordinal, so an older attempt cannot overwrite a newer one.
91
+
92
+ ## Immediate and dependent events
93
+
94
+ Handlers have two ways to publish more events:
95
+
96
+ - `ctx.session.append(name, ...events)` commits immediately. Its events may run
97
+ before the current handler finishes. The stable name makes the append
98
+ idempotent across retries.
99
+ - `return event` or `return [eventA, eventB]` publishes only when the handler
100
+ succeeds. A2 completes the parent and appends the whole returned batch in
101
+ one transaction.
102
+
103
+ ```ts server/pipeline.ts
104
+ import { z } from 'zod'
105
+ import * as a2 from 'experimental-a2'
106
+ import { createServer } from 'experimental-a2/server'
107
+
108
+ const pipeline = a2.contract({
109
+ name: 'pipeline',
110
+ events: {
111
+ 'build.requested': z.object({ buildId: z.string() }),
112
+ 'build.completed': z.object({ buildId: z.string() }),
113
+ 'deploy.requested': z.object({ buildId: z.string() }),
114
+ },
115
+ })
116
+
117
+ export const pipelineServer = createServer({
118
+ contract: pipeline,
119
+ handlers: {
120
+ 'build.requested': async ({ event }) => {
121
+ // your side effect, idempotent via event.id:
122
+ // await build(event.payload, { idempotencyKey: event.id })
123
+ return [
124
+ { type: 'build.completed', payload: { buildId: event.payload.buildId } },
125
+ { type: 'deploy.requested', payload: { buildId: event.payload.buildId } },
126
+ ]
127
+ },
128
+ },
129
+ })
130
+ ```
131
+
132
+ If the handler throws, neither returned event exists. If the completion
133
+ transaction commits but its acknowledgment is lost, A2 retries with the same
134
+ ordered child ids and returns the already-committed rows. A partial or changed
135
+ batch is rejected.
136
+
137
+ This transaction covers only A2's log. An HTTP call, database write, Bash
138
+ command, or other external effect cannot join it. If an effect succeeds and
139
+ the process dies before completion commits, the handler runs it again. Use
140
+ `event.id` as an idempotency key or add fencing in the external system.
141
+
142
+ ## Lanes
143
+
144
+ A lane is a session-scoped serial group. A2 resolves the lane when it appends
145
+ the event and stores the value beside it.
146
+
147
+ - Events without a lane are independently eligible.
148
+ - Only the lowest-index unfinished event in a lane is eligible.
149
+ - Different lanes run concurrently.
150
+ - A failure blocks later events in that lane, not the whole session.
151
+
152
+ Persisting the value matters for recovery. A deployment that changes a lane
153
+ resolver affects new events, not pending ones already in the log.
154
+
155
+ A lane controls A2 dispatch, not the outside world. A worker can perform an
156
+ external effect and disappear before recording completion. Its successor then
157
+ retries the same event. External idempotency is still required.
158
+
159
+ A2 claims every currently eligible event. It does not add a global concurrency
160
+ limit or priority queue. Use lanes for ordered mutual exclusion. Put capacity
161
+ limits near the constrained resource, such as an API client, connection pool,
162
+ or execution runtime.
94
163
 
95
164
  ## Append and recovery
96
165
 
97
- A top-level append commits first, then starts the inline drain and optional
98
- watchdog arm in parallel. The drain never waits for the queue. Append joins the
99
- arm for at most two seconds; on failure, its telemetry span records
100
- `a2.append.armed = false`, but committed work continues. `ctx.append` rides the
101
- active drain and adds no arm.
166
+ A top-level append commits first. When pending handler work remains, it starts
167
+ the inline drain and optional watchdog arm in parallel. The drain never waits
168
+ for the queue. Append joins the arm for at most two seconds; on failure, its
169
+ telemetry span records `a2.append.armed = false`, but committed work continues.
170
+ `ctx.session.append` rides the active session drain and adds no arm. Returned
171
+ events enter the log as part of completion, then become eligible immediately.
172
+
173
+ An event type without a handler settles in the append transaction with no
174
+ dispatch attempt. It starts no drain or recovery arm when the session has no
175
+ older pending work. If older handled work is pending, the append still wakes
176
+ the session-wide healing path.
102
177
 
103
178
  ## Causal trees are durable
104
179
 
105
- Every event written by `ctx.append` stores one atomic `cause` with the
106
- triggering event's index and current dispatch ordinal. Top-level appends store
107
- null. In newly written data that is a root, but durable readers also use null
108
- for legacy events whose origin is unknown. Several appends from one handler are
109
- siblings. Nested handler appends create further levels. If a handler retries
110
- after writing a child, idempotency preserves that child's original cause.
180
+ Every event written by `ctx.session.append` or returned by a handler stores one
181
+ atomic `cause` with the triggering event's index and current dispatch ordinal.
182
+ Top-level appends store null. Several children from one handler are siblings.
183
+ Nested handler output creates further levels. If a handler retries after
184
+ writing a child, idempotency preserves that child's original cause.
111
185
 
112
186
  The edge is part of the existing append batch and survives process restarts.
113
- The stored event also carries `firstClaimedAt`, `lastClaimedAt`,
114
- `lastFailedAt`, `lastFailedAttempt`, `processedAt`, and `processedByAttempt`.
115
- These timestamps are adapter clock values captured for atomic log operations,
116
- not exact database commit times. `processedAt` is write-once, so a stale worker
117
- cannot rewrite a newer completion. The durable log can therefore rebuild a
118
- causal forest and show useful lifecycle boundaries. `attemptCount` summarizes
119
- intermediate claims instead of storing one row per attempt.
187
+ The stored event also carries `lane`, `firstClaimedAt`, `lastClaimedAt`,
188
+ `claimHolder`, `claimExpiresAt`, `lastFailedAt`, `lastFailedAttempt`,
189
+ `processedAt`, `processedByAttempt`, and `returnedEventIds`. Timestamps are
190
+ adapter clock values captured for atomic log operations, not exact database
191
+ commit times. A stale worker cannot rewrite a newer completion. The durable
192
+ log can rebuild a causal forest and useful lifecycle boundaries without one
193
+ row per attempt.
120
194
 
121
- ## Lease and watchdog timing
195
+ ## Claim and watchdog timing
122
196
 
123
- A heartbeat renews the session lease and arms a watchdog after each lease
124
- window. For illustration, use a five-second lease, two-second heartbeat, and
125
- one-second grace:
197
+ A heartbeat renews every live claim owned by the drain and arms a watchdog
198
+ after each claim window. For illustration, use a five-second claim, two-second
199
+ heartbeat, and one-second grace:
126
200
 
127
- | Time | Live worker | Lease | Watchdog |
201
+ | Time | Live worker | Event claim | Watchdog |
128
202
  | ---: | --- | --- | --- |
129
203
  | t0 | Claim attempt 1 | Through t5 | Arm t6 |
130
204
  | t2 | Heartbeat | Through t7 | Arm t8 |
@@ -135,15 +209,17 @@ one-second grace:
135
209
  | t9 | | Expires | t10 remains scheduled |
136
210
  | t10 | Claim attempt 2 | New window | Deliver t10: wins |
137
211
 
138
- Lease renewal and queue sends do not block each other. A busy watchdog ensures
139
- a later watchdog before acknowledging; if that send fails, it does not
140
- acknowledge. If a deadline is available, A2 caps the current lease window at
141
- it. The deadline never gates handler dispatch.
212
+ Claim renewal and queue sends do not block each other. An expired claim cannot
213
+ be renewed. It becomes eligible for a new claim, which increments the attempt
214
+ and fences the previous worker. A busy watchdog ensures a later watchdog
215
+ before acknowledging; if that send fails, it does not acknowledge. If a
216
+ deadline is available, A2 caps the current claim window at it. The deadline
217
+ never gates handler dispatch.
142
218
 
143
219
  ## A chain that crosses the function timeout
144
220
 
145
- Four eight-second handlers do not fit together in a 20-second invocation, but
146
- each fits in a fresh one:
221
+ Four eight-second handlers returned one after another do not fit together in a
222
+ 20-second invocation, but each fits in a fresh one:
147
223
 
148
224
  | Time | Work | Durable state |
149
225
  | ---: | --- | --- |
@@ -161,55 +237,57 @@ One handler that always exceeds a fresh invocation cannot finish this way. A2
161
237
  cannot checkpoint arbitrary async code. Split the work into smaller events or
162
238
  increase the function duration.
163
239
 
164
- ## Crash after a child append
240
+ ## Crash around a returned batch
165
241
 
166
242
  ```text
167
- +------------------------+
168
- | Invocation A |
169
- | event 2, attempt 1 |
170
- | append child event 3 |
171
- +-----------+------------+
172
- |
173
- v
174
- +------------------------+
175
- | Durable log |
176
- | event 2: pending |
177
- | event 3: pending |
178
- +-----------+------------+
179
- |
180
- SIGKILL, then lease expiry
181
- |
182
- v
183
- +------------------------+
184
- | Invocation B |
185
- | event 2, attempt 2 |
186
- | append reuses event 3 |
187
- +-----------+------------+
188
- |
189
- v
190
- complete 2, drain 3
243
+ +-------------------------+
244
+ | Invocation A |
245
+ | event 2, attempt 1 |
246
+ | handler returns event 3 |
247
+ +------------+------------+
248
+ |
249
+ completeAttempt
250
+ |
251
+ +-----+------+
252
+ | |
253
+ v v
254
+ transaction SIGKILL before
255
+ commits transaction
256
+ | |
257
+ v v
258
+ event 2 done event 2 pending
259
+ event 3 exists event 3 absent
191
260
  ```
192
261
 
193
- The deterministic child id makes the retry reuse event 3. External effects
194
- use event 2's id as their idempotency key.
262
+ If the commit acknowledgment is lost, a retry with the exact returned ids gets
263
+ event 3 back without duplicating it. If the transaction did not commit, event
264
+ 2 retries and produces the same deterministic returned id. External effects
265
+ still use event 2's id as their idempotency key.
266
+
267
+ With `ctx.session.append`, the child commits before its parent finishes. It
268
+ remains in the log if the parent crashes and can run concurrently unless both
269
+ events use the same lane.
195
270
 
196
271
  ## Why this stays correct
197
272
 
198
273
  | Property | Durable rule |
199
274
  | --- | --- |
200
275
  | Commit | Store the event before any wakeup. |
201
- | Order | Claim only the lowest pending index. |
202
- | Ownership | Only a live lease holder can claim the next event. |
203
- | Handoff | Complete the current event and claim the next atomically. |
276
+ | Concurrency | Claim every eligible unlaned event and one unfinished head per lane. |
277
+ | Ownership | Store an expiring claim on each event. |
278
+ | Completion | Complete the parent and append its returned batch atomically. |
279
+ | Fencing | Only the current attempt can complete or record a failure. |
204
280
  | Retry | Without a processed marker, the same event remains pending. |
205
- | Identity | Event and derived child ids stay stable across retries. |
281
+ | Identity | Event, immediate child, and returned child ids stay stable across retries. |
206
282
  | Liveness | A watchdog, later append, or explicit drain re-enters `drain(session)`. |
207
283
 
208
284
  ## When a handler keeps failing
209
285
 
210
- A caught failure increments `failureCount`; a stale failure changes nothing.
211
- Ten caught failures dead-letter the event and stall that session. Hard kills
212
- do not consume this budget. Resolution is manual: fix and retry, or skip.
286
+ A caught failure increments `failureCount`, clears that event's claim, and
287
+ leaves other eligible events running. A stale failure changes nothing. Ten
288
+ caught failures dead-letter the event. Later events in its lane remain blocked;
289
+ unlaned events and other lanes continue. Hard kills do not consume this budget.
290
+ Resolution is manual: fix and retry, or skip.
213
291
 
214
292
  ## Limits
215
293
 
@@ -218,7 +296,8 @@ do not consume this budget. Resolution is manual: fix and retry, or skip.
218
296
  | Process dies before the first arm is durable | Retry with the same ids, append again later, or call `drain()`. |
219
297
  | No recovery configured | A later top-level append or explicit `drain()` wakes the session. Reads never do. |
220
298
  | Recovery dies after claiming but before arming | Its unacknowledged queue delivery is the slower fallback. |
221
- | Log or lease backend unavailable | Safe progress stops until it returns. |
299
+ | Log backend unavailable | Safe progress stops until it returns. |
300
+ | External effect succeeds before A2 completion | The handler may repeat it. Use idempotency or external fencing. |
222
301
 
223
302
  ## What append never throws for
224
303
 
@@ -67,6 +67,25 @@ const { state, index } = await ordersServer
67
67
  .state(ordersReducer)
68
68
  ```
69
69
 
70
+ Handlers receive the same session interface for their triggering session:
71
+
72
+ ```ts server.ts
73
+ import { createServer } from 'experimental-a2/server'
74
+ import { orders } from './contracts'
75
+ import { ordersReducer } from './reducer'
76
+
77
+ export const ordersServer = createServer({
78
+ contract: orders,
79
+ handlers: {
80
+ created: async ({ session }) => {
81
+ const { state, index } = await session.state(ordersReducer)
82
+ void state
83
+ void index
84
+ },
85
+ },
86
+ })
87
+ ```
88
+
70
89
  A reducer is a name (its identity, more on that below) plus the values that
71
90
  anchor it: `initialState` (the seed), an optional `stateSchema`, and the
72
91
  pure `fold`, `(state, event) => state`. It's derived *from* the
@@ -83,7 +102,14 @@ The snapshot and its remaining event tail come back in one consistent log
83
102
  operation. A missing snapshot reads the full log. A snapshot rejected by
84
103
  `stateSchema`, or a failed cache read, falls back to the full log and rebuilds
85
104
  from truth. `state()` is observational: it never runs handlers or waits for
86
- pending work to finish.
105
+ pending work to finish. Inside a handler, the returned index includes the
106
+ triggering event and may include later events that committed before the read.
107
+
108
+ The read and a following `ctx.session.append(name, ...events)` are separate
109
+ operations. Concurrent appends and retries can move the frontier between them.
110
+ For joins, use a monotone readiness check (once ready, always ready) and give
111
+ the output event a stable explicit `id`. Repeated attempts then converge on the
112
+ same append.
87
113
 
88
114
  `stateSchema` declares the state's shape once. Without it, the state type
89
115
  is inferred from `initialState`, fine while every fold arm returns the
@@ -25,14 +25,14 @@ import { orders } from '@/contracts'
25
25
  export const ordersServer = createServer({
26
26
  contract: orders,
27
27
  handlers: {
28
- created: async ({ event }) => {
28
+ created: async ({ session }) => {
29
29
  // schedule with anything that can deliver an HTTP call later:
30
30
  // QStash, cron, a provider webhook:
31
31
  //
32
32
  // await scheduleHttpCall({
33
33
  // delay: '5d',
34
34
  // url: '/api/append',
35
- // body: { sessionId: event.sessionId, type: 'expired', payload: {} },
35
+ // body: { sessionId: session.id, type: 'expired', payload: {} },
36
36
  // })
37
37
  },
38
38
  },
@@ -70,8 +70,8 @@ import { orders } from '@/contracts'
70
70
  export const ordersServer = createServer({
71
71
  contract: orders,
72
72
  handlers: {
73
- expired: async ({ history }) => {
74
- const events = await history()
73
+ expired: async ({ session }) => {
74
+ const events = await session.history()
75
75
  if (events.some((e) => e.type === 'shop.started')) return // stale, ignore
76
76
  // ...actually expire the order
77
77
  },
@@ -65,10 +65,38 @@ generate: {
65
65
  The pushing side already knows the id it's cancelling; it's in the
66
66
  `events` feed the hook exposes.
67
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.
68
+ Handlers are concurrent by default. A `cancelled` handler may therefore run
69
+ while the interrupted handler is still unwinding. The signal is the
70
+ preemption channel; the event is the durable record.
71
+
72
+ When cleanup must wait for the interrupted handler, put both event types in
73
+ one lane. The event fires the signal as soon as it is appended, but its own
74
+ handler waits for earlier work in that lane:
75
+
76
+ ```ts server/chat-with-cleanup.ts
77
+ import { createServer } from 'experimental-a2/server'
78
+ import { chat } from '@/contracts'
79
+
80
+ export const chatServer = createServer({
81
+ contract: chat,
82
+ handlers: {
83
+ generate: {
84
+ lane: 'generation',
85
+ abortOn: ['cancelled'],
86
+ handler: async (ctx) => {
87
+ // your abortable work; hand it ctx.signal:
88
+ // await streamText({ signal: ctx.signal, ... })
89
+ },
90
+ },
91
+ cancelled: {
92
+ lane: 'generation',
93
+ handler: async () => {
94
+ // your cleanup, after generate has returned
95
+ },
96
+ },
97
+ },
98
+ })
99
+ ```
72
100
 
73
101
  ## The view cuts
74
102
 
@@ -1,13 +1,13 @@
1
1
  ---
2
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.
3
+ description: Point the log at durable storage, add recovery for handler work, and know what to do when an event dead-letters.
4
4
  ---
5
5
 
6
6
  ## Two pieces
7
7
 
8
8
  Development needs zero setup: SQLite appears under `.a2/`, and tests run
9
- in memory. Production needs two deliberate pieces. Neither changes your
10
- handlers.
9
+ in memory. Every production server needs a durable log. Servers with handlers
10
+ also need recovery. Neither choice changes your event contract.
11
11
 
12
12
  ## 1. Choose a log
13
13
 
@@ -30,7 +30,8 @@ export const ordersServer = createServer({
30
30
  ```
31
31
 
32
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
33
+ session on an advisory lock, while handler claims remain concurrent. The live
34
+ stream polls the log with an
34
35
  activity-adaptive cadence: 25ms while a session is producing events
35
36
  (a token stream reads smoothly, not in clumps), backing off to 250ms
36
37
  when it goes quiet (a LISTEN/NOTIFY upgrade could still land without
@@ -43,7 +44,7 @@ This configures storage for A2's session logs. It does not connect A2 to your
43
44
  application tables or make them part of the append transaction. See
44
45
  [A2 and your database](/guides/application-data) for that boundary.
45
46
 
46
- ## 2. Add recovery
47
+ ## 2. Add recovery for handlers
47
48
 
48
49
  Recovery is what puts a clock on healing. `experimental-a2/recovery-vercel` rides
49
50
  Vercel Queues (`@vercel/queue` is a peer
@@ -94,33 +95,35 @@ export const POST = recovery.handler(ordersServer, billingServer)
94
95
  The trigger makes the route private. Only queue infrastructure can invoke
95
96
  it, so it needs no auth of its own.
96
97
 
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
98
+ One route, one job. A top-level append that leaves or finds pending handler
99
+ work starts a delayed, coalesced "drain this session" arm alongside its inline
100
+ handler. The handler does not wait for the queue, while `append` joins the
101
+ initial arm for up to two seconds before it returns. An unresponsive queue
102
+ therefore cannot hold the append open indefinitely. Claim renewals arm another
103
+ watchdog for just after the current window. Live handlers keep moving their
104
+ per-event claims and the watchdog forward. A killed holder stops heartbeating,
105
+ its claims expire, and the next watchdog retries those pending events. When
106
+ Vercel exposes the
107
+ function deadline, A2 caps the final claim window there so timeout recovery
108
+ starts promptly. A failing handler keeps the current message and redelivers
109
+ with backoff. Work outside its lane continues.
110
+
111
+ Due times are rounded to one-second slots. Top-level arms, claim renewals, and
110
112
  racing deliveries targeting the same slot deduplicate into one queue message.
111
113
  Busy deliveries continue the current message's heartbeat-aligned slot series,
112
114
  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
+ Events appended or returned by handlers ride their current execution window
116
+ and add no recovery operation of their own.
115
117
 
116
118
  No cron, no sweep, no notification bookkeeping. The queue message is
117
119
  the recovery state, and the log is the only thing it consults.
118
120
 
119
121
  ## 3. When an event dead-letters
120
122
 
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
123
+ After ten caught handler failures, A2 stops retrying an event. It blocks later
124
+ events in the same lane; unlaned events and other lanes continue.
125
+ [Durability](/concepts/durability#when-a-handler-keeps-failing) explains this
126
+ boundary. Resolution is manual, and has
124
127
  exactly two shapes:
125
128
 
126
129
  - **Fix and retry.** Deploy the handler fix, clear the event's failure
@@ -155,14 +158,20 @@ path lands in a single trace: the request → `a2.append` → the inline
155
158
  make: the whole causal chain, visually. Handler failures mark their `a2.event`
156
159
  span with the exception, each dispatch reports `ctx.attempt` as
157
160
  `a2.event.attempt`, and `a2.event.outcome = dead_lettered` is the attribute to
158
- alert on when a session [stalls for manual
161
+ alert on when an event [needs manual
159
162
  resolution](#3-when-an-event-dead-letters). See the
160
163
  [API reference](/reference/api#a2otel) for the span catalogue.
161
164
 
162
165
  ## Running without a queue
163
166
 
164
- Skip `recovery`, and the only wakeups are a top-level append or explicit
165
- `server.drain()`. Reads never wake the session.
167
+ If none of a contract's event types have handlers, skip `recovery`. Those
168
+ events settle in their append transaction, so they create no drains, claims,
169
+ queue messages, or recovery callbacks. The server is a durable event log for
170
+ history, reducers, and live sync. That is a complete production configuration,
171
+ not degraded recovery, because there is no reaction to recover.
172
+
173
+ With handlers, skipping `recovery` means the only wakeups are a top-level
174
+ append or explicit `server.drain()`. Reads never wake the session.
166
175
 
167
176
  That's a real configuration, not a broken one. Fine for internal tools
168
177
  and low-stakes apps where "heals on the next write" is acceptable. But
@@ -174,6 +183,6 @@ For production, configure recovery.
174
183
  | Piece | Done when |
175
184
  | ------------------- | -------------------------------------------------------------------- |
176
185
  | Log | `log: postgres(...)` on every server |
177
- | Recovery | one shared `vercelQueues()`, route mounted, trigger in `vercel.json` |
186
+ | Recovery | for servers with handlers: one shared `vercelQueues()`, route, and trigger |
178
187
  | Idempotent handlers | external side effects take `event.id` as an idempotency key |
179
188
  | Client split | contracts/reducers isomorphic; only `experimental-a2/server` touches backends |