experimental-a2 0.4.0 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (182) hide show
  1. package/CHANGELOG.md +66 -0
  2. package/dist/{ai-B4YhEnfw.d.ts → ai-D_PGS-JR.d.ts} +3 -2
  3. package/dist/ai-D_PGS-JR.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 +2 -2
  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 +3 -2
  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-YtPq7hjw.d.ts → server-DpvjhdoE.d.ts} +6 -5
  77. package/dist/server-DpvjhdoE.d.ts.map +1 -0
  78. package/dist/{server-CcNnFnoW.js → server-Duw6MVlB.js} +112 -54
  79. package/dist/server-Duw6MVlB.js.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 → telemetry-CpeclqB2.d.ts} +4 -3
  115. package/dist/telemetry-CpeclqB2.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 +54 -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/store.ts ADDED
@@ -0,0 +1,385 @@
1
+ /**
2
+ * The A2Store interface — the storage contract every store backend
3
+ * implements. See specs/a2-implementation.md §2–3.
4
+ *
5
+ * This is the whole storage contract: append, read, dispatch claims,
6
+ * failure markers, snapshots, and the live stream. The scheduler
7
+ * needs nothing extra — the armed queue message is its own state, and
8
+ * the store is the only thing it consults.
9
+ */
10
+
11
+ import type { PresencePatch } from './contract.ts'
12
+
13
+ /** A stored event, as the public API exposes it. */
14
+ export type Event = {
15
+ id: string
16
+ type: string
17
+ payload: unknown
18
+ /** Position in the session's event log, from 1. */
19
+ index: number
20
+ sessionId: string
21
+ createdAt: Date
22
+ }
23
+
24
+ /** The handler dispatch whose append first persisted a child event. */
25
+ export type EventCause = {
26
+ index: number
27
+ attempt: number
28
+ /** Size of one named handler append, used to reject truncated retries. */
29
+ batchSize?: number
30
+ }
31
+
32
+ /**
33
+ * What the store persists: immutable event history, including its causal edge,
34
+ * plus derived dispatch and failure bookkeeping. The bookkeeping is disposable;
35
+ * the event and its cause are not.
36
+ */
37
+ export type StoredEvent = Event & {
38
+ /** Same-session handler dispatch that appended this event; null means root. */
39
+ cause: EventCause | null
40
+ /** Session-scoped serial execution key resolved when the event is appended. */
41
+ lane: string | null
42
+ /** Adapter clock time recorded by append settlement, completion, or manual skip; null while pending. */
43
+ processedAt: Date | null
44
+ /** Dispatch attempt that completed this event; null without dispatch or while pending. */
45
+ processedByAttempt: number | null
46
+ /** Exact ordered child ids atomically returned by the completing attempt. */
47
+ returnedEventIds: string[] | null
48
+ /** Adapter clock time recorded by the first durable dispatch claim. */
49
+ firstClaimedAt: Date | null
50
+ /** Adapter clock time recorded by the most recent durable dispatch claim. */
51
+ lastClaimedAt: Date | null
52
+ /** Durable dispatch claims, including claims abandoned by hard kills. */
53
+ attemptCount: number
54
+ /** Current dispatch holder; null when the event is not claimed. */
55
+ claimHolder: string | null
56
+ /** Adapter clock expiry for the current dispatch claim. */
57
+ claimExpiresAt: Date | null
58
+ /** Caught handler failures. This alone drives dead-lettering. */
59
+ failureCount: number
60
+ /** Adapter clock time recorded by the most recent caught handler failure. */
61
+ lastFailedAt: Date | null
62
+ /** Dispatch attempt that produced the most recent caught handler failure. */
63
+ lastFailedAttempt: number | null
64
+ /** The last handler failure, stringified. */
65
+ lastError: string | null
66
+ /** Adapter clock time recorded when dead-lettered; null otherwise. */
67
+ failedAt: Date | null
68
+ }
69
+
70
+ /** Durable, read-only session metadata for administrative inspection. */
71
+ export type StoredSessionSummary = {
72
+ sessionId: string
73
+ eventCount: number
74
+ pendingCount: number
75
+ failedCount: number
76
+ /** Durable dispatch claims across every event in the session. */
77
+ attemptCount: number
78
+ /** Caught handler failures across every event in the session. */
79
+ failureCount: number
80
+ firstEventAt: Date
81
+ updatedAt: Date
82
+ }
83
+
84
+ /**
85
+ * One live presence value, as `A2Store.presence.read` returns it. The
86
+ * row is the LWW unit — one participant's one field. `at` is the
87
+ * sender's stamp, the LWW comparator. `expiresAt` is the storage's
88
+ * own clock at the applied set plus its `ttlMs` — never derived from
89
+ * the sender stamp; a row is live strictly before it.
90
+ */
91
+ export type PresenceRow = {
92
+ participant: string
93
+ field: string
94
+ value: unknown
95
+ seen: number
96
+ at: Date
97
+ expiresAt: Date
98
+ }
99
+
100
+ /** Metadata for the latest cached fold of one reducer. */
101
+ export type StoredSnapshot = {
102
+ reducerName: string
103
+ index: number
104
+ updatedAt: Date
105
+ }
106
+
107
+ /** One consistent cache-plus-tail read for a reducer fold. */
108
+ export type StoreStateRead = {
109
+ /** The latest cached fold for this reducer, if one exists. */
110
+ snapshot: { index: number; state: unknown } | null
111
+ /** Immutable events strictly after `snapshot.index`, or the full log on a miss. */
112
+ events: Event[]
113
+ }
114
+
115
+ export type StoredSessionPage = {
116
+ sessions: StoredSessionSummary[]
117
+ /** Opaque backend cursor; null means there is no next page. */
118
+ cursor: string | null
119
+ }
120
+
121
+ export type StoredEventPage = {
122
+ events: StoredEvent[]
123
+ /** Fixed inclusive log frontier shared by every page in one inspection. */
124
+ throughIndex: number
125
+ }
126
+
127
+ /**
128
+ * Optional read-only administration implemented by A2's shipped stores.
129
+ * It is separate from the correctness contract so custom stores do not
130
+ * need to expose storage-wide discovery merely to process events.
131
+ */
132
+ export type A2StoreInspection = {
133
+ listSessions(options: {
134
+ /** Storage namespace prefix, including the contract separator. */
135
+ prefix: string
136
+ cursor?: string
137
+ limit: number
138
+ }): Promise<StoredSessionPage>
139
+ /**
140
+ * A bounded event page for read-only administration. The first page captures
141
+ * `throughIndex`; callers pass it back so concurrent appends cannot extend a
142
+ * walk indefinitely. Optional for custom stores; A2 falls back to `read()`.
143
+ */
144
+ readEvents?(
145
+ sessionId: string,
146
+ options: { afterIndex: number; throughIndex?: number; limit: number },
147
+ ): Promise<StoredEventPage>
148
+ listSnapshots(sessionId: string): Promise<StoredSnapshot[]>
149
+ }
150
+
151
+ /** The result of atomically claiming every currently eligible event. */
152
+ export type StoreClaimAvailableResult =
153
+ | { outcome: 'claimed'; events: StoredEvent[] }
154
+ | { outcome: 'busy'; retryAt: Date }
155
+ | { outcome: 'settled' }
156
+
157
+ /** A completion may lose to a newer claim or an earlier completion. */
158
+ export type CompleteAttemptResult =
159
+ { outcome: 'completed'; events: StoredEvent[] } | { outcome: 'superseded' }
160
+
161
+ /** The result of one claim-renewal heartbeat, both lists in append order. */
162
+ export type RenewClaimsResult = {
163
+ /** Listed claims that remain owned by the holder. */
164
+ renewed: number[]
165
+ /** Listed claims whose event a newer attempt has durably taken. */
166
+ superseded: number[]
167
+ }
168
+
169
+ /** The result of atomically recording a caught handler failure. */
170
+ export type FailAttemptResult = {
171
+ outcome: 'failed' | 'dead_lettered' | 'superseded'
172
+ failureCount: number
173
+ }
174
+
175
+ /** Input to `A2Store.append` — already validated by the machine. */
176
+ export type AppendEvent = {
177
+ type: string
178
+ payload: unknown
179
+ /** Caller-supplied idempotency key; generated when absent. */
180
+ id?: string
181
+ /** Internal causal edge supplied atomically by handler `session.append`. */
182
+ cause?: EventCause
183
+ /** Session-scoped serial execution key resolved before persistence. */
184
+ lane?: string
185
+ /** Internal hint: settle this event in the append transaction; no handler is registered. */
186
+ settled?: true
187
+ }
188
+
189
+ /** A handler-returned event with the deterministic id retries require. */
190
+ export type ReturnedEvent = AppendEvent & { id: string }
191
+
192
+ /** The rows written by an append and the session's pending state. */
193
+ export type StoreAppendResult = {
194
+ events: StoredEvent[]
195
+ /** Whether the session contains an event without a completion marker. */
196
+ hasPending: boolean
197
+ }
198
+
199
+ /**
200
+ * An injectable clock. Adapters take one so tests can drive claim
201
+ * expiry, failure timestamps, and (later) stuck-session detection
202
+ * deterministically — against real storage, no mocking.
203
+ */
204
+ export type Clock = {
205
+ now(): Date
206
+ }
207
+
208
+ /** An injectable id source for generated event ids. */
209
+ export type IdSource = () => string
210
+
211
+ export const SYSTEM_CLOCK: Clock = {
212
+ now: () => new Date(),
213
+ }
214
+
215
+ export const RANDOM_IDS: IdSource = () => crypto.randomUUID()
216
+
217
+ export interface A2Store {
218
+ /**
219
+ * Accepts a batch; the batch is atomic — one transaction, consecutive
220
+ * `index`es, all-or-nothing. The idempotency key covers the whole
221
+ * operation, not each item: if *every* event's `id` already exists in
222
+ * this session, this is a retry of a committed batch whose ack was
223
+ * lost — return the existing rows as success. If only *some* ids
224
+ * exist, the caller mixed an already-sent batch with fresh events —
225
+ * always a caller bug — so throw `A2Error('PARTIAL_DUPLICATE_BATCH')`.
226
+ * Events carrying `settled: true` get `processedAt` in this same atomic
227
+ * operation, with no dispatch claim or `processedByAttempt`. The result's
228
+ * `hasPending` reflects the whole session in the same atomic operation,
229
+ * including older events and idempotent retries.
230
+ *
231
+ * An event carrying `cause` is a handler append, fenced by attempt
232
+ * currency: accept it only while `cause.attempt` is still the parent
233
+ * event's latest attempt and the parent is not dead-lettered; otherwise
234
+ * throw `A2Error('SUPERSEDED_ATTEMPT')` and write nothing. The check is
235
+ * part of this atomic operation and must serialize against a concurrent
236
+ * `claimAvailable` — an unlocked read of the parent admits write skew.
237
+ * The idempotent-replay path runs first, so a batch whose ids all exist
238
+ * replays regardless of the current attempt.
239
+ */
240
+ append(sessionId: string, events: AppendEvent[]): Promise<StoreAppendResult>
241
+
242
+ /** Events for one session, oldest first. Bounds form `(afterIndex, throughIndex]`. */
243
+ read(
244
+ sessionId: string,
245
+ opts?: { afterIndex?: number; throughIndex?: number },
246
+ ): Promise<StoredEvent[]>
247
+
248
+ /**
249
+ * Atomically claims every eligible pending event. Unlaned events are all
250
+ * independently eligible. Within a lane, only the lowest-index unfinished
251
+ * event is eligible. A live claim produces `busy` only when it is the sole
252
+ * remaining obstacle to actionable work. Claimed events are returned in event-log
253
+ * order. Excluded rows remain lane barriers.
254
+ */
255
+ claimAvailable(options: {
256
+ sessionId: string
257
+ holder: string
258
+ ttlMs: number
259
+ expiresAtMs?: number
260
+ excludeIndexes?: readonly number[]
261
+ }): Promise<StoreClaimAvailableResult>
262
+
263
+ /**
264
+ * Renews the listed live claims still owned by `holder`. Each listed claim
265
+ * carries the attempt ordinal the holder owns. `renewed` lists the claims
266
+ * that remain owned after the operation; `superseded` lists the claims
267
+ * whose event's `attemptCount` has durably passed the listed attempt. An
268
+ * expired claim no successor has taken appears in neither list — its
269
+ * attempt may still complete (`completeAttempt` is the fence), so it is
270
+ * not reported as lost. Renewal never revives an expired claim.
271
+ */
272
+ renewClaims(options: {
273
+ sessionId: string
274
+ holder: string
275
+ claims: readonly { index: number; attempt: number }[]
276
+ ttlMs: number
277
+ expiresAtMs?: number
278
+ }): Promise<RenewClaimsResult>
279
+
280
+ /**
281
+ * Atomically completes one current attempt and appends its returned events.
282
+ * Retrying a committed completion with the same attempt and deterministic
283
+ * child ids returns the existing children. A stale attempt never appends.
284
+ */
285
+ completeAttempt(options: {
286
+ sessionId: string
287
+ index: number
288
+ attempt: number
289
+ events: ReturnedEvent[]
290
+ }): Promise<CompleteAttemptResult>
291
+
292
+ /**
293
+ * Atomically records a caught failure for one claimed attempt. A stale
294
+ * attempt cannot poison a processed event or a newer dispatch. Accepted
295
+ * failures record the operation's clock time.
296
+ */
297
+ failAttempt(options: {
298
+ sessionId: string
299
+ index: number
300
+ attempt: number
301
+ error: string
302
+ maxFailures: number
303
+ }): Promise<FailAttemptResult>
304
+
305
+ /**
306
+ * Reads a reducer snapshot and its event tail as one consistent adapter
307
+ * operation. On a cache miss, `snapshot` is null and `events` is the full
308
+ * log. The snapshot is untrusted; core may reject it and issue a full
309
+ * `read()` when its state schema no longer accepts the cached value.
310
+ */
311
+ readState(sessionId: string, reducerName: string): Promise<StoreStateRead>
312
+
313
+ /**
314
+ * Optional batched `readState` — one consistent snapshot-plus-tail read
315
+ * per session id, aligned positionally with the input (duplicates
316
+ * allowed). Each element has its own frontier; the batch makes no
317
+ * cross-session consistency claim. Core falls back to parallel
318
+ * `readState` calls when absent.
319
+ */
320
+ readStates?(
321
+ sessionIds: string[],
322
+ reducerName: string,
323
+ ): Promise<StoreStateRead[]>
324
+
325
+ /**
326
+ * Writes a disposable reducer cache. Guard this operation so a slower
327
+ * concurrent writer can never clobber a further-along snapshot
328
+ * (`where up_to_index < excluded.up_to_index`).
329
+ */
330
+ putSnapshot(
331
+ sessionId: string,
332
+ reducerName: string,
333
+ index: number,
334
+ state: unknown,
335
+ ): Promise<void>
336
+
337
+ /**
338
+ * Optional ephemeral-plane capability (specs/a2-implementation.md
339
+ * §15.1) — optional like snapshots are. Values arrive already
340
+ * validated by core; adapters store them opaquely. Presence never
341
+ * touches the event log: no index, no history row, no recovery arm.
342
+ */
343
+ presence?: {
344
+ /**
345
+ * Field-wise last-writer-wins merge of one participant's values:
346
+ * a field whose existing row has a strictly newer `at` (the
347
+ * sender's stamp) is left untouched; `null` deletes the row. Every
348
+ * applied write refreshes that field's expiry to the storage's own
349
+ * clock plus `ttlMs` — the sender stamp orders writes but never
350
+ * anchors their lifetime.
351
+ */
352
+ set(
353
+ ns: string,
354
+ participant: string,
355
+ values: Record<string, unknown | null>,
356
+ meta: { seen: number; at: Date; ttlMs: number },
357
+ ): Promise<void>
358
+
359
+ /** The current map, pruned of rows at or past their `expiresAt`. */
360
+ read(ns: string): Promise<PresenceRow[]>
361
+
362
+ /**
363
+ * Push-tier patch delivery; present only on backends with a real
364
+ * broadcast primitive. Without it the backend is the degraded
365
+ * tier: live feeds surface presence by re-reading on their
366
+ * existing poll cadence.
367
+ */
368
+ subscribe?(ns: string, onPatch: (patch: PresencePatch) => void): () => void
369
+ }
370
+
371
+ /** Read-only operational data used by `experimental-a2/devtools/server`. */
372
+ inspect?: A2StoreInspection
373
+
374
+ /**
375
+ * A live feed of one session's events, starting after `startAfter`
376
+ * (exclusive). Transport is the backend's choice — in-process pub/sub,
377
+ * polling, LISTEN/NOTIFY — callers never branch on which. The iterable
378
+ * ends when the consumer calls `return()` (e.g. a disconnecting SSE
379
+ * client) and must deliver events appended after subscription.
380
+ */
381
+ stream(
382
+ sessionId: string,
383
+ opts?: { startAfter?: number },
384
+ ): AsyncIterable<Event>
385
+ }
@@ -0,0 +1,54 @@
1
+ /**
2
+ * The A2Telemetry interface — the instrumentation seam. Same philosophy
3
+ * as store backends: the interface lives in core, implementations ship as
4
+ * entry points (`experimental-a2/otel` adapts it to OpenTelemetry). Without
5
+ * one, spans are free and swallowed errors print to the console.
6
+ */
7
+
8
+ /** Attribute values a2 emits. */
9
+ export type A2AttributeValue = string | number | boolean
10
+
11
+ /** The spans a2 emits today. The catalogue grows with the surface. */
12
+ export type A2SpanName = 'a2.append' | 'a2.drain' | 'a2.event' | 'a2.state'
13
+
14
+ export type A2SpanHandle = {
15
+ /** Attach or update an attribute mid-span (e.g. the outcome). */
16
+ setAttribute(key: string, value: A2AttributeValue): void
17
+ /**
18
+ * Mark the span failed without a2's control flow throwing — used for
19
+ * handler failures, which a2 swallows by design (they go into the
20
+ * retry machinery, not up the stack) but telemetry must still see.
21
+ */
22
+ recordError(error: unknown): void
23
+ }
24
+
25
+ export type A2Telemetry = {
26
+ /**
27
+ * Wrap one unit of a2 work. Implementations should time it, record a
28
+ * thrown error as a failure, propagate context so nested spans tree
29
+ * up, and always return `fn`'s result (or rethrow its error) —
30
+ * telemetry observes, it never alters behavior.
31
+ */
32
+ span<T>(
33
+ name: A2SpanName,
34
+ attributes: Record<string, A2AttributeValue>,
35
+ fn: (span: A2SpanHandle) => Promise<T>,
36
+ ): Promise<T>
37
+ }
38
+
39
+ /**
40
+ * The default when no telemetry is configured. Spans cost nothing, but
41
+ * `recordError` prints: it only ever receives errors a2 swallows by
42
+ * design, so without a configured sink the console is the one place
43
+ * they can surface. Passing `telemetry` replaces this sink wholesale.
44
+ */
45
+ export const CONSOLE_TELEMETRY: A2Telemetry = {
46
+ span: (name, attributes, fn) =>
47
+ fn({
48
+ setAttribute: () => {},
49
+ recordError: (error) => {
50
+ // oxlint-disable-next-line no-console -- the whole point: swallowed errors must surface without configuration
51
+ console.error(`[a2] error in ${name}`, attributes, error)
52
+ },
53
+ }),
54
+ }
@@ -0,0 +1,5 @@
1
+ /** experimental-a2/testing loads local capture files and is Node-only. */
2
+ throw new Error(
3
+ 'experimental-a2/testing is Node-only. Load captures in a local test or script.',
4
+ )
5
+ export {}
package/src/testing.ts ADDED
@@ -0,0 +1,185 @@
1
+ /**
2
+ * Offline helpers for loading a captured A2 session and replaying its events
3
+ * through the application's current reducers. This module never constructs a
4
+ * server or dispatches handlers.
5
+ */
6
+
7
+ import { readFile } from 'node:fs/promises'
8
+ import { fileURLToPath } from 'node:url'
9
+ import type { Contract, ContractEvent, EventDefs } from './contract.ts'
10
+ import {
11
+ parseDevtoolsCapture,
12
+ verifyDevtoolsCapture,
13
+ type DevtoolsCapture,
14
+ type DevtoolsCaptureEvent,
15
+ } from './devtools.ts'
16
+ import type { Reducer } from './reducer.ts'
17
+ import { validateSync } from './validate.ts'
18
+
19
+ export type CaptureSource = string | URL
20
+
21
+ export type LoadedCapture<D extends EventDefs> = {
22
+ /** The verified capture, including its footer and operational records. */
23
+ readonly capture: DevtoolsCapture
24
+ /** The contract used to validate and type the captured event history. */
25
+ readonly contract: Contract<D>
26
+ /** Contract-typed public events with `createdAt` revived to `Date`. */
27
+ readonly events: readonly ContractEvent<D>[]
28
+ /** Unmodified wire events, including dispatch and failure metadata. */
29
+ readonly rawEvents: readonly DevtoolsCaptureEvent[]
30
+ }
31
+
32
+ export type ReplayCaptureOptions = {
33
+ /** Fold events through this log index, inclusive. Zero means no events. */
34
+ throughIndex?: number
35
+ }
36
+
37
+ export type CaptureReplay<S> = {
38
+ readonly state: S
39
+ /** The last event index actually folded, or zero for the initial state. */
40
+ readonly index: number
41
+ }
42
+
43
+ const captureError = (message: string): TypeError =>
44
+ new TypeError(`invalid A2 capture: ${message}`)
45
+
46
+ const typedEvents = <D extends EventDefs>(
47
+ capture: DevtoolsCapture,
48
+ contract: Contract<D>,
49
+ ): ContractEvent<D>[] => {
50
+ const events: ContractEvent<D>[] = []
51
+ for (const event of capture.events) {
52
+ const schema = Object.hasOwn(contract.events, event.type)
53
+ ? contract.events[event.type]
54
+ : undefined
55
+ if (schema === undefined) {
56
+ throw captureError(
57
+ `contract '${contract.name}' has no event type '${event.type}' at index ${event.index}`,
58
+ )
59
+ }
60
+ const result = validateSync(
61
+ schema,
62
+ structuredClone(event.payload),
63
+ `captured event '${event.type}'`,
64
+ )
65
+ if (result.issues !== undefined) {
66
+ throw captureError(
67
+ `event #${event.index} '${event.type}' has an invalid payload: ${result.issues
68
+ .map((issue) => issue.message)
69
+ .join('; ')}`,
70
+ )
71
+ }
72
+ events.push({
73
+ id: event.id,
74
+ type: event.type,
75
+ payload: result.value,
76
+ index: event.index,
77
+ sessionId: event.sessionId,
78
+ createdAt: new Date(event.createdAt),
79
+ } as ContractEvent<D>)
80
+ }
81
+ return events
82
+ }
83
+
84
+ /** Validate an already-parsed capture against an application's contract. */
85
+ export async function prepareCapture<D extends EventDefs>(
86
+ capture: DevtoolsCapture,
87
+ contract: Contract<D>,
88
+ ): Promise<LoadedCapture<D>> {
89
+ await verifyDevtoolsCapture(capture)
90
+ if (capture.contract !== contract.name) {
91
+ throw captureError(
92
+ `expected contract '${contract.name}', received '${capture.contract}'`,
93
+ )
94
+ }
95
+ return {
96
+ capture,
97
+ contract,
98
+ events: typedEvents(capture, contract),
99
+ rawEvents: capture.events,
100
+ }
101
+ }
102
+
103
+ const displayUrl = (url: URL): string => {
104
+ if (url.protocol === 'data:') return 'data: URL'
105
+ const safe = new URL(url)
106
+ safe.username = ''
107
+ safe.password = ''
108
+ safe.search = ''
109
+ safe.hash = ''
110
+ return safe.href
111
+ }
112
+
113
+ const fetchCapture = async (url: URL): Promise<Uint8Array> => {
114
+ const location = displayUrl(url)
115
+ let response: Response
116
+ try {
117
+ response = await fetch(url)
118
+ } catch {
119
+ throw new Error(`failed to load A2 capture from '${location}'`)
120
+ }
121
+ if (!response.ok) {
122
+ throw new Error(
123
+ `failed to load A2 capture from '${location}': ${response.status} ${response.statusText}`,
124
+ )
125
+ }
126
+ return new Uint8Array(await response.arrayBuffer())
127
+ }
128
+
129
+ const readCapture = async (source: CaptureSource): Promise<Uint8Array> => {
130
+ if (source instanceof URL) {
131
+ return source.protocol === 'file:'
132
+ ? readFile(fileURLToPath(source))
133
+ : fetchCapture(source)
134
+ }
135
+ if (/^[a-z]:[\\/]/i.test(source)) return readFile(source)
136
+ let url: URL | undefined
137
+ try {
138
+ url = new URL(source)
139
+ } catch {
140
+ return readFile(source)
141
+ }
142
+ return readCapture(url)
143
+ }
144
+
145
+ /** Load, integrity-check, and contract-type a `.a2log` file or URL. */
146
+ export async function loadCapture<D extends EventDefs>(
147
+ source: CaptureSource,
148
+ contract: Contract<D>,
149
+ ): Promise<LoadedCapture<D>> {
150
+ const encoded = await readCapture(source)
151
+ const capture = parseDevtoolsCapture(encoded)
152
+ return prepareCapture(capture, contract)
153
+ }
154
+
155
+ const cloneInitial = <S>(initialState: S): S => {
156
+ try {
157
+ return structuredClone(initialState)
158
+ } catch {
159
+ return initialState
160
+ }
161
+ }
162
+
163
+ /** Purely fold captured events through a reducer, optionally to a cutpoint. */
164
+ export function replayCapture<D extends EventDefs, S>(
165
+ loaded: LoadedCapture<D>,
166
+ reducer: Reducer<D, S>,
167
+ options: ReplayCaptureOptions = {},
168
+ ): CaptureReplay<S> {
169
+ const { throughIndex } = options
170
+ if (
171
+ throughIndex !== undefined &&
172
+ (!Number.isInteger(throughIndex) || throughIndex < 0)
173
+ ) {
174
+ throw new TypeError('throughIndex must be a non-negative integer')
175
+ }
176
+
177
+ let state = cloneInitial(reducer.initialState)
178
+ let index = 0
179
+ for (const event of loaded.events) {
180
+ if (throughIndex !== undefined && event.index > throughIndex) break
181
+ state = reducer.fold(state, event)
182
+ index = event.index
183
+ }
184
+ return { state, index }
185
+ }
@@ -0,0 +1,39 @@
1
+ import type { StandardSchemaV1 } from './standard-schema.ts'
2
+
3
+ /**
4
+ * Run a Standard Schema validator, requiring a synchronous result.
5
+ * Validators must be synchronous (specs/a2-api.md §1) — an async one is
6
+ * a definition bug, rejected at definition time where possible and here
7
+ * as a backstop. `what` names the schema in error messages, e.g.
8
+ * "event 'created'" or "the stateSchema".
9
+ */
10
+ export function validateSync<Input, Output>(
11
+ schema: StandardSchemaV1<Input, Output>,
12
+ value: unknown,
13
+ what: string,
14
+ ): StandardSchemaV1.Result<Output> {
15
+ const result = schema['~standard'].validate(value)
16
+ if (result instanceof Promise) {
17
+ throw new TypeError(
18
+ `the schema for ${what} is asynchronous — a2 schemas must validate synchronously`,
19
+ )
20
+ }
21
+ return result
22
+ }
23
+
24
+ /**
25
+ * Probe a schema to reject always-async validators early. The probe
26
+ * value is deliberately garbage — we only care whether the result is a
27
+ * Promise, not whether it passes.
28
+ */
29
+ export function assertSyncSchema(schema: StandardSchemaV1, what: string): void {
30
+ const probe = Symbol('a2.schema-probe')
31
+ const result = schema['~standard'].validate(probe)
32
+ if (result instanceof Promise) {
33
+ // Swallow the eventual rejection — the probe input was garbage.
34
+ result.catch(() => {})
35
+ throw new TypeError(
36
+ `the schema for ${what} is asynchronous — a2 schemas must validate synchronously`,
37
+ )
38
+ }
39
+ }