experimental-a2 0.3.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 (205) hide show
  1. package/CHANGELOG.md +207 -0
  2. package/dist/ai-CFNeCrRl.d.ts +334 -0
  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 +49 -9
  7. package/dist/ai-server.d.ts.map +1 -0
  8. package/dist/ai-server.js +123 -49
  9. package/dist/ai-server.js.map +1 -0
  10. package/dist/ai.d.ts +2 -303
  11. package/dist/ai.js +233 -86
  12. package/dist/ai.js.map +1 -0
  13. package/dist/cli-B3VuxoDe.js +599 -0
  14. package/dist/cli-B3VuxoDe.js.map +1 -0
  15. package/dist/cli-bin.d.ts +1 -0
  16. package/dist/cli-bin.js +7 -0
  17. package/dist/cli-bin.js.map +1 -0
  18. package/dist/cli.d.ts +20 -0
  19. package/dist/cli.d.ts.map +1 -0
  20. package/dist/cli.js +2 -0
  21. package/dist/client-BKlyLiOU.js +1008 -0
  22. package/dist/client-BKlyLiOU.js.map +1 -0
  23. package/dist/client-D7mvIXrF.d.ts +191 -0
  24. package/dist/client-D7mvIXrF.d.ts.map +1 -0
  25. package/dist/client.d.ts +2 -90
  26. package/dist/client.js +1 -409
  27. package/dist/{contract-CG_adnu_.js → contract-48bUMgcL.js} +12 -2
  28. package/dist/contract-48bUMgcL.js.map +1 -0
  29. package/dist/{contract-C_3dIIEU.d.ts → contract-jIfaR085.d.ts} +63 -8
  30. package/dist/contract-jIfaR085.d.ts.map +1 -0
  31. package/dist/devtools-J_jZ2vQf.d.ts +152 -0
  32. package/dist/devtools-J_jZ2vQf.d.ts.map +1 -0
  33. package/dist/devtools-kJJaORn-.js +340 -0
  34. package/dist/devtools-kJJaORn-.js.map +1 -0
  35. package/dist/devtools-server.browser.js +3 -1
  36. package/dist/devtools-server.browser.js.map +1 -0
  37. package/dist/devtools-server.d.ts +4 -3
  38. package/dist/devtools-server.d.ts.map +1 -0
  39. package/dist/devtools-server.js +226 -43
  40. package/dist/devtools-server.js.map +1 -0
  41. package/dist/devtools.d.ts +2 -0
  42. package/dist/devtools.js +2 -0
  43. package/dist/{errors-BJRMd-h6.js → errors-BQuJpe82.js} +6 -4
  44. package/dist/errors-BQuJpe82.js.map +1 -0
  45. package/dist/{errors-xL_JTXsY.d.ts → errors-W6nwJ-fm.d.ts} +3 -2
  46. package/dist/errors-W6nwJ-fm.d.ts.map +1 -0
  47. package/dist/http.d.ts +135 -28
  48. package/dist/http.d.ts.map +1 -0
  49. package/dist/http.js +647 -61
  50. package/dist/http.js.map +1 -0
  51. package/dist/{idempotent-replay-BMyHrP0L.js → idempotent-replay-DuqEkYA7.js} +4 -2
  52. package/dist/idempotent-replay-DuqEkYA7.js.map +1 -0
  53. package/dist/index.d.ts +5 -5
  54. package/dist/index.js +2 -2
  55. package/dist/{inspection-E7qbD0Xj.js → inspection-DaxB5jM2.js} +4 -1
  56. package/dist/inspection-DaxB5jM2.js.map +1 -0
  57. package/dist/internal-DstsI6Re.js +211 -0
  58. package/dist/internal-DstsI6Re.js.map +1 -0
  59. package/dist/otel.d.ts +3 -2
  60. package/dist/otel.d.ts.map +1 -0
  61. package/dist/otel.js +2 -0
  62. package/dist/otel.js.map +1 -0
  63. package/dist/platform-B4TnJtWu.js +34 -0
  64. package/dist/platform-B4TnJtWu.js.map +1 -0
  65. package/dist/react.d.ts +47 -15
  66. package/dist/react.d.ts.map +1 -0
  67. package/dist/react.js +30 -15
  68. package/dist/react.js.map +1 -0
  69. package/dist/retryable-lazy-DZWmHpii.js +2 -0
  70. package/dist/retryable-lazy-DZWmHpii.js.map +1 -0
  71. package/dist/scheduler-qstash.d.ts +79 -0
  72. package/dist/scheduler-qstash.d.ts.map +1 -0
  73. package/dist/scheduler-qstash.js +501 -0
  74. package/dist/scheduler-qstash.js.map +1 -0
  75. package/dist/scheduler-task-BpzhPnRS.js +56 -0
  76. package/dist/scheduler-task-BpzhPnRS.js.map +1 -0
  77. package/dist/{recovery-vercel.d.ts → scheduler-vercel.d.ts} +18 -24
  78. package/dist/scheduler-vercel.d.ts.map +1 -0
  79. package/dist/scheduler-vercel.js +228 -0
  80. package/dist/scheduler-vercel.js.map +1 -0
  81. package/dist/server-C72KOw51.js +1457 -0
  82. package/dist/server-C72KOw51.js.map +1 -0
  83. package/dist/server-DUF9pjsx.d.ts +261 -0
  84. package/dist/server-DUF9pjsx.d.ts.map +1 -0
  85. package/dist/server.browser.js +2 -0
  86. package/dist/server.browser.js.map +1 -0
  87. package/dist/server.d.ts +4 -155
  88. package/dist/server.js +2 -2
  89. package/dist/{log-ldf5g8Cx.d.ts → store-DysUkTH3.d.ts} +120 -35
  90. package/dist/store-DysUkTH3.d.ts.map +1 -0
  91. package/dist/{log-yJbXUf72.js → store-N8PXxDAS.js} +3 -1
  92. package/dist/store-N8PXxDAS.js.map +1 -0
  93. package/dist/store-codec-DTG0Ftek.js +10 -0
  94. package/dist/store-codec-DTG0Ftek.js.map +1 -0
  95. package/dist/store-memory.d.ts +12 -0
  96. package/dist/store-memory.d.ts.map +1 -0
  97. package/dist/{log-memory.js → store-memory.js} +145 -34
  98. package/dist/store-memory.js.map +1 -0
  99. package/dist/{log-polling-DZ1MiKLg.js → store-polling-dSeLxzfb.js} +7 -5
  100. package/dist/store-polling-dSeLxzfb.js.map +1 -0
  101. package/dist/{log-postgres.d.ts → store-postgres.d.ts} +7 -6
  102. package/dist/store-postgres.d.ts.map +1 -0
  103. package/dist/{log-postgres.js → store-postgres.js} +214 -24
  104. package/dist/store-postgres.js.map +1 -0
  105. package/dist/{log-redis-core-CyJ5L8yR.js → store-redis-core-BFLwz0Wj.js} +271 -66
  106. package/dist/store-redis-core-BFLwz0Wj.js.map +1 -0
  107. package/dist/{log-redis-http.d.ts → store-redis-http.d.ts} +8 -7
  108. package/dist/store-redis-http.d.ts.map +1 -0
  109. package/dist/{log-redis-http.js → store-redis-http.js} +22 -12
  110. package/dist/store-redis-http.js.map +1 -0
  111. package/dist/{log-redis.d.ts → store-redis.d.ts} +7 -6
  112. package/dist/store-redis.d.ts.map +1 -0
  113. package/dist/{log-redis.js → store-redis.js} +101 -39
  114. package/dist/store-redis.js.map +1 -0
  115. package/dist/{log-sqlite.d.ts → store-sqlite.d.ts} +7 -6
  116. package/dist/store-sqlite.d.ts.map +1 -0
  117. package/dist/{log-sqlite.js → store-sqlite.js} +118 -22
  118. package/dist/store-sqlite.js.map +1 -0
  119. package/dist/{telemetry-Cso0qyHQ.d.ts → telemetry-BjYHTfh2.d.ts} +3 -2
  120. package/dist/telemetry-BjYHTfh2.d.ts.map +1 -0
  121. package/dist/testing.browser.d.ts +1 -0
  122. package/dist/testing.browser.js +6 -0
  123. package/dist/testing.browser.js.map +1 -0
  124. package/dist/testing.d.ts +32 -0
  125. package/dist/testing.d.ts.map +1 -0
  126. package/dist/testing.js +103 -0
  127. package/dist/testing.js.map +1 -0
  128. package/dist/validate-XKT4FSNn.js +2 -0
  129. package/dist/validate-XKT4FSNn.js.map +1 -0
  130. package/dist/wire-BFQmSJ-9.js +284 -0
  131. package/dist/wire-BFQmSJ-9.js.map +1 -0
  132. package/docs/01-quickstart.mdx +3 -3
  133. package/docs/concepts/01-contracts.mdx +18 -13
  134. package/docs/concepts/02-handlers.mdx +7 -7
  135. package/docs/concepts/03-durability.mdx +21 -21
  136. package/docs/concepts/04-state.mdx +18 -21
  137. package/docs/guides/01-timers.mdx +154 -49
  138. package/docs/guides/02-cancellation.mdx +30 -4
  139. package/docs/guides/03-react.mdx +76 -57
  140. package/docs/guides/04-local-first.mdx +1 -1
  141. package/docs/guides/05-production.mdx +310 -61
  142. package/docs/guides/06-ai-agents.mdx +252 -74
  143. package/docs/guides/07-devtools.mdx +137 -12
  144. package/docs/guides/08-application-data.mdx +1 -1
  145. package/docs/guides/09-presence.mdx +263 -0
  146. package/docs/guides/10-transports.mdx +140 -0
  147. package/docs/index.mdx +18 -14
  148. package/docs/reference/01-api.mdx +839 -110
  149. package/docs/reference/02-errors.mdx +13 -6
  150. package/package.json +24 -7
  151. package/src/ai-coordinator.ts +358 -0
  152. package/src/ai-projector.ts +524 -0
  153. package/src/ai-sdk-step.ts +261 -0
  154. package/src/ai-server.browser.ts +5 -0
  155. package/src/ai-server.ts +1719 -0
  156. package/src/ai.ts +2155 -0
  157. package/src/cache-indexeddb.ts +10 -0
  158. package/src/cli-bin.ts +5 -0
  159. package/src/cli.ts +1046 -0
  160. package/src/client.ts +1826 -0
  161. package/src/contract.ts +206 -0
  162. package/src/deterministic-id.ts +72 -0
  163. package/src/devtools-app.ts +989 -0
  164. package/src/devtools-server.browser.ts +5 -0
  165. package/src/devtools-server.ts +604 -0
  166. package/src/devtools.ts +716 -0
  167. package/src/errors.ts +50 -0
  168. package/src/http.ts +394 -0
  169. package/src/idempotent-replay.ts +53 -0
  170. package/src/index.ts +37 -0
  171. package/src/inspection.ts +39 -0
  172. package/src/internal.ts +426 -0
  173. package/src/otel.ts +59 -0
  174. package/src/platform.ts +60 -0
  175. package/src/push-envelope.ts +137 -0
  176. package/src/react.ts +284 -0
  177. package/src/reducer.ts +108 -0
  178. package/src/retryable-lazy.ts +27 -0
  179. package/src/scheduler-qstash.ts +915 -0
  180. package/src/scheduler-task.ts +106 -0
  181. package/src/scheduler-vercel.ts +437 -0
  182. package/src/server.browser.ts +12 -0
  183. package/src/server.ts +2700 -0
  184. package/src/session-socket.ts +548 -0
  185. package/src/sse.ts +141 -0
  186. package/src/standard-schema.ts +77 -0
  187. package/src/store-codec.ts +10 -0
  188. package/src/store-memory.ts +788 -0
  189. package/src/store-polling.ts +102 -0
  190. package/src/store-postgres.ts +1212 -0
  191. package/src/store-redis-core.ts +1494 -0
  192. package/src/store-redis-http.ts +116 -0
  193. package/src/store-redis.ts +458 -0
  194. package/src/store-sqlite.ts +1108 -0
  195. package/src/store.ts +385 -0
  196. package/src/telemetry.ts +47 -0
  197. package/src/testing.browser.ts +5 -0
  198. package/src/testing.ts +185 -0
  199. package/src/validate.ts +39 -0
  200. package/src/wire.ts +454 -0
  201. package/dist/internal-gCd5qMry.js +0 -44
  202. package/dist/log-memory.d.ts +0 -11
  203. package/dist/recovery-vercel.js +0 -119
  204. package/dist/server-BcLa4RFL.js +0 -877
  205. package/dist/wire-BVsgR8o9.js +0 -62
@@ -0,0 +1,1108 @@
1
+ /**
2
+ * experimental-a2/store-sqlite — sqlite store backend (the dev default, `.a2/dev.db`).
3
+ *
4
+ * Built on `node:sqlite` (Node ≥ 22.13) so it ships with zero
5
+ * dependencies. Implements the A2Store interface; the conformance suite
6
+ * in test/conformance is the executable contract.
7
+ *
8
+ * The spec's `index` column is stored as `idx` (a2-implementation.md
9
+ * §11 — `index` collides with a reserved word) and mapped back at the
10
+ * API boundary. Timestamps are epoch milliseconds written from the
11
+ * injected clock — never SQL `now()` — so tests can time-travel against
12
+ * real storage.
13
+ */
14
+
15
+ import { mkdirSync } from 'node:fs'
16
+ import { dirname, resolve } from 'node:path'
17
+ import { DatabaseSync } from 'node:sqlite'
18
+ import { A2Error } from './errors.ts'
19
+ import { idempotentReplay } from './idempotent-replay.ts'
20
+ import { decodeReturnedEventIds } from './store-codec.ts'
21
+ import { pollingStream } from './store-polling.ts'
22
+ import {
23
+ RANDOM_IDS,
24
+ SYSTEM_CLOCK,
25
+ type A2Store,
26
+ type Clock,
27
+ type Event,
28
+ type EventCause,
29
+ type IdSource,
30
+ type StoredEvent,
31
+ } from './store.ts'
32
+
33
+ export type SqliteStoreOptions = {
34
+ /** Database file path. Defaults to `.a2/dev.db`; `:memory:` works. */
35
+ path?: string
36
+ /** Injectable clock — every stored timestamp comes from here. */
37
+ clock?: Clock
38
+ /** Injectable id source for generated event ids. */
39
+ ids?: IdSource
40
+ }
41
+
42
+ export type SqliteStore = A2Store & {
43
+ /** Close the underlying database handle. */
44
+ close(): void
45
+ }
46
+
47
+ const SCHEMA = `
48
+ create table if not exists a2_events (
49
+ session_id text not null,
50
+ idx integer not null,
51
+ event_type text not null,
52
+ payload text not null,
53
+ event_id text not null,
54
+ created_at integer not null,
55
+ cause text,
56
+ lane text,
57
+ lane_ready integer not null default 0,
58
+ processed_at integer,
59
+ processed_by_attempt integer,
60
+ returned_event_ids text,
61
+ first_claimed_at integer,
62
+ last_claimed_at integer,
63
+ attempt_count integer not null default 0,
64
+ failure_count integer not null default 0,
65
+ last_failed_at integer,
66
+ last_failed_attempt integer,
67
+ last_error text,
68
+ failed_at integer,
69
+ claim_holder text,
70
+ claim_expires_at integer,
71
+ primary key (session_id, idx)
72
+ ) strict;
73
+
74
+ create unique index if not exists a2_events_event_id on a2_events (event_id);
75
+ create index if not exists a2_events_unprocessed
76
+ on a2_events (session_id, idx) where processed_at is null;
77
+ create index if not exists a2_events_lane_pending
78
+ on a2_events (session_id, lane, idx) where processed_at is null;
79
+ create index if not exists a2_events_dispatch_ready
80
+ on a2_events (session_id, idx)
81
+ where lane_ready = 1 and processed_at is null and failed_at is null;
82
+ create index if not exists a2_events_claim_expiry
83
+ on a2_events (session_id, claim_expires_at)
84
+ where lane_ready = 1 and processed_at is null and failed_at is null;
85
+
86
+ create table if not exists a2_snapshots (
87
+ session_id text not null,
88
+ reducer_name text not null,
89
+ up_to_index integer not null,
90
+ state text not null,
91
+ updated_at integer not null,
92
+ primary key (session_id, reducer_name)
93
+ ) strict;
94
+
95
+ create table if not exists a2_presence (
96
+ ns text not null,
97
+ participant text not null,
98
+ field text not null,
99
+ value text not null,
100
+ seen integer not null,
101
+ at integer not null,
102
+ expires_at integer not null,
103
+ primary key (ns, participant, field)
104
+ ) strict;
105
+ `
106
+
107
+ /**
108
+ * Switching journal modes takes an exclusive lock, and — unlike normal
109
+ * statements — the switch can return SQLITE_BUSY without consulting the
110
+ * busy handler while several connections race it (instances cold-booting
111
+ * against one file). Bounded synchronous retry; the window is boot-only
112
+ * and tiny. Found by the multi-process torture test.
113
+ */
114
+ const setWalJournalMode = (db: DatabaseSync): void => {
115
+ for (let attempt = 0; ; attempt += 1) {
116
+ try {
117
+ db.exec('pragma journal_mode = wal')
118
+ return
119
+ } catch (err) {
120
+ const busy = (err as { errcode?: number }).errcode === 5
121
+ if (!busy || attempt >= 100) throw err
122
+ // Synchronous 5ms sleep — sqlite() is a sync constructor.
123
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 5)
124
+ }
125
+ }
126
+ }
127
+
128
+ const wrap = <T>(fn: () => T): T => {
129
+ try {
130
+ return fn()
131
+ } catch (err) {
132
+ if (err instanceof A2Error || err instanceof TypeError) throw err
133
+ throw new A2Error('STORE_UNAVAILABLE', 'sqlite store operation failed', {
134
+ cause: err,
135
+ })
136
+ }
137
+ }
138
+
139
+ const toDate = (ms: number | bigint | null): Date | null =>
140
+ ms === null ? null : new Date(Number(ms))
141
+
142
+ const toCause = (json: string | null): EventCause | null => {
143
+ if (json === null) return null
144
+ const value = JSON.parse(json) as {
145
+ index?: unknown
146
+ attempt?: unknown
147
+ batchSize?: unknown
148
+ }
149
+ if (
150
+ !Number.isInteger(value.index) ||
151
+ Number(value.index) < 1 ||
152
+ !Number.isInteger(value.attempt) ||
153
+ Number(value.attempt) < 1
154
+ ) {
155
+ throw new TypeError('stored event has an invalid cause')
156
+ }
157
+ if (
158
+ value.batchSize !== undefined &&
159
+ (!Number.isInteger(value.batchSize) || Number(value.batchSize) < 1)
160
+ ) {
161
+ throw new TypeError('stored event has an invalid cause')
162
+ }
163
+ return {
164
+ index: Number(value.index),
165
+ attempt: Number(value.attempt),
166
+ ...(value.batchSize === undefined
167
+ ? {}
168
+ : { batchSize: Number(value.batchSize) }),
169
+ }
170
+ }
171
+
172
+ const toStored = (row: EventRow): StoredEvent => ({
173
+ id: row.event_id,
174
+ type: row.event_type,
175
+ payload: JSON.parse(row.payload),
176
+ index: Number(row.idx),
177
+ sessionId: row.session_id,
178
+ createdAt: new Date(Number(row.created_at)),
179
+ cause: toCause(row.cause),
180
+ lane: row.lane,
181
+ processedAt: toDate(row.processed_at),
182
+ processedByAttempt:
183
+ row.processed_by_attempt === null ? null : Number(row.processed_by_attempt),
184
+ returnedEventIds:
185
+ row.returned_event_ids === null
186
+ ? null
187
+ : decodeReturnedEventIds(row.returned_event_ids),
188
+ firstClaimedAt: toDate(row.first_claimed_at),
189
+ lastClaimedAt: toDate(row.last_claimed_at),
190
+ attemptCount: Number(row.attempt_count),
191
+ failureCount: Number(row.failure_count),
192
+ lastFailedAt: toDate(row.last_failed_at),
193
+ lastFailedAttempt:
194
+ row.last_failed_attempt === null ? null : Number(row.last_failed_attempt),
195
+ lastError: row.last_error,
196
+ failedAt: toDate(row.failed_at),
197
+ claimHolder: row.claim_holder,
198
+ claimExpiresAt: toDate(row.claim_expires_at),
199
+ })
200
+
201
+ const toEvent = (row: EventRow): Event => ({
202
+ id: row.event_id,
203
+ type: row.event_type,
204
+ payload: JSON.parse(row.payload),
205
+ index: Number(row.idx),
206
+ sessionId: row.session_id,
207
+ createdAt: new Date(Number(row.created_at)),
208
+ })
209
+
210
+ type EventRow = {
211
+ session_id: string
212
+ idx: number | bigint
213
+ event_type: string
214
+ payload: string
215
+ event_id: string
216
+ created_at: number | bigint
217
+ cause: string | null
218
+ lane: string | null
219
+ processed_at: number | bigint | null
220
+ processed_by_attempt: number | bigint | null
221
+ returned_event_ids: string | null
222
+ first_claimed_at: number | bigint | null
223
+ last_claimed_at: number | bigint | null
224
+ attempt_count: number | bigint
225
+ failure_count: number | bigint
226
+ last_failed_at: number | bigint | null
227
+ last_failed_attempt: number | bigint | null
228
+ last_error: string | null
229
+ failed_at: number | bigint | null
230
+ claim_holder: string | null
231
+ claim_expires_at: number | bigint | null
232
+ }
233
+
234
+ type SessionSummaryRow = {
235
+ session_id: string
236
+ event_count: number | bigint
237
+ pending_count: number | bigint
238
+ failed_count: number | bigint
239
+ attempt_count: number | bigint
240
+ failure_count: number | bigint
241
+ first_event_at: number | bigint
242
+ updated_at: number | bigint
243
+ }
244
+
245
+ type SnapshotRow = {
246
+ reducer_name: string
247
+ up_to_index: number | bigint
248
+ updated_at: number | bigint
249
+ }
250
+
251
+ type PresenceDbRow = {
252
+ participant: string
253
+ field: string
254
+ value: string
255
+ seen: number | bigint
256
+ at: number | bigint
257
+ expires_at: number | bigint
258
+ }
259
+
260
+ type StateReadRow = Partial<EventRow> & {
261
+ row_kind: 'snapshot' | 'event'
262
+ snapshot_index: number | bigint | null
263
+ snapshot_state: string | null
264
+ }
265
+
266
+ type AppendStatusRow = {
267
+ max: number | bigint
268
+ has_pending: number | bigint
269
+ }
270
+
271
+ type ExistingEventRow = EventRow & {
272
+ session_has_pending: number | bigint
273
+ }
274
+
275
+ export function sqlite(options: SqliteStoreOptions = {}): SqliteStore {
276
+ const path = options.path ?? '.a2/dev.db'
277
+ const clock = options.clock ?? SYSTEM_CLOCK
278
+ const generateId = options.ids ?? RANDOM_IDS
279
+
280
+ if (path !== ':memory:') {
281
+ // The dev-default backend only ever runs outside production, but
282
+ // bundlers can't know that: without the ignore, Turbopack sees a
283
+ // dynamic fs path and traces the entire project into the output.
284
+ mkdirSync(dirname(resolve(/* turbopackIgnore: true */ path)), {
285
+ recursive: true,
286
+ })
287
+ }
288
+ const db = new DatabaseSync(path)
289
+ // busy_timeout before anything that takes locks: concurrent
290
+ // cold-opens (several instances booting at once) race the WAL switch
291
+ // and schema creation.
292
+ db.exec('pragma busy_timeout = 5000')
293
+ setWalJournalMode(db)
294
+ db.exec('pragma synchronous = normal')
295
+ db.exec(SCHEMA)
296
+
297
+ const insertEvent = db.prepare(
298
+ `insert into a2_events
299
+ (session_id, idx, event_type, payload, event_id, created_at,
300
+ cause, lane, lane_ready, processed_at)
301
+ select ?, ?, ?, ?, ?, ?, ?, ?,
302
+ case
303
+ when ? is null then 1
304
+ when not exists (
305
+ select 1 from a2_events
306
+ where session_id = ? and lane = ? and processed_at is null
307
+ ) then 1
308
+ else 0
309
+ end,
310
+ ?`,
311
+ )
312
+ const promoteLaneHead = db.prepare(
313
+ `update a2_events set lane_ready = 1
314
+ where session_id = ?
315
+ and idx = (
316
+ select idx from a2_events
317
+ where session_id = ? and lane = ? and processed_at is null
318
+ order by idx limit 1
319
+ )`,
320
+ )
321
+ const appendStatus = db.prepare(
322
+ `select coalesce(
323
+ (select max(idx) from a2_events where session_id = ?),
324
+ 0
325
+ ) as max,
326
+ exists(
327
+ select 1 from a2_events
328
+ where session_id = ? and processed_at is null
329
+ ) as has_pending`,
330
+ )
331
+ const upsertPresence = db.prepare(
332
+ `insert into a2_presence (ns, participant, field, value, seen, at, expires_at)
333
+ values (?, ?, ?, ?, ?, ?, ?)
334
+ on conflict (ns, participant, field) do update set
335
+ value = excluded.value,
336
+ seen = excluded.seen,
337
+ at = excluded.at,
338
+ expires_at = excluded.expires_at
339
+ where a2_presence.at <= excluded.at`,
340
+ )
341
+ const deletePresence = db.prepare(
342
+ `delete from a2_presence
343
+ where ns = ? and participant = ? and field = ? and at <= ?`,
344
+ )
345
+ const sweepPresence = db.prepare(
346
+ 'delete from a2_presence where ns = ? and expires_at <= ?',
347
+ )
348
+ const selectPresence = db.prepare(
349
+ `select participant, field, value, seen, at, expires_at
350
+ from a2_presence
351
+ where ns = ? and expires_at > ?
352
+ order by participant, field`,
353
+ )
354
+ const selectByIds = (count: number) =>
355
+ db.prepare(
356
+ `select stored.*,
357
+ exists(
358
+ select 1 from a2_events pending
359
+ where pending.session_id = ?
360
+ and pending.processed_at is null
361
+ ) as session_has_pending
362
+ from a2_events stored
363
+ where stored.event_id in (${Array.from({ length: count }, () => '?').join(', ')})
364
+ order by stored.idx`,
365
+ )
366
+
367
+ const tx = <T>(fn: () => T): T => {
368
+ db.exec('begin immediate')
369
+ try {
370
+ const result = fn()
371
+ db.exec('commit')
372
+ return result
373
+ } catch (err) {
374
+ try {
375
+ db.exec('rollback')
376
+ } catch {
377
+ // Rollback can fail if the transaction never started; the
378
+ // original error is what matters.
379
+ }
380
+ throw err
381
+ }
382
+ }
383
+
384
+ return {
385
+ async append(sessionId, events) {
386
+ if (events.length === 0) {
387
+ return wrap(() => {
388
+ const status = appendStatus.get(
389
+ sessionId,
390
+ sessionId,
391
+ ) as AppendStatusRow
392
+ return {
393
+ events: [],
394
+ hasPending: Number(status.has_pending) === 1,
395
+ }
396
+ })
397
+ }
398
+ return wrap(() =>
399
+ tx(() => {
400
+ const supplied = events.filter((e) => e.id !== undefined)
401
+ const suppliedIds = supplied.map((e) => e.id as string)
402
+ if (new Set(suppliedIds).size !== suppliedIds.length) {
403
+ throw new A2Error(
404
+ 'PARTIAL_DUPLICATE_BATCH',
405
+ 'batch contains the same event id more than once',
406
+ )
407
+ }
408
+ if (suppliedIds.length > 0) {
409
+ const existing = selectByIds(suppliedIds.length).all(
410
+ sessionId,
411
+ ...suppliedIds,
412
+ ) as unknown as ExistingEventRow[]
413
+ if (existing.length > 0) {
414
+ const foreign = existing.find(
415
+ (row) => row.session_id !== sessionId,
416
+ )
417
+ if (foreign) {
418
+ throw new A2Error(
419
+ 'PARTIAL_DUPLICATE_BATCH',
420
+ `event id '${foreign.event_id}' already exists in another session`,
421
+ )
422
+ }
423
+ if (existing.length === events.length) {
424
+ return {
425
+ events: idempotentReplay(events, existing.map(toStored)),
426
+ hasPending: Number(existing[0]!.session_has_pending) === 1,
427
+ }
428
+ }
429
+ throw new A2Error(
430
+ 'PARTIAL_DUPLICATE_BATCH',
431
+ `batch mixes ${existing.length} already-appended and ${events.length - existing.length} fresh events`,
432
+ )
433
+ }
434
+ }
435
+
436
+ // Attempt-currency fence: a fresh handler append commits only
437
+ // while its causal attempt is still the parent's latest
438
+ // (store.ts `append`). `begin immediate` serializes this read
439
+ // against a concurrent claim.
440
+ for (const e of events) {
441
+ if (!e.cause) continue
442
+ const parent = db
443
+ .prepare(
444
+ 'select attempt_count, failed_at from a2_events where session_id = ? and idx = ?',
445
+ )
446
+ .get(sessionId, e.cause.index) as
447
+ { attempt_count: number | bigint; failed_at: unknown } | undefined
448
+ if (!parent) {
449
+ throw new TypeError(
450
+ `no event at index ${e.cause.index} in session '${sessionId}'`,
451
+ )
452
+ }
453
+ if (
454
+ Number(parent.attempt_count) !== e.cause.attempt ||
455
+ parent.failed_at !== null
456
+ ) {
457
+ throw new A2Error(
458
+ 'SUPERSEDED_ATTEMPT',
459
+ `attempt ${e.cause.attempt} no longer owns event ${e.cause.index} in session '${sessionId}'`,
460
+ )
461
+ }
462
+ }
463
+
464
+ const status = appendStatus.get(
465
+ sessionId,
466
+ sessionId,
467
+ ) as AppendStatusRow
468
+ const base = Number(status.max)
469
+ const now = clock.now().getTime()
470
+ const inserted: StoredEvent[] = events.map((e, i) => {
471
+ const id = e.id ?? generateId()
472
+ const index = base + 1 + i
473
+ insertEvent.run(
474
+ sessionId,
475
+ index,
476
+ e.type,
477
+ JSON.stringify(e.payload) ?? 'null',
478
+ id,
479
+ now,
480
+ e.cause ? JSON.stringify(e.cause) : null,
481
+ e.lane ?? null,
482
+ e.lane ?? null,
483
+ sessionId,
484
+ e.lane ?? null,
485
+ e.settled ? now : null,
486
+ )
487
+ return {
488
+ id,
489
+ type: e.type,
490
+ payload: structuredClone(e.payload),
491
+ index,
492
+ sessionId,
493
+ createdAt: new Date(now),
494
+ cause: e.cause ? { ...e.cause } : null,
495
+ lane: e.lane ?? null,
496
+ processedAt: e.settled ? new Date(now) : null,
497
+ processedByAttempt: null,
498
+ returnedEventIds: null,
499
+ firstClaimedAt: null,
500
+ lastClaimedAt: null,
501
+ attemptCount: 0,
502
+ failureCount: 0,
503
+ lastFailedAt: null,
504
+ lastFailedAttempt: null,
505
+ lastError: null,
506
+ failedAt: null,
507
+ claimHolder: null,
508
+ claimExpiresAt: null,
509
+ }
510
+ })
511
+ return {
512
+ events: inserted,
513
+ hasPending:
514
+ Number(status.has_pending) === 1 ||
515
+ events.some((event) => event.settled !== true),
516
+ }
517
+ }),
518
+ )
519
+ },
520
+
521
+ async read(sessionId, opts) {
522
+ return wrap(() => {
523
+ const conditions = ['session_id = ?']
524
+ const params: (string | number)[] = [sessionId]
525
+ if (opts?.afterIndex !== undefined) {
526
+ conditions.push('idx > ?')
527
+ params.push(opts.afterIndex)
528
+ }
529
+ if (opts?.throughIndex !== undefined) {
530
+ conditions.push('idx <= ?')
531
+ params.push(opts.throughIndex)
532
+ }
533
+ const rows = db
534
+ .prepare(
535
+ `select * from a2_events where ${conditions.join(' and ')} order by idx`,
536
+ )
537
+ .all(...params) as unknown as EventRow[]
538
+ return rows.map(toStored)
539
+ })
540
+ },
541
+
542
+ async claimAvailable({
543
+ sessionId,
544
+ holder,
545
+ ttlMs,
546
+ expiresAtMs,
547
+ excludeIndexes = [],
548
+ }) {
549
+ return wrap(() =>
550
+ tx(() => {
551
+ const now = clock.now().getTime()
552
+ const expiresAt = expiresAtMs ?? now + ttlMs
553
+ const exclusion =
554
+ excludeIndexes.length === 0
555
+ ? ''
556
+ : `and event.idx not in (${excludeIndexes.map(() => '?').join(', ')})`
557
+ const eligible = db
558
+ .prepare(
559
+ `select event.idx
560
+ from a2_events event
561
+ where event.session_id = ?
562
+ and event.processed_at is null
563
+ and event.failed_at is null
564
+ and event.lane_ready = 1
565
+ and (event.claim_expires_at is null or event.claim_expires_at <= ?)
566
+ ${exclusion}
567
+ order by event.idx`,
568
+ )
569
+ .all(sessionId, now, ...excludeIndexes) as unknown as Array<{
570
+ idx: number | bigint
571
+ }>
572
+ if (eligible.length > 0) {
573
+ const update = db.prepare(
574
+ `update a2_events set
575
+ attempt_count = attempt_count + 1,
576
+ first_claimed_at = coalesce(first_claimed_at, ?),
577
+ last_claimed_at = ?,
578
+ claim_holder = ?,
579
+ claim_expires_at = ?
580
+ where session_id = ? and idx = ?
581
+ returning *`,
582
+ )
583
+ const events = eligible.map((row) => {
584
+ const claimed = update.get(
585
+ now,
586
+ now,
587
+ holder,
588
+ expiresAt,
589
+ sessionId,
590
+ row.idx,
591
+ ) as EventRow
592
+ return toStored(claimed)
593
+ })
594
+ return { outcome: 'claimed', events }
595
+ }
596
+
597
+ const active = db
598
+ .prepare(
599
+ `select min(claim_expires_at) as retry_at
600
+ from a2_events
601
+ where session_id = ?
602
+ and processed_at is null
603
+ and failed_at is null
604
+ and lane_ready = 1
605
+ and claim_expires_at > ?`,
606
+ )
607
+ .get(sessionId, now) as {
608
+ retry_at: number | bigint | null
609
+ }
610
+ return active.retry_at === null
611
+ ? { outcome: 'settled' }
612
+ : { outcome: 'busy', retryAt: new Date(Number(active.retry_at)) }
613
+ }),
614
+ )
615
+ },
616
+
617
+ async renewClaims({ sessionId, holder, claims, ttlMs, expiresAtMs }) {
618
+ if (claims.length === 0) return { renewed: [], superseded: [] }
619
+ return wrap(() =>
620
+ tx(() => {
621
+ const now = clock.now().getTime()
622
+ const expiresAt = expiresAtMs ?? now + ttlMs
623
+ const renewed: number[] = []
624
+ const superseded: number[] = []
625
+ const currentAttempt = db.prepare(
626
+ 'select attempt_count from a2_events where session_id = ? and idx = ?',
627
+ )
628
+ const extend = db.prepare(
629
+ `update a2_events set claim_expires_at = ?
630
+ where session_id = ?
631
+ and idx = ?
632
+ and attempt_count = ?
633
+ and processed_at is null
634
+ and failed_at is null
635
+ and claim_holder = ?
636
+ and claim_expires_at > ?`,
637
+ )
638
+ for (const claim of claims) {
639
+ const row = currentAttempt.get(sessionId, claim.index) as
640
+ { attempt_count: number | bigint } | undefined
641
+ if (!row) continue
642
+ if (Number(row.attempt_count) > claim.attempt) {
643
+ superseded.push(claim.index)
644
+ continue
645
+ }
646
+ const updated = extend.run(
647
+ expiresAt,
648
+ sessionId,
649
+ claim.index,
650
+ claim.attempt,
651
+ holder,
652
+ now,
653
+ )
654
+ if (Number(updated.changes) === 1) renewed.push(claim.index)
655
+ }
656
+ return {
657
+ renewed: renewed.toSorted((a, b) => a - b),
658
+ superseded: superseded.toSorted((a, b) => a - b),
659
+ }
660
+ }),
661
+ )
662
+ },
663
+
664
+ async completeAttempt({ sessionId, index, attempt, events }) {
665
+ return wrap(() =>
666
+ tx(() => {
667
+ const parent = db
668
+ .prepare('select * from a2_events where session_id = ? and idx = ?')
669
+ .get(sessionId, index) as EventRow | undefined
670
+ if (!parent) {
671
+ throw new TypeError(
672
+ `no event at index ${index} in session '${sessionId}'`,
673
+ )
674
+ }
675
+ const ids = events.map((event) => event.id)
676
+ if (new Set(ids).size !== ids.length) {
677
+ throw new A2Error(
678
+ 'PARTIAL_DUPLICATE_BATCH',
679
+ 'returned event batch contains the same event id more than once',
680
+ )
681
+ }
682
+
683
+ if (parent.processed_at !== null) {
684
+ if (Number(parent.processed_by_attempt) !== attempt) {
685
+ return { outcome: 'superseded' }
686
+ }
687
+ const returnedIds =
688
+ parent.returned_event_ids === null
689
+ ? null
690
+ : decodeReturnedEventIds(parent.returned_event_ids)
691
+ if (
692
+ returnedIds === null ||
693
+ returnedIds.length !== ids.length ||
694
+ returnedIds.some((id, offset) => id !== ids[offset])
695
+ ) {
696
+ throw new A2Error(
697
+ 'PARTIAL_DUPLICATE_BATCH',
698
+ 'completed attempt does not match the returned event batch',
699
+ )
700
+ }
701
+ const existing =
702
+ ids.length === 0
703
+ ? []
704
+ : (selectByIds(ids.length).all(
705
+ sessionId,
706
+ ...ids,
707
+ ) as unknown as ExistingEventRow[])
708
+ const byId = new Map(existing.map((row) => [row.event_id, row]))
709
+ const ordered = ids.map((id) => byId.get(id))
710
+ if (
711
+ ordered.some(
712
+ (row) =>
713
+ !row ||
714
+ row.session_id !== sessionId ||
715
+ row.cause === null ||
716
+ toCause(row.cause)?.index !== index ||
717
+ toCause(row.cause)?.attempt !== attempt,
718
+ )
719
+ ) {
720
+ throw new A2Error(
721
+ 'PARTIAL_DUPLICATE_BATCH',
722
+ 'returned event retry does not match the committed batch',
723
+ )
724
+ }
725
+ return {
726
+ outcome: 'completed',
727
+ events: idempotentReplay(
728
+ events,
729
+ (ordered as ExistingEventRow[]).map(toStored),
730
+ ),
731
+ }
732
+ }
733
+ if (
734
+ Number(parent.attempt_count) !== attempt ||
735
+ parent.claim_holder === null ||
736
+ parent.failed_at !== null
737
+ ) {
738
+ return { outcome: 'superseded' }
739
+ }
740
+
741
+ if (
742
+ ids.length > 0 &&
743
+ (selectByIds(ids.length).all(sessionId, ...ids) as unknown[])
744
+ .length > 0
745
+ ) {
746
+ throw new A2Error(
747
+ 'PARTIAL_DUPLICATE_BATCH',
748
+ 'returned event batch contains an already-appended event id',
749
+ )
750
+ }
751
+
752
+ const status = appendStatus.get(
753
+ sessionId,
754
+ sessionId,
755
+ ) as AppendStatusRow
756
+ const now = clock.now().getTime()
757
+ db.prepare(
758
+ `update a2_events set
759
+ processed_at = ?,
760
+ processed_by_attempt = ?,
761
+ returned_event_ids = ?,
762
+ lane_ready = 0,
763
+ claim_holder = null,
764
+ claim_expires_at = null
765
+ where session_id = ? and idx = ?`,
766
+ ).run(now, attempt, JSON.stringify(ids), sessionId, index)
767
+ if (parent.lane !== null) {
768
+ promoteLaneHead.run(sessionId, sessionId, parent.lane)
769
+ }
770
+ const inserted = events.map((event, offset) => {
771
+ const id = event.id
772
+ const childIndex = Number(status.max) + offset + 1
773
+ const cause = { index, attempt }
774
+ insertEvent.run(
775
+ sessionId,
776
+ childIndex,
777
+ event.type,
778
+ JSON.stringify(event.payload) ?? 'null',
779
+ id,
780
+ now,
781
+ JSON.stringify(cause),
782
+ event.lane ?? null,
783
+ event.lane ?? null,
784
+ sessionId,
785
+ event.lane ?? null,
786
+ event.settled ? now : null,
787
+ )
788
+ return toStored(
789
+ db
790
+ .prepare(
791
+ 'select * from a2_events where session_id = ? and idx = ?',
792
+ )
793
+ .get(sessionId, childIndex) as EventRow,
794
+ )
795
+ })
796
+ return { outcome: 'completed', events: inserted }
797
+ }),
798
+ )
799
+ },
800
+
801
+ async failAttempt({ sessionId, index, attempt, error, maxFailures }) {
802
+ return wrap(() =>
803
+ tx(() => {
804
+ const current = db
805
+ .prepare('select * from a2_events where session_id = ? and idx = ?')
806
+ .get(sessionId, index) as EventRow | undefined
807
+ if (!current) {
808
+ throw new TypeError(
809
+ `no event at index ${index} in session '${sessionId}'`,
810
+ )
811
+ }
812
+ const failureCount = Number(current.failure_count)
813
+ if (
814
+ current.processed_at !== null ||
815
+ Number(current.attempt_count) !== attempt
816
+ ) {
817
+ return { outcome: 'superseded', failureCount }
818
+ }
819
+ if (current.failed_at !== null) {
820
+ return { outcome: 'dead_lettered', failureCount }
821
+ }
822
+ if (
823
+ current.claim_holder === null &&
824
+ current.last_failed_attempt !== null &&
825
+ Number(current.last_failed_attempt) === attempt
826
+ ) {
827
+ return { outcome: 'failed', failureCount }
828
+ }
829
+ if (current.claim_holder === null) {
830
+ return { outcome: 'superseded', failureCount }
831
+ }
832
+
833
+ const now = clock.now().getTime()
834
+ const nextFailureCount = failureCount + 1
835
+ const deadLettered = nextFailureCount >= maxFailures
836
+ db.prepare(
837
+ `update a2_events set
838
+ failure_count = ?,
839
+ last_error = ?,
840
+ last_failed_at = ?,
841
+ last_failed_attempt = ?,
842
+ failed_at = ?,
843
+ claim_holder = null,
844
+ claim_expires_at = null
845
+ where session_id = ? and idx = ?`,
846
+ ).run(
847
+ nextFailureCount,
848
+ error,
849
+ now,
850
+ attempt,
851
+ deadLettered ? now : null,
852
+ sessionId,
853
+ index,
854
+ )
855
+ return {
856
+ outcome: deadLettered ? 'dead_lettered' : 'failed',
857
+ failureCount: nextFailureCount,
858
+ }
859
+ }),
860
+ )
861
+ },
862
+
863
+ async readState(sessionId, reducerName) {
864
+ return wrap(() => {
865
+ const rows = db
866
+ .prepare(
867
+ `with snapshot as materialized (
868
+ select up_to_index, state
869
+ from a2_snapshots
870
+ where session_id = ? and reducer_name = ?
871
+ )
872
+ select 0 as row_order, 'snapshot' as row_kind,
873
+ snapshot.up_to_index as snapshot_index,
874
+ snapshot.state as snapshot_state,
875
+ null as session_id, null as idx, null as event_type,
876
+ null as payload, null as event_id, null as created_at,
877
+ null as cause, null as lane, null as lane_ready,
878
+ null as processed_at,
879
+ null as processed_by_attempt, null as returned_event_ids,
880
+ null as first_claimed_at,
881
+ null as last_claimed_at, null as attempt_count,
882
+ null as failure_count, null as last_failed_at,
883
+ null as last_failed_attempt, null as last_error,
884
+ null as failed_at, null as claim_holder,
885
+ null as claim_expires_at
886
+ from snapshot
887
+ union all
888
+ select 1 as row_order, 'event' as row_kind,
889
+ null as snapshot_index, null as snapshot_state,
890
+ event.*
891
+ from a2_events as event
892
+ where event.session_id = ?
893
+ and event.idx > coalesce((select up_to_index from snapshot), 0)
894
+ order by row_order, idx`,
895
+ )
896
+ .all(sessionId, reducerName, sessionId) as unknown as StateReadRow[]
897
+ const snapshot = rows.find((row) => row.row_kind === 'snapshot')
898
+ return {
899
+ snapshot: snapshot
900
+ ? {
901
+ index: Number(snapshot.snapshot_index),
902
+ state: JSON.parse(snapshot.snapshot_state!),
903
+ }
904
+ : null,
905
+ events: rows
906
+ .filter(
907
+ (row): row is StateReadRow & EventRow => row.row_kind === 'event',
908
+ )
909
+ .map(toEvent),
910
+ }
911
+ })
912
+ },
913
+
914
+ async putSnapshot(sessionId, reducerName, index, state) {
915
+ wrap(() => {
916
+ // Guarded upsert: a slower concurrent writer must never clobber
917
+ // a further-along snapshot (spec §3).
918
+ db.prepare(
919
+ `insert into a2_snapshots (session_id, reducer_name, up_to_index, state, updated_at)
920
+ values (?, ?, ?, ?, ?)
921
+ on conflict (session_id, reducer_name) do update set
922
+ up_to_index = excluded.up_to_index,
923
+ state = excluded.state,
924
+ updated_at = excluded.updated_at
925
+ where excluded.up_to_index > a2_snapshots.up_to_index`,
926
+ ).run(
927
+ sessionId,
928
+ reducerName,
929
+ index,
930
+ JSON.stringify(state) ?? 'null',
931
+ clock.now().getTime(),
932
+ )
933
+ })
934
+ },
935
+
936
+ presence: {
937
+ async set(ns, participant, values, meta) {
938
+ const entries = Object.entries(values)
939
+ if (entries.length === 0) return
940
+ wrap(() =>
941
+ tx(() => {
942
+ const atMs = meta.at.getTime()
943
+ // Expiry anchors on the storage clock — the sender's `at`
944
+ // orders writes but never anchors their lifetime.
945
+ const expiresAtMs = clock.now().getTime() + meta.ttlMs
946
+ for (const [field, value] of entries) {
947
+ // Field-wise LWW: a stored row with a strictly newer `at`
948
+ // survives; ties go to the incoming write (memory parity).
949
+ if (value === null) {
950
+ deletePresence.run(ns, participant, field, atMs)
951
+ } else {
952
+ upsertPresence.run(
953
+ ns,
954
+ participant,
955
+ field,
956
+ JSON.stringify(value) ?? 'null',
957
+ meta.seen,
958
+ atMs,
959
+ expiresAtMs,
960
+ )
961
+ }
962
+ }
963
+ }),
964
+ )
965
+ },
966
+
967
+ async read(ns) {
968
+ return wrap(() => {
969
+ const now = clock.now().getTime()
970
+ // The sweep is opportunistic cleanup; the select's filter
971
+ // alone keeps expired rows out of this result.
972
+ sweepPresence.run(ns, now)
973
+ const rows = selectPresence.all(ns, now) as unknown as PresenceDbRow[]
974
+ return rows.map((row) => ({
975
+ participant: row.participant,
976
+ field: row.field,
977
+ value: JSON.parse(row.value) as unknown,
978
+ seen: Number(row.seen),
979
+ at: new Date(Number(row.at)),
980
+ expiresAt: new Date(Number(row.expires_at)),
981
+ }))
982
+ })
983
+ },
984
+ },
985
+
986
+ inspect: {
987
+ async listSessions(inspectionOptions) {
988
+ return wrap(() => {
989
+ const conditions = ['substr(session_id, 1, ?) = ?']
990
+ const params: (string | number)[] = [
991
+ inspectionOptions.prefix.length,
992
+ inspectionOptions.prefix,
993
+ ]
994
+ if (inspectionOptions.cursor !== undefined) {
995
+ conditions.push('session_id > ?')
996
+ params.push(inspectionOptions.cursor)
997
+ }
998
+ params.push(inspectionOptions.limit + 1)
999
+ const rows = db
1000
+ .prepare(
1001
+ `select
1002
+ session_id,
1003
+ count(*) as event_count,
1004
+ sum(case when processed_at is null and failed_at is null then 1 else 0 end) as pending_count,
1005
+ sum(case when failed_at is not null then 1 else 0 end) as failed_count,
1006
+ sum(attempt_count) as attempt_count,
1007
+ sum(failure_count) as failure_count,
1008
+ min(created_at) as first_event_at,
1009
+ max(max(
1010
+ created_at,
1011
+ coalesce(first_claimed_at, 0),
1012
+ coalesce(last_claimed_at, 0),
1013
+ coalesce(last_failed_at, 0),
1014
+ coalesce(processed_at, 0),
1015
+ coalesce(failed_at, 0)
1016
+ )) as updated_at
1017
+ from a2_events
1018
+ where ${conditions.join(' and ')}
1019
+ group by session_id
1020
+ order by session_id
1021
+ limit ?`,
1022
+ )
1023
+ .all(...params) as unknown as SessionSummaryRow[]
1024
+ const hasMore = rows.length > inspectionOptions.limit
1025
+ const visible = rows.slice(0, inspectionOptions.limit)
1026
+ return {
1027
+ sessions: visible.map((row) => ({
1028
+ sessionId: row.session_id,
1029
+ eventCount: Number(row.event_count),
1030
+ pendingCount: Number(row.pending_count),
1031
+ failedCount: Number(row.failed_count),
1032
+ attemptCount: Number(row.attempt_count),
1033
+ failureCount: Number(row.failure_count),
1034
+ firstEventAt: new Date(Number(row.first_event_at)),
1035
+ updatedAt: new Date(Number(row.updated_at)),
1036
+ })),
1037
+ cursor: hasMore ? (visible.at(-1)?.session_id ?? null) : null,
1038
+ }
1039
+ })
1040
+ },
1041
+ async readEvents(sessionId, pageOptions) {
1042
+ return wrap(() => {
1043
+ const currentFrontier = Number(
1044
+ (
1045
+ db
1046
+ .prepare(
1047
+ 'select coalesce(max(idx), 0) as frontier from a2_events where session_id = ?',
1048
+ )
1049
+ .get(sessionId) as { frontier: number }
1050
+ ).frontier,
1051
+ )
1052
+ const throughIndex = Math.min(
1053
+ pageOptions.throughIndex ?? currentFrontier,
1054
+ currentFrontier,
1055
+ )
1056
+ const rows = db
1057
+ .prepare(
1058
+ `select * from a2_events
1059
+ where session_id = ? and idx > ? and idx <= ?
1060
+ order by idx limit ?`,
1061
+ )
1062
+ .all(
1063
+ sessionId,
1064
+ pageOptions.afterIndex,
1065
+ throughIndex,
1066
+ pageOptions.limit,
1067
+ ) as unknown as EventRow[]
1068
+ return { events: rows.map(toStored), throughIndex }
1069
+ })
1070
+ },
1071
+ async listSnapshots(sessionId) {
1072
+ return wrap(() => {
1073
+ const rows = db
1074
+ .prepare(
1075
+ `select reducer_name, up_to_index, updated_at
1076
+ from a2_snapshots where session_id = ? order by reducer_name`,
1077
+ )
1078
+ .all(sessionId) as unknown as SnapshotRow[]
1079
+ return rows.map((row) => ({
1080
+ reducerName: row.reducer_name,
1081
+ index: Number(row.up_to_index),
1082
+ updatedAt: new Date(Number(row.updated_at)),
1083
+ }))
1084
+ })
1085
+ },
1086
+ },
1087
+
1088
+ stream(sessionId, opts) {
1089
+ const readAfter = (afterIndex: number): StoredEvent[] =>
1090
+ wrap(() => {
1091
+ const rows = db
1092
+ .prepare(
1093
+ 'select * from a2_events where session_id = ? and idx > ? order by idx',
1094
+ )
1095
+ .all(sessionId, afterIndex) as unknown as EventRow[]
1096
+ return rows.map(toStored)
1097
+ })
1098
+ return pollingStream(
1099
+ readAfter,
1100
+ opts?.startAfter !== undefined ? { startAfter: opts.startAfter } : {},
1101
+ )
1102
+ },
1103
+
1104
+ close() {
1105
+ db.close()
1106
+ },
1107
+ }
1108
+ }