experimental-a2 0.4.0 → 0.5.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 (182) hide show
  1. package/CHANGELOG.md +51 -0
  2. package/dist/{ai-B4YhEnfw.d.ts → ai-CFNeCrRl.d.ts} +3 -2
  3. package/dist/ai-CFNeCrRl.d.ts.map +1 -0
  4. package/dist/ai-server.browser.js +2 -0
  5. package/dist/ai-server.browser.js.map +1 -0
  6. package/dist/ai-server.d.ts +4 -3
  7. package/dist/ai-server.d.ts.map +1 -0
  8. package/dist/ai-server.js +4 -2
  9. package/dist/ai-server.js.map +1 -0
  10. package/dist/ai.d.ts +1 -1
  11. package/dist/ai.js +3 -1
  12. package/dist/ai.js.map +1 -0
  13. package/dist/cli-B3VuxoDe.js +2 -0
  14. package/dist/cli-B3VuxoDe.js.map +1 -0
  15. package/dist/cli-bin.js +2 -0
  16. package/dist/cli-bin.js.map +1 -0
  17. package/dist/cli.d.ts +2 -1
  18. package/dist/cli.d.ts.map +1 -0
  19. package/dist/{client-Bt4tAKi9.js → client-BKlyLiOU.js} +295 -85
  20. package/dist/client-BKlyLiOU.js.map +1 -0
  21. package/dist/{client-BrfDXQ8A.d.ts → client-D7mvIXrF.d.ts} +40 -4
  22. package/dist/client-D7mvIXrF.d.ts.map +1 -0
  23. package/dist/client.d.ts +2 -2
  24. package/dist/client.js +1 -1
  25. package/dist/contract-48bUMgcL.js +2 -0
  26. package/dist/contract-48bUMgcL.js.map +1 -0
  27. package/dist/contract-jIfaR085.d.ts +2 -1
  28. package/dist/contract-jIfaR085.d.ts.map +1 -0
  29. package/dist/devtools-J_jZ2vQf.d.ts +2 -1
  30. package/dist/devtools-J_jZ2vQf.d.ts.map +1 -0
  31. package/dist/devtools-kJJaORn-.js +2 -0
  32. package/dist/devtools-kJJaORn-.js.map +1 -0
  33. package/dist/devtools-server.browser.js +2 -0
  34. package/dist/devtools-server.browser.js.map +1 -0
  35. package/dist/devtools-server.d.ts +2 -1
  36. package/dist/devtools-server.d.ts.map +1 -0
  37. package/dist/devtools-server.js +2 -0
  38. package/dist/devtools-server.js.map +1 -0
  39. package/dist/errors-BQuJpe82.js +2 -0
  40. package/dist/errors-BQuJpe82.js.map +1 -0
  41. package/dist/errors-W6nwJ-fm.d.ts +2 -1
  42. package/dist/errors-W6nwJ-fm.d.ts.map +1 -0
  43. package/dist/http.d.ts +121 -72
  44. package/dist/http.d.ts.map +1 -0
  45. package/dist/http.js +503 -178
  46. package/dist/http.js.map +1 -0
  47. package/dist/idempotent-replay-DuqEkYA7.js +2 -0
  48. package/dist/idempotent-replay-DuqEkYA7.js.map +1 -0
  49. package/dist/index.d.ts +1 -1
  50. package/dist/inspection-DaxB5jM2.js +2 -0
  51. package/dist/inspection-DaxB5jM2.js.map +1 -0
  52. package/dist/{internal-aEotMzu_.js → internal-DstsI6Re.js} +3 -1
  53. package/dist/internal-DstsI6Re.js.map +1 -0
  54. package/dist/otel.d.ts +2 -1
  55. package/dist/otel.d.ts.map +1 -0
  56. package/dist/otel.js +2 -0
  57. package/dist/otel.js.map +1 -0
  58. package/dist/platform-B4TnJtWu.js +2 -0
  59. package/dist/platform-B4TnJtWu.js.map +1 -0
  60. package/dist/react.d.ts +12 -3
  61. package/dist/react.d.ts.map +1 -0
  62. package/dist/react.js +5 -1
  63. package/dist/react.js.map +1 -0
  64. package/dist/retryable-lazy-DZWmHpii.js +2 -0
  65. package/dist/retryable-lazy-DZWmHpii.js.map +1 -0
  66. package/dist/scheduler-qstash.d.ts +4 -3
  67. package/dist/scheduler-qstash.d.ts.map +1 -0
  68. package/dist/scheduler-qstash.js +4 -2
  69. package/dist/scheduler-qstash.js.map +1 -0
  70. package/dist/scheduler-task-BpzhPnRS.js +2 -0
  71. package/dist/scheduler-task-BpzhPnRS.js.map +1 -0
  72. package/dist/scheduler-vercel.d.ts +4 -3
  73. package/dist/scheduler-vercel.d.ts.map +1 -0
  74. package/dist/scheduler-vercel.js +4 -2
  75. package/dist/scheduler-vercel.js.map +1 -0
  76. package/dist/{server-CcNnFnoW.js → server-C72KOw51.js} +100 -48
  77. package/dist/server-C72KOw51.js.map +1 -0
  78. package/dist/{server-YtPq7hjw.d.ts → server-DUF9pjsx.d.ts} +5 -4
  79. package/dist/server-DUF9pjsx.d.ts.map +1 -0
  80. package/dist/server.browser.js +2 -0
  81. package/dist/server.browser.js.map +1 -0
  82. package/dist/server.d.ts +2 -2
  83. package/dist/server.js +1 -1
  84. package/dist/{store-C3sNAaBT.d.ts → store-DysUkTH3.d.ts} +10 -1
  85. package/dist/store-DysUkTH3.d.ts.map +1 -0
  86. package/dist/store-N8PXxDAS.js +2 -0
  87. package/dist/store-N8PXxDAS.js.map +1 -0
  88. package/dist/store-codec-DTG0Ftek.js +2 -0
  89. package/dist/store-codec-DTG0Ftek.js.map +1 -0
  90. package/dist/store-memory.d.ts +3 -2
  91. package/dist/store-memory.d.ts.map +1 -0
  92. package/dist/store-memory.js +19 -11
  93. package/dist/store-memory.js.map +1 -0
  94. package/dist/{store-polling-DgrrAE3d.js → store-polling-dSeLxzfb.js} +3 -1
  95. package/dist/store-polling-dSeLxzfb.js.map +1 -0
  96. package/dist/store-postgres.d.ts +3 -2
  97. package/dist/store-postgres.d.ts.map +1 -0
  98. package/dist/store-postgres.js +57 -1
  99. package/dist/store-postgres.js.map +1 -0
  100. package/dist/{store-redis-core-DWqx3F47.js → store-redis-core-BFLwz0Wj.js} +3 -1
  101. package/dist/store-redis-core-BFLwz0Wj.js.map +1 -0
  102. package/dist/store-redis-http.d.ts +3 -2
  103. package/dist/store-redis-http.d.ts.map +1 -0
  104. package/dist/store-redis-http.js +4 -2
  105. package/dist/store-redis-http.js.map +1 -0
  106. package/dist/store-redis.d.ts +3 -2
  107. package/dist/store-redis.d.ts.map +1 -0
  108. package/dist/store-redis.js +5 -3
  109. package/dist/store-redis.js.map +1 -0
  110. package/dist/store-sqlite.d.ts +3 -2
  111. package/dist/store-sqlite.d.ts.map +1 -0
  112. package/dist/store-sqlite.js +3 -1
  113. package/dist/store-sqlite.js.map +1 -0
  114. package/dist/telemetry-BjYHTfh2.d.ts +2 -1
  115. package/dist/telemetry-BjYHTfh2.d.ts.map +1 -0
  116. package/dist/testing.browser.js +2 -0
  117. package/dist/testing.browser.js.map +1 -0
  118. package/dist/testing.d.ts +2 -1
  119. package/dist/testing.d.ts.map +1 -0
  120. package/dist/testing.js +2 -0
  121. package/dist/testing.js.map +1 -0
  122. package/dist/validate-XKT4FSNn.js +2 -0
  123. package/dist/validate-XKT4FSNn.js.map +1 -0
  124. package/dist/{wire-DCUZBUlT.js → wire-BFQmSJ-9.js} +77 -15
  125. package/dist/wire-BFQmSJ-9.js.map +1 -0
  126. package/docs/guides/03-react.mdx +59 -39
  127. package/docs/guides/06-ai-agents.mdx +5 -27
  128. package/docs/guides/09-presence.mdx +19 -40
  129. package/docs/guides/10-transports.mdx +49 -40
  130. package/docs/reference/01-api.mdx +107 -26
  131. package/docs/reference/02-errors.mdx +4 -2
  132. package/package.json +2 -1
  133. package/src/ai-coordinator.ts +358 -0
  134. package/src/ai-projector.ts +524 -0
  135. package/src/ai-sdk-step.ts +261 -0
  136. package/src/ai-server.browser.ts +5 -0
  137. package/src/ai-server.ts +1719 -0
  138. package/src/ai.ts +2155 -0
  139. package/src/cache-indexeddb.ts +10 -0
  140. package/src/cli-bin.ts +5 -0
  141. package/src/cli.ts +1046 -0
  142. package/src/client.ts +1826 -0
  143. package/src/contract.ts +206 -0
  144. package/src/deterministic-id.ts +72 -0
  145. package/src/devtools-app.ts +989 -0
  146. package/src/devtools-server.browser.ts +5 -0
  147. package/src/devtools-server.ts +604 -0
  148. package/src/devtools.ts +716 -0
  149. package/src/errors.ts +50 -0
  150. package/src/http.ts +394 -0
  151. package/src/idempotent-replay.ts +53 -0
  152. package/src/index.ts +37 -0
  153. package/src/inspection.ts +39 -0
  154. package/src/internal.ts +426 -0
  155. package/src/otel.ts +59 -0
  156. package/src/platform.ts +60 -0
  157. package/src/push-envelope.ts +137 -0
  158. package/src/react.ts +284 -0
  159. package/src/reducer.ts +108 -0
  160. package/src/retryable-lazy.ts +27 -0
  161. package/src/scheduler-qstash.ts +915 -0
  162. package/src/scheduler-task.ts +106 -0
  163. package/src/scheduler-vercel.ts +437 -0
  164. package/src/server.browser.ts +12 -0
  165. package/src/server.ts +2700 -0
  166. package/src/session-socket.ts +548 -0
  167. package/src/sse.ts +141 -0
  168. package/src/standard-schema.ts +77 -0
  169. package/src/store-codec.ts +10 -0
  170. package/src/store-memory.ts +788 -0
  171. package/src/store-polling.ts +102 -0
  172. package/src/store-postgres.ts +1212 -0
  173. package/src/store-redis-core.ts +1494 -0
  174. package/src/store-redis-http.ts +116 -0
  175. package/src/store-redis.ts +458 -0
  176. package/src/store-sqlite.ts +1108 -0
  177. package/src/store.ts +385 -0
  178. package/src/telemetry.ts +47 -0
  179. package/src/testing.browser.ts +5 -0
  180. package/src/testing.ts +185 -0
  181. package/src/validate.ts +39 -0
  182. package/src/wire.ts +454 -0
