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,860 @@
1
+ ---
2
+ title: Server
3
+ description: "`experimental-a2/server`: `createServer`, its route, the store and scheduler adapter interfaces, and every session method."
4
+ ---
5
+
6
+ ## `experimental-a2/server`
7
+
8
+ ### `handler(entry)`
9
+
10
+ Normalizes a handler entry so you can compose an existing handler without
11
+ branching on its shape. For ordinary use, define handlers inline in
12
+ `createServer({ handlers })`.
13
+
14
+ - Accepts a function or `{ handler, lane?, abortOn? }` and returns the object
15
+ shape: a function becomes `{ handler: entry }`, an object is returned
16
+ unchanged, and both keep their original types.
17
+ - Does not bind a contract; `createServer({ handlers })` checks the match.
18
+ Annotate a new handler's context, as below, or pass an already typed
19
+ function.
20
+ - Spreading the result keeps `lane` and `abortOn`; set either after the spread
21
+ to override it. Changing a built-in AI handler's lane can separate it from
22
+ the handlers it coordinates with.
23
+ - A composition runs in one handler attempt. Work after the original resolves
24
+ still runs before its returned events commit, a throw fails the composed
25
+ attempt, and a retry can run both again.
26
+
27
+ ```ts server/composed-orders.ts
28
+ import * as a2 from 'experimental-a2'
29
+ import { createServer, handler, type HandlerContext } from 'experimental-a2/server'
30
+ import { z } from 'zod'
31
+
32
+ const orders = a2.contract({
33
+ name: 'composed-orders',
34
+ events: {
35
+ created: z.object({ shopId: z.string() }),
36
+ notified: z.object({ shopId: z.string() }),
37
+ },
38
+ })
39
+
40
+ const created = handler({
41
+ lane: 'orders',
42
+ handler: async (ctx: HandlerContext<typeof orders.events, 'created'>) => {
43
+ return orders.batch({ type: 'notified', payload: ctx.event.payload })
44
+ },
45
+ })
46
+
47
+ export const ordersServer = createServer({
48
+ contract: orders,
49
+ handlers: {
50
+ created: {
51
+ ...created,
52
+ handler: async (ctx) => {
53
+ const result = await created.handler(ctx)
54
+ // Your additional work goes here:
55
+ // await notifyShop(ctx.event.payload, { idempotencyKey: ctx.event.id })
56
+ return result
57
+ },
58
+ },
59
+ },
60
+ })
61
+ ```
62
+
63
+ ### `createServer(options)`
64
+
65
+ ```ts
66
+ createServer(options: {
67
+ contract: Contract // the vocabulary this server implements
68
+ store?: A2Store // default: sqlite in dev, memory in tests, required in prod
69
+ scheduler?: A2Scheduler
70
+ telemetry?: A2Telemetry // optional instrumentation; see experimental-a2/otel
71
+ presence?: { ttlMs?: number } // presence expiry policy; default 60s
72
+ handlers?: {
73
+ [type]:
74
+ | Handler
75
+ | {
76
+ abortOn?: AbortSpec
77
+ lane?: string | ((ctx: LaneContext) => string)
78
+ handler: Handler
79
+ }
80
+ }
81
+ }): A2Server
82
+
83
+ type Handler = (
84
+ ctx: Context,
85
+ ) => Promise<void | AppendInput | readonly AppendInput[]>
86
+ ```
87
+
88
+ Implements a contract: binds its vocabulary to storage and reactions.
89
+ `experimental-a2/server` is the only entry point that can reach a store
90
+ backend; under the browser condition its exports resolve to a loud error. See
91
+ [Handlers](/server/handlers).
92
+
93
+ | Option | Behavior |
94
+ | --- | --- |
95
+ | `handlers` | One table, complete at construction. Compose files by spreading objects in; a duplicate key silently last-wins. |
96
+ | `lane` | Within one session, same-lane events run one at a time in log order; all others run concurrently. A resolver receives `{ sessionId, event }`. The value is resolved and stored at append, so a later deployment can't reinterpret pending work. See [Lanes](/server/handlers#lanes). |
97
+ | `abortOn` | Events that fire `ctx.signal` while the handler runs: an array matches by type; an object maps types to predicates `(event, trigger, { attempt })`, whose `attempt` is the trigger's durable attempt, so a cancellation can fence one recovered run. See [Cancellation](/server/cancellation). |
98
+ | `presence.ttlMs` | How long a presence value survives without a refreshing set: a positive integer of milliseconds, default 60 seconds, counted on the storage's clock, never the sender's stamp. Only for contracts that declare `presence`; elsewhere construction throws a `TypeError`. |
99
+
100
+ ```ts
101
+ generate: {
102
+ abortOn: {
103
+ cancelled: (event, trigger, { attempt }) =>
104
+ event.payload.of === `${trigger.id}:${attempt}`,
105
+ },
106
+ handler: async (ctx) => { /* ... */ },
107
+ }
108
+ ```
109
+
110
+ - Handlers are concurrent by default. A2 starts every eligible event once its
111
+ append commits.
112
+ - A handler returns nothing, one event, or an array. A2 marks the trigger
113
+ processed and appends the returned batch in one atomic store operation; when
114
+ the handler throws, the returned events never exist.
115
+ - Handlers are optional per event type. An event type without one settles in
116
+ its append transaction with no dispatch attempt, and starts no drain or
117
+ scheduler arm when the session has no older pending handler work. See
118
+ [Events without handlers](/server/handlers#events-without-handlers).
119
+
120
+ The context every handler receives:
121
+
122
+ | Property | Meaning |
123
+ | --- | --- |
124
+ | `ctx.event` | `Event`: the triggering event |
125
+ | `ctx.attempt` | durable 1-based dispatch claim ordinal; increments on every durable claim and may skip when a process dies before handler entry |
126
+ | `ctx.session` | this session: `id` (equal to `ctx.event.sessionId`), `append`, `schedule`, `clear`, `history`, `state`, `stream`, `setPresence` |
127
+ | `ctx.signal` | `AbortSignal`: fires on lease lapse (`CLAIM_EXPIRED`), proven supersession (`SUPERSEDED_ATTEMPT`), and matching `abortOn` events |
128
+
129
+ `ctx.session` shares the `server.session(id)` surface; handler-specific
130
+ behavior is noted under each method. Its `state(reducer)` folds through the
131
+ trigger's index by default (see
132
+ [`session.state`](#sessionstatereducer-options)), and its `append` takes a
133
+ name:
134
+
135
+ ```ts
136
+ ctx.session.append(name: string, ...events: AppendInput[]): Promise<Event[]>
137
+ ```
138
+
139
+ - It commits immediately, so its events may run while the handler is still
140
+ active unless a lane orders them. See
141
+ [Append what should happen now](/server/handlers#append-what-should-happen-now).
142
+ - The name is the idempotency key, scoped to the trigger, for events whose
143
+ `id` is omitted: each such id derives from the trigger id, the name, and the
144
+ event's position. The same name and generated-id batch returns the existing
145
+ rows on retry; a changed batch conflicts with them.
146
+ - An explicit `id` wins, so different triggering events can converge on one
147
+ fact. Root `server.session(id).append(...events)` takes no name.
148
+
149
+ ### `server.fetch(request, options?)`
150
+
151
+ ```ts
152
+ server.fetch(
153
+ request: Request,
154
+ options?: {
155
+ authorize?: (operation: A2Operation) => boolean | Promise<boolean>
156
+ upgradeWebSocket?: (
157
+ attach: (socket: A2Socket) => void,
158
+ ) => Response | Promise<Response>
159
+ multiplexWebSocket?: boolean
160
+ },
161
+ ): Promise<Response>
162
+
163
+ type A2Operation =
164
+ | {
165
+ type: 'stream'
166
+ sessionId: string
167
+ startAfter: number
168
+ transport: 'http' | 'websocket'
169
+ }
170
+ | {
171
+ type: 'history'
172
+ sessionId: string
173
+ gte: number
174
+ lte: number
175
+ transport: 'http'
176
+ }
177
+ | {
178
+ type: 'push'
179
+ sessionId: string
180
+ events: readonly {
181
+ type: ContractEventType | string
182
+ payload: unknown
183
+ id?: string
184
+ }[]
185
+ presence?: {
186
+ participant: string
187
+ values: Readonly<Record<string, unknown>>
188
+ seen?: number
189
+ at?: number
190
+ }
191
+ transport: 'http' | 'websocket'
192
+ }
193
+ ```
194
+
195
+ The bound Fetch API route for this server: an unprotected route exports it
196
+ directly as `GET` and `POST`. See [Routes](/server/routes).
197
+
198
+ | Request | Response |
199
+ | --- | --- |
200
+ | `GET` | streams one session over SSE, resumed after the `index` query parameter |
201
+ | `GET` with `gte` and `lte` | the closed history range as JSON; `a2-history-covered` is `true` when every requested index was returned. If the range includes the latest clear marker, `a2-clear-index` is set and coverage counts only the requested indexes after it; a range entirely below the marker returns `[]` |
202
+ | `GET` with `Upgrade: websocket` | calls `upgradeWebSocket`, or returns 426 without it |
203
+ | `POST` | accepts `{ sessionId, events, presence? }` and returns the appended events |
204
+
205
+ A contract that declares presence streams it and accepts presence pushes
206
+ automatically.
207
+
208
+ | Option | Behavior |
209
+ | --- | --- |
210
+ | `authorize` | Runs after the request or socket frame is parsed, before schema validation, presence validation, or I/O. Event and presence field names are typed from the contract; values stay `unknown`. Operation objects, push events, event arrays, and presence patches are frozen: authorize them, don't rewrite them. `false` denies with `FORBIDDEN`; a throw becomes `STORE_UNAVAILABLE`. |
211
+ | `upgradeWebSocket` | receives an `attach` callback for the platform socket and returns the upgrade response |
212
+ | `multiplexWebSocket` | `true` shares one socket across sessions, with `multiplex: true` in the client's `ws` API |
213
+
214
+ - Authentication stays in the route, where headers, cookies, and framework
215
+ context are available. Capture the authenticated principal in `authorize`.
216
+ - `authorize` is an ingress policy for this `fetch` call, not a global server
217
+ policy: direct `session.append`, handler appends, scheduled appends, and
218
+ server-side `setPresence` bypass it.
219
+ - By default the `sessionId` and `index` query parameters bind one session to
220
+ the socket. A2 authorizes the stream before upgrading, and frames can't
221
+ select another session.
222
+ - A multiplexed socket runs `authorize` for each session subscribe and each
223
+ event or presence push. Authenticate the physical WebSocket GET before
224
+ calling `server.fetch`, and validate `Origin` when browser cookies
225
+ authenticate it (handshakes skip the CORS preflight). Enable multiplexing
226
+ only when that request context may access every session `authorize` accepts.
227
+ - A denied subscribe receives `unsubscribed` and leaves other sessions alone. A
228
+ denied event push receives a `FORBIDDEN` ack. Denied presence is silently
229
+ dropped, because it is fire-and-forget.
230
+ - SSE and WebSocket lifetimes use the platform's ambient invocation deadline,
231
+ and background work uses its ambient `waitUntil`. Neither is an option.
232
+
233
+ ### `server.session(id)`
234
+
235
+ ```ts
236
+ server.session(id: string): Session
237
+ ```
238
+
239
+ A handle on one instance of the contract: the unit of log ordering, lane keys,
240
+ recovery, state, and live sync. Creating it does no I/O; nothing loads until
241
+ you append, read, or stream. `session.id` is the `id` passed in. See
242
+ [Sessions](/contract/sessions).
243
+
244
+ It has every [session method](#session), plus
245
+ [`deadLetters()`](#sessiondeadletters) and
246
+ [`redrive()`](#sessionredriveindex-options), which `ctx.session` doesn't.
247
+
248
+ ### `server.drain(sessionId)`
249
+
250
+ ```ts
251
+ server.drain(sessionId: string): Promise<{ settled: boolean }>
252
+ ```
253
+
254
+ Claims every eligible event and runs their handlers concurrently.
255
+ `settled` means no actionable or live-claimed work remains. Past the
256
+ invocation's deadline it claims nothing and returns `settled: false`. A dead-lettered
257
+ event can block later work in its lane while other lanes continue, until
258
+ [`session.redrive()`](#sessionredriveindex-options) resolves it. Scheduler
259
+ callbacks use it; you'll rarely call it yourself. The public result stays this
260
+ simple boolean.
261
+
262
+ ### `A2Store`
263
+
264
+ Custom store adapters implement these atomic drain methods:
265
+
266
+ ```ts
267
+ // Custom server store adapter:
268
+ type EventCause = {
269
+ index: number
270
+ attempt: number
271
+ batchSize?: number // present on named handler appends
272
+ }
273
+
274
+ type AppendEvent = {
275
+ type: string
276
+ payload: unknown
277
+ id?: string
278
+ cause?: EventCause
279
+ lane?: string
280
+ settled?: true // internal: no handler is registered for this event type
281
+ }
282
+
283
+ type ReturnedEvent = AppendEvent & { id: string }
284
+
285
+ type StoredEvent = Event & {
286
+ cause: EventCause | null
287
+ lane: string | null
288
+ processedAt: Date | null
289
+ processedByAttempt: number | null
290
+ returnedEventIds: string[] | null
291
+ firstClaimedAt: Date | null
292
+ lastClaimedAt: Date | null
293
+ attemptCount: number
294
+ claimHolder: string | null
295
+ claimExpiresAt: Date | null
296
+ failureCount: number
297
+ lastFailedAt: Date | null
298
+ lastFailedAttempt: number | null
299
+ lastError: string | null
300
+ failedAt: Date | null
301
+ clearIndex?: number // on claimed events, once the session has been cleared
302
+ }
303
+
304
+ type StoreAppendResult = {
305
+ events: StoredEvent[]
306
+ hasPending: boolean
307
+ }
308
+
309
+ type StoreClaimAvailableResult =
310
+ | { outcome: 'claimed'; events: StoredEvent[] }
311
+ | { outcome: 'busy'; retryAt: Date }
312
+ | { outcome: 'settled' }
313
+
314
+ type CompleteAttemptResult =
315
+ | { outcome: 'completed'; events: StoredEvent[] }
316
+ | { outcome: 'superseded' }
317
+
318
+ interface A2Store {
319
+ append(
320
+ sessionId: string,
321
+ events: AppendEvent[],
322
+ options?: { originIndex?: number },
323
+ ): Promise<StoreAppendResult>
324
+
325
+ // optional together; required for session.clear()
326
+ clear?(options: {
327
+ sessionId: string
328
+ ifLastIndex?: number
329
+ cause?: EventCause
330
+ }): Promise<{ cleared: boolean; index: number; clearIndex: number }>
331
+ reclaim?(sessionId: string): Promise<{ deleted: number; done: boolean }>
332
+
333
+ // optional together; required for session.deadLetters() and redrive()
334
+ deadLetters?(sessionId: string): Promise<StoredEvent[]>
335
+ redrive?(options: {
336
+ sessionId: string
337
+ index: number
338
+ action: 'retry' | 'skip'
339
+ }): Promise<{ redriven: boolean; hasPending: boolean }>
340
+
341
+ claimAvailable(options: {
342
+ sessionId: string
343
+ holder: string
344
+ ttlMs: number
345
+ expiresAtMs?: number
346
+ excludeIndexes?: readonly number[]
347
+ maxFailures?: number
348
+ }): Promise<StoreClaimAvailableResult>
349
+
350
+ renewClaims(options: {
351
+ sessionId: string
352
+ holder: string
353
+ claims: readonly { index: number; attempt: number }[]
354
+ ttlMs: number
355
+ expiresAtMs?: number
356
+ }): Promise<{ renewed: number[]; superseded: number[] }>
357
+
358
+ completeAttempt(options: {
359
+ sessionId: string
360
+ index: number
361
+ attempt: number
362
+ events: ReturnedEvent[]
363
+ }): Promise<CompleteAttemptResult>
364
+
365
+ failAttempt(options: {
366
+ sessionId: string
367
+ index: number
368
+ attempt: number
369
+ error: string
370
+ maxFailures: number
371
+ retryAfterMs?: number
372
+ }): Promise<{
373
+ outcome: 'failed' | 'dead_lettered' | 'superseded'
374
+ failureCount: number
375
+ }>
376
+ }
377
+ ```
378
+
379
+ | Method | Atomic effect |
380
+ | --- | --- |
381
+ | `append` | Write a consecutive batch and report session-wide pending handler work as `hasPending`. Events with the internal `settled` flag get `processedAt` in the same transaction, with no dispatch attempt. An event with a `cause` commits only while `cause.attempt` is the parent's latest attempt and the parent isn't dead-lettered; otherwise throw `SUPERSEDED_ATTEMPT`. The check serializes against a concurrent claim, after the idempotent-replay path. |
382
+ | `claimAvailable` | Claim every eligible event. All unlaned events are independent; only the lowest-index unfinished event in each lane is eligible. A claim records its holder, expiry, timestamps, and next `attemptCount`. Taking over a lapsed claim, one its holder never completed or failed, adds one to `failureCount`; when that reaches `maxFailures`, dead-letter the event instead of claiming it: advance `attemptCount` to fence the lapsed attempt, leave `lastFailedAttempt` unchanged, and return it with `failedAt` set. A failure's retry hold has no holder, so it is no lapsed claim. |
383
+ | `renewClaims` | Extend the listed live claims the holder still owns. `renewed` lists those; `superseded` lists claims whose event's `attemptCount` passed the listed attempt. An expired claim no successor took is in neither and may still complete; renewal never revives it. |
384
+ | `completeAttempt` | In one transaction: fence on the current attempt, mark the parent processed, store its exact ordered `returnedEventIds`, and append the returned batch. A same-attempt retry returns the committed children; a stale attempt returns `superseded`. |
385
+ | `failAttempt` | Record one current caught failure, clear its claim, and dead-letter at `maxFailures`. Short of that, with `retryAfterMs` the event stays unclaimable for that long: set its claim expiry with no holder, so `claimAvailable` reports it `busy` until then. A repeated acknowledgment is idempotent; a stale attempt returns `superseded`. |
386
+ | `clear` | Serialized with `append`. Check `ifLastIndex` against the head and the latest marker, then append a settled clear marker at the next index, touching no existing rows. A `cause` is fenced like a handler append. |
387
+ | `reclaim` | Delete one bounded batch of rows below the latest clear marker, with their bookkeeping, event-id records, lane pointers, and cached state. Report how many rows went, and `done` when none are left. Never wait on a lock other methods hold. |
388
+ | `deadLetters` | Return the unprocessed events with `failedAt` set above the latest clear marker, oldest first. |
389
+ | `redrive` | Only for an unprocessed event with `failedAt` set above the latest clear marker. `retry` clears `failedAt` and `failureCount`, leaving claims and the last failure. `skip` sets `processedAt` with no `processedByAttempt` or `returnedEventIds` and exposes the next event in its lane. Anything else reports `redriven: false` and writes nothing. Both serialize with claims and handler appends, `skip` also with lane promotion; report `hasPending` like `append`. |
390
+
391
+ `readHistory(sessionId, options)` is an optional capability with the same
392
+ exclusive `afterIndex` and inclusive `throughIndex` bounds as `read()`. Without
393
+ it, `session.history()` falls back to `read()`.
394
+
395
+ A store that supports clearing treats every event at or below the latest
396
+ marker as superseded, whether or not its row still exists. Each check belongs
397
+ inside the operation's existing atomic step:
398
+
399
+ - `claimAvailable` never claims it; lane readiness at append and completion
400
+ ignores it, so a dead-lettered lane head stops blocking.
401
+ - `renewClaims` reports it in `superseded`; `completeAttempt` and
402
+ `failAttempt` return `superseded`.
403
+ - An `append` whose `cause` names it throws `SUPERSEDED_ATTEMPT`; one whose
404
+ `originIndex` is at or below the marker throws `SESSION_CLEARED`.
405
+ - Reads start at the marker; `readState` returns its index as `clearIndex`,
406
+ and claimed events carry it as `clearIndex`.
407
+ - In `append` and `completeAttempt`, an event id whose row is below this
408
+ session's marker is free to reuse.
409
+
410
+ Without `clear` and `reclaim`, `session.clear()` and `session.schedule.clear()`
411
+ throw a `TypeError` before writing anything. Without `deadLetters` and
412
+ `redrive`, so do `session.deadLetters()` and `session.redrive()`. All built-in
413
+ stores support both.
414
+
415
+ Stored fields:
416
+
417
+ - `attemptCount` counts claims, including abandoned ones. `failureCount`
418
+ counts failed attempts: caught failures, and lapsed claims a later claim
419
+ took over. Ten dead-letter the event. See
420
+ [Guarantees](/server/guarantees#one-event-many-attempts).
421
+ - `cause` names the event and handler attempt whose `ctx.session.append` or
422
+ return value first persisted the child; `null` means a top-level event.
423
+ - `lane` is the session-scoped serialized group, resolved before append.
424
+ - `returnedEventIds` makes a lost completion acknowledgment recoverable without
425
+ accepting a partial child batch.
426
+ - Lifecycle timestamps are adapter clock values for their atomic operations,
427
+ not exact database commit times. Built-in adapters persist all these fields
428
+ inside their existing atomic operations, with no extra round trip.
429
+
430
+ ### `A2Scheduler`
431
+
432
+ Custom scheduler adapters implement two methods:
433
+
434
+ | Method | Effect |
435
+ | --- | --- |
436
+ | `schedule(task: SchedulerTask): Promise<void>` | put one versioned task on durable delivery infrastructure |
437
+ | `handler(...servers)` | return the route that receives drain, append, and clear tasks; requires at least one drainable server and rejects duplicate contract names synchronously |
438
+
439
+ Application routes call `scheduler.handler(...servers)` on the configured
440
+ adapter. See
441
+ [Production](/server/production#2-add-a-scheduler-for-handlers-and-timers).
442
+
443
+ `SchedulerTask` is `SchedulerDrainTask | SchedulerAppendTask |
444
+ SchedulerClearTask`. These task types, plus `ScheduledEvent`, are exported
445
+ from `experimental-a2/server`.
446
+
447
+ | Task | Fields |
448
+ | --- | --- |
449
+ | `SchedulerDrainTask` | `version: 1`, `kind: 'drain'`, `contract`, `sessionId`, required epoch-ms `dueAt` |
450
+ | `SchedulerAppendTask` | `version: 1`, `kind: 'append'`, stable `id`, `contract`, `sessionId`, required epoch-ms `dueAt`, readonly `events`, optional `originIndex` |
451
+ | `SchedulerClearTask` | `version: 1`, `kind: 'clear'`, stable `id`, `contract`, `sessionId`, required epoch-ms `dueAt`, `ifLastIndex` |
452
+ | `ScheduledEvent` | fixed `id`, `type`, and JSON `payload` |
453
+
454
+ Drain tasks wake pending work and carry no application payload. Append tasks
455
+ carry the events for `session.schedule`; `originIndex` is the scheduling
456
+ handler's event, absent for root schedules. Clear tasks carry the condition for
457
+ `session.schedule.clear`.
458
+
459
+ Deliver an authenticated, normalized append or clear task through the server
460
+ helpers:
461
+
462
+ ```ts custom-scheduler.ts
463
+ import {
464
+ deliverSchedulerAppend,
465
+ deliverSchedulerClear,
466
+ type DrainableServer,
467
+ type SchedulerAppendTask,
468
+ type SchedulerClearTask,
469
+ } from 'experimental-a2/server'
470
+
471
+ export async function deliver(
472
+ server: DrainableServer,
473
+ task: SchedulerAppendTask | SchedulerClearTask,
474
+ ) {
475
+ if (task.kind === 'clear') await deliverSchedulerClear(server, task)
476
+ else await deliverSchedulerAppend(server, task)
477
+ }
478
+ ```
479
+
480
+ Authenticate the transport and validate the task envelope first. Each helper
481
+ verifies that the task and server contract match.
482
+
483
+ - `deliverSchedulerAppend` revalidates the carried event inputs and uses the
484
+ ordinary top-level append. The target server must have a scheduler. When
485
+ the append leaves pending handler work, it resolves only after a recovery
486
+ watchdog is durable, so a send failure keeps the delivery retryable. A task
487
+ whose `originIndex` was cleared resolves without appending.
488
+ - `deliverSchedulerClear` runs `clear({ ifLastIndex })` and resolves after its
489
+ deletion finishes. A store failure rejects, so the provider redelivers and
490
+ the retry finishes deleting.
491
+
492
+ ## Session
493
+
494
+ ### `session.append(...events)`
495
+
496
+ ```ts
497
+ session.append(
498
+ ...events: Array<{ type: string; payload: unknown; id?: string }>
499
+ ): Promise<Event[]>
500
+ ```
501
+
502
+ The only way to move a session forward. See [Guarantees](/server/guarantees).
503
+
504
+ - Validates payloads against the contract's schemas before writing anything.
505
+ - A multi-event append is atomic: all-or-nothing, consecutive positions, one
506
+ transaction.
507
+ - An explicit `id` makes it idempotent across retries; re-sending an identical
508
+ batch returns the original rows.
509
+ - Events pushed through `server.fetch` arrive through this same append.
510
+ - Only event types with registered handlers are dispatched; the append itself
511
+ settles the rest. An unhandled append still starts session healing when
512
+ older handled work is pending. The store reports that pending state in the
513
+ atomic append, so the decision needs no follow-up read.
514
+
515
+ ### `session.append.dispatch(...events)`
516
+
517
+ ```ts
518
+ session.append.dispatch(
519
+ ...events: Array<{ type: string; payload: unknown; id?: string }>
520
+ ): Promise<Event[]>
521
+ ```
522
+
523
+ Commits the same atomic batch, then sends pending work to the configured
524
+ scheduler instead of starting an inline drain. See
525
+ [Dispatch in a fresh invocation](/server/production#dispatch-in-a-fresh-invocation).
526
+
527
+ - Awaits acceptance of an immediate scheduler message. A batch that leaves no
528
+ pending handler work sends none.
529
+ - Throws before writing when the server has no scheduler.
530
+ - If the send fails, the events are already durable: give them explicit IDs
531
+ and retry the same dispatch.
532
+ - It picks how the session wakes, not which worker runs: an already active
533
+ drain may claim the new work first.
534
+ - Exists only on a server session's top-level append. `ctx.session.append`
535
+ runs inside an active drain, where its immediate and returned children
536
+ become eligible, so it has no `dispatch`.
537
+
538
+ ### `session.schedule(name, timing, ...events)`
539
+
540
+ ```ts
541
+ session.schedule(
542
+ name: string,
543
+ timing: ScheduleTiming, // { delay: ScheduleDelay } | { at: Date }
544
+ ...events: Array<{ type: string; payload: unknown; id?: string }>
545
+ ): Promise<void>
546
+ ```
547
+
548
+ Appends the events later through the configured scheduler, from root and
549
+ handler sessions. Resolves after provider acceptance, not after append. `name`
550
+ must be non-empty, with at least one event. `ScheduleDelay`, `ScheduleTiming`,
551
+ and `SessionSchedule` are exported from `experimental-a2/server`. See
552
+ [Timers](/server/timers) for stale-timer guards, provider limits, and
553
+ deployment compatibility.
554
+
555
+ | Timing | Meaning |
556
+ | --- | --- |
557
+ | `{ delay: '30s' }` | relative delay with unit `ms`, `s`, `m`, `h`, or `d`; from the call's wall clock on a root session, from the trigger's durable `createdAt` in a handler, so handler retries keep the delivery time |
558
+ | `{ at: Date }` | absolute delivery time; use it when a retried root call needs one target |
559
+
560
+ - The template type checks the unit. At runtime the number must be an
561
+ unsigned base-10 decimal without leading zeros, finite, and above zero; `0s`,
562
+ signs, exponents, malformed durations, invalid dates, and both fields at
563
+ once are rejected.
564
+ - The target is the earliest useful delivery time. Adapters encode it in
565
+ whole-second slots; an already-due target is sent immediately.
566
+ - The task id derives from the contract, session, and name. Handler tasks add
567
+ the trigger id and, once the session has been cleared, the clear, so a
568
+ trigger id reused in a new lifetime schedules a new task. Omitted event ids
569
+ derive from the task id and position; explicit ids win.
570
+ - Reuse a name only for identical timing and events. Provider deduplication is
571
+ an optimization; stable event ids make later duplicate deliveries converge in
572
+ the store.
573
+ - Payloads are snapshotted into plain JSON, then validated before sending.
574
+ Encode dates as ISO strings and classes as plain data. `undefined`, bigint,
575
+ functions, symbols, cycles, accessors, and non-plain prototypes are rejected;
576
+ shared references become independent values.
577
+ - Delivery validates the snapshotted schema input through an ordinary
578
+ top-level append, outside any handler's completion transaction, so a schema
579
+ transform produces the stored payload from the original input. The events
580
+ have `cause: null` and run through handlers, lanes, state, and streams
581
+ normally.
582
+ - A handler task belongs to its trigger's lifetime: if the session is cleared
583
+ before delivery, it is acknowledged without appending. A root task joins
584
+ whichever lifetime exists at delivery, and its name identifies one task
585
+ across lifetimes, so put something from the new lifetime in the name when
586
+ scheduling again after a clear.
587
+ - There is no cancellation, reschedule, cron, or timer-listing API.
588
+
589
+ ### `session.clear(options?)`
590
+
591
+ ```ts
592
+ session.clear(options?: { ifLastIndex?: number }): Promise<{
593
+ cleared: boolean
594
+ index: number
595
+ }>
596
+ ```
597
+
598
+ Ends the session's current lifetime: one atomic step checks the condition and
599
+ appends a reserved clear marker at the next index. The session id stays usable
600
+ and indexes never restart. See
601
+ [Clearing a session](/contract/sessions#clearing-a-session).
602
+
603
+ `ifLastIndex` is a non-negative safe integer. The first matching rule decides,
604
+ and `index` is always the head after the call:
605
+
606
+ | When | Result |
607
+ | --- | --- |
608
+ | the latest marker is at `ifLastIndex + 1` | `{ cleared: true, index }`, a retry of that clear |
609
+ | `ifLastIndex` is given and is not the head | `{ cleared: false, index }`, nothing changes |
610
+ | the head is `0` or already a clear marker | `{ cleared: true, index }`, nothing changes |
611
+ | otherwise | `{ cleared: true, index }`, the new marker |
612
+
613
+ - Every event at or below the marker is superseded: never dispatched again,
614
+ never blocking its lane. Its running handler's appends throw
615
+ `SUPERSEDED_ATTEMPT` and its state reads throw `SESSION_CLEARED` rather than
616
+ fold the new lifetime. Exception: after a clear from another instance, a
617
+ read bounded at the handler's own event may return that event's state from
618
+ memory until its `ctx.signal` aborts.
619
+ - Pushes and appends after the marker start the new lifetime.
620
+ - `clear()` then deletes the old events, their bookkeeping and event-id
621
+ records, and cached state below the marker in short batches, resolving when
622
+ they're gone. Other work on the session runs between batches. There is no
623
+ background sweep.
624
+ - If deletion fails, the call rejects with the session already cleared; any
625
+ later clear of the session, including a retry, finishes the deletion.
626
+ - Old event ids can be used again. Presence is not cleared.
627
+ - On `ctx.session`, the clear is fenced like the handler's appends and ends the
628
+ handler's own attempt: `ctx.signal` aborts with `SUPERSEDED_ATTEMPT` and
629
+ returned events are discarded. Its deletion keeps running under the
630
+ platform's `waitUntil`.
631
+ - All built-in stores support clearing.
632
+
633
+ ### `session.schedule.clear(name, options)`
634
+
635
+ ```ts
636
+ session.schedule.clear(
637
+ name: string,
638
+ options: ({ delay: ScheduleDelay } | { at: Date }) & {
639
+ ifLastIndex: number
640
+ },
641
+ ): Promise<void>
642
+ ```
643
+
644
+ Publishes a conditional clear to the configured scheduler and resolves after
645
+ provider acceptance. `name` is non-empty and `ifLastIndex` is required; timing
646
+ works as in `session.schedule`, with the same provider limits. See
647
+ [Scheduling a clear](/contract/sessions#scheduling-a-clear).
648
+
649
+ - The task id derives from the contract, session, name, and condition, plus
650
+ the trigger id and any earlier clear inside a handler. Reuse a name only for
651
+ the same timing.
652
+ - Delivery runs `clear({ ifLastIndex })` and acknowledges after the deletion
653
+ finishes. No event is appended; a stale or duplicate delivery changes
654
+ nothing. Indexes never repeat, so a condition from an older lifetime never
655
+ matches a newer one.
656
+ - `ifLastIndex: ctx.event.index` matches only while the trigger is still the
657
+ head. The handler's own appends and returned events move the head, so a
658
+ handler that writes should use the index of its last `ctx.session.append`.
659
+ Actor handlers always commit state, so they can't use the trigger's index.
660
+ - A delay short enough to deliver while the handler still runs can match
661
+ before its writes land; the clear then ends the handler's attempt and
662
+ discards its returned events.
663
+ - Each delivery attempt resumes the deletion. Vercel Queues redelivers until
664
+ retention ends. QStash retries up to its `retries` budget; a deletion that
665
+ outlasts them leaves the message in QStash's dead-letter queue, the rest of
666
+ the old history still invisible, and any later clear of the session finishes
667
+ it.
668
+
669
+ ### `session.deadLetters()`
670
+
671
+ ```ts
672
+ session.deadLetters(): Promise<DeadLetter[]>
673
+
674
+ type DeadLetter = Event & {
675
+ lane: string | null
676
+ attemptCount: number
677
+ failureCount: number
678
+ lastError: string | null
679
+ failedAt: Date
680
+ }
681
+ ```
682
+
683
+ The session's dead-lettered events, oldest first, each with why it failed. See
684
+ [When an event dead-letters](/server/production#3-when-an-event-dead-letters).
685
+
686
+ - `failureCount` counts the attempts that spent its budget: caught failures,
687
+ and attempts that ended before they finished. `attemptCount` counts every
688
+ claim. `lastError` is the last error a handler threw, stringified, or null
689
+ when every failed attempt ended before it finished.
690
+ - Events at or below the latest clear marker are never listed.
691
+ - Only on `server.session(id)`. `DeadLetter` is exported from
692
+ `experimental-a2/server`.
693
+
694
+ ### `session.redrive(index, options)`
695
+
696
+ ```ts
697
+ session.redrive(
698
+ index: number,
699
+ options: { action: 'retry' | 'skip' },
700
+ ): Promise<{ redriven: boolean }>
701
+ ```
702
+
703
+ Resolves the dead-lettered event at `index` in one atomic store operation.
704
+
705
+ | Action | Effect |
706
+ | --- | --- |
707
+ | `retry` | The handler runs again as a new attempt, with a fresh ten-failure budget. Once it completes, its lane continues. |
708
+ | `skip` | The event is marked processed without running its handler, and the next event in its lane runs. Its effects never happen. |
709
+
710
+ - `index` is a positive safe integer and `action` is required; anything else
711
+ throws a `TypeError` before any I/O.
712
+ - `redriven` is `false`, with nothing written, when the event isn't
713
+ dead-lettered: still pending, already processed, already redriven, missing,
714
+ or at or below the latest clear marker. Calling it again is safe.
715
+ - Afterwards, unfinished work wakes the session as an append does: an inline
716
+ drain, plus a scheduler watchdog when one is configured, joined for up to
717
+ `schedulerArmTimeoutMs`. A failed send doesn't reject the call; it marks the
718
+ `a2.redrive` span and leaves later appends, `server.drain()`, or another
719
+ `redrive` to wake the session.
720
+ - A retried event keeps its `attemptCount` and last failure, so the next run's
721
+ `ctx.attempt` keeps counting. A skipped event keeps `failedAt` and has no
722
+ `processedByAttempt`.
723
+ - Only on `server.session(id)`. `SessionRedriveOptions` and
724
+ `SessionRedriveResult` are exported from `experimental-a2/server`.
725
+
726
+ ### `session.history()`
727
+
728
+ ```ts
729
+ session.history(options?: { gte?: number; lte?: number }): Promise<Event[]>
730
+ ```
731
+
732
+ Raw events, oldest first, without dispatch metadata. See
733
+ [History](/contract/state#history).
734
+
735
+ - `gte` and `lte` are inclusive, non-negative safe-integer indexes; omit one to
736
+ leave that end open. `lte: 0` returns `[]`; `gte > lte` throws.
737
+ - After a clear, history starts after the marker; the marker itself is not
738
+ returned.
739
+
740
+ ### `session.state(reducer, options?)`
741
+
742
+ ```ts
743
+ session.state(
744
+ reducer: Reducer<S>,
745
+ options?: { through?: number | 'latest' },
746
+ ): Promise<{ state: S; index: number; clearIndex?: number }>
747
+ ```
748
+
749
+ The log folded through one committed prefix. `index` is its exact boundary, an
750
+ append-order cursor rather than a handler-settlement marker; hand it to the
751
+ client to resume there. See [State](/contract/state).
752
+
753
+ - Omitting `through` reads the latest committed frontier on a root session,
754
+ and through `ctx.event.index` on `ctx.session`, inclusive and stable across
755
+ concurrent appends and retries. In a handler, use `{ through: 'latest' }`
756
+ only when later committed events are intentionally part of the decision.
757
+ - A numeric `through` is an inclusive, non-negative safe integer;
758
+ `through: 0` returns the reducer's initial state.
759
+ - A latest read and a following append are not atomic. See
760
+ [State](/contract/state#reducers) for joins that converge.
761
+ - Never dispatches handlers or waits for pending work. Snapshot write-back
762
+ uses platform `waitUntil`, and its failures never fail the completed read:
763
+ they go to `a2.snapshot` telemetry or the default console sink, without
764
+ snapshot contents.
765
+ - After a clear, the result includes `clearIndex`, the latest marker, and
766
+ state folds from there; forward the whole result when hydrating a client. A
767
+ positive `through` below `clearIndex` throws `SESSION_CLEARED`; `through: 0`
768
+ returns the seed without `clearIndex`.
769
+ - On `ctx.session`, every state read throws `SESSION_CLEARED` once the trigger
770
+ has been cleared, including `{ through: 'latest' }`. Handler `history()` and
771
+ `stream()` are not fenced.
772
+ - Default and numeric handler reads retain their exact folded checkpoint until
773
+ the trigger completes or dead-letters and no other unfinished handler uses
774
+ it. Handler `{ through: 'latest' }` reads and root reads retain nothing.
775
+ There are no retention options. A numeric read behind the latest snapshot
776
+ can use the greatest eligible retained checkpoint. See
777
+ [Snapshots are a cache](/contract/state#snapshots-are-a-cache).
778
+ - Operational snapshot-read failures keep existing A2 errors and turn others
779
+ into `STORE_UNAVAILABLE` with the original error as `cause`, without falling
780
+ back to individual reads or raw history. Cache misses and `stateSchema`
781
+ rejections rebuild from the log.
782
+
783
+ ### `session.stream(options?)`
784
+
785
+ ```ts
786
+ session.stream(options?: { startAfter?: number }): AsyncIterable<Event | SessionCleared>
787
+ session.stream(options: {
788
+ startAfter?: number
789
+ presence: true
790
+ }): AsyncIterable<Event | SessionCleared | PresencePatch | PresenceSnapshot>
791
+ ```
792
+
793
+ A live feed of the session's events. Server-side only; `server.fetch` exposes
794
+ it over SSE and WebSocket. Subscribing never dispatches handlers.
795
+
796
+ - `startAfter` is a non-negative safe integer; `startAfter: 20` begins with
797
+ event 21.
798
+ - A clear arrives in order as a `SessionCleared` marker (`type: 'a2.cleared'`).
799
+ A stream that opens below the latest marker begins with it. A running stream
800
+ delivers the log in order, so events from before the clear can arrive ahead
801
+ of the marker. Reset when it arrives. Clearing never closes a stream.
802
+ - With `presence: true`, the feed first yields one `PresenceSnapshot`,
803
+ `{ snapshot }`: the current pruned map with each field's `value`, `seen`, and
804
+ `at`. Live `PresencePatch` values, `{ participant, values, seen, at }`, then
805
+ interleave with events; `values` is typed by the contract's presence schemas,
806
+ and `null` marks a cleared field. Over SSE both are named frames, which
807
+ clients that don't know them skip.
808
+ - The return type widens only under the literal `presence: true`. The option
809
+ exists only on sessions of contracts that declare `presence`; elsewhere it is
810
+ a type error, not a runtime throw.
811
+
812
+ ### `session.setPresence(patch)`
813
+
814
+ ```ts
815
+ session.setPresence(patch: {
816
+ participant: string
817
+ values: { [field]: payload | null } // partial; null clears a field
818
+ seen?: number
819
+ at?: number // the sender's LWW stamp, epoch ms; receipt time when absent
820
+ }): Promise<void>
821
+ ```
822
+
823
+ Validates each field against the contract's `presence` schemas, then
824
+ broadcasts. No log row, no dispatch, no scheduler arm; `history()` and
825
+ `state()` never see it. See [Presence](/client/presence).
826
+
827
+ - Last writer wins per participant per field, ordered by `at`, the sender's
828
+ epoch-ms stamp; the browser client stamps its sends automatically. Without
829
+ `at`, receipt time stands in: a stampless caller is a single writer, so
830
+ receipt order is its send order.
831
+ - A bad payload throws `INVALID_PAYLOAD`, an unknown field throws
832
+ `UNKNOWN_PRESENCE_FIELD`, and a non-finite or negative `at` throws a
833
+ `TypeError`.
834
+ - `seen` is the sender's stream frontier, which view code uses to reconcile
835
+ presence with the fold. See
836
+ [Reconciling the planes](/client/presence#reconciling-the-planes-seen).
837
+ - Values expire by backend TTL when a participant goes silent (default 60
838
+ seconds; see [`presence.ttlMs`](#createserveroptions)). Expiry counts on the
839
+ storage clock, so a hostile stamp can only vandalize its own field, which
840
+ still expires on schedule.
841
+ - The patch is the push envelope's `presence` sibling; `server.fetch`'s push
842
+ lane forwards it whole to `session.setPresence(presence)`.
843
+ - `ctx.session.setPresence(...)` is the same operation; `seen` defaults to the
844
+ triggering event's `index`.
845
+
846
+ ### `session.presence()`
847
+
848
+ ```ts
849
+ session.presence(): Promise<{
850
+ [participant: string]: {
851
+ [field: string]: { value: unknown; seen: number; at: Date }
852
+ }
853
+ }>
854
+ ```
855
+
856
+ The current presence map, expired values pruned: a point-in-time read for
857
+ server renders. Live consumers use `stream({ presence: true })`. Each entry's
858
+ `at` is the sender's stamp for when the value was set, the last-writer-wins
859
+ comparator; how long a value
860
+ stays painted is view logic, decided at render time.