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
@@ -0,0 +1,788 @@
1
+ /**
2
+ * experimental-a2/store-memory — in-memory store backend (the test default).
3
+ *
4
+ * Implements the A2Store interface with zero dependencies. See
5
+ * specs/a2-implementation.md §3 for the contract; the conformance suite
6
+ * in test/conformance is the executable version of it.
7
+ */
8
+
9
+ import type { PresencePatch } from './contract.ts'
10
+ import { A2Error } from './errors.ts'
11
+ import { idempotentReplay } from './idempotent-replay.ts'
12
+ import { nullProtoRecord } from './internal.ts'
13
+ import {
14
+ RANDOM_IDS,
15
+ SYSTEM_CLOCK,
16
+ type A2Store,
17
+ type AppendEvent,
18
+ type Clock,
19
+ type Event,
20
+ type EventCause,
21
+ type IdSource,
22
+ type PresenceRow,
23
+ type StoredEvent,
24
+ type StoredSessionSummary,
25
+ type StoreStateRead,
26
+ } from './store.ts'
27
+
28
+ export type MemoryStoreOptions = {
29
+ /** Injectable clock — every stored timestamp comes from here. */
30
+ clock?: Clock
31
+ /** Injectable id source for generated event ids. */
32
+ ids?: IdSource
33
+ }
34
+
35
+ type Row = {
36
+ id: string
37
+ type: string
38
+ payload: unknown
39
+ index: number
40
+ sessionId: string
41
+ createdAt: Date
42
+ cause: EventCause | null
43
+ lane: string | null
44
+ processedAt: Date | null
45
+ processedByAttempt: number | null
46
+ returnedEventIds: string[] | null
47
+ firstClaimedAt: Date | null
48
+ lastClaimedAt: Date | null
49
+ attemptCount: number
50
+ claimHolder: string | null
51
+ claimExpiresAt: Date | null
52
+ failureCount: number
53
+ lastFailedAt: Date | null
54
+ lastFailedAttempt: number | null
55
+ lastError: string | null
56
+ failedAt: Date | null
57
+ }
58
+
59
+ type PresenceEntry = {
60
+ value: unknown
61
+ seen: number
62
+ at: Date
63
+ expiresAt: Date
64
+ }
65
+
66
+ type LaneQueue = { rows: Row[]; head: number }
67
+ type SessionDispatch = {
68
+ ready: Map<number, Row>
69
+ lanes: Map<string, LaneQueue>
70
+ }
71
+
72
+ const toStored = (row: Row): StoredEvent => ({
73
+ id: row.id,
74
+ type: row.type,
75
+ payload: structuredClone(row.payload),
76
+ index: row.index,
77
+ sessionId: row.sessionId,
78
+ createdAt: new Date(row.createdAt),
79
+ cause: row.cause ? { ...row.cause } : null,
80
+ lane: row.lane,
81
+ processedAt: row.processedAt ? new Date(row.processedAt) : null,
82
+ processedByAttempt: row.processedByAttempt,
83
+ returnedEventIds: row.returnedEventIds ? [...row.returnedEventIds] : null,
84
+ firstClaimedAt: row.firstClaimedAt ? new Date(row.firstClaimedAt) : null,
85
+ lastClaimedAt: row.lastClaimedAt ? new Date(row.lastClaimedAt) : null,
86
+ attemptCount: row.attemptCount,
87
+ claimHolder: row.claimHolder,
88
+ claimExpiresAt: row.claimExpiresAt ? new Date(row.claimExpiresAt) : null,
89
+ failureCount: row.failureCount,
90
+ lastFailedAt: row.lastFailedAt ? new Date(row.lastFailedAt) : null,
91
+ lastFailedAttempt: row.lastFailedAttempt,
92
+ lastError: row.lastError,
93
+ failedAt: row.failedAt ? new Date(row.failedAt) : null,
94
+ })
95
+
96
+ const toEvent = (row: Row): Event => ({
97
+ id: row.id,
98
+ type: row.type,
99
+ payload: structuredClone(row.payload),
100
+ index: row.index,
101
+ sessionId: row.sessionId,
102
+ createdAt: new Date(row.createdAt),
103
+ })
104
+
105
+ export function memory(options: MemoryStoreOptions = {}): A2Store {
106
+ const clock = options.clock ?? SYSTEM_CLOCK
107
+ const generateId = options.ids ?? RANDOM_IDS
108
+
109
+ const sessions = new Map<string, Row[]>()
110
+ const dispatch = new Map<string, SessionDispatch>()
111
+ /** Global unique index on event ids, like the SQL schema's. */
112
+ const byEventId = new Map<string, Row>()
113
+ /** Keyed by `${sessionId}\u0000${reducerName}` — a pure cache. */
114
+ const snapshots = new Map<
115
+ string,
116
+ { index: number; state: unknown; updatedAt: Date }
117
+ >()
118
+ const streamSubscribers = new Map<string, Set<(row: Row) => void>>()
119
+ /** ns → participant → field → latest surviving write. */
120
+ const presenceRows = new Map<
121
+ string,
122
+ Map<string, Map<string, PresenceEntry>>
123
+ >()
124
+ const presenceSubscribers = new Map<
125
+ string,
126
+ Set<(patch: PresencePatch) => void>
127
+ >()
128
+
129
+ const notifyStreams = (sessionId: string, rows: Row[]): void => {
130
+ const subs = streamSubscribers.get(sessionId)
131
+ if (!subs) return
132
+ for (const listener of subs) {
133
+ for (const row of rows) listener(row)
134
+ }
135
+ }
136
+
137
+ const rowsOf = (sessionId: string): Row[] => {
138
+ let rows = sessions.get(sessionId)
139
+ if (!rows) {
140
+ rows = []
141
+ sessions.set(sessionId, rows)
142
+ }
143
+ return rows
144
+ }
145
+
146
+ const dispatchOf = (sessionId: string): SessionDispatch => {
147
+ let state = dispatch.get(sessionId)
148
+ if (!state) {
149
+ state = { ready: new Map(), lanes: new Map() }
150
+ dispatch.set(sessionId, state)
151
+ }
152
+ return state
153
+ }
154
+
155
+ const enqueue = (sessionId: string, row: Row): void => {
156
+ if (row.processedAt !== null) return
157
+ const state = dispatchOf(sessionId)
158
+ if (row.lane === null) {
159
+ state.ready.set(row.index, row)
160
+ return
161
+ }
162
+ let queue = state.lanes.get(row.lane)
163
+ if (!queue) {
164
+ queue = { rows: [], head: 0 }
165
+ state.lanes.set(row.lane, queue)
166
+ }
167
+ queue.rows.push(row)
168
+ if (queue.rows.length === 1) state.ready.set(row.index, row)
169
+ }
170
+
171
+ const settle = (sessionId: string, row: Row): void => {
172
+ const state = dispatchOf(sessionId)
173
+ state.ready.delete(row.index)
174
+ if (row.lane === null) return
175
+ const queue = state.lanes.get(row.lane)!
176
+ if (queue.rows[queue.head] !== row) return
177
+ queue.head += 1
178
+ if (queue.head === queue.rows.length) {
179
+ state.lanes.delete(row.lane)
180
+ return
181
+ }
182
+ const next = queue.rows[queue.head]!
183
+ if (next.failedAt === null) state.ready.set(next.index, next)
184
+ }
185
+
186
+ const find = (sessionId: string, index: number): Row => {
187
+ const row = sessions.get(sessionId)?.[index - 1]
188
+ if (!row) {
189
+ throw new TypeError(
190
+ `no event at index ${index} in session '${sessionId}'`,
191
+ )
192
+ }
193
+ return row
194
+ }
195
+
196
+ const appendResult = (sessionId: string, events: StoredEvent[]) => ({
197
+ events,
198
+ hasPending: (sessions.get(sessionId) ?? []).some(
199
+ (row) => row.processedAt === null,
200
+ ),
201
+ })
202
+
203
+ const claim = (
204
+ row: Row,
205
+ holder: string,
206
+ now: Date,
207
+ expiresAt: Date,
208
+ ): StoredEvent => {
209
+ row.attemptCount += 1
210
+ row.firstClaimedAt ??= new Date(now)
211
+ row.lastClaimedAt = new Date(now)
212
+ row.claimHolder = holder
213
+ row.claimExpiresAt = new Date(expiresAt)
214
+ return toStored(row)
215
+ }
216
+
217
+ const checkBatchIds = (
218
+ sessionId: string,
219
+ events: readonly (AppendEvent & { id?: string })[],
220
+ ): Row[] => {
221
+ const supplied = events.filter(
222
+ (candidate): candidate is AppendEvent & { id: string } =>
223
+ candidate.id !== undefined,
224
+ )
225
+ const suppliedIds = new Set(supplied.map((candidate) => candidate.id))
226
+ if (suppliedIds.size !== supplied.length) {
227
+ throw new A2Error(
228
+ 'PARTIAL_DUPLICATE_BATCH',
229
+ 'batch contains the same event id more than once',
230
+ )
231
+ }
232
+ const existing = supplied
233
+ .map((candidate) => byEventId.get(candidate.id))
234
+ .filter((row): row is Row => row !== undefined)
235
+ const foreign = existing.find((row) => row.sessionId !== sessionId)
236
+ if (foreign) {
237
+ throw new A2Error(
238
+ 'PARTIAL_DUPLICATE_BATCH',
239
+ `event id '${foreign.id}' already exists in another session`,
240
+ )
241
+ }
242
+ return existing
243
+ }
244
+
245
+ const insert = (
246
+ sessionId: string,
247
+ events: readonly AppendEvent[],
248
+ now: Date,
249
+ cause?: EventCause,
250
+ ): Row[] => {
251
+ const rows = rowsOf(sessionId)
252
+ const base = rows.length === 0 ? 0 : rows[rows.length - 1]!.index
253
+ const inserted = events.map((candidate, offset): Row => ({
254
+ id: candidate.id ?? generateId(),
255
+ type: candidate.type,
256
+ payload: structuredClone(candidate.payload),
257
+ index: base + 1 + offset,
258
+ sessionId,
259
+ createdAt: new Date(now),
260
+ cause: cause
261
+ ? { ...cause }
262
+ : candidate.cause
263
+ ? { ...candidate.cause }
264
+ : null,
265
+ lane: candidate.lane ?? null,
266
+ processedAt: candidate.settled ? new Date(now) : null,
267
+ processedByAttempt: null,
268
+ returnedEventIds: null,
269
+ firstClaimedAt: null,
270
+ lastClaimedAt: null,
271
+ attemptCount: 0,
272
+ claimHolder: null,
273
+ claimExpiresAt: null,
274
+ failureCount: 0,
275
+ lastFailedAt: null,
276
+ lastFailedAttempt: null,
277
+ lastError: null,
278
+ failedAt: null,
279
+ }))
280
+ for (const row of inserted) {
281
+ if (byEventId.has(row.id)) {
282
+ throw new A2Error(
283
+ 'PARTIAL_DUPLICATE_BATCH',
284
+ `event id '${row.id}' already exists`,
285
+ )
286
+ }
287
+ }
288
+ for (const row of inserted) {
289
+ rows.push(row)
290
+ byEventId.set(row.id, row)
291
+ enqueue(sessionId, row)
292
+ }
293
+ return inserted
294
+ }
295
+
296
+ const readStateOf = (
297
+ sessionId: string,
298
+ reducerName: string,
299
+ ): StoreStateRead => {
300
+ const snap = snapshots.get(`${sessionId}\u0000${reducerName}`)
301
+ const snapshot = snap
302
+ ? { index: snap.index, state: structuredClone(snap.state) }
303
+ : null
304
+ const afterIndex = snapshot?.index ?? 0
305
+ return {
306
+ snapshot,
307
+ events: (sessions.get(sessionId) ?? [])
308
+ .filter((row) => row.index > afterIndex)
309
+ .map(toEvent),
310
+ }
311
+ }
312
+
313
+ return {
314
+ async append(sessionId, events) {
315
+ if (events.length === 0) return appendResult(sessionId, [])
316
+
317
+ // Batch-scoped idempotency (a2-implementation.md §3): all ids
318
+ // already present → lost-ack retry, return the original rows;
319
+ // some present → the caller mixed sent and fresh events.
320
+ const existing = checkBatchIds(sessionId, events)
321
+ if (existing.length > 0) {
322
+ if (existing.length === events.length) {
323
+ return appendResult(
324
+ sessionId,
325
+ idempotentReplay(events, existing.map(toStored)),
326
+ )
327
+ }
328
+ throw new A2Error(
329
+ 'PARTIAL_DUPLICATE_BATCH',
330
+ `batch mixes ${existing.length} already-appended and ${events.length - existing.length} fresh events`,
331
+ )
332
+ }
333
+
334
+ // Attempt-currency fence: a fresh handler append commits only while
335
+ // its causal attempt is still the parent's latest (store.ts `append`).
336
+ const rows = sessions.get(sessionId) ?? []
337
+ for (const event of events) {
338
+ if (!event.cause) continue
339
+ const parent = rows[event.cause.index - 1]
340
+ if (!parent) {
341
+ throw new TypeError(
342
+ `no event at index ${event.cause.index} in session '${sessionId}'`,
343
+ )
344
+ }
345
+ if (
346
+ parent.attemptCount !== event.cause.attempt ||
347
+ parent.failedAt !== null
348
+ ) {
349
+ throw new A2Error(
350
+ 'SUPERSEDED_ATTEMPT',
351
+ `attempt ${event.cause.attempt} no longer owns event ${event.cause.index} in session '${sessionId}'`,
352
+ )
353
+ }
354
+ }
355
+
356
+ const now = clock.now()
357
+ const inserted = insert(sessionId, events, now)
358
+ notifyStreams(sessionId, inserted)
359
+ return appendResult(sessionId, inserted.map(toStored))
360
+ },
361
+
362
+ async read(sessionId, opts) {
363
+ const rows = sessions.get(sessionId) ?? []
364
+ const start = Math.max(0, opts?.afterIndex ?? 0)
365
+ const end = Math.max(0, opts?.throughIndex ?? rows.length)
366
+ return rows.slice(start, end).map(toStored)
367
+ },
368
+
369
+ async claimAvailable({
370
+ sessionId,
371
+ holder,
372
+ ttlMs,
373
+ expiresAtMs,
374
+ excludeIndexes,
375
+ }) {
376
+ const claimedAt = clock.now()
377
+ const now = claimedAt.getTime()
378
+ const expiresAt = new Date(expiresAtMs ?? now + ttlMs)
379
+ const excluded = new Set(excludeIndexes ?? [])
380
+ const eligible: Row[] = []
381
+ let retryAt: Date | null = null
382
+
383
+ for (const row of dispatchOf(sessionId).ready.values()) {
384
+ if (row.failedAt !== null || excluded.has(row.index)) continue
385
+ if (row.claimExpiresAt && row.claimExpiresAt.getTime() > now) {
386
+ if (!retryAt || row.claimExpiresAt < retryAt) {
387
+ retryAt = new Date(row.claimExpiresAt)
388
+ }
389
+ continue
390
+ }
391
+ eligible.push(row)
392
+ }
393
+
394
+ if (eligible.length === 0) {
395
+ return retryAt ? { outcome: 'busy', retryAt } : { outcome: 'settled' }
396
+ }
397
+ eligible.sort((a, b) => a.index - b.index)
398
+ return {
399
+ outcome: 'claimed',
400
+ events: eligible.map((row) => claim(row, holder, claimedAt, expiresAt)),
401
+ }
402
+ },
403
+
404
+ async renewClaims({ sessionId, holder, claims, ttlMs, expiresAtMs }) {
405
+ const now = clock.now()
406
+ const expiresAt = new Date(expiresAtMs ?? now.getTime() + ttlMs)
407
+ const requested = new Map(claims.map((c) => [c.index, c.attempt]))
408
+ const renewed: number[] = []
409
+ const superseded: number[] = []
410
+ for (const row of sessions.get(sessionId) ?? []) {
411
+ const attempt = requested.get(row.index)
412
+ if (attempt === undefined) continue
413
+ if (row.attemptCount > attempt) {
414
+ superseded.push(row.index)
415
+ continue
416
+ }
417
+ if (
418
+ row.processedAt === null &&
419
+ row.failedAt === null &&
420
+ row.claimHolder === holder &&
421
+ row.claimExpiresAt !== null &&
422
+ row.claimExpiresAt > now
423
+ ) {
424
+ row.claimExpiresAt = new Date(expiresAt)
425
+ renewed.push(row.index)
426
+ }
427
+ }
428
+ return { renewed, superseded }
429
+ },
430
+
431
+ async completeAttempt({ sessionId, index, attempt, events }) {
432
+ const parent = find(sessionId, index)
433
+ const cause = { index, attempt }
434
+ if (parent.processedAt !== null) {
435
+ if (parent.processedByAttempt !== attempt) {
436
+ return { outcome: 'superseded' }
437
+ }
438
+ const requestedIds = events.map((event) => event.id)
439
+ if (
440
+ parent.returnedEventIds === null ||
441
+ parent.returnedEventIds.length !== requestedIds.length ||
442
+ parent.returnedEventIds.some(
443
+ (id, offset) => id !== requestedIds[offset],
444
+ )
445
+ ) {
446
+ throw new A2Error(
447
+ 'PARTIAL_DUPLICATE_BATCH',
448
+ 'completed attempt does not match the returned event batch',
449
+ )
450
+ }
451
+ const existing = checkBatchIds(sessionId, events)
452
+ if (
453
+ existing.length !== events.length ||
454
+ existing.some(
455
+ (row) =>
456
+ row.cause?.index !== index || row.cause.attempt !== attempt,
457
+ )
458
+ ) {
459
+ throw new A2Error(
460
+ 'PARTIAL_DUPLICATE_BATCH',
461
+ 'completed attempt does not match the returned event batch',
462
+ )
463
+ }
464
+ return {
465
+ outcome: 'completed',
466
+ events: idempotentReplay(events, existing.map(toStored)),
467
+ }
468
+ }
469
+ if (
470
+ parent.attemptCount !== attempt ||
471
+ parent.failedAt !== null ||
472
+ parent.claimHolder === null
473
+ ) {
474
+ return { outcome: 'superseded' }
475
+ }
476
+
477
+ const existing = checkBatchIds(sessionId, events)
478
+ if (existing.length > 0) {
479
+ throw new A2Error(
480
+ 'PARTIAL_DUPLICATE_BATCH',
481
+ 'returned event batch contains already-appended ids',
482
+ )
483
+ }
484
+ const now = clock.now()
485
+ const inserted = insert(sessionId, events, now, cause)
486
+ parent.processedAt = new Date(now)
487
+ parent.processedByAttempt = attempt
488
+ parent.returnedEventIds = events.map((event) => event.id)
489
+ parent.claimHolder = null
490
+ parent.claimExpiresAt = null
491
+ settle(sessionId, parent)
492
+ notifyStreams(sessionId, inserted)
493
+ return { outcome: 'completed', events: inserted.map(toStored) }
494
+ },
495
+
496
+ async failAttempt({ sessionId, index, attempt, error, maxFailures }) {
497
+ const row = find(sessionId, index)
498
+ if (
499
+ row.processedAt ||
500
+ row.attemptCount !== attempt ||
501
+ (row.claimHolder === null && row.lastFailedAttempt !== attempt)
502
+ ) {
503
+ return { outcome: 'superseded', failureCount: row.failureCount }
504
+ }
505
+ if (row.lastFailedAttempt === attempt && row.claimHolder === null) {
506
+ return {
507
+ outcome: row.failedAt ? 'dead_lettered' : 'failed',
508
+ failureCount: row.failureCount,
509
+ }
510
+ }
511
+ if (row.failedAt) {
512
+ return { outcome: 'dead_lettered', failureCount: row.failureCount }
513
+ }
514
+ row.failureCount += 1
515
+ row.lastError = error
516
+ const failedAt = clock.now()
517
+ row.lastFailedAt = new Date(failedAt)
518
+ row.lastFailedAttempt = attempt
519
+ row.claimHolder = null
520
+ row.claimExpiresAt = null
521
+ if (row.failureCount >= maxFailures) {
522
+ row.failedAt = new Date(failedAt)
523
+ dispatchOf(sessionId).ready.delete(row.index)
524
+ return {
525
+ outcome: 'dead_lettered',
526
+ failureCount: row.failureCount,
527
+ }
528
+ }
529
+ return { outcome: 'failed', failureCount: row.failureCount }
530
+ },
531
+
532
+ async readState(sessionId, reducerName) {
533
+ return readStateOf(sessionId, reducerName)
534
+ },
535
+
536
+ async readStates(sessionIds, reducerName) {
537
+ return sessionIds.map((sessionId) => readStateOf(sessionId, reducerName))
538
+ },
539
+
540
+ async putSnapshot(sessionId, reducerName, index, state) {
541
+ const key = `${sessionId}\u0000${reducerName}`
542
+ const existing = snapshots.get(key)
543
+ // Guarded upsert: a slower concurrent writer must never clobber a
544
+ // further-along snapshot. A lost race costs a few refolded events
545
+ // next read — never correctness.
546
+ if (existing && existing.index >= index) return
547
+ snapshots.set(key, {
548
+ index,
549
+ state: structuredClone(state),
550
+ updatedAt: clock.now(),
551
+ })
552
+ },
553
+
554
+ presence: {
555
+ async set(ns, participant, values, meta) {
556
+ let participants = presenceRows.get(ns)
557
+ if (!participants) {
558
+ participants = new Map()
559
+ presenceRows.set(ns, participants)
560
+ }
561
+ let fields = participants.get(participant)
562
+ if (!fields) {
563
+ fields = new Map()
564
+ participants.set(participant, fields)
565
+ }
566
+
567
+ // Expiry anchors on the storage's own clock — the sender's
568
+ // `at` orders writes but never extends or shortens a lifetime.
569
+ const expiresAtMs = clock.now().getTime() + meta.ttlMs
570
+ // Field-keyed and caller-named — null-prototype, like every
571
+ // presence map (see `nullProtoRecord`).
572
+ const applied: Record<string, unknown> = nullProtoRecord()
573
+ let appliedCount = 0
574
+ for (const [field, value] of Object.entries(values)) {
575
+ // Field-wise LWW by `at`; ties go to the incoming write, so
576
+ // same-stamp sets keep set-then-read intuition.
577
+ const existing = fields.get(field)
578
+ if (existing && existing.at > meta.at) continue
579
+ if (value === null) {
580
+ fields.delete(field)
581
+ } else {
582
+ fields.set(field, {
583
+ value: structuredClone(value),
584
+ seen: meta.seen,
585
+ at: new Date(meta.at),
586
+ expiresAt: new Date(expiresAtMs),
587
+ })
588
+ }
589
+ applied[field] = structuredClone(value)
590
+ appliedCount += 1
591
+ }
592
+ if (fields.size === 0) participants.delete(participant)
593
+ if (participants.size === 0) presenceRows.delete(ns)
594
+
595
+ // Only applied fields broadcast — a losing write repaints nothing,
596
+ // so subscribers stay consistent with what read() returns.
597
+ if (appliedCount === 0) return
598
+ const subs = presenceSubscribers.get(ns)
599
+ if (!subs) return
600
+ const patch: PresencePatch = {
601
+ participant,
602
+ values: applied,
603
+ seen: meta.seen,
604
+ at: new Date(meta.at),
605
+ }
606
+ for (const listener of subs) listener(patch)
607
+ },
608
+
609
+ async read(ns) {
610
+ const participants = presenceRows.get(ns)
611
+ if (!participants) return []
612
+ const now = clock.now()
613
+ const rows: PresenceRow[] = []
614
+ for (const [participant, fields] of participants) {
615
+ for (const [field, entry] of fields) {
616
+ // Expiry is enforced lazily on read — no timers; a silent
617
+ // participant's rows vanish the next time anyone looks.
618
+ if (entry.expiresAt <= now) {
619
+ fields.delete(field)
620
+ continue
621
+ }
622
+ rows.push({
623
+ participant,
624
+ field,
625
+ value: structuredClone(entry.value),
626
+ seen: entry.seen,
627
+ at: new Date(entry.at),
628
+ expiresAt: new Date(entry.expiresAt),
629
+ })
630
+ }
631
+ if (fields.size === 0) participants.delete(participant)
632
+ }
633
+ if (participants.size === 0) presenceRows.delete(ns)
634
+ return rows
635
+ },
636
+
637
+ subscribe(ns, onPatch) {
638
+ let subs = presenceSubscribers.get(ns)
639
+ if (!subs) {
640
+ subs = new Set()
641
+ presenceSubscribers.set(ns, subs)
642
+ }
643
+ subs.add(onPatch)
644
+ return () => {
645
+ subs.delete(onPatch)
646
+ if (subs.size === 0) presenceSubscribers.delete(ns)
647
+ }
648
+ },
649
+ },
650
+
651
+ inspect: {
652
+ async listSessions(inspectionOptions) {
653
+ const ids = [...sessions.keys()]
654
+ .filter(
655
+ (sessionId) =>
656
+ sessionId.startsWith(inspectionOptions.prefix) &&
657
+ (inspectionOptions.cursor === undefined ||
658
+ sessionId > inspectionOptions.cursor),
659
+ )
660
+ .toSorted()
661
+ const visible = ids.slice(0, inspectionOptions.limit)
662
+ const summaries = visible.map((sessionId): StoredSessionSummary => {
663
+ const rows = sessions.get(sessionId)!
664
+ const first = rows[0]!
665
+ let updatedAt = first.createdAt
666
+ let pendingCount = 0
667
+ let failedCount = 0
668
+ let attemptCount = 0
669
+ let failureCount = 0
670
+ for (const row of rows) {
671
+ attemptCount += row.attemptCount
672
+ failureCount += row.failureCount
673
+ if (row.processedAt === null && row.failedAt === null) {
674
+ pendingCount += 1
675
+ }
676
+ if (row.failedAt !== null) failedCount += 1
677
+ for (const timestamp of [
678
+ row.createdAt,
679
+ row.firstClaimedAt,
680
+ row.lastClaimedAt,
681
+ row.lastFailedAt,
682
+ row.processedAt,
683
+ row.failedAt,
684
+ ]) {
685
+ if (timestamp && timestamp > updatedAt) updatedAt = timestamp
686
+ }
687
+ }
688
+ return {
689
+ sessionId,
690
+ eventCount: rows.length,
691
+ pendingCount,
692
+ failedCount,
693
+ attemptCount,
694
+ failureCount,
695
+ firstEventAt: new Date(first.createdAt),
696
+ updatedAt: new Date(updatedAt),
697
+ }
698
+ })
699
+ return {
700
+ sessions: summaries,
701
+ cursor: ids.length > visible.length ? (visible.at(-1) ?? null) : null,
702
+ }
703
+ },
704
+ async readEvents(sessionId, pageOptions) {
705
+ const rows = sessions.get(sessionId) ?? []
706
+ const throughIndex = Math.min(
707
+ pageOptions.throughIndex ?? rows.length,
708
+ rows.length,
709
+ )
710
+ return {
711
+ events: rows
712
+ .slice(pageOptions.afterIndex, throughIndex)
713
+ .slice(0, pageOptions.limit)
714
+ .map(toStored),
715
+ throughIndex,
716
+ }
717
+ },
718
+ async listSnapshots(sessionId) {
719
+ const prefix = `${sessionId}\u0000`
720
+ return [...snapshots.entries()]
721
+ .filter(([key]) => key.startsWith(prefix))
722
+ .map(([key, snapshot]) => ({
723
+ reducerName: key.slice(prefix.length),
724
+ index: snapshot.index,
725
+ updatedAt: new Date(snapshot.updatedAt),
726
+ }))
727
+ .toSorted((a, b) => a.reducerName.localeCompare(b.reducerName))
728
+ },
729
+ },
730
+
731
+ stream(sessionId, opts) {
732
+ const startAfter = opts?.startAfter ?? 0
733
+ return {
734
+ [Symbol.asyncIterator](): AsyncIterator<Event> {
735
+ let last = startAfter
736
+ const buffer: Row[] = []
737
+ let wake: (() => void) | null = null
738
+ let closed = false
739
+
740
+ const onRow = (row: Row): void => {
741
+ buffer.push(row)
742
+ wake?.()
743
+ }
744
+ let subs = streamSubscribers.get(sessionId)
745
+ if (!subs) {
746
+ subs = new Set()
747
+ streamSubscribers.set(sessionId, subs)
748
+ }
749
+ subs.add(onRow)
750
+ // Seed with history — synchronously, in the same tick as the
751
+ // subscription, so nothing can slip between the two.
752
+ const existing = sessions.get(sessionId) ?? []
753
+ buffer.unshift(...existing.filter((row) => row.index > startAfter))
754
+
755
+ const unsubscribe = (): void => {
756
+ subs.delete(onRow)
757
+ if (subs.size === 0) streamSubscribers.delete(sessionId)
758
+ }
759
+
760
+ return {
761
+ async next(): Promise<IteratorResult<Event>> {
762
+ for (;;) {
763
+ while (buffer.length > 0) {
764
+ const row = buffer.shift()!
765
+ if (row.index <= last) continue
766
+ last = row.index
767
+ return { value: toStored(row), done: false }
768
+ }
769
+ if (closed) return { value: undefined, done: true }
770
+ // oxlint-disable-next-line no-await-in-loop -- wait-for-wake
771
+ await new Promise<void>((resolve) => {
772
+ wake = resolve
773
+ })
774
+ wake = null
775
+ }
776
+ },
777
+ async return(): Promise<IteratorResult<Event>> {
778
+ closed = true
779
+ unsubscribe()
780
+ wake?.()
781
+ return { value: undefined, done: true }
782
+ },
783
+ }
784
+ },
785
+ }
786
+ },
787
+ }
788
+ }