package/src/server.ts ADDED
@@ -0,0 +1,2700 @@
1
+ // oxlint-disable no-await-in-loop -- scheduler advances after durable outcomes
2
+ /**
3
+ * experimental-a2/server — where a contract is implemented. `createServer({
4
+ * contract, store, handlers, ... })` binds the vocabulary to storage and
5
+ * reactions; sessions, drains, claims, and scheduler all live here.
6
+ * Server-only by construction: this entry point is the only one that
7
+ * can reach a store backend, and its exports map fails loudly in browser
8
+ * bundles.
9
+ */
10
+
11
+ import type {
12
+ AppendInput,
13
+ Contract,
14
+ ContractEvent,
15
+ EventDefs,
16
+ PresenceDefs,
17
+ PresenceMap,
18
+ PresencePatch,
19
+ PresenceSnapshot,
20
+ WithPresence,
21
+ } from './contract.ts'
22
+ import { A2Error, asStoreUnavailable } from './errors.ts'
23
+ import {
24
+ DRAIN_TIMINGS,
25
+ POLL_TIMINGS,
26
+ MAX_DATE_MS,
27
+ RESERVED_PARTICIPANT_IDS,
28
+ markSchedulerSendFailure,
29
+ nullProtoRecord,
30
+ serverInternals,
31
+ serverSchedulerBindings,
32
+ type DrainOutcome,
33
+ type DrainResult,
34
+ } from './internal.ts'
35
+ import { InspectionUnsupportedError, serverInspection } from './inspection.ts'
36
+ import { defaultSleep } from './store-polling.ts'
37
+ import type {
38
+ A2Store,
39
+ AppendEvent,
40
+ Event,
41
+ EventCause,
42
+ StoreClaimAvailableResult,
43
+ StoreStateRead,
44
+ PresenceRow,
45
+ ReturnedEvent,
46
+ StoredEvent,
47
+ } from './store.ts'
48
+ import type { Reducer } from './reducer.ts'
49
+ import { validateSync } from './validate.ts'
50
+ import {
51
+ deterministicEventId,
52
+ deterministicReturnedEventId,
53
+ deterministicScheduledEventId,
54
+ deterministicScheduleId,
55
+ } from './deterministic-id.ts'
56
+ import { invocationDeadlineMs, platformWaitUntil } from './platform.ts'
57
+ import { retryableLazy } from './retryable-lazy.ts'
58
+ import type {
59
+ ScheduledEvent,
60
+ SchedulerAppendTask,
61
+ SchedulerTask,
62
+ } from './scheduler-task.ts'
63
+ import {
64
+ NOOP_TELEMETRY,
65
+ type A2SpanHandle,
66
+ type A2Telemetry,
67
+ } from './telemetry.ts'
68
+
69
+ /**
70
+ * Events that arrived over the wire through `parsePushBody` — already
71
+ * envelope-validated, headed for schema validation inside `append`.
72
+ * The brand lets the documented push route hand them straight to
73
+ * `session.append` without weakening typed appends for app code: a
74
+ * hand-written `{ type: string }` literal still fails to compile.
75
+ */
76
+ export type PushedEvent = {
77
+ type: string
78
+ payload: unknown
79
+ id?: string
80
+ readonly '~a2.pushed': true
81
+ }
82
+
83
+ /**
84
+ * A presence patch that arrived over the wire through `parsePushBody`
85
+ * — same provenance brand as `PushedEvent`, so the documented route
86
+ * hands it whole to `session.setPresence` while a hand-written
87
+ * untyped patch still fails to compile. Field validation happens
88
+ * inside `setPresence`.
89
+ */
90
+ export type PushedPresence = {
91
+ participant: string
92
+ values: Record<string, unknown>
93
+ seen?: number
94
+ /** The sender's LWW stamp in epoch ms; receipt time when absent. */
95
+ at?: number
96
+ readonly '~a2.pushed': true
97
+ }
98
+
99
+ export type PushValidationContext = {
100
+ sessionId: string
101
+ events: readonly PushedEvent[]
102
+ /** The whole pushed patch — present exactly on the presence-plane
103
+ * invocation, so the callback authorizes the participant id and can
104
+ * apply size or cardinality policy. */
105
+ presence?: PushedPresence
106
+ }
107
+
108
+ /** What every handler receives. */
109
+ export type HandlerContext<
110
+ D extends EventDefs,
111
+ K extends keyof D & string = keyof D & string,
112
+ P extends PresenceDefs = Record<never, never>,
113
+ > = {
114
+ /** The triggering event. */
115
+ event: ContractEvent<D, K>
116
+ /** Durable, 1-based dispatch ordinal for this event. */
117
+ attempt: number
118
+ /** This session, with handler-scoped idempotent append. */
119
+ session: Session<D, HandlerAppend<D>, P>
120
+ /** Fires on `abortOn` events (cancellation slice); dormant otherwise. */
121
+ signal: AbortSignal
122
+ }
123
+
124
+ export type Handler<
125
+ D extends EventDefs,
126
+ K extends keyof D & string = keyof D & string,
127
+ P extends PresenceDefs = Record<never, never>,
128
+ > = (
129
+ ctx: HandlerContext<D, K, P>,
130
+ ) => Promise<void | AppendInput<D> | readonly AppendInput<D>[]>
131
+
132
+ export type LaneContext<
133
+ D extends EventDefs,
134
+ K extends keyof D & string = keyof D & string,
135
+ > = {
136
+ sessionId: string
137
+ event: Pick<ContractEvent<D, K>, 'type' | 'payload'> & { id?: string }
138
+ }
139
+
140
+ export type Lane<
141
+ D extends EventDefs,
142
+ K extends keyof D & string = keyof D & string,
143
+ > = string | ((context: LaneContext<D, K>) => string)
144
+
145
+ export type SessionDispatch<D extends EventDefs> = {
146
+ (...events: AppendInput<D>[]): Promise<ContractEvent<D>[]>
147
+ /** The push-route path: events from `parsePushBody`. */
148
+ (...events: PushedEvent[]): Promise<ContractEvent<D>[]>
149
+ }
150
+
151
+ export type SessionAppend<D extends EventDefs> = SessionDispatch<D> & {
152
+ /** Commit, then hand pending work directly to configured scheduler. */
153
+ dispatch: SessionDispatch<D>
154
+ }
155
+
156
+ export type HandlerAppend<D extends EventDefs> = (
157
+ name: string,
158
+ ...events: AppendInput<D>[]
159
+ ) => Promise<ContractEvent<D>[]>
160
+
161
+ export type ScheduleDelay = `${number}${'ms' | 's' | 'm' | 'h' | 'd'}`
162
+
163
+ export type ScheduleTiming =
164
+ { delay: ScheduleDelay; at?: never } | { at: Date; delay?: never }
165
+
166
+ export type SessionSchedule<D extends EventDefs> = (
167
+ name: string,
168
+ timing: ScheduleTiming,
169
+ ...events: AppendInput<D>[]
170
+ ) => Promise<void>
171
+
172
+ /**
173
+ * The presence members of a session — intersected in via
174
+ * `WithPresence`, so they exist exactly when the contract declares
175
+ * presence fields. Intersected before the base members so the widened
176
+ * `stream` overload is tried first: the literal `presence: true`
177
+ * selects it, everything else falls through to the events-only base.
178
+ */
179
+ export type SessionPresence<D extends EventDefs, P extends PresenceDefs> = {
180
+ /**
181
+ * The two-plane feed: one snapshot of the current pruned map first
182
+ * (per-field stamps exact), then presence patches interleaved with
183
+ * events.
184
+ */
185
+ stream(opts: {
186
+ startAfter?: number
187
+ presence: true
188
+ }): AsyncIterable<ContractEvent<D> | PresencePatch<P> | PresenceSnapshot<P>>
189
+ /**
190
+ * Validate against the contract's presence schemas, then broadcast.
191
+ * Never an append: no log row, no dispatch, no scheduler arm. `at`
192
+ * is the sender's LWW stamp in epoch ms; omitted, receipt time
193
+ * stands in (single writer, so receipt order is sender order).
194
+ */
195
+ setPresence(
196
+ patch:
197
+ | {
198
+ participant: string
199
+ values: PresencePatch<P>['values']
200
+ seen?: number
201
+ at?: number
202
+ }
203
+ | PushedPresence,
204
+ ): Promise<void>
205
+ /** The current map, expired values pruned — a point-in-time read. */
206
+ presence(): Promise<PresenceMap<P>>
207
+ }
208
+
209
+ /** A handle on one instance of the machine. Creating it does no I/O. */
210
+ export type Session<
211
+ D extends EventDefs,
212
+ Append = SessionAppend<D>,
213
+ P extends PresenceDefs = Record<never, never>,
214
+ > = WithPresence<P, SessionPresence<D, P>> & {
215
+ readonly id: string
216
+ append: Append
217
+ schedule: SessionSchedule<D>
218
+ history(options?: { gte?: number; lte?: number }): Promise<ContractEvent<D>[]>
219
+ state<S>(reducer: Reducer<D, S>): Promise<{ state: S; index: number }>
220
+ /**
221
+ * A live feed of this session's events, starting after `startAfter`
222
+ * (exclusive). Server-side only — `handle` from experimental-a2/http
223
+ * exposes it over SSE as the route's stream lane.
224
+ */
225
+ stream(opts?: { startAfter?: number }): AsyncIterable<ContractEvent<D>>
226
+ }
227
+
228
+ /**
229
+ * The public server shape scheduler handlers accept. It stays structural so
230
+ * servers of any contract mix in one route; A2's private server internals add
231
+ * delayed-append delivery without exposing an untyped append method here.
232
+ */
233
+ export type DrainableServer = {
234
+ readonly contract: { readonly name: string }
235
+ drain(sessionId: string): Promise<{ settled: boolean }>
236
+ }
237
+
238
+ /**
239
+ * The scheduler seam (a2-implementation.md §7, §9). `schedule` puts a
240
+ * versioned drain or delayed append task on durable infrastructure;
241
+ * `handler` returns the route the transport delivers to. Claim holders
242
+ * move the watchdog alongside their renewable execution window.
243
+ * Implementations ship as
244
+ * entry points (`experimental-a2/scheduler-vercel`); core never imports a
245
+ * transport.
246
+ */
247
+ export type A2Scheduler = {
248
+ schedule(task: SchedulerTask): Promise<void>
249
+ handler(...servers: DrainableServer[]): (req: Request) => Promise<Response>
250
+ }
251
+
252
+ export type A2Server<
253
+ D extends EventDefs,
254
+ P extends PresenceDefs = Record<never, never>,
255
+ > = {
256
+ /** The contract this server implements. */
257
+ readonly contract: Contract<D, P>
258
+ session(id: string): Session<D, SessionAppend<D>, P>
259
+ /**
260
+ * Process every currently eligible event. `settled` means nothing
261
+ * actionable remains, including work blocked behind a dead letter.
262
+ */
263
+ drain(sessionId: string): Promise<{ settled: boolean }>
264
+ }
265
+
266
+ /**
267
+ * Deliver one authenticated scheduler append through an A2 server's ordinary
268
+ * top-level append path. Custom scheduler adapters call this after validating
269
+ * their transport envelope; application code normally uses `session.schedule`.
270
+ */
271
+ export async function deliverSchedulerAppend(
272
+ server: DrainableServer,
273
+ task: SchedulerAppendTask,
274
+ ): Promise<void> {
275
+ if (server.contract.name !== task.contract) {
276
+ throw new TypeError(
277
+ `a2 scheduler: append task for contract '${task.contract}' cannot be delivered to '${server.contract.name}'`,
278
+ )
279
+ }
280
+ const internals = serverInternals.get(server)
281
+ if (!internals) {
282
+ throw new TypeError(
283
+ `a2 scheduler: server for contract '${task.contract}' cannot receive scheduled appends`,
284
+ )
285
+ }
286
+ await internals.schedulerAppend(task.sessionId, task.events)
287
+ }
288
+
289
+ /**
290
+ * Which events fire `ctx.signal` while a handler runs — the preemption
291
+ * channel for user cancellation. The array form matches by type; the
292
+ * object form takes per-type predicates for targeted cancellation
293
+ * (`(event, trigger) => event.payload.of === trigger.id`). Handlers
294
+ * without `abortOn` pay nothing. An aborted handler should catch and
295
+ * return normally; throwing means "retry me".
296
+ */
297
+ export type AbortSpec<D extends EventDefs, K extends keyof D & string> =
298
+ | Array<keyof D & string>
299
+ | {
300
+ [T in keyof D & string]?:
301
+ | true
302
+ | ((
303
+ event: ContractEvent<D, T>,
304
+ trigger: ContractEvent<D, K>,
305
+ context: { attempt: number },
306
+ ) => boolean)
307
+ }
308
+
309
+ export type HandlerEntry<
310
+ D extends EventDefs,
311
+ K extends keyof D & string = keyof D & string,
312
+ P extends PresenceDefs = Record<never, never>,
313
+ > =
314
+ | Handler<D, K, P>
315
+ | {
316
+ abortOn?: AbortSpec<D, K>
317
+ /** Session-scoped FIFO key, resolved and persisted when the event lands. */
318
+ lane?: Lane<D, K>
319
+ handler: Handler<D, K, P>
320
+ }
321
+
322
+ export type ServerOptions<
323
+ D extends EventDefs,
324
+ P extends PresenceDefs = Record<never, never>,
325
+ > = {
326
+ /** The contract this server implements (see `a2.contract`). */
327
+ contract: Contract<D, P>
328
+ /** Where events live. Defaults: sqlite in dev, memory in tests, required in prod. */
329
+ store?: A2Store
330
+ /**
331
+ * Queue-backed scheduler — e.g. `vercelQueues()` from
332
+ * `experimental-a2/scheduler-vercel`. Absent means append-driven healing only: a
333
+ * working configuration, but a clockless one. Recommended in
334
+ * production.
335
+ */
336
+ scheduler?: A2Scheduler
337
+ /** Optional instrumentation — e.g. `otel()` from `experimental-a2/otel`. */
338
+ telemetry?: A2Telemetry
339
+ /** Validate events that came through `parsePushBody` before writing them. */
340
+ validatePush?: (context: PushValidationContext) => void | PromiseLike<void>
341
+ /**
342
+ * Presence-plane policy — valid only when the contract declares
343
+ * presence fields (TypeError at construction otherwise). `ttlMs` is
344
+ * how long a value survives without a refreshing set; default 60s.
345
+ */
346
+ presence?: { ttlMs?: number }
347
+ /**
348
+ * The reactions, keyed by event type — all present at construction,
349
+ * so a handler can never be silently missing because its module
350
+ * wasn't imported. Compose across files by spreading objects (note:
351
+ * a duplicate key under spread silently last-wins).
352
+ */
353
+ handlers?: { [K in keyof D & string]?: HandlerEntry<D, K, P> }
354
+ }
355
+
356
+ const MAX_FAILURES = 10
357
+
358
+ /** Default: how long a presence value survives without a refreshing set. */
359
+ const PRESENCE_TTL_MS = 60_000
360
+
361
+ type ClaimWindow = {
362
+ ttlMs: number
363
+ expiresAtMs: number
364
+ watchdogAtMs: number
365
+ deadlineCapped: boolean
366
+ }
367
+
368
+ type DrainSignal = {
369
+ version: number
370
+ closed: boolean
371
+ notify(): void
372
+ wait(version: number): { promise: Promise<void>; cancel(): void }
373
+ }
374
+
375
+ type AbortSubscription = {
376
+ controller: AbortController
377
+ triggerIndex: number
378
+ types: Set<string>
379
+ matches(event: Event): boolean
380
+ }
381
+
382
+ type AbortHub = {
383
+ subscriptions: Set<AbortSubscription>
384
+ seen: Map<number, Event>
385
+ startAfter: number
386
+ ready: Promise<void>
387
+ watch: Promise<void> | null
388
+ error: unknown
389
+ iterator: AsyncIterator<Event> | null
390
+ stopped: boolean
391
+ }
392
+
393
+ function createDrainSignal(): DrainSignal {
394
+ const listeners = new Set<() => void>()
395
+ return {
396
+ version: 0,
397
+ closed: false,
398
+ notify() {
399
+ this.version += 1
400
+ for (const listener of listeners) listener()
401
+ listeners.clear()
402
+ },
403
+ wait(version) {
404
+ if (this.version !== version) {
405
+ return { promise: Promise.resolve(), cancel: () => {} }
406
+ }
407
+ let resolve!: () => void
408
+ const promise = new Promise<void>((done) => {
409
+ resolve = done
410
+ })
411
+ listeners.add(resolve)
412
+ return { promise, cancel: () => listeners.delete(resolve) }
413
+ },
414
+ }
415
+ }
416
+
417
+ function dispatchAbortEvent(hub: AbortHub, event: Event): void {
418
+ let relevant = false
419
+ for (const subscription of hub.subscriptions) {
420
+ if (!subscription.types.has(event.type)) continue
421
+ relevant = true
422
+ if (subscription.matches(event)) subscription.controller.abort()
423
+ }
424
+ if (relevant) hub.seen.set(event.index, event)
425
+ }
426
+
427
+ function pruneAbortHub(hub: AbortHub): void {
428
+ let minimum = Infinity
429
+ const types = new Set<string>()
430
+ for (const subscription of hub.subscriptions) {
431
+ minimum = Math.min(minimum, subscription.triggerIndex)
432
+ for (const type of subscription.types) types.add(type)
433
+ }
434
+ for (const [index, event] of hub.seen) {
435
+ if (index <= minimum || !types.has(event.type)) hub.seen.delete(index)
436
+ }
437
+ }
438
+
439
+ /**
440
+ * Deadlines tighten the claim/watchdog window; they never decide whether a
441
+ * handler starts. A platform timeout therefore follows the same path as any
442
+ * other process death: the in-flight event remains pending and is retried.
443
+ */
444
+ function nextClaimWindow(): ClaimWindow {
445
+ const nowMs = Date.now()
446
+ const deadlineMs = invocationDeadlineMs()
447
+ const deadlineTtlMs =
448
+ deadlineMs === null ? DRAIN_TIMINGS.claimTtlMs : deadlineMs - nowMs
449
+ const deadlineCapped =
450
+ deadlineMs !== null && deadlineTtlMs <= DRAIN_TIMINGS.claimTtlMs
451
+ const ttlMs = Math.max(1, Math.min(DRAIN_TIMINGS.claimTtlMs, deadlineTtlMs))
452
+ const expiresAtMs = nowMs + ttlMs
453
+ return {
454
+ ttlMs,
455
+ expiresAtMs,
456
+ watchdogAtMs: expiresAtMs + DRAIN_TIMINGS.watchdogGraceMs,
457
+ deadlineCapped,
458
+ }
459
+ }
460
+
461
+ function schedulerSlot(dueAt: number): number {
462
+ return Math.ceil(dueAt / 1_000) * 1_000
463
+ }
464
+
465
+ function currentSchedulerSlot(now: number): number {
466
+ return Math.floor(now / 1_000) * 1_000
467
+ }
468
+
469
+ /**
470
+ * The namespace separator between machine name and session id in
471
+ * storage. Machines sharing one store backend (the dev-default sqlite
472
+ * file, a shared Postgres) must not collide on session ids; the machine
473
+ * name is what makes multi-machine apps unambiguous (a2-api.md §1), so
474
+ * it prefixes every storage key.
475
+ */
476
+ const NS = '\u001f'
477
+
478
+ const devDefaultStore = retryableLazy(() =>
479
+ import('./store-sqlite.ts').then((m) => m.sqlite()),
480
+ )
481
+
482
+ function environment(): 'development' | 'test' | 'production' {
483
+ const env =
484
+ typeof process === 'undefined' ? undefined : process.env?.['NODE_ENV']
485
+ if (env === 'test') return 'test'
486
+ if (env === 'production') return 'production'
487
+ return 'development'
488
+ }
489
+
490
+ function describeError(err: unknown): string {
491
+ if (err instanceof Error) return err.stack ?? `${err.name}: ${err.message}`
492
+ return String(err)
493
+ }
494
+
495
+ /**
496
+ * Comparison key for a presence value in the degraded-tier diff — an
497
+ * LWW tie can replace a value without moving its `seen`/`at` stamp.
498
+ */
499
+ function fingerprint(value: unknown): string {
500
+ return JSON.stringify(value) ?? ''
501
+ }
502
+
503
+ function foldPresenceRows(
504
+ rows: PresenceRow[],
505
+ ): Record<string, Record<string, { value: unknown; seen: number; at: Date }>> {
506
+ // Null-prototype at both levels: participants and fields are caller
507
+ // strings, and a '__proto__' key on a normal object rewrites its
508
+ // prototype instead of setting an own property.
509
+ const map =
510
+ nullProtoRecord<
511
+ Record<string, Record<string, { value: unknown; seen: number; at: Date }>>
512
+ >()
513
+ for (const row of rows) {
514
+ ;(map[row.participant] ??= nullProtoRecord())[row.field] = {
515
+ value: row.value,
516
+ seen: row.seen,
517
+ at: row.at,
518
+ }
519
+ }
520
+ return map
521
+ }
522
+
523
+ /** Implement a contract: bind its vocabulary to storage and reactions. */
524
+ export function createServer<
525
+ D extends EventDefs,
526
+ P extends PresenceDefs = Record<never, never>,
527
+ >(options: ServerOptions<D, P>): A2Server<D, P> {
528
+ const serverContract = options?.contract
529
+ if (
530
+ serverContract === null ||
531
+ typeof serverContract !== 'object' ||
532
+ typeof serverContract.name !== 'string' ||
533
+ serverContract.events === null
534
+ ) {
535
+ throw new TypeError(
536
+ 'createServer expects options with a contract (see a2.contract)',
537
+ )
538
+ }
539
+ const name = serverContract.name
540
+ const defs = serverContract.events
541
+ const presenceDefs: Readonly<PresenceDefs> = serverContract.presence
542
+ const declaresPresence = Object.keys(presenceDefs).length > 0
543
+ const presenceNotSupported = (): A2Error =>
544
+ new A2Error(
545
+ 'PRESENCE_NOT_SUPPORTED',
546
+ `the contract '${name}' declares presence but the store backend for its server has no presence capability (A2Store.presence)`,
547
+ )
548
+
549
+ if (options.presence !== undefined) {
550
+ // An option for a plane the contract doesn't declare is a mistake
551
+ // worth failing loud at construction.
552
+ if (!declaresPresence) {
553
+ throw new TypeError(
554
+ `the presence option requires a contract that declares presence fields — '${name}' has none`,
555
+ )
556
+ }
557
+ const { ttlMs } = options.presence
558
+ if (ttlMs !== undefined && (!Number.isInteger(ttlMs) || ttlMs <= 0)) {
559
+ throw new TypeError(
560
+ 'presence.ttlMs must be a positive integer of milliseconds',
561
+ )
562
+ }
563
+ }
564
+ const presenceTtlMs = options.presence?.ttlMs ?? PRESENCE_TTL_MS
565
+
566
+ // Resolve where events live. Explicit store wins; otherwise the
567
+ // environment decides — and production refuses to guess
568
+ // (a2-implementation.md §8): a failed boot beats events written to an
569
+ // ephemeral filesystem.
570
+ let makeStore: () => Promise<A2Store>
571
+ if (options.store) {
572
+ const explicit = options.store
573
+ // Fail at boot, not first use (a2-api.md §12).
574
+ if (declaresPresence && !explicit.presence) throw presenceNotSupported()
575
+ makeStore = () => Promise.resolve(explicit)
576
+ } else {
577
+ const env = environment()
578
+ if (env === 'production') {
579
+ const error = (): A2Error =>
580
+ new A2Error(
581
+ 'STORE_NOT_CONFIGURED',
582
+ `the server for '${name}' has no store configured and NODE_ENV is 'production' — pass an explicit store backend (e.g. postgres from 'experimental-a2/store-postgres')`,
583
+ )
584
+ // `next build` evaluates route modules with NODE_ENV=production
585
+ // to collect page data — in a CI without the runtime env vars, a
586
+ // construction-time throw fails the build for nothing (the store is
587
+ // never used during collection). During that phase only, defer
588
+ // the failure to first use; a real production boot still fails at
589
+ // construction, before any event can land somewhere ephemeral.
590
+ if (process.env['NEXT_PHASE'] !== 'phase-production-build') {
591
+ throw error()
592
+ }
593
+ makeStore = () => Promise.reject(error())
594
+ } else {
595
+ makeStore =
596
+ env === 'test'
597
+ ? () => import('./store-memory.ts').then((m) => m.memory())
598
+ : devDefaultStore.get
599
+ }
600
+ }
601
+
602
+ // Environment-resolved stores arrive lazily; assert the capability the
603
+ // moment one resolves — the closest available moment to construction.
604
+ if (declaresPresence && !options.store) {
605
+ const inner = makeStore
606
+ makeStore = async () => {
607
+ const store = await inner()
608
+ if (!store.presence) throw presenceNotSupported()
609
+ return store
610
+ }
611
+ }
612
+
613
+ const resolveStore = retryableLazy(makeStore).get
614
+
615
+ const telemetry = options.telemetry ?? NOOP_TELEMETRY
616
+ const scheduler = options.scheduler
617
+
618
+ type AbortMatcher = Map<
619
+ string,
620
+ | true
621
+ | ((
622
+ event: ContractEvent<D>,
623
+ trigger: ContractEvent<D>,
624
+ context: { attempt: number },
625
+ ) => boolean)
626
+ >
627
+ const handlers = new Map<
628
+ string,
629
+ { handler: Handler<D>; abortOn: AbortMatcher | null; lane: Lane<D> | null }
630
+ >()
631
+ for (const [type, entry] of Object.entries(options.handlers ?? {})) {
632
+ if (entry === undefined) continue
633
+ if (!Object.hasOwn(defs, type)) {
634
+ throw new TypeError(
635
+ `contract '${name}' has no event type '${type}' — cannot register a handler for it`,
636
+ )
637
+ }
638
+ const handler = typeof entry === 'function' ? entry : entry?.handler
639
+ if (typeof handler !== 'function') {
640
+ throw new TypeError(`handler for '${type}' must be a function`)
641
+ }
642
+ let abortOn: AbortMatcher | null = null
643
+ const spec = typeof entry === 'function' ? undefined : entry.abortOn
644
+ if (spec !== undefined) {
645
+ abortOn = new Map()
646
+ const pairs = Array.isArray(spec)
647
+ ? spec.map((abortType) => [abortType, true as const] as const)
648
+ : (Object.entries(spec) as Array<
649
+ [
650
+ string,
651
+ (
652
+ | true
653
+ | ((
654
+ e: never,
655
+ t: never,
656
+ context: { attempt: number },
657
+ ) => boolean)
658
+ ),
659
+ ]
660
+ >)
661
+ for (const [abortType, matcher] of pairs) {
662
+ if (matcher === undefined) continue
663
+ if (!Object.hasOwn(defs, abortType)) {
664
+ throw new TypeError(
665
+ `contract '${name}' has no event type '${String(abortType)}' — cannot abort on it`,
666
+ )
667
+ }
668
+ abortOn.set(String(abortType), matcher as never)
669
+ }
670
+ if (abortOn.size === 0) abortOn = null
671
+ }
672
+ const lane = typeof entry === 'function' ? null : (entry.lane ?? null)
673
+ if (
674
+ lane !== null &&
675
+ typeof lane !== 'function' &&
676
+ (typeof lane !== 'string' || lane.length === 0)
677
+ ) {
678
+ throw new TypeError(
679
+ `lane for '${type}' must be a non-empty string or a function`,
680
+ )
681
+ }
682
+ handlers.set(type, {
683
+ handler: handler as Handler<D>,
684
+ abortOn,
685
+ lane: lane as Lane<D> | null,
686
+ })
687
+ }
688
+
689
+ const prefix = `${name}${NS}`
690
+ const nsId = (sessionId: string): string => `${prefix}${sessionId}`
691
+ const stripNs = (stored: string): string => stored.slice(prefix.length)
692
+
693
+ const toPublic = (row: Event): ContractEvent<D> =>
694
+ ({
695
+ id: row.id,
696
+ type: row.type,
697
+ payload: row.payload,
698
+ index: row.index,
699
+ sessionId: stripNs(row.sessionId),
700
+ createdAt: row.createdAt,
701
+ }) as ContractEvent<D>
702
+
703
+ // ── background work tracking ─────────────────────────────────────
704
+ // Every fire-and-forget operation is tracked so (a) platforms with
705
+ // waitUntil keep the invocation alive and (b) tests can flush
706
+ // deterministically via serverInternals.
707
+ const inFlight = new Set<Promise<unknown>>()
708
+ const track = (p: Promise<unknown>): void => {
709
+ inFlight.add(p)
710
+ const drop = (): void => {
711
+ inFlight.delete(p)
712
+ }
713
+ p.then(drop, drop)
714
+ }
715
+
716
+ type SchedulerArmSlot = {
717
+ promise: Promise<void>
718
+ forget: ReturnType<typeof setTimeout>
719
+ }
720
+ const schedulerArmSlots = new Map<string, SchedulerArmSlot>()
721
+
722
+ /**
723
+ * Start an optional scheduler arm without putting it on the execution path.
724
+ * The returned raw promise lets the initial append report failure; the
725
+ * tracked copy is always rejection-safe for heartbeat callers.
726
+ */
727
+ const startSchedulerArm = (
728
+ sessionId: string,
729
+ dueAt: number,
730
+ ): Promise<void> | null => {
731
+ if (!scheduler) return null
732
+ const slottedDueAt = schedulerSlot(dueAt)
733
+ const slotKey = JSON.stringify([sessionId, slottedDueAt])
734
+ const existing = schedulerArmSlots.get(slotKey)
735
+ if (existing) return existing.promise
736
+ // Normalize even a custom adapter's synchronous throw into a rejected
737
+ // promise. Optional scheduler must never stop inline claiming.
738
+ const raw = Promise.resolve().then(() =>
739
+ scheduler.schedule({
740
+ version: 1,
741
+ kind: 'drain',
742
+ contract: name,
743
+ sessionId,
744
+ dueAt: slottedDueAt,
745
+ }),
746
+ )
747
+ const forget = setTimeout(
748
+ () => {
749
+ if (schedulerArmSlots.get(slotKey)?.promise === raw) {
750
+ schedulerArmSlots.delete(slotKey)
751
+ }
752
+ },
753
+ Math.max(0, slottedDueAt - Date.now()) + 1_000,
754
+ )
755
+ ;(forget as { unref?: () => void }).unref?.()
756
+ schedulerArmSlots.set(slotKey, { promise: raw, forget })
757
+ void raw.then(undefined, () => {
758
+ const current = schedulerArmSlots.get(slotKey)
759
+ if (current?.promise !== raw) return
760
+ clearTimeout(current.forget)
761
+ schedulerArmSlots.delete(slotKey)
762
+ })
763
+ const safe = raw.catch(() => {})
764
+ track(safe)
765
+ platformWaitUntil(safe)
766
+ return raw
767
+ }
768
+
769
+ const scheduledDrains = new Map<
770
+ string,
771
+ { signal: DrainSignal; promise: Promise<void> }
772
+ >()
773
+
774
+ const scheduleDrain = (sessionId: string, watchdogDueAt?: number): void => {
775
+ const existing = scheduledDrains.get(sessionId)
776
+ if (existing && !existing.signal.closed) {
777
+ existing.signal.notify()
778
+ return
779
+ }
780
+ if (existing) scheduledDrains.delete(sessionId)
781
+ const signal = createDrainSignal()
782
+ const p = new Promise<void>((resolve) => {
783
+ queueMicrotask(() => {
784
+ void drainSession(sessionId, watchdogDueAt, signal)
785
+ .then(() => resolve())
786
+ .catch(() => resolve())
787
+ })
788
+ }).finally(() => {
789
+ if (scheduledDrains.get(sessionId)?.promise === p) {
790
+ scheduledDrains.delete(sessionId)
791
+ }
792
+ })
793
+ scheduledDrains.set(sessionId, { signal, promise: p })
794
+ track(p)
795
+ platformWaitUntil(p)
796
+ }
797
+
798
+ // ── append ───────────────────────────────────────────────────────
799
+
800
+ const validateEvents = (
801
+ sessionId: string,
802
+ events: AppendInput<D>[],
803
+ resolveDispatchMetadata = true,
804
+ ): AppendEvent[] => {
805
+ if (events.length === 0) {
806
+ throw new TypeError('append requires at least one event')
807
+ }
808
+ return events.map((e) => {
809
+ const schema = Object.hasOwn(defs, e.type) ? defs[e.type] : undefined
810
+ if (!schema) {
811
+ throw new A2Error(
812
+ 'UNKNOWN_EVENT_TYPE',
813
+ `contract '${name}' has no event type '${String(e.type)}'`,
814
+ )
815
+ }
816
+ const result = validateSync(schema, e.payload, `event '${e.type}'`)
817
+ if (result.issues) {
818
+ throw new A2Error(
819
+ 'INVALID_PAYLOAD',
820
+ `invalid payload for event '${e.type}' on machine '${name}'`,
821
+ { details: result.issues },
822
+ )
823
+ }
824
+ const validated: AppendEvent = { type: e.type, payload: result.value }
825
+ if (e.id !== undefined) validated.id = e.id
826
+ if (!resolveDispatchMetadata) return validated
827
+ const registration = handlers.get(e.type)
828
+ if (!registration) {
829
+ validated.settled = true
830
+ } else if (registration.lane !== null) {
831
+ const lane =
832
+ typeof registration.lane === 'function'
833
+ ? registration.lane({
834
+ sessionId,
835
+ event: {
836
+ type: e.type,
837
+ payload: structuredClone(result.value),
838
+ ...(e.id === undefined ? {} : { id: e.id }),
839
+ } as never,
840
+ })
841
+ : registration.lane
842
+ if (typeof lane !== 'string' || lane.length === 0) {
843
+ throw new TypeError(
844
+ `lane for '${e.type}' must resolve to a non-empty string`,
845
+ )
846
+ }
847
+ validated.lane = lane
848
+ }
849
+ return validated
850
+ })
851
+ }
852
+
853
+ /**
854
+ * Validate + write. `onAppended` runs inside the telemetry span, so
855
+ * the drain it schedules is created in the append's active context —
856
+ * with a real tracer, causal work remains connected.
857
+ */
858
+ const appendCore = (
859
+ sessionId: string,
860
+ events: AppendInput<D>[],
861
+ source: 'external' | 'handler',
862
+ mode: 'inline' | 'dispatch',
863
+ onAppended: (rows: ContractEvent<D>[], watchdogDueAt?: number) => void,
864
+ cause?: EventCause,
865
+ generatedIds?: readonly boolean[],
866
+ requireDurableInitialArm = false,
867
+ ): Promise<ContractEvent<D>[]> =>
868
+ telemetry.span(
869
+ 'a2.append',
870
+ {
871
+ 'a2.contract': name,
872
+ 'a2.session_id': sessionId,
873
+ 'a2.append.source': source,
874
+ 'a2.append.mode': mode,
875
+ 'a2.append.types': events.map((e) => String(e.type)).join(','),
876
+ 'a2.append.count': events.length,
877
+ },
878
+ async (span) => {
879
+ if (mode === 'dispatch' && !scheduler) {
880
+ throw new TypeError(
881
+ 'append.dispatch requires a configured scheduler adapter',
882
+ )
883
+ }
884
+ assertSessionId(sessionId)
885
+ const validated = validateEvents(sessionId, events)
886
+ if (cause) {
887
+ for (const [index, event] of validated.entries()) {
888
+ event.cause = generatedIds?.[index]
889
+ ? { ...cause, batchSize: events.length }
890
+ : cause
891
+ }
892
+ }
893
+ const store = await resolveStore()
894
+ let rows: StoredEvent[]
895
+ let shouldHealSession: boolean
896
+ try {
897
+ const result = await store.append(nsId(sessionId), validated)
898
+ rows = result.events
899
+ shouldHealSession = result.hasPending
900
+ } catch (err) {
901
+ throw asStoreUnavailable(err)
902
+ }
903
+ const appended = rows.map(toPublic)
904
+
905
+ const initialWatchdogAt =
906
+ shouldHealSession &&
907
+ scheduler &&
908
+ source === 'external' &&
909
+ mode === 'inline'
910
+ ? schedulerSlot(nextClaimWindow().watchdogAtMs)
911
+ : undefined
912
+ const initialArm =
913
+ initialWatchdogAt !== undefined
914
+ ? startSchedulerArm(sessionId, initialWatchdogAt)
915
+ : null
916
+ const dispatchArm =
917
+ shouldHealSession && mode === 'dispatch'
918
+ ? startSchedulerArm(sessionId, currentSchedulerSlot(Date.now()))
919
+ : null
920
+ if (shouldHealSession && mode === 'inline') {
921
+ onAppended(appended, initialWatchdogAt)
922
+ }
923
+ if (dispatchArm) {
924
+ await dispatchArm
925
+ }
926
+ if (initialArm) {
927
+ let timeout: ReturnType<typeof setTimeout> | null = null
928
+ try {
929
+ await Promise.race([
930
+ initialArm,
931
+ new Promise<never>((_, reject) => {
932
+ timeout = setTimeout(() => {
933
+ reject(
934
+ new Error(
935
+ `a2 scheduler arm timed out after ${DRAIN_TIMINGS.schedulerArmTimeoutMs}ms`,
936
+ ),
937
+ )
938
+ }, DRAIN_TIMINGS.schedulerArmTimeoutMs)
939
+ }),
940
+ ])
941
+ } catch (err) {
942
+ span.setAttribute('a2.append.armed', false)
943
+ span.recordError(err)
944
+ if (requireDurableInitialArm) throw err
945
+ } finally {
946
+ if (timeout) clearTimeout(timeout)
947
+ }
948
+ }
949
+ return appended
950
+ },
951
+ )
952
+
953
+ const readHistory = async (
954
+ sessionId: string,
955
+ bounds?: { gte?: number; lte?: number },
956
+ ): Promise<StoredEvent[]> => {
957
+ const gte = bounds?.gte
958
+ const lte = bounds?.lte
959
+ assertHistoryIndex('gte', gte)
960
+ assertHistoryIndex('lte', lte)
961
+ if (gte !== undefined && lte !== undefined && gte > lte) {
962
+ throw new RangeError(
963
+ 'history.gte must be less than or equal to history.lte',
964
+ )
965
+ }
966
+ if (lte === 0) return []
967
+
968
+ const store = await resolveStore()
969
+ try {
970
+ const readOptions =
971
+ gte === undefined && lte === undefined
972
+ ? undefined
973
+ : {
974
+ ...(gte === undefined
975
+ ? {}
976
+ : { afterIndex: Math.max(0, gte - 1) }),
977
+ ...(lte === undefined ? {} : { throughIndex: lte }),
978
+ }
979
+ const rows = await store.read(nsId(sessionId), readOptions)
980
+ return rows.filter(
981
+ (row) =>
982
+ (gte === undefined || row.index >= gte) &&
983
+ (lte === undefined || row.index <= lte),
984
+ )
985
+ } catch (err) {
986
+ throw asStoreUnavailable(err)
987
+ }
988
+ }
989
+
990
+ // Snapshot plus tail is one adapter operation. A cache-path failure is
991
+ // not load-bearing: fall back to the raw store, which still reports a
992
+ // real storage failure.
993
+ const readCachedState = async (
994
+ store: A2Store,
995
+ sessionId: string,
996
+ reducerName: string,
997
+ ): Promise<StoreStateRead> => {
998
+ try {
999
+ return await store.readState(nsId(sessionId), reducerName)
1000
+ } catch {
1001
+ try {
1002
+ return { snapshot: null, events: await store.read(nsId(sessionId)) }
1003
+ } catch (err) {
1004
+ throw asStoreUnavailable(err)
1005
+ }
1006
+ }
1007
+ }
1008
+
1009
+ // The cache is untrusted: schema rejection refolds from the raw store.
1010
+ const foldStateRead = async <S>(
1011
+ store: A2Store,
1012
+ sessionId: string,
1013
+ reducer: Reducer<D, S>,
1014
+ stateRead: StoreStateRead,
1015
+ span: A2SpanHandle,
1016
+ ): Promise<{ state: S; index: number; folded: number }> => {
1017
+ let state = cloneInitial(reducer.initialState)
1018
+ let index = 0
1019
+ let snapshotOutcome = 'miss'
1020
+ const snap = stateRead.snapshot
1021
+ let rows = stateRead.events
1022
+ if (snap) {
1023
+ if (reducer.stateSchema) {
1024
+ const result = validateSync(
1025
+ reducer.stateSchema,
1026
+ snap.state,
1027
+ 'the stateSchema',
1028
+ )
1029
+ if (result.issues) {
1030
+ snapshotOutcome = 'rejected'
1031
+ } else {
1032
+ state = result.value as S
1033
+ index = snap.index
1034
+ snapshotOutcome = 'hit'
1035
+ }
1036
+ } else {
1037
+ state = snap.state as S
1038
+ index = snap.index
1039
+ snapshotOutcome = 'hit'
1040
+ }
1041
+ }
1042
+ span.setAttribute('a2.state.snapshot', snapshotOutcome)
1043
+
1044
+ if (snapshotOutcome === 'rejected') {
1045
+ try {
1046
+ rows = await store.read(nsId(sessionId))
1047
+ } catch (err) {
1048
+ throw asStoreUnavailable(err)
1049
+ }
1050
+ }
1051
+ for (const row of rows) {
1052
+ state = reducer.fold(state, toPublic(row) as never)
1053
+ index = row.index
1054
+ }
1055
+ span.setAttribute('a2.state.folded', rows.length)
1056
+ span.setAttribute('a2.state.index', index)
1057
+ return { state, index, folded: rows.length }
1058
+ }
1059
+
1060
+ // ── same-tick state-read coalescing ──────────────────────────────
1061
+ // Concurrent state() calls for one reducer name coalesce into a
1062
+ // single store.readStates() round trip when the adapter has one.
1063
+ // Unlike DataLoader there is deliberately NO result cache: a batch
1064
+ // exists only between enqueue and flush, and nothing is shared after
1065
+ // distribution — every state() call still observes a fresh frontier.
1066
+ type PendingStateRead = {
1067
+ sessionId: string
1068
+ resolve: (read: StoreStateRead | Promise<StoreStateRead>) => void
1069
+ }
1070
+ const pendingStateReads = new Map<string, PendingStateRead[]>()
1071
+
1072
+ // The flush microtask voids this promise, so the function must be
1073
+ // total: never reject, never leave an entry unresolved — even for a
1074
+ // synchronously throwing adapter or a read structuredClone rejects.
1075
+ const flushStateReads = async (
1076
+ store: A2Store,
1077
+ reducerName: string,
1078
+ batch: PendingStateRead[],
1079
+ ): Promise<void> => {
1080
+ let reads: StoreStateRead[] | null
1081
+ try {
1082
+ reads = store.readStates
1083
+ ? await store.readStates(
1084
+ batch.map((entry) => nsId(entry.sessionId)),
1085
+ reducerName,
1086
+ )
1087
+ : null
1088
+ } catch {
1089
+ reads = null
1090
+ }
1091
+ // A failed or misaligned batch is an ill-behaved cache path, never a
1092
+ // batch-shaped error: each caller falls back to its own per-call read
1093
+ // (promise adoption carries a real storage failure to that caller).
1094
+ if (!reads || reads.length !== batch.length) {
1095
+ for (const entry of batch) {
1096
+ entry.resolve(readCachedState(store, entry.sessionId, reducerName))
1097
+ }
1098
+ return
1099
+ }
1100
+ // Duplicate ids share one store read; their folds must not share
1101
+ // payload objects, exactly as two separate state() calls would not.
1102
+ const seen = new Set<string>()
1103
+ for (const [position, entry] of batch.entries()) {
1104
+ const shared = seen.has(entry.sessionId)
1105
+ seen.add(entry.sessionId)
1106
+ try {
1107
+ const read = reads[position]!
1108
+ entry.resolve(shared ? structuredClone(read) : read)
1109
+ } catch {
1110
+ entry.resolve(readCachedState(store, entry.sessionId, reducerName))
1111
+ }
1112
+ }
1113
+ }
1114
+
1115
+ const enqueueStateRead = (
1116
+ store: A2Store,
1117
+ sessionId: string,
1118
+ reducerName: string,
1119
+ ): Promise<StoreStateRead> =>
1120
+ new Promise((resolve) => {
1121
+ let batch = pendingStateReads.get(reducerName)
1122
+ if (!batch) {
1123
+ const opened: PendingStateRead[] = []
1124
+ pendingStateReads.set(reducerName, opened)
1125
+ // One microtask is the whole batching window — deliberately NOT
1126
+ // DataLoader's post-promise-job nextTick, which would defer
1127
+ // handler-internal reads past the entire microtask cascade and
1128
+ // reorder drain interleaving (and has no edge-runtime primitive).
1129
+ // It still coalesces reliably: siblings of one Promise.all
1130
+ // traverse identical awaits (store resolution, span wrapper), so
1131
+ // their enqueues sit at consecutive queue positions — cold or
1132
+ // warm — and this flush, queued by the first of them, runs after
1133
+ // the last. A deeper-staggered caller just opens the next batch.
1134
+ queueMicrotask(() => {
1135
+ pendingStateReads.delete(reducerName)
1136
+ void flushStateReads(store, reducerName, opened)
1137
+ })
1138
+ batch = opened
1139
+ }
1140
+ batch.push({ sessionId, resolve })
1141
+ })
1142
+
1143
+ const readState = async <S>(
1144
+ sessionId: string,
1145
+ reducer: Reducer<D, S>,
1146
+ ): Promise<{ state: S; index: number }> => {
1147
+ const store = await resolveStore()
1148
+ return telemetry.span(
1149
+ 'a2.state',
1150
+ {
1151
+ 'a2.contract': name,
1152
+ 'a2.session_id': sessionId,
1153
+ 'a2.state.reducer': reducer.name,
1154
+ },
1155
+ async (span) => {
1156
+ const stateRead = store.readStates
1157
+ ? await enqueueStateRead(store, sessionId, reducer.name)
1158
+ : await readCachedState(store, sessionId, reducer.name)
1159
+ const { state, index, folded } = await foldStateRead(
1160
+ store,
1161
+ sessionId,
1162
+ reducer,
1163
+ stateRead,
1164
+ span,
1165
+ )
1166
+ // Write-back is a disposable cache. Copy before returning so caller
1167
+ // mutations cannot race the background persistence.
1168
+ if (folded > 0) {
1169
+ const snapshotState = cloneInitial(state)
1170
+ const write = Promise.resolve()
1171
+ .then(() =>
1172
+ store.putSnapshot(
1173
+ nsId(sessionId),
1174
+ reducer.name,
1175
+ index,
1176
+ snapshotState,
1177
+ ),
1178
+ )
1179
+ .catch(() => {})
1180
+ track(write)
1181
+ platformWaitUntil(write)
1182
+ }
1183
+ return { state, index }
1184
+ },
1185
+ )
1186
+ }
1187
+
1188
+ const makeSchedule = (
1189
+ sessionId: string,
1190
+ trigger: Pick<StoredEvent, 'id' | 'createdAt'> | null,
1191
+ ): SessionSchedule<D> => {
1192
+ const triggerEventId = trigger?.id ?? null
1193
+ const triggerCreatedAt = trigger?.createdAt.getTime()
1194
+ return async (scheduleName, timing, ...events) => {
1195
+ if (!scheduler) {
1196
+ throw new TypeError(
1197
+ 'session.schedule requires a configured scheduler adapter',
1198
+ )
1199
+ }
1200
+ assertScheduleName(scheduleName)
1201
+ if (events.length === 0) {
1202
+ throw new TypeError('schedule requires at least one event')
1203
+ }
1204
+ const dueAt = scheduleDueAt(timing, triggerCreatedAt ?? Date.now())
1205
+ const payloads = events.map((event) =>
1206
+ cloneScheduledPayload(event.payload),
1207
+ )
1208
+ const snapshottedEvents = events.map((event, index) => ({
1209
+ type: event.type,
1210
+ payload: payloads[index],
1211
+ ...(event.id === undefined ? {} : { id: event.id }),
1212
+ })) as AppendInput<D>[]
1213
+ const validated = validateEvents(
1214
+ sessionId,
1215
+ structuredClone(snapshottedEvents),
1216
+ false,
1217
+ )
1218
+ const scheduleId = await deterministicScheduleId(
1219
+ name,
1220
+ sessionId,
1221
+ triggerEventId,
1222
+ scheduleName,
1223
+ )
1224
+ const scheduledEvents: ScheduledEvent[] = await Promise.all(
1225
+ validated.map(async (event, item) => ({
1226
+ id:
1227
+ event.id ?? (await deterministicScheduledEventId(scheduleId, item)),
1228
+ type: event.type,
1229
+ payload: payloads[item],
1230
+ })),
1231
+ )
1232
+ if (
1233
+ new Set(scheduledEvents.map((event) => event.id)).size !==
1234
+ scheduledEvents.length
1235
+ ) {
1236
+ throw new A2Error(
1237
+ 'PARTIAL_DUPLICATE_BATCH',
1238
+ 'scheduled batch contains the same event id more than once',
1239
+ )
1240
+ }
1241
+ try {
1242
+ await scheduler.schedule({
1243
+ version: 1,
1244
+ kind: 'append',
1245
+ id: scheduleId,
1246
+ contract: name,
1247
+ sessionId,
1248
+ dueAt,
1249
+ events: scheduledEvents,
1250
+ })
1251
+ } catch (error) {
1252
+ throw markSchedulerSendFailure(error)
1253
+ }
1254
+ }
1255
+ }
1256
+
1257
+ const toSessionEvent = (id: string, event: Event): ContractEvent<D> =>
1258
+ ({
1259
+ id: event.id,
1260
+ type: event.type,
1261
+ payload: event.payload,
1262
+ index: event.index,
1263
+ sessionId: id,
1264
+ createdAt: event.createdAt,
1265
+ }) as ContractEvent<D>
1266
+
1267
+ const streamEvents = (
1268
+ id: string,
1269
+ startAfter: number,
1270
+ ): AsyncIterable<ContractEvent<D>> => {
1271
+ const store = resolveStore()
1272
+ const outer = async function* (): AsyncGenerator<ContractEvent<D>> {
1273
+ const inner = (await store).stream(nsId(id), { startAfter })
1274
+ for await (const event of inner) yield toSessionEvent(id, event)
1275
+ }
1276
+ return outer()
1277
+ }
1278
+
1279
+ // ── presence ──────────────────────────────────────────────
1280
+
1281
+ const requirePresence = (
1282
+ store: A2Store,
1283
+ ): NonNullable<A2Store['presence']> => {
1284
+ if (!store.presence) throw presenceNotSupported()
1285
+ return store.presence
1286
+ }
1287
+
1288
+ const setPresence = async (
1289
+ sessionId: string,
1290
+ patch: {
1291
+ participant: string
1292
+ values: Record<string, unknown>
1293
+ seen?: number
1294
+ at?: number
1295
+ },
1296
+ fallbackSeen: number,
1297
+ ): Promise<void> => {
1298
+ // The push-route provenance seam, mirroring appendExternal: a
1299
+ // branded patch came from the wire, so `validatePush` runs before
1300
+ // field validation and the broadcast — with `events: []`, the
1301
+ // documented presence-only shape.
1302
+ if (Reflect.get(patch, '~a2.pushed') === true) {
1303
+ // Frozen before the callback: this is the authorization seam,
1304
+ // and an authorization hook must not be able to rewrite
1305
+ // authorship or values on its way through (assignment throws
1306
+ // under strict mode, failing the push loudly).
1307
+ Object.freeze(patch.values)
1308
+ await options.validatePush?.({
1309
+ sessionId,
1310
+ events: [],
1311
+ presence: Object.freeze(patch as PushedPresence),
1312
+ })
1313
+ }
1314
+ if (
1315
+ typeof patch.participant !== 'string' ||
1316
+ patch.participant.length === 0
1317
+ ) {
1318
+ throw new TypeError('presence participant must be a non-empty string')
1319
+ }
1320
+ if (RESERVED_PARTICIPANT_IDS.has(patch.participant)) {
1321
+ throw new TypeError(
1322
+ `presence participant must not be '${patch.participant}'`,
1323
+ )
1324
+ }
1325
+ if (
1326
+ patch.at !== undefined &&
1327
+ (typeof patch.at !== 'number' ||
1328
+ !Number.isFinite(patch.at) ||
1329
+ patch.at < 0 ||
1330
+ patch.at > MAX_DATE_MS)
1331
+ ) {
1332
+ throw new TypeError(
1333
+ 'presence at must be non-negative epoch milliseconds within the Date range',
1334
+ )
1335
+ }
1336
+ // Validate the whole patch before the adapter sees any of it — a
1337
+ // bad field means nothing is broadcast.
1338
+ const validated: Record<string, unknown> = nullProtoRecord()
1339
+ for (const [field, value] of Object.entries(patch.values)) {
1340
+ if (value === undefined) continue
1341
+ const schema = Object.hasOwn(presenceDefs, field)
1342
+ ? presenceDefs[field]
1343
+ : undefined
1344
+ if (!schema) {
1345
+ throw new A2Error(
1346
+ 'UNKNOWN_PRESENCE_FIELD',
1347
+ `contract '${name}' has no presence field '${field}'`,
1348
+ )
1349
+ }
1350
+ if (value === null) {
1351
+ validated[field] = null
1352
+ continue
1353
+ }
1354
+ const result = validateSync(schema, value, `presence field '${field}'`)
1355
+ if (result.issues) {
1356
+ throw new A2Error(
1357
+ 'INVALID_PAYLOAD',
1358
+ `invalid value for presence field '${field}' on machine '${name}'`,
1359
+ { details: result.issues },
1360
+ )
1361
+ }
1362
+ validated[field] = result.value
1363
+ }
1364
+ const store = await resolveStore()
1365
+ try {
1366
+ await requirePresence(store).set(
1367
+ nsId(sessionId),
1368
+ patch.participant,
1369
+ validated,
1370
+ {
1371
+ seen: patch.seen ?? fallbackSeen,
1372
+ // The sender's stamp is the LWW order; receipt time is the
1373
+ // fallback for stampless callers (handlers, old wire
1374
+ // clients), where receipt order is sender order — single
1375
+ // writer. Storage anchors expiry on its own clock either way.
1376
+ at: new Date(patch.at ?? Date.now()),
1377
+ ttlMs: presenceTtlMs,
1378
+ },
1379
+ )
1380
+ } catch (err) {
1381
+ throw asStoreUnavailable(err)
1382
+ }
1383
+ }
1384
+
1385
+ const readPresenceMap = async (
1386
+ sessionId: string,
1387
+ ): Promise<PresenceMap<P>> => {
1388
+ const store = await resolveStore()
1389
+ let rows: PresenceRow[]
1390
+ try {
1391
+ rows = await requirePresence(store).read(nsId(sessionId))
1392
+ } catch (err) {
1393
+ throw asStoreUnavailable(err)
1394
+ }
1395
+ return foldPresenceRows(rows) as PresenceMap<P>
1396
+ }
1397
+
1398
+ /**
1399
+ * The two-plane feed. Patches ride `subscribe` when the adapter has
1400
+ * the push tier; without it the map is re-read and diffed whenever
1401
+ * the event feed wakes, and at the poll idle ceiling while it is
1402
+ * silent — degraded means later, not lost while watched.
1403
+ */
1404
+ const streamWithPresence = (
1405
+ id: string,
1406
+ startAfter: number,
1407
+ ): AsyncIterable<ContractEvent<D> | PresencePatch | PresenceSnapshot> => {
1408
+ const ns = nsId(id)
1409
+ const outer = async function* (): AsyncGenerator<
1410
+ ContractEvent<D> | PresencePatch | PresenceSnapshot
1411
+ > {
1412
+ const store = await resolveStore()
1413
+ const api = requirePresence(store)
1414
+
1415
+ const readRows = async (): Promise<PresenceRow[]> => {
1416
+ try {
1417
+ return await api.read(ns)
1418
+ } catch (err) {
1419
+ throw asStoreUnavailable(err)
1420
+ }
1421
+ }
1422
+
1423
+ // What this feed has already painted, per (participant, field) —
1424
+ // the comparison state for re-read diffs.
1425
+ const painted = new Map<
1426
+ string,
1427
+ Map<string, { seen: number; atMs: number; value: string }>
1428
+ >()
1429
+
1430
+ // One patch per (participant, field), never merged — a merged
1431
+ // stamp would let a stale field inherit a fresher sibling's
1432
+ // `seen` and survive the documented frontier reconciliation.
1433
+ const diffRows = (rows: PresenceRow[]): PresencePatch[] => {
1434
+ const patches: PresencePatch[] = []
1435
+ const live = new Map<string, Set<string>>()
1436
+ for (const row of rows) {
1437
+ let liveFields = live.get(row.participant)
1438
+ if (!liveFields) {
1439
+ liveFields = new Set()
1440
+ live.set(row.participant, liveFields)
1441
+ }
1442
+ liveFields.add(row.field)
1443
+ const stamp = {
1444
+ seen: row.seen,
1445
+ atMs: row.at.getTime(),
1446
+ value: fingerprint(row.value),
1447
+ }
1448
+ const prior = painted.get(row.participant)?.get(row.field)
1449
+ if (
1450
+ prior &&
1451
+ prior.seen === stamp.seen &&
1452
+ prior.atMs === stamp.atMs &&
1453
+ prior.value === stamp.value
1454
+ ) {
1455
+ continue
1456
+ }
1457
+ let paintedFields = painted.get(row.participant)
1458
+ if (!paintedFields) {
1459
+ paintedFields = new Map()
1460
+ painted.set(row.participant, paintedFields)
1461
+ }
1462
+ paintedFields.set(row.field, stamp)
1463
+ patches.push({
1464
+ participant: row.participant,
1465
+ values: { [row.field]: row.value },
1466
+ seen: row.seen,
1467
+ at: row.at,
1468
+ })
1469
+ }
1470
+ for (const [participant, fields] of painted) {
1471
+ for (const [field, prior] of fields) {
1472
+ if (live.get(participant)?.has(field)) continue
1473
+ fields.delete(field)
1474
+ patches.push({
1475
+ participant,
1476
+ values: { [field]: null },
1477
+ // A cleared field has no surviving row; its last painted
1478
+ // `seen` and the observation time are the honest stamps.
1479
+ seen: prior.seen,
1480
+ at: new Date(),
1481
+ })
1482
+ }
1483
+ if (fields.size === 0) painted.delete(participant)
1484
+ }
1485
+ return patches
1486
+ }
1487
+
1488
+ const paint = (patch: PresencePatch): void => {
1489
+ let fields = painted.get(patch.participant)
1490
+ for (const [field, value] of Object.entries(patch.values)) {
1491
+ if (value === null || value === undefined) {
1492
+ fields?.delete(field)
1493
+ continue
1494
+ }
1495
+ if (!fields) {
1496
+ fields = new Map()
1497
+ painted.set(patch.participant, fields)
1498
+ }
1499
+ fields.set(field, {
1500
+ seen: patch.seen,
1501
+ atMs: patch.at.getTime(),
1502
+ value: fingerprint(value),
1503
+ })
1504
+ }
1505
+ if (fields?.size === 0) painted.delete(patch.participant)
1506
+ }
1507
+
1508
+ // Arrivals coalesce per (participant, field): latest value wins,
1509
+ // each field keeps its own seen/at stamps (never merged — a stale
1510
+ // field must not inherit a fresher sibling's `seen`), one patch
1511
+ // per (participant, field) on drain. A stalled consumer therefore
1512
+ // holds at most the live presence cardinality, never the arrival
1513
+ // history.
1514
+ const queue = new Map<
1515
+ string,
1516
+ Map<string, { value: unknown; seen: number; at: Date }>
1517
+ >()
1518
+ let wakeQueue: (() => void) | null = null
1519
+ const enqueue = (patch: PresencePatch): void => {
1520
+ for (const [field, value] of Object.entries(patch.values)) {
1521
+ if (value === undefined) continue
1522
+ let fields = queue.get(patch.participant)
1523
+ if (!fields) {
1524
+ fields = new Map()
1525
+ queue.set(patch.participant, fields)
1526
+ }
1527
+ // Per-field LWW in the queue itself: pub/sub delivery order
1528
+ // is not apply order (two instances' PUBLISHes race), and an
1529
+ // unconditional overwrite would let an older patch swallow a
1530
+ // newer queued value that then never reaches the subscriber.
1531
+ // Ties go to the incoming patch, like every other comparator.
1532
+ const queued = fields.get(field)
1533
+ if (queued && queued.at.getTime() > patch.at.getTime()) continue
1534
+ fields.set(field, { value, seen: patch.seen, at: patch.at })
1535
+ }
1536
+ wakeQueue?.()
1537
+ }
1538
+ const dequeue = (): PresencePatch | null => {
1539
+ for (const [participant, fields] of queue) {
1540
+ for (const [field, stamp] of fields) {
1541
+ fields.delete(field)
1542
+ if (fields.size === 0) queue.delete(participant)
1543
+ return {
1544
+ participant,
1545
+ values: { [field]: stamp.value },
1546
+ seen: stamp.seen,
1547
+ at: stamp.at,
1548
+ }
1549
+ }
1550
+ queue.delete(participant)
1551
+ }
1552
+ return null
1553
+ }
1554
+ // Subscribe before the snapshot read: a patch landing between
1555
+ // the two is painted twice, never lost — repaints are idempotent.
1556
+ const unsubscribe = api.subscribe?.(ns, enqueue)
1557
+
1558
+ const events = store.stream(ns, { startAfter })[Symbol.asyncIterator]()
1559
+ let pendingEvent: Promise<IteratorResult<Event>> | null = null
1560
+ try {
1561
+ // Exactly one snapshot before any live item: the pruned map,
1562
+ // per-field stamps exact — a reconnecting client replaces its
1563
+ // foreign entries wholesale from it.
1564
+ const snapshotRows = await readRows()
1565
+ for (const row of snapshotRows) {
1566
+ let fields = painted.get(row.participant)
1567
+ if (!fields) {
1568
+ fields = new Map()
1569
+ painted.set(row.participant, fields)
1570
+ }
1571
+ fields.set(row.field, {
1572
+ seen: row.seen,
1573
+ atMs: row.at.getTime(),
1574
+ value: fingerprint(row.value),
1575
+ })
1576
+ }
1577
+ yield { snapshot: foldPresenceRows(snapshotRows) }
1578
+
1579
+ for (;;) {
1580
+ for (;;) {
1581
+ const patch = dequeue()
1582
+ if (!patch) break
1583
+ paint(patch)
1584
+ yield patch
1585
+ }
1586
+ pendingEvent ??= events.next()
1587
+ let woke: 'event' | 'patch' | 'tick'
1588
+ if (unsubscribe) {
1589
+ const arrival = new Promise<void>((resolve) => {
1590
+ wakeQueue = resolve
1591
+ })
1592
+ woke = await Promise.race([
1593
+ pendingEvent.then(() => 'event' as const),
1594
+ arrival.then(() => 'patch' as const),
1595
+ ])
1596
+ wakeQueue = null
1597
+ if (woke === 'patch') continue
1598
+ } else {
1599
+ const sleeper = defaultSleep(POLL_TIMINGS.idleCeilingMs)
1600
+ woke = await Promise.race([
1601
+ pendingEvent.then(() => 'event' as const),
1602
+ sleeper.promise.then(() => 'tick' as const),
1603
+ ])
1604
+ sleeper.cancel()
1605
+ }
1606
+ if (woke === 'event') {
1607
+ const result = await pendingEvent
1608
+ pendingEvent = null
1609
+ if (result.done) return
1610
+ yield toSessionEvent(id, result.value)
1611
+ }
1612
+ if (!unsubscribe) {
1613
+ for (const patch of diffRows(await readRows())) yield patch
1614
+ }
1615
+ }
1616
+ } finally {
1617
+ wakeQueue = null
1618
+ unsubscribe?.()
1619
+ // The consumer may close while an event read is in flight; its
1620
+ // settlement is no longer anyone's business.
1621
+ pendingEvent?.catch(() => {})
1622
+ await events.return?.()
1623
+ }
1624
+ }
1625
+ return outer()
1626
+ }
1627
+
1628
+ const makeSession = <Append>(
1629
+ id: string,
1630
+ append: Append,
1631
+ trigger: Pick<StoredEvent, 'id' | 'createdAt' | 'index'> | null,
1632
+ ): Session<D, Append, P> =>
1633
+ ({
1634
+ id,
1635
+ append,
1636
+ schedule: makeSchedule(id, trigger),
1637
+ history: async (bounds?: { gte?: number; lte?: number }) =>
1638
+ (await readHistory(id, bounds)).map(toPublic),
1639
+ state: <S>(reducer: Reducer<D, S>) => readState(id, reducer),
1640
+ stream: (opts?: { startAfter?: number; presence?: boolean }) => {
1641
+ const startAfter = opts?.startAfter ?? 0
1642
+ assertStreamIndex(startAfter)
1643
+ return opts?.presence === true
1644
+ ? streamWithPresence(id, startAfter)
1645
+ : streamEvents(id, startAfter)
1646
+ },
1647
+ // Only on presence-declaring contracts — the type surface promises
1648
+ // no runtime-inert members (a2-api.md §12).
1649
+ ...(declaresPresence
1650
+ ? {
1651
+ setPresence: (patch: {
1652
+ participant: string
1653
+ values: Record<string, unknown>
1654
+ seen?: number
1655
+ at?: number
1656
+ }) => setPresence(id, patch, trigger?.index ?? 0),
1657
+ presence: () => readPresenceMap(id),
1658
+ }
1659
+ : {}),
1660
+ }) as Session<D, Append, P>
1661
+
1662
+ // ── drain ────────────────────────────────────────────────────────
1663
+
1664
+ const makeCtx = (
1665
+ trigger: StoredEvent,
1666
+ attempt: number,
1667
+ signal: AbortSignal,
1668
+ ): HandlerContext<D> => {
1669
+ const publicSessionId = stripNs(trigger.sessionId)
1670
+ const append: HandlerAppend<D> = async (appendName, ...events) => {
1671
+ assertAppendName(appendName)
1672
+ const generatedIds = events.map((event) => event.id === undefined)
1673
+ const withIds = await Promise.all(
1674
+ // oxlint-disable-next-line no-map-spread
1675
+ events.map(async (event, item) => {
1676
+ if (event.id !== undefined) return event
1677
+ const id = await deterministicEventId(trigger.id, appendName, item)
1678
+ return { ...event, id }
1679
+ }),
1680
+ )
1681
+ return appendCore(
1682
+ publicSessionId,
1683
+ withIds,
1684
+ 'handler',
1685
+ 'inline',
1686
+ (_rows, watchdogDueAt) => scheduleDrain(publicSessionId, watchdogDueAt),
1687
+ { index: trigger.index, attempt },
1688
+ generatedIds,
1689
+ )
1690
+ }
1691
+ return {
1692
+ event: toPublic(trigger),
1693
+ attempt,
1694
+ // The handler-scoped `setPresence` stamps `seen` with the
1695
+ // triggering event's index by default — the frontier the handler
1696
+ // provably reflects.
1697
+ session: makeSession(publicSessionId, append, trigger),
1698
+ signal,
1699
+ }
1700
+ }
1701
+
1702
+ const abortHubs = new Map<string, AbortHub>()
1703
+
1704
+ const createAbortHub = (
1705
+ store: A2Store,
1706
+ ns: string,
1707
+ startAfter: number,
1708
+ ): AbortHub => {
1709
+ const hub: AbortHub = {
1710
+ subscriptions: new Set(),
1711
+ seen: new Map(),
1712
+ startAfter,
1713
+ ready: Promise.resolve(),
1714
+ watch: null,
1715
+ error: null,
1716
+ iterator: null,
1717
+ stopped: false,
1718
+ }
1719
+ hub.ready = (async () => {
1720
+ const existing = await store.read(ns, { afterIndex: startAfter })
1721
+ for (const event of existing) dispatchAbortEvent(hub, event)
1722
+ const tail = existing.at(-1)?.index ?? startAfter
1723
+ const feed = store.stream(ns, { startAfter: tail })
1724
+ const iterator = feed[Symbol.asyncIterator]()
1725
+ hub.iterator = iterator
1726
+ hub.watch = (async () => {
1727
+ for (;;) {
1728
+ const { value, done } = await iterator.next()
1729
+ if (done || value === undefined || hub.stopped) return
1730
+ dispatchAbortEvent(hub, value)
1731
+ }
1732
+ })().catch((error: unknown) => {
1733
+ hub.error = error
1734
+ })
1735
+ })().catch((error: unknown) => {
1736
+ hub.error = error
1737
+ if (abortHubs.get(ns) === hub) abortHubs.delete(ns)
1738
+ throw error
1739
+ })
1740
+ abortHubs.set(ns, hub)
1741
+ return hub
1742
+ }
1743
+
1744
+ const subscribeAbort = async (
1745
+ store: A2Store,
1746
+ ns: string,
1747
+ trigger: StoredEvent,
1748
+ abortOn: AbortMatcher,
1749
+ attempt: number,
1750
+ ): Promise<{
1751
+ signal: AbortSignal
1752
+ monitor<T>(operation: Promise<T>): Promise<T>
1753
+ close(): Promise<void>
1754
+ }> => {
1755
+ let hub = abortHubs.get(ns)
1756
+ if (!hub) hub = createAbortHub(store, ns, trigger.index)
1757
+ const activeTypes = new Set<string>()
1758
+ for (const active of hub.subscriptions) {
1759
+ for (const type of active.types) activeTypes.add(type)
1760
+ }
1761
+ const publicTrigger = toPublic(trigger)
1762
+ const subscription: AbortSubscription = {
1763
+ controller: new AbortController(),
1764
+ triggerIndex: trigger.index,
1765
+ types: new Set(abortOn.keys()),
1766
+ matches: (event) => {
1767
+ if (event.index <= trigger.index) return false
1768
+ const matcher = abortOn.get(event.type)
1769
+ if (matcher === undefined) return false
1770
+ return matcher === true
1771
+ ? true
1772
+ : matcher(toPublic(event as StoredEvent), publicTrigger, { attempt })
1773
+ },
1774
+ }
1775
+ hub.subscriptions.add(subscription)
1776
+ const needsCatchup =
1777
+ trigger.index < hub.startAfter ||
1778
+ [...subscription.types].some((type) => !activeTypes.has(type))
1779
+ if (needsCatchup) {
1780
+ const earlier = await store.read(ns, { afterIndex: trigger.index })
1781
+ hub.startAfter = Math.min(hub.startAfter, trigger.index)
1782
+ for (const event of earlier) dispatchAbortEvent(hub, event)
1783
+ }
1784
+ for (const event of hub.seen.values()) {
1785
+ if (subscription.matches(event)) subscription.controller.abort()
1786
+ }
1787
+ await hub.ready
1788
+ if (hub.error !== null) throw hub.error
1789
+ return {
1790
+ signal: subscription.controller.signal,
1791
+ monitor: async <T>(operation: Promise<T>): Promise<T> => {
1792
+ const outcome = await Promise.race([
1793
+ operation.then(
1794
+ (value) => ({ type: 'completed' as const, value }),
1795
+ (error: unknown) => ({ type: 'handler-failed' as const, error }),
1796
+ ),
1797
+ hub.watch!.then(() =>
1798
+ hub.error === null
1799
+ ? { type: 'monitor-closed' as const }
1800
+ : { type: 'monitor-failed' as const, error: hub.error },
1801
+ ),
1802
+ ])
1803
+ if (outcome.type === 'completed') return outcome.value
1804
+ if (outcome.type === 'handler-failed') throw outcome.error
1805
+ if (outcome.type === 'monitor-failed') {
1806
+ for (const active of hub.subscriptions) {
1807
+ active.controller.abort(outcome.error)
1808
+ }
1809
+ await operation.catch(() => {})
1810
+ throw outcome.error
1811
+ }
1812
+ await operation.catch(() => {})
1813
+ throw new Error('abort monitor closed while handlers were active')
1814
+ },
1815
+ close: async () => {
1816
+ hub.subscriptions.delete(subscription)
1817
+ if (hub.subscriptions.size > 0) {
1818
+ pruneAbortHub(hub)
1819
+ return
1820
+ }
1821
+ hub.stopped = true
1822
+ abortHubs.delete(ns)
1823
+ await hub.ready
1824
+ await hub.iterator?.return?.()
1825
+ await hub.watch
1826
+ },
1827
+ }
1828
+ }
1829
+
1830
+ const returnedEvents = async (
1831
+ sessionId: string,
1832
+ trigger: StoredEvent,
1833
+ result: void | AppendInput<D> | readonly AppendInput<D>[],
1834
+ ): Promise<ReturnedEvent[]> => {
1835
+ if (result === undefined) return []
1836
+ const events = Array.isArray(result) ? [...result] : [result]
1837
+ if (events.length === 0) return []
1838
+ const withIds = await Promise.all(
1839
+ // oxlint-disable-next-line oxc/no-map-spread -- handler results remain caller-owned
1840
+ events.map(async (event, item) => {
1841
+ const withId = { ...event }
1842
+ withId.id =
1843
+ event.id ?? (await deterministicReturnedEventId(trigger.id, item))
1844
+ return withId
1845
+ }),
1846
+ )
1847
+ return validateEvents(sessionId, withIds).map((event) =>
1848
+ Object.assign(event, {
1849
+ id: event.id!,
1850
+ cause: {
1851
+ index: trigger.index,
1852
+ attempt: trigger.attemptCount,
1853
+ },
1854
+ }),
1855
+ )
1856
+ }
1857
+
1858
+ type EventStatus =
1859
+ 'processed' | 'failed' | 'dead_lettered' | 'superseded' | 'surrendered'
1860
+
1861
+ type EventOutcome = {
1862
+ status: EventStatus
1863
+ opensLane: boolean
1864
+ appendsEvents: boolean
1865
+ }
1866
+
1867
+ const drainSession = (
1868
+ sessionId: string,
1869
+ watchdogDueAt?: number,
1870
+ signal?: DrainSignal,
1871
+ ): Promise<DrainResult> =>
1872
+ telemetry.span(
1873
+ 'a2.drain',
1874
+ { 'a2.contract': name, 'a2.session_id': sessionId },
1875
+ async (span) => {
1876
+ const store = await resolveStore()
1877
+ const ns = nsId(sessionId)
1878
+ const holder = crypto.randomUUID()
1879
+ type ActiveEvent = {
1880
+ attempt: number
1881
+ execution: Promise<EventOutcome>
1882
+ supersede: AbortController
1883
+ /** Resolves the tracked execution early once supersession is durable. */
1884
+ fence: (outcome: EventOutcome) => void
1885
+ /** Local lease estimate: request time + ttl — at or before the store's. */
1886
+ expiresAtMs: number
1887
+ lapsed: boolean
1888
+ }
1889
+ const active = new Map<number, ActiveEvent>()
1890
+ const excluded = new Set<number>()
1891
+ let processed = 0
1892
+ let sawFailure = false
1893
+ let schedulerArm: Promise<void> | undefined
1894
+ let heartbeat: ReturnType<typeof setInterval> | null = null
1895
+ let lapseTimer: ReturnType<typeof setTimeout> | null = null
1896
+ let retryTimer: ReturnType<typeof setTimeout> | null = null
1897
+ let renewal: Promise<void> | null = null
1898
+ let stopRenewing = false
1899
+
1900
+ // Self-fencing at local lease lapse: the drain wrote every expiry it
1901
+ // holds (request time + ttl, at or before the store's own stamp), so
1902
+ // it can conclude "assume a successor" with zero I/O — including
1903
+ // while the store is unreachable or in the first tick after a stall.
1904
+ const lapseDue = (): void => {
1905
+ lapseTimer = null
1906
+ const nowMs = Date.now()
1907
+ for (const [index, entry] of active) {
1908
+ if (entry.lapsed || entry.expiresAtMs > nowMs) continue
1909
+ entry.lapsed = true
1910
+ entry.supersede.abort(
1911
+ new A2Error(
1912
+ 'CLAIM_EXPIRED',
1913
+ `the claim on event ${index} in session '${sessionId}' lapsed without renewal`,
1914
+ ),
1915
+ )
1916
+ }
1917
+ scheduleLapse()
1918
+ }
1919
+ const scheduleLapse = (): void => {
1920
+ if (lapseTimer) {
1921
+ clearTimeout(lapseTimer)
1922
+ lapseTimer = null
1923
+ }
1924
+ let next = Number.POSITIVE_INFINITY
1925
+ for (const entry of active.values()) {
1926
+ if (!entry.lapsed) next = Math.min(next, entry.expiresAtMs)
1927
+ }
1928
+ if (next === Number.POSITIVE_INFINITY) return
1929
+ lapseTimer = setTimeout(lapseDue, Math.max(0, next - Date.now()))
1930
+ ;(lapseTimer as { unref?: () => void }).unref?.()
1931
+ }
1932
+
1933
+ // A missed beat spends lease slack; the next scheduled beat may be
1934
+ // provably too late. Retry promptly (jittered against fleet-wide
1935
+ // synchronization on a struggling store) while a lease can still be
1936
+ // saved — the lapse ends the retries naturally.
1937
+ const scheduleRenewRetry = (): void => {
1938
+ if (stopRenewing || retryTimer) return
1939
+ const nowMs = Date.now()
1940
+ const saveable = [...active.values()].some(
1941
+ (entry) => !entry.lapsed && entry.expiresAtMs > nowMs,
1942
+ )
1943
+ if (!saveable) return
1944
+ const delay = DRAIN_TIMINGS.renewRetryMs * (0.5 + Math.random() * 0.5)
1945
+ retryTimer = setTimeout(() => {
1946
+ retryTimer = null
1947
+ void renew()
1948
+ }, delay)
1949
+ ;(retryTimer as { unref?: () => void }).unref?.()
1950
+ }
1951
+
1952
+ const alignWatchdogAt = (minimumDueAt: number): number => {
1953
+ if (watchdogDueAt === undefined) return minimumDueAt
1954
+ const beats = Math.max(
1955
+ 0,
1956
+ Math.ceil(
1957
+ (minimumDueAt - watchdogDueAt) / DRAIN_TIMINGS.claimHeartbeatMs,
1958
+ ),
1959
+ )
1960
+ return watchdogDueAt + beats * DRAIN_TIMINGS.claimHeartbeatMs
1961
+ }
1962
+
1963
+ const requestWatchdog = (dueAt: number): void => {
1964
+ const arm = startSchedulerArm(sessionId, alignWatchdogAt(dueAt))
1965
+ if (arm) schedulerArm = arm
1966
+ }
1967
+
1968
+ const renew = (): Promise<void> => {
1969
+ if (stopRenewing || renewal || active.size === 0) {
1970
+ return renewal ?? Promise.resolve()
1971
+ }
1972
+ const window = nextClaimWindow()
1973
+ requestWatchdog(window.watchdogAtMs)
1974
+ const claims = [...active.entries()].map(([index, entry]) => ({
1975
+ index,
1976
+ attempt: entry.attempt,
1977
+ }))
1978
+ const operation = store
1979
+ .renewClaims({
1980
+ sessionId: ns,
1981
+ holder,
1982
+ claims,
1983
+ ttlMs: window.ttlMs,
1984
+ ...(window.deadlineCapped
1985
+ ? { expiresAtMs: window.expiresAtMs }
1986
+ : {}),
1987
+ })
1988
+ .then(
1989
+ (result) => {
1990
+ for (const index of result.renewed) {
1991
+ const entry = active.get(index)
1992
+ if (entry && !entry.lapsed) {
1993
+ entry.expiresAtMs = window.expiresAtMs
1994
+ }
1995
+ }
1996
+ for (const index of result.superseded) {
1997
+ const entry = active.get(index)
1998
+ if (!entry) continue
1999
+ // Idempotent: the local lapse normally aborted this signal
2000
+ // already — this is the proof, whose job is the release.
2001
+ entry.supersede.abort(
2002
+ new A2Error(
2003
+ 'SUPERSEDED_ATTEMPT',
2004
+ `a newer attempt owns event ${index} in session '${sessionId}'`,
2005
+ ),
2006
+ )
2007
+ entry.fence({
2008
+ status: 'superseded',
2009
+ opensLane: false,
2010
+ appendsEvents: false,
2011
+ })
2012
+ }
2013
+ if (window.deadlineCapped) stopRenewing = true
2014
+ scheduleLapse()
2015
+ return undefined
2016
+ },
2017
+ () => {
2018
+ scheduleRenewRetry()
2019
+ return undefined
2020
+ },
2021
+ )
2022
+ let tracked!: Promise<void>
2023
+ tracked = operation.finally(() => {
2024
+ if (renewal === tracked) renewal = null
2025
+ })
2026
+ renewal = tracked
2027
+ return tracked
2028
+ }
2029
+
2030
+ const runEvent = async (
2031
+ event: StoredEvent,
2032
+ supersedeSignal: AbortSignal,
2033
+ ): Promise<EventOutcome> => {
2034
+ const registration = handlers.get(event.type)
2035
+ return telemetry.span(
2036
+ 'a2.event',
2037
+ {
2038
+ 'a2.contract': name,
2039
+ 'a2.session_id': sessionId,
2040
+ 'a2.event.type': event.type,
2041
+ 'a2.event.index': event.index,
2042
+ 'a2.event.id': event.id,
2043
+ 'a2.event.attempt': event.attemptCount,
2044
+ 'a2.event.handled': registration !== undefined,
2045
+ ...(event.lane === null ? {} : { 'a2.event.lane': event.lane }),
2046
+ },
2047
+ async (eventSpan) => {
2048
+ let subscription:
2049
+ | {
2050
+ signal: AbortSignal
2051
+ monitor<T>(operation: Promise<T>): Promise<T>
2052
+ close(): Promise<void>
2053
+ }
2054
+ | undefined
2055
+ let handlerSignal = supersedeSignal
2056
+ let returned: ReturnedEvent[]
2057
+ try {
2058
+ if (registration?.abortOn) {
2059
+ subscription = await subscribeAbort(
2060
+ store,
2061
+ ns,
2062
+ event,
2063
+ registration.abortOn,
2064
+ event.attemptCount,
2065
+ )
2066
+ handlerSignal = AbortSignal.any([
2067
+ supersedeSignal,
2068
+ subscription.signal,
2069
+ ])
2070
+ }
2071
+ const operation = registration?.handler(
2072
+ makeCtx(event, event.attemptCount, handlerSignal),
2073
+ )
2074
+ const result = operation
2075
+ ? await (subscription
2076
+ ? subscription.monitor(operation)
2077
+ : operation)
2078
+ : undefined
2079
+ if (subscription) {
2080
+ await subscription.close()
2081
+ subscription = undefined
2082
+ }
2083
+ returned = await returnedEvents(sessionId, event, result)
2084
+ } catch (err) {
2085
+ if (supersedeSignal.aborted && err === supersedeSignal.reason) {
2086
+ // The handler surrendered to its own abort — identity, not
2087
+ // code, so it cannot arrive from another context. A lapse
2088
+ // is not a handler failure: no completion, no failure
2089
+ // budget spent; the claim simply expires and recovery
2090
+ // retries.
2091
+ eventSpan.setAttribute('a2.event.outcome', 'surrendered')
2092
+ return {
2093
+ status: 'surrendered',
2094
+ opensLane: false,
2095
+ appendsEvents: false,
2096
+ }
2097
+ }
2098
+ // Everything else — including a store fence rejection — is
2099
+ // adjudicated by failAttempt, which is itself fenced: a
2100
+ // genuinely superseded attempt gets 'superseded' back with
2101
+ // no mutation. One adjudicator, not two.
2102
+ eventSpan.recordError(err)
2103
+ const failure = await store.failAttempt({
2104
+ sessionId: ns,
2105
+ index: event.index,
2106
+ attempt: event.attemptCount,
2107
+ error: describeError(err),
2108
+ maxFailures: MAX_FAILURES,
2109
+ })
2110
+ eventSpan.setAttribute('a2.event.outcome', failure.outcome)
2111
+ return {
2112
+ status: failure.outcome,
2113
+ opensLane: false,
2114
+ appendsEvents: false,
2115
+ }
2116
+ } finally {
2117
+ await subscription?.close()
2118
+ }
2119
+ await renewal
2120
+ const completion = await store.completeAttempt({
2121
+ sessionId: ns,
2122
+ index: event.index,
2123
+ attempt: event.attemptCount,
2124
+ events: returned,
2125
+ })
2126
+ eventSpan.setAttribute(
2127
+ 'a2.event.outcome',
2128
+ completion.outcome === 'completed'
2129
+ ? 'processed'
2130
+ : completion.outcome,
2131
+ )
2132
+ if (completion.outcome === 'superseded') {
2133
+ return {
2134
+ status: 'superseded',
2135
+ opensLane: false,
2136
+ appendsEvents: false,
2137
+ }
2138
+ }
2139
+ if (handlerSignal.aborted) {
2140
+ eventSpan.setAttribute('a2.event.aborted', true)
2141
+ }
2142
+ return {
2143
+ status: 'processed',
2144
+ opensLane: event.lane !== null,
2145
+ appendsEvents: returned.length > 0,
2146
+ }
2147
+ },
2148
+ )
2149
+ }
2150
+
2151
+ let claimAgain = false
2152
+
2153
+ const start = (event: StoredEvent, expiresAtMs: number): void => {
2154
+ // Re-claiming an index this drain still tracks is durable proof
2155
+ // that the old attempt lost: our own new claim advanced the
2156
+ // ordinal. Fence the old entry before it can be confused with
2157
+ // the new one.
2158
+ const previous = active.get(event.index)
2159
+ if (previous) {
2160
+ previous.supersede.abort(
2161
+ new A2Error(
2162
+ 'SUPERSEDED_ATTEMPT',
2163
+ `a newer attempt owns event ${event.index} in session '${sessionId}'`,
2164
+ ),
2165
+ )
2166
+ previous.fence({
2167
+ status: 'superseded',
2168
+ opensLane: false,
2169
+ appendsEvents: false,
2170
+ })
2171
+ }
2172
+ const supersede = new AbortController()
2173
+ let fence!: (outcome: EventOutcome) => void
2174
+ const fenced = new Promise<EventOutcome>((resolve) => {
2175
+ fence = resolve
2176
+ })
2177
+ // The race lets a durable supersession release this slot while the
2178
+ // zombie handler keeps running — its completion and failure are
2179
+ // fenced by the store, so nothing it does from here is counted.
2180
+ let entry!: ActiveEvent
2181
+ const execution = Promise.race([
2182
+ runEvent(event, supersede.signal),
2183
+ fenced,
2184
+ ]).then((eventOutcome) => {
2185
+ // Identity-guarded: a fenced predecessor's continuation must
2186
+ // not delete the entry of the attempt that replaced it.
2187
+ if (active.get(event.index) === entry) active.delete(event.index)
2188
+ if (eventOutcome.status === 'processed') processed += 1
2189
+ if (eventOutcome.status === 'failed') {
2190
+ excluded.add(event.index)
2191
+ sawFailure = true
2192
+ }
2193
+ claimAgain ||= eventOutcome.opensLane || eventOutcome.appendsEvents
2194
+ return eventOutcome
2195
+ })
2196
+ entry = {
2197
+ attempt: event.attemptCount,
2198
+ execution,
2199
+ supersede,
2200
+ fence,
2201
+ expiresAtMs,
2202
+ lapsed: false,
2203
+ }
2204
+ active.set(event.index, entry)
2205
+ scheduleLapse()
2206
+ if (!heartbeat && !stopRenewing) {
2207
+ heartbeat = setInterval(
2208
+ () => void renew(),
2209
+ DRAIN_TIMINGS.claimHeartbeatMs,
2210
+ )
2211
+ ;(heartbeat as { unref?: () => void }).unref?.()
2212
+ }
2213
+ }
2214
+
2215
+ let outcome: DrainOutcome = 'settled'
2216
+ let shouldClaim = true
2217
+ try {
2218
+ for (;;) {
2219
+ const signalVersion = signal?.version ?? 0
2220
+ let claim: StoreClaimAvailableResult | undefined
2221
+ if (shouldClaim) {
2222
+ const window = nextClaimWindow()
2223
+ claim = await store.claimAvailable({
2224
+ sessionId: ns,
2225
+ holder,
2226
+ ttlMs: window.ttlMs,
2227
+ ...(window.deadlineCapped
2228
+ ? { expiresAtMs: window.expiresAtMs }
2229
+ : {}),
2230
+ ...(excluded.size === 0
2231
+ ? {}
2232
+ : { excludeIndexes: [...excluded] }),
2233
+ })
2234
+ if (claim.outcome === 'claimed') {
2235
+ requestWatchdog(window.watchdogAtMs)
2236
+ if (window.deadlineCapped) stopRenewing = true
2237
+ for (const event of claim.events) {
2238
+ start(event, window.expiresAtMs)
2239
+ }
2240
+ shouldClaim = false
2241
+ }
2242
+ }
2243
+ if (active.size > 0) {
2244
+ const wake = signal?.wait(signalVersion)
2245
+ await Promise.race([
2246
+ ...[...active.values()].map((entry) => entry.execution),
2247
+ ...(wake ? [wake.promise] : []),
2248
+ ])
2249
+ wake?.cancel()
2250
+ shouldClaim =
2251
+ claimAgain ||
2252
+ active.size === 0 ||
2253
+ (signal !== undefined && signal.version !== signalVersion)
2254
+ claimAgain = false
2255
+ continue
2256
+ }
2257
+ if (!shouldClaim) {
2258
+ shouldClaim = true
2259
+ continue
2260
+ }
2261
+ if (claim === undefined) continue
2262
+ if (signal && signal.version !== signalVersion) continue
2263
+ if (claim.outcome === 'busy') {
2264
+ requestWatchdog(
2265
+ claim.retryAt.getTime() + DRAIN_TIMINGS.watchdogGraceMs,
2266
+ )
2267
+ outcome = 'busy'
2268
+ } else if (sawFailure) {
2269
+ outcome = 'stalled'
2270
+ }
2271
+ if (signal) signal.closed = true
2272
+ break
2273
+ }
2274
+ } finally {
2275
+ if (signal) signal.closed = true
2276
+ stopRenewing = true
2277
+ if (heartbeat) clearInterval(heartbeat)
2278
+ if (retryTimer) clearTimeout(retryTimer)
2279
+ await renewal
2280
+ await Promise.allSettled(
2281
+ [...active.values()].map((entry) => entry.execution),
2282
+ )
2283
+ if (lapseTimer) clearTimeout(lapseTimer)
2284
+ }
2285
+ span.setAttribute('a2.drain.processed', processed)
2286
+ span.setAttribute('a2.drain.outcome', outcome)
2287
+ return {
2288
+ settled: outcome === 'settled',
2289
+ outcome,
2290
+ processed,
2291
+ ...(schedulerArm ? { schedulerArm } : {}),
2292
+ }
2293
+ },
2294
+ )
2295
+
2296
+ // ── public surface ───────────────────────────────────────────────
2297
+
2298
+ const appendExternal = async (
2299
+ sessionId: string,
2300
+ mode: 'inline' | 'dispatch',
2301
+ events: Array<AppendInput<D> | PushedEvent>,
2302
+ requireDurableInitialArm = false,
2303
+ ): Promise<ContractEvent<D>[]> => {
2304
+ const pushed = events.filter(
2305
+ (event): event is PushedEvent =>
2306
+ Reflect.get(event, '~a2.pushed') === true,
2307
+ )
2308
+ if (pushed.length > 0) {
2309
+ await options.validatePush?.({ sessionId, events: pushed })
2310
+ }
2311
+ return appendCore(
2312
+ sessionId,
2313
+ events as AppendInput<D>[],
2314
+ 'external',
2315
+ mode,
2316
+ (_rows, watchdogDueAt) => scheduleDrain(sessionId, watchdogDueAt),
2317
+ undefined,
2318
+ undefined,
2319
+ requireDurableInitialArm,
2320
+ )
2321
+ }
2322
+
2323
+ const self: A2Server<D, P> = {
2324
+ contract: serverContract,
2325
+ session(id) {
2326
+ assertSessionId(id)
2327
+ const appendInline = async (
2328
+ ...events: Array<AppendInput<D> | PushedEvent>
2329
+ ) => appendExternal(id, 'inline', events)
2330
+ const appendDispatch = async (
2331
+ ...events: Array<AppendInput<D> | PushedEvent>
2332
+ ) => appendExternal(id, 'dispatch', events)
2333
+ const append = Object.assign(appendInline, {
2334
+ dispatch: appendDispatch,
2335
+ }) as SessionAppend<D>
2336
+ return makeSession(id, append, null)
2337
+ },
2338
+
2339
+ async drain(sessionId) {
2340
+ assertSessionId(sessionId)
2341
+ const { settled } = await drainSession(sessionId)
2342
+ return { settled }
2343
+ },
2344
+ }
2345
+
2346
+ serverInternals.set(self, {
2347
+ settle: async () => {
2348
+ while (inFlight.size > 0) {
2349
+ await Promise.allSettled(inFlight)
2350
+ }
2351
+ },
2352
+ schedulerDrain: (sessionId, opts) => {
2353
+ assertSessionId(sessionId)
2354
+ return drainSession(sessionId, opts?.watchdogDueAt)
2355
+ },
2356
+ schedulerAppend: async (sessionId, events) => {
2357
+ if (!scheduler) {
2358
+ throw new TypeError(
2359
+ 'scheduled append delivery requires a configured scheduler adapter',
2360
+ )
2361
+ }
2362
+ assertSessionId(sessionId)
2363
+ await appendExternal(sessionId, 'inline', [...events], true)
2364
+ },
2365
+ })
2366
+ serverSchedulerBindings.set(self, { scheduler })
2367
+
2368
+ serverInspection.set(self, {
2369
+ async listSessions(inspectionOptions) {
2370
+ const store = await resolveStore()
2371
+ if (!store.inspect) {
2372
+ throw new InspectionUnsupportedError(
2373
+ 'this store backend does not support inspection',
2374
+ )
2375
+ }
2376
+ const page = await store.inspect.listSessions({
2377
+ prefix: `${name}${NS}`,
2378
+ limit: inspectionOptions.limit,
2379
+ ...(inspectionOptions.cursor !== undefined
2380
+ ? { cursor: inspectionOptions.cursor }
2381
+ : {}),
2382
+ })
2383
+ return {
2384
+ cursor: page.cursor,
2385
+ // oxlint-disable-next-line oxc/no-map-spread -- inspection results remain adapter-owned
2386
+ sessions: page.sessions.map((session) => {
2387
+ const publicSession = { ...session }
2388
+ publicSession.sessionId = stripNs(session.sessionId)
2389
+ return publicSession
2390
+ }),
2391
+ }
2392
+ },
2393
+ async readSessionPage(sessionId, inspectionOptions) {
2394
+ assertSessionId(sessionId)
2395
+ if (
2396
+ !Number.isSafeInteger(inspectionOptions.afterIndex) ||
2397
+ inspectionOptions.afterIndex < 0 ||
2398
+ !Number.isSafeInteger(inspectionOptions.limit) ||
2399
+ inspectionOptions.limit < 1 ||
2400
+ (inspectionOptions.throughIndex !== undefined &&
2401
+ (!Number.isSafeInteger(inspectionOptions.throughIndex) ||
2402
+ inspectionOptions.throughIndex < 0))
2403
+ ) {
2404
+ throw new TypeError('invalid inspection event page bounds')
2405
+ }
2406
+ const store = await resolveStore()
2407
+ if (!store.inspect) {
2408
+ throw new InspectionUnsupportedError(
2409
+ 'this store backend does not support inspection',
2410
+ )
2411
+ }
2412
+ const ns = nsId(sessionId)
2413
+ const snapshots =
2414
+ inspectionOptions.afterIndex === 0
2415
+ ? await store.inspect.listSnapshots(ns)
2416
+ : []
2417
+ const page = store.inspect.readEvents
2418
+ ? await store.inspect.readEvents(ns, inspectionOptions)
2419
+ : await (async () => {
2420
+ const all = await store.read(ns)
2421
+ const currentFrontier = all.at(-1)?.index ?? 0
2422
+ const throughIndex = Math.min(
2423
+ inspectionOptions.throughIndex ?? currentFrontier,
2424
+ currentFrontier,
2425
+ )
2426
+ return {
2427
+ events: all
2428
+ .filter(
2429
+ (event) =>
2430
+ event.index > inspectionOptions.afterIndex &&
2431
+ event.index <= throughIndex,
2432
+ )
2433
+ .slice(0, inspectionOptions.limit),
2434
+ throughIndex,
2435
+ }
2436
+ })()
2437
+ const { events, throughIndex } = page
2438
+ if (
2439
+ !Number.isSafeInteger(throughIndex) ||
2440
+ throughIndex < 0 ||
2441
+ events.length > inspectionOptions.limit ||
2442
+ (inspectionOptions.throughIndex !== undefined &&
2443
+ inspectionOptions.afterIndex > 0 &&
2444
+ throughIndex !== inspectionOptions.throughIndex)
2445
+ ) {
2446
+ throw new A2Error(
2447
+ 'STORE_UNAVAILABLE',
2448
+ `inspection event page for session '${sessionId}' has invalid bounds`,
2449
+ )
2450
+ }
2451
+ for (let position = 0; position < events.length; position += 1) {
2452
+ if (
2453
+ events[position]!.index !==
2454
+ inspectionOptions.afterIndex + position + 1 ||
2455
+ events[position]!.index > throughIndex
2456
+ ) {
2457
+ throw new A2Error(
2458
+ 'STORE_UNAVAILABLE',
2459
+ `inspection event page for session '${sessionId}' is not contiguous`,
2460
+ )
2461
+ }
2462
+ }
2463
+ if (inspectionOptions.afterIndex < throughIndex && events.length === 0) {
2464
+ throw new A2Error(
2465
+ 'STORE_UNAVAILABLE',
2466
+ `inspection event page for session '${sessionId}' ended before index ${throughIndex}`,
2467
+ )
2468
+ }
2469
+ const lastIndex = events.at(-1)?.index ?? inspectionOptions.afterIndex
2470
+ return {
2471
+ // oxlint-disable-next-line oxc/no-map-spread -- inspection results remain adapter-owned
2472
+ events: events.map((event) => {
2473
+ const publicEvent = { ...event }
2474
+ publicEvent.sessionId = sessionId
2475
+ return publicEvent
2476
+ }),
2477
+ snapshots: snapshots.filter(
2478
+ (snapshot) => snapshot.index <= throughIndex,
2479
+ ),
2480
+ throughIndex,
2481
+ nextIndex: lastIndex < throughIndex ? lastIndex : null,
2482
+ }
2483
+ },
2484
+ })
2485
+
2486
+ return self
2487
+ }
2488
+
2489
+ function assertSessionId(id: string): void {
2490
+ if (typeof id !== 'string' || id.length === 0) {
2491
+ throw new TypeError('session id must be a non-empty string')
2492
+ }
2493
+ if (id.includes(NS)) {
2494
+ throw new TypeError('session id contains a reserved control character')
2495
+ }
2496
+ }
2497
+
2498
+ function assertHistoryIndex(name: 'gte' | 'lte', value?: number): void {
2499
+ if (value !== undefined && (!Number.isSafeInteger(value) || value < 0)) {
2500
+ throw new TypeError(`history.${name} must be a non-negative safe integer`)
2501
+ }
2502
+ }
2503
+
2504
+ function assertStreamIndex(value: number): void {
2505
+ if (!Number.isSafeInteger(value) || value < 0) {
2506
+ throw new TypeError('stream.startAfter must be a non-negative safe integer')
2507
+ }
2508
+ }
2509
+
2510
+ function assertAppendName(name: string): void {
2511
+ if (typeof name !== 'string' || name.length === 0) {
2512
+ throw new TypeError('handler append name must be a non-empty string')
2513
+ }
2514
+ }
2515
+
2516
+ function assertScheduleName(name: string): void {
2517
+ if (typeof name !== 'string' || name.length === 0) {
2518
+ throw new TypeError('schedule name must be a non-empty string')
2519
+ }
2520
+ }
2521
+
2522
+ const SCHEDULE_DELAY = /^(?:0|[1-9]\d*)(?:\.\d+)?(ms|s|m|h|d)$/
2523
+ const SCHEDULE_DELAY_MULTIPLIER: Record<'ms' | 's' | 'm' | 'h' | 'd', number> =
2524
+ {
2525
+ ms: 1,
2526
+ s: 1_000,
2527
+ m: 60_000,
2528
+ h: 3_600_000,
2529
+ d: 86_400_000,
2530
+ }
2531
+
2532
+ function scheduleDueAt(timing: ScheduleTiming, baseTime: number): number {
2533
+ if (timing === null || typeof timing !== 'object' || Array.isArray(timing)) {
2534
+ throw new TypeError(
2535
+ "schedule timing must contain exactly one of 'delay' or 'at'",
2536
+ )
2537
+ }
2538
+ const record = timing as Record<string, unknown>
2539
+ const hasDelay = Object.hasOwn(record, 'delay')
2540
+ const hasAt = Object.hasOwn(record, 'at')
2541
+ if (hasDelay === hasAt) {
2542
+ throw new TypeError(
2543
+ "schedule timing must contain exactly one of 'delay' or 'at'",
2544
+ )
2545
+ }
2546
+
2547
+ let dueAt: number
2548
+ if (hasAt) {
2549
+ const at = record['at']
2550
+ if (!(at instanceof Date) || !Number.isFinite(at.getTime())) {
2551
+ throw new TypeError('schedule at must be a valid Date')
2552
+ }
2553
+ dueAt = at.getTime()
2554
+ } else {
2555
+ const delay = record['delay']
2556
+ if (typeof delay !== 'string') {
2557
+ throw new TypeError(
2558
+ "schedule delay must use ms, s, m, h, or d (for example '30s')",
2559
+ )
2560
+ }
2561
+ const match = SCHEDULE_DELAY.exec(delay)
2562
+ const amount = match ? Number(delay.slice(0, -match[1]!.length)) : NaN
2563
+ if (!match || !Number.isFinite(amount) || amount <= 0) {
2564
+ throw new TypeError(
2565
+ "schedule delay must be positive and use ms, s, m, h, or d (for example '30s')",
2566
+ )
2567
+ }
2568
+ dueAt =
2569
+ baseTime +
2570
+ amount *
2571
+ SCHEDULE_DELAY_MULTIPLIER[
2572
+ match[1] as keyof typeof SCHEDULE_DELAY_MULTIPLIER
2573
+ ]
2574
+ }
2575
+ if (!Number.isFinite(dueAt)) {
2576
+ throw new TypeError('schedule due time must be a finite epoch time')
2577
+ }
2578
+ return dueAt
2579
+ }
2580
+
2581
+ function assertScheduledPayload(payload: unknown): void {
2582
+ try {
2583
+ if (isJsonValue(payload, new WeakSet())) return
2584
+ } catch {
2585
+ // Proxies and hostile accessors are not stable transport values either.
2586
+ }
2587
+ throw new TypeError(
2588
+ 'session.schedule event payload must be JSON-serializable',
2589
+ )
2590
+ }
2591
+
2592
+ function cloneScheduledPayload(payload: unknown): unknown {
2593
+ assertScheduledPayload(payload)
2594
+ try {
2595
+ const cloned = structuredClone(payload)
2596
+ const encoded = JSON.stringify(cloned)
2597
+ if (encoded === undefined) throw new TypeError()
2598
+ return JSON.parse(encoded) as unknown
2599
+ } catch {
2600
+ throw new TypeError(
2601
+ 'session.schedule event payload must be JSON-serializable',
2602
+ )
2603
+ }
2604
+ }
2605
+
2606
+ function isJsonValue(value: unknown, ancestors: WeakSet<object>): boolean {
2607
+ if (value === null) return true
2608
+ if (typeof value === 'string' || typeof value === 'boolean') return true
2609
+ if (typeof value === 'number') {
2610
+ return Number.isFinite(value) && !Object.is(value, -0)
2611
+ }
2612
+ if (typeof value !== 'object') return false
2613
+ if (ancestors.has(value)) return false
2614
+ ancestors.add(value)
2615
+ try {
2616
+ if (Array.isArray(value)) {
2617
+ const keys = Reflect.ownKeys(value)
2618
+ if (keys.length !== value.length + 1 || !keys.includes('length')) {
2619
+ return false
2620
+ }
2621
+ for (let index = 0; index < value.length; index += 1) {
2622
+ const descriptor = Object.getOwnPropertyDescriptor(value, String(index))
2623
+ if (
2624
+ !descriptor ||
2625
+ !descriptor.enumerable ||
2626
+ !('value' in descriptor) ||
2627
+ !isJsonValue(descriptor.value, ancestors)
2628
+ ) {
2629
+ return false
2630
+ }
2631
+ }
2632
+ return true
2633
+ }
2634
+ const prototype = Object.getPrototypeOf(value)
2635
+ if (prototype !== Object.prototype) return false
2636
+ for (const key of Reflect.ownKeys(value)) {
2637
+ if (typeof key !== 'string') return false
2638
+ const descriptor = Object.getOwnPropertyDescriptor(value, key)
2639
+ if (
2640
+ !descriptor ||
2641
+ !descriptor.enumerable ||
2642
+ !('value' in descriptor) ||
2643
+ !isJsonValue(descriptor.value, ancestors)
2644
+ ) {
2645
+ return false
2646
+ }
2647
+ }
2648
+ return true
2649
+ } finally {
2650
+ ancestors.delete(value)
2651
+ }
2652
+ }
2653
+
2654
+ function cloneInitial<S>(initial: S): S {
2655
+ // Defensive: a reducer that mutates state in place must not corrupt
2656
+ // the shared initialState across folds. Fall back to the original for
2657
+ // non-cloneable values (functions, class instances).
2658
+ try {
2659
+ return structuredClone(initial)
2660
+ } catch {
2661
+ return initial
2662
+ }
2663
+ }
2664
+
2665
+ // Server-side types users need when passing custom stores or building
2666
+ // transports — re-exported so experimental-a2/server is self-sufficient.
2667
+ export type {
2668
+ A2Store,
2669
+ A2StoreInspection,
2670
+ AppendEvent,
2671
+ Clock,
2672
+ Event,
2673
+ EventCause,
2674
+ FailAttemptResult,
2675
+ IdSource,
2676
+ StoreAppendResult,
2677
+ StoreClaimAvailableResult,
2678
+ StoreStateRead,
2679
+ PresenceRow,
2680
+ StoredEvent,
2681
+ StoredSessionPage,
2682
+ StoredSessionSummary,
2683
+ StoredSnapshot,
2684
+ } from './store.ts'
2685
+ export type {
2686
+ ScheduledEvent,
2687
+ SchedulerAppendTask,
2688
+ SchedulerDrainTask,
2689
+ SchedulerTask,
2690
+ } from './scheduler-task.ts'
2691
+ export type {
2692
+ AppendInput,
2693
+ Contract,
2694
+ ContractEvent,
2695
+ EventDefs,
2696
+ PresenceDefs,
2697
+ PresenceMap,
2698
+ PresencePatch,
2699
+ PresenceSnapshot,
2700
+ } from './contract.ts'