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,1212 @@
1
+ /**
2
+ * experimental-a2/store-postgres — postgres store backend (the production path).
3
+ *
4
+ * Implements the A2Store interface over any client that speaks the tiny
5
+ * `PostgresClient` shape below — a `pg` Pool matches it structurally
6
+ * (and is what `connectionString` creates, lazily; `pg` is an optional
7
+ * peer dependency). The conformance suite in test/conformance is the
8
+ * executable contract, run against PGlite (real Postgres, in-process)
9
+ * and against a real server when `TEST_DATABASE_URL` is set.
10
+ *
11
+ * Semantics mirror the sqlite backend: `index` is stored as `idx`,
12
+ * timestamps are epoch milliseconds written from the injected clock —
13
+ * never SQL `now()` — and `stream()` is a poll loop. (A LISTEN/NOTIFY
14
+ * upgrade can land later without any API change; callers never branch
15
+ * on the transport.) Appends serialize per session via
16
+ * `pg_advisory_xact_lock`, so concurrent writers can't race the index
17
+ * assignment.
18
+ */
19
+
20
+ import { A2Error } from './errors.ts'
21
+ import { idempotentReplay } from './idempotent-replay.ts'
22
+ import { decodeReturnedEventIds } from './store-codec.ts'
23
+ import { pollingStream } from './store-polling.ts'
24
+ import { retryableLazy } from './retryable-lazy.ts'
25
+ import {
26
+ RANDOM_IDS,
27
+ SYSTEM_CLOCK,
28
+ type A2Store,
29
+ type Clock,
30
+ type Event,
31
+ type EventCause,
32
+ type IdSource,
33
+ type StoredEvent,
34
+ type StoreStateRead,
35
+ } from './store.ts'
36
+
37
+ /** The result shape this backend reads: just rows. */
38
+ export type PostgresQueryResult = { rows: Record<string, unknown>[] }
39
+
40
+ /** One checked-out connection — what transactions run on. */
41
+ export type PostgresConnection = {
42
+ query(text: string, values?: unknown[]): Promise<PostgresQueryResult>
43
+ release(): void
44
+ }
45
+
46
+ /**
47
+ * The minimal client this backend needs. `pg.Pool` matches it
48
+ * structurally; so does PGlite (which has no `connect` — transactions
49
+ * then serialize on an internal mutex, fine for a single-connection
50
+ * client).
51
+ */
52
+ export type PostgresClient = {
53
+ query(text: string, values?: unknown[]): Promise<PostgresQueryResult>
54
+ connect?(): Promise<PostgresConnection>
55
+ end?(): Promise<void>
56
+ }
57
+
58
+ export type PostgresStoreOptions = {
59
+ /** Creates a `pg` Pool lazily (requires the optional peer dep `pg`). */
60
+ connectionString?: string | undefined
61
+ /** Bring your own client — a `pg` Pool, PGlite, a pooler wrapper. */
62
+ client?: PostgresClient
63
+ /** Injectable clock — every stored timestamp comes from here. */
64
+ clock?: Clock
65
+ /** Injectable id source for generated event ids. */
66
+ ids?: IdSource
67
+ }
68
+
69
+ export type PostgresStore = A2Store & {
70
+ /** End the underlying pool/client (when this backend created it, or
71
+ * the injected client has `end`). */
72
+ close(): Promise<void>
73
+ }
74
+
75
+ const SCHEMA = `
76
+ create table if not exists a2_events (
77
+ session_id text not null,
78
+ idx bigint not null,
79
+ event_type text not null,
80
+ payload jsonb not null,
81
+ event_id text not null,
82
+ created_at bigint not null,
83
+ cause jsonb,
84
+ lane text,
85
+ lane_ready boolean not null default false,
86
+ processed_at bigint,
87
+ processed_by_attempt integer,
88
+ returned_event_ids jsonb,
89
+ first_claimed_at bigint,
90
+ last_claimed_at bigint,
91
+ attempt_count integer not null default 0,
92
+ failure_count integer not null default 0,
93
+ last_failed_at bigint,
94
+ last_failed_attempt integer,
95
+ last_error text,
96
+ failed_at bigint,
97
+ claim_holder text,
98
+ claim_expires_at bigint,
99
+ primary key (session_id, idx)
100
+ );
101
+
102
+ create unique index if not exists a2_events_event_id on a2_events (event_id);
103
+ create index if not exists a2_events_unprocessed
104
+ on a2_events (session_id, idx) where processed_at is null;
105
+ create index if not exists a2_events_lane_pending
106
+ on a2_events (session_id, lane, idx) where processed_at is null;
107
+ create index if not exists a2_events_dispatch_ready
108
+ on a2_events (session_id, idx)
109
+ where lane_ready and processed_at is null and failed_at is null;
110
+ create index if not exists a2_events_claim_expiry
111
+ on a2_events (session_id, claim_expires_at)
112
+ where lane_ready and processed_at is null and failed_at is null;
113
+
114
+ create table if not exists a2_snapshots (
115
+ session_id text not null,
116
+ reducer_name text not null,
117
+ up_to_index bigint not null,
118
+ state jsonb not null,
119
+ updated_at bigint not null,
120
+ primary key (session_id, reducer_name)
121
+ );
122
+
123
+ create table if not exists a2_presence (
124
+ ns text not null,
125
+ participant text not null,
126
+ field text not null,
127
+ value jsonb not null,
128
+ seen bigint not null,
129
+ at bigint not null,
130
+ expires_at bigint not null,
131
+ primary key (ns, participant, field)
132
+ );
133
+ `
134
+
135
+ const asNumber = (value: unknown): number => Number(value) // bigint columns arrive as strings
136
+
137
+ const toDate = (ms: unknown): Date | null =>
138
+ ms === null || ms === undefined ? null : new Date(asNumber(ms))
139
+
140
+ const toCause = (raw: unknown): EventCause | null => {
141
+ if (raw === null || raw === undefined) return null
142
+ const value = (typeof raw === 'string' ? JSON.parse(raw) : raw) as {
143
+ index?: unknown
144
+ attempt?: unknown
145
+ batchSize?: unknown
146
+ }
147
+ if (
148
+ !Number.isInteger(value.index) ||
149
+ Number(value.index) < 1 ||
150
+ !Number.isInteger(value.attempt) ||
151
+ Number(value.attempt) < 1
152
+ ) {
153
+ throw new TypeError('stored event has an invalid cause')
154
+ }
155
+ if (
156
+ value.batchSize !== undefined &&
157
+ (!Number.isInteger(value.batchSize) || Number(value.batchSize) < 1)
158
+ ) {
159
+ throw new TypeError('stored event has an invalid cause')
160
+ }
161
+ return {
162
+ index: Number(value.index),
163
+ attempt: Number(value.attempt),
164
+ ...(value.batchSize === undefined
165
+ ? {}
166
+ : { batchSize: Number(value.batchSize) }),
167
+ }
168
+ }
169
+
170
+ const toStored = (row: Record<string, unknown>): StoredEvent => ({
171
+ id: row['event_id'] as string,
172
+ type: row['event_type'] as string,
173
+ payload: row['payload'],
174
+ index: asNumber(row['idx']),
175
+ sessionId: row['session_id'] as string,
176
+ createdAt: new Date(asNumber(row['created_at'])),
177
+ cause: toCause(row['cause']),
178
+ lane: (row['lane'] as string | null) ?? null,
179
+ processedAt: toDate(row['processed_at']),
180
+ processedByAttempt:
181
+ row['processed_by_attempt'] === null ||
182
+ row['processed_by_attempt'] === undefined
183
+ ? null
184
+ : asNumber(row['processed_by_attempt']),
185
+ returnedEventIds:
186
+ row['returned_event_ids'] === null ||
187
+ row['returned_event_ids'] === undefined
188
+ ? null
189
+ : decodeReturnedEventIds(row['returned_event_ids']),
190
+ firstClaimedAt: toDate(row['first_claimed_at']),
191
+ lastClaimedAt: toDate(row['last_claimed_at']),
192
+ attemptCount: asNumber(row['attempt_count']),
193
+ failureCount: asNumber(row['failure_count']),
194
+ lastFailedAt: toDate(row['last_failed_at']),
195
+ lastFailedAttempt:
196
+ row['last_failed_attempt'] === null ||
197
+ row['last_failed_attempt'] === undefined
198
+ ? null
199
+ : asNumber(row['last_failed_attempt']),
200
+ lastError: (row['last_error'] as string | null) ?? null,
201
+ failedAt: toDate(row['failed_at']),
202
+ claimHolder: (row['claim_holder'] as string | null) ?? null,
203
+ claimExpiresAt: toDate(row['claim_expires_at']),
204
+ })
205
+
206
+ const toEvent = (row: Record<string, unknown>): Event => ({
207
+ id: row['event_id'] as string,
208
+ type: row['event_type'] as string,
209
+ payload: row['payload'],
210
+ index: asNumber(row['idx']),
211
+ sessionId: row['session_id'] as string,
212
+ createdAt: new Date(asNumber(row['created_at'])),
213
+ })
214
+
215
+ const wrap = async <T>(fn: () => Promise<T>): Promise<T> => {
216
+ try {
217
+ return await fn()
218
+ } catch (err) {
219
+ if (err instanceof A2Error || err instanceof TypeError) throw err
220
+ throw new A2Error('STORE_UNAVAILABLE', 'postgres store operation failed', {
221
+ cause: err,
222
+ })
223
+ }
224
+ }
225
+
226
+ const jsonb = (value: unknown): string => JSON.stringify(value) ?? 'null'
227
+
228
+ const initializeSchema = async (client: PostgresClient): Promise<void> => {
229
+ if (!client.connect) {
230
+ await client.query(SCHEMA)
231
+ return
232
+ }
233
+
234
+ const conn = await client.connect()
235
+ try {
236
+ await conn.query('begin')
237
+ // `if not exists` does not serialize concurrent catalog writes. Every
238
+ // serverless isolate takes the same transaction-scoped lock before DDL.
239
+ await conn.query(
240
+ "select pg_advisory_xact_lock(hashtext('a2:postgres:schema'))",
241
+ )
242
+ await conn.query(SCHEMA)
243
+ await conn.query('commit')
244
+ } catch (err) {
245
+ await conn.query('rollback').catch(() => {})
246
+ throw err
247
+ } finally {
248
+ conn.release()
249
+ }
250
+ }
251
+
252
+ export function postgres(options: PostgresStoreOptions = {}): PostgresStore {
253
+ const clock = options.clock ?? SYSTEM_CLOCK
254
+ const generateId = options.ids ?? RANDOM_IDS
255
+
256
+ if (!options.client && !options.connectionString) {
257
+ throw new TypeError(
258
+ 'postgres() needs a connectionString or an injected client',
259
+ )
260
+ }
261
+
262
+ // Lazy init: the first operation creates the client (dynamic import
263
+ // of the optional peer dep `pg`) and the schema. Constructing the
264
+ // backend does no I/O — same contract as every other adapter.
265
+ const connection = retryableLazy(async () => {
266
+ let owned: PostgresClient | null = null
267
+ try {
268
+ let c = options.client
269
+ if (!c) {
270
+ const pg = await import('pg').catch(() => {
271
+ throw new A2Error(
272
+ 'STORE_NOT_CONFIGURED',
273
+ "experimental-a2/store-postgres with a connectionString needs the 'pg' package (optional peer dependency) — install it, or inject a client",
274
+ )
275
+ })
276
+ c = new pg.default.Pool({
277
+ connectionString: options.connectionString,
278
+ }) as unknown as PostgresClient
279
+ owned = c
280
+ }
281
+ await initializeSchema(c)
282
+ return c
283
+ } catch (err) {
284
+ if (owned?.end) await owned.end().catch(() => {})
285
+ throw err
286
+ }
287
+ })
288
+ const client = connection.get
289
+
290
+ // Transactions run on one connection. Pools check one out; a
291
+ // single-connection client (PGlite) serializes on this mutex instead.
292
+ let mutex: Promise<unknown> = Promise.resolve()
293
+ const withTx = async <T>(
294
+ fn: (query: PostgresConnection['query']) => Promise<T>,
295
+ ): Promise<T> => {
296
+ const c = await client()
297
+ if (c.connect) {
298
+ const conn = await c.connect()
299
+ try {
300
+ await conn.query('begin')
301
+ const result = await fn((text, values) => conn.query(text, values))
302
+ await conn.query('commit')
303
+ return result
304
+ } catch (err) {
305
+ await conn.query('rollback').catch(() => {})
306
+ throw err
307
+ } finally {
308
+ conn.release()
309
+ }
310
+ }
311
+ const run = mutex.then(async () => {
312
+ await c.query('begin')
313
+ try {
314
+ const result = await fn((text, values) => c.query(text, values))
315
+ await c.query('commit')
316
+ return result
317
+ } catch (err) {
318
+ await c.query('rollback').catch(() => {})
319
+ throw err
320
+ }
321
+ })
322
+ mutex = run.catch(() => {})
323
+ return run
324
+ }
325
+
326
+ return {
327
+ async append(sessionId, events) {
328
+ if (events.length === 0) {
329
+ return wrap(async () => {
330
+ const c = await client()
331
+ const { rows } = await c.query(
332
+ `select exists(
333
+ select 1 from a2_events
334
+ where session_id = $1 and processed_at is null
335
+ ) as has_pending`,
336
+ [sessionId],
337
+ )
338
+ return {
339
+ events: [],
340
+ hasPending: rows[0]?.['has_pending'] === true,
341
+ }
342
+ })
343
+ }
344
+ return wrap(() =>
345
+ withTx(async (query) => {
346
+ // Serialize appends per session — index assignment must not
347
+ // race across writers. Transaction-scoped, self-releasing.
348
+ await query('select pg_advisory_xact_lock(hashtext($1))', [sessionId])
349
+
350
+ const supplied = events.filter((e) => e.id !== undefined)
351
+ const suppliedIds = supplied.map((e) => e.id as string)
352
+ if (new Set(suppliedIds).size !== suppliedIds.length) {
353
+ throw new A2Error(
354
+ 'PARTIAL_DUPLICATE_BATCH',
355
+ 'batch contains the same event id more than once',
356
+ )
357
+ }
358
+ if (suppliedIds.length > 0) {
359
+ const { rows: existing } = await query(
360
+ `select stored.*,
361
+ exists(
362
+ select 1 from a2_events pending
363
+ where pending.session_id = $2
364
+ and pending.processed_at is null
365
+ ) as session_has_pending
366
+ from a2_events stored
367
+ where stored.event_id = any($1)
368
+ order by stored.idx`,
369
+ [suppliedIds, sessionId],
370
+ )
371
+ if (existing.length > 0) {
372
+ const foreign = existing.find(
373
+ (row) => row['session_id'] !== sessionId,
374
+ )
375
+ if (foreign) {
376
+ throw new A2Error(
377
+ 'PARTIAL_DUPLICATE_BATCH',
378
+ `event id '${String(foreign['event_id'])}' already exists in another session`,
379
+ )
380
+ }
381
+ if (existing.length === events.length) {
382
+ return {
383
+ events: idempotentReplay(events, existing.map(toStored)),
384
+ hasPending: existing[0]?.['session_has_pending'] === true,
385
+ }
386
+ }
387
+ throw new A2Error(
388
+ 'PARTIAL_DUPLICATE_BATCH',
389
+ `batch mixes ${existing.length} already-appended and ${events.length - existing.length} fresh events`,
390
+ )
391
+ }
392
+ }
393
+
394
+ // Attempt-currency fence: a fresh handler append commits only
395
+ // while its causal attempt is still the parent's latest
396
+ // (store.ts `append`). `for update` serializes this read against
397
+ // a concurrent `claimAvailable` — an unlocked read admits write
398
+ // skew.
399
+ const causeIndexes = [
400
+ ...new Set(
401
+ events
402
+ .filter((e) => e.cause !== undefined)
403
+ .map((e) => e.cause!.index),
404
+ ),
405
+ ]
406
+ if (causeIndexes.length > 0) {
407
+ const { rows: parents } = await query(
408
+ `select idx, attempt_count, failed_at from a2_events
409
+ where session_id = $1 and idx = any($2::bigint[])
410
+ for update`,
411
+ [sessionId, causeIndexes],
412
+ )
413
+ const byIndex = new Map(
414
+ parents.map((row) => [asNumber(row['idx']), row]),
415
+ )
416
+ for (const e of events) {
417
+ if (!e.cause) continue
418
+ const parent = byIndex.get(e.cause.index)
419
+ if (!parent) {
420
+ throw new TypeError(
421
+ `no event at index ${e.cause.index} in session '${sessionId}'`,
422
+ )
423
+ }
424
+ if (
425
+ asNumber(parent['attempt_count']) !== e.cause.attempt ||
426
+ parent['failed_at'] !== null
427
+ ) {
428
+ throw new A2Error(
429
+ 'SUPERSEDED_ATTEMPT',
430
+ `attempt ${e.cause.attempt} no longer owns event ${e.cause.index} in session '${sessionId}'`,
431
+ )
432
+ }
433
+ }
434
+ }
435
+
436
+ const { rows: maxRows } = await query(
437
+ `select coalesce(max(idx), 0) as max,
438
+ exists(
439
+ select 1 from a2_events pending
440
+ where pending.session_id = $1
441
+ and pending.processed_at is null
442
+ ) as session_has_pending
443
+ from a2_events
444
+ where session_id = $1`,
445
+ [sessionId],
446
+ )
447
+ const base = asNumber(maxRows[0]?.['max'])
448
+ const now = clock.now().getTime()
449
+ const inserted: StoredEvent[] = []
450
+ for (let i = 0; i < events.length; i += 1) {
451
+ const e = events[i]!
452
+ const id = e.id ?? generateId()
453
+ const index = base + 1 + i
454
+ // oxlint-disable-next-line no-await-in-loop -- one tx, ordered inserts
455
+ await query(
456
+ `insert into a2_events
457
+ (session_id, idx, event_type, payload, event_id, created_at,
458
+ cause, lane, lane_ready, processed_at)
459
+ values (
460
+ $1, $2, $3, $4::jsonb, $5, $6, $7::jsonb, $8,
461
+ $8::text is null or not exists (
462
+ select 1 from a2_events
463
+ where session_id = $1 and lane = $8 and processed_at is null
464
+ ),
465
+ $9
466
+ )`,
467
+ [
468
+ sessionId,
469
+ index,
470
+ e.type,
471
+ jsonb(e.payload),
472
+ id,
473
+ now,
474
+ e.cause ? jsonb(e.cause) : null,
475
+ e.lane ?? null,
476
+ e.settled ? now : null,
477
+ ],
478
+ )
479
+ inserted.push({
480
+ id,
481
+ type: e.type,
482
+ payload: structuredClone(e.payload),
483
+ index,
484
+ sessionId,
485
+ createdAt: new Date(now),
486
+ cause: e.cause ? { ...e.cause } : null,
487
+ lane: e.lane ?? null,
488
+ processedAt: e.settled ? new Date(now) : null,
489
+ processedByAttempt: null,
490
+ returnedEventIds: null,
491
+ firstClaimedAt: null,
492
+ lastClaimedAt: null,
493
+ attemptCount: 0,
494
+ failureCount: 0,
495
+ lastFailedAt: null,
496
+ lastFailedAttempt: null,
497
+ lastError: null,
498
+ failedAt: null,
499
+ claimHolder: null,
500
+ claimExpiresAt: null,
501
+ })
502
+ }
503
+ return {
504
+ events: inserted,
505
+ hasPending:
506
+ maxRows[0]?.['session_has_pending'] === true ||
507
+ events.some((event) => event.settled !== true),
508
+ }
509
+ }),
510
+ )
511
+ },
512
+
513
+ async read(sessionId, opts) {
514
+ return wrap(async () => {
515
+ const c = await client()
516
+ const conditions = ['session_id = $1']
517
+ const params: unknown[] = [sessionId]
518
+ if (opts?.afterIndex !== undefined) {
519
+ params.push(opts.afterIndex)
520
+ conditions.push(`idx > $${params.length}`)
521
+ }
522
+ if (opts?.throughIndex !== undefined) {
523
+ params.push(opts.throughIndex)
524
+ conditions.push(`idx <= $${params.length}`)
525
+ }
526
+ const { rows } = await c.query(
527
+ `select * from a2_events where ${conditions.join(' and ')} order by idx`,
528
+ params,
529
+ )
530
+ return rows.map(toStored)
531
+ })
532
+ },
533
+
534
+ async claimAvailable({
535
+ sessionId,
536
+ holder,
537
+ ttlMs,
538
+ expiresAtMs,
539
+ excludeIndexes = [],
540
+ }) {
541
+ return wrap(() =>
542
+ withTx(async (query) => {
543
+ const now = clock.now().getTime()
544
+ const expiresAt = expiresAtMs ?? now + ttlMs
545
+ const { rows } = await query(
546
+ `with eligible as materialized (
547
+ select event.idx
548
+ from a2_events event
549
+ where event.session_id = $1
550
+ and event.processed_at is null
551
+ and event.failed_at is null
552
+ and event.lane_ready
553
+ and (event.claim_expires_at is null or event.claim_expires_at <= $2)
554
+ and not (event.idx = any($5::bigint[]))
555
+ for update of event
556
+ )
557
+ update a2_events claimed set
558
+ attempt_count = claimed.attempt_count + 1,
559
+ first_claimed_at = coalesce(claimed.first_claimed_at, $2),
560
+ last_claimed_at = $2,
561
+ claim_holder = $3,
562
+ claim_expires_at = $4
563
+ from eligible
564
+ where claimed.session_id = $1 and claimed.idx = eligible.idx
565
+ returning claimed.*`,
566
+ [sessionId, now, holder, expiresAt, [...excludeIndexes]],
567
+ )
568
+ if (rows.length > 0) {
569
+ const events = rows
570
+ .map(toStored)
571
+ .toSorted((a, b) => a.index - b.index)
572
+ return { outcome: 'claimed', events }
573
+ }
574
+ const { rows: activeRows } = await query(
575
+ `select min(claim_expires_at) as retry_at
576
+ from a2_events
577
+ where session_id = $1
578
+ and processed_at is null
579
+ and failed_at is null
580
+ and lane_ready
581
+ and claim_expires_at > $2`,
582
+ [sessionId, now],
583
+ )
584
+ const retryAt = activeRows[0]?.['retry_at']
585
+ return retryAt === null || retryAt === undefined
586
+ ? { outcome: 'settled' }
587
+ : { outcome: 'busy', retryAt: new Date(asNumber(retryAt)) }
588
+ }),
589
+ )
590
+ },
591
+
592
+ async renewClaims({ sessionId, holder, claims, ttlMs, expiresAtMs }) {
593
+ if (claims.length === 0) return { renewed: [], superseded: [] }
594
+ return wrap(async () => {
595
+ const c = await client()
596
+ const now = clock.now().getTime()
597
+ const expiresAt = expiresAtMs ?? now + ttlMs
598
+ const indexes = claims.map((claim) => claim.index)
599
+ const attempts = new Map(
600
+ claims.map((claim) => [claim.index, claim.attempt]),
601
+ )
602
+ const { rows } = await c.query(
603
+ `with listed as (
604
+ select idx, attempt
605
+ from unnest($3::bigint[], $6::int[]) as listed(idx, attempt)
606
+ ),
607
+ renewed as (
608
+ update a2_events event set claim_expires_at = $1
609
+ from listed
610
+ where event.session_id = $2
611
+ and event.idx = listed.idx
612
+ and event.attempt_count = listed.attempt
613
+ and event.processed_at is null
614
+ and event.failed_at is null
615
+ and event.claim_holder = $4
616
+ and event.claim_expires_at > $5
617
+ returning event.idx
618
+ )
619
+ select event.idx, event.attempt_count,
620
+ exists(select 1 from renewed where renewed.idx = event.idx)
621
+ as renewed
622
+ from a2_events event
623
+ where event.session_id = $2 and event.idx = any($3::bigint[])`,
624
+ [
625
+ expiresAt,
626
+ sessionId,
627
+ indexes,
628
+ holder,
629
+ now,
630
+ claims.map((claim) => claim.attempt),
631
+ ],
632
+ )
633
+ const renewed: number[] = []
634
+ const superseded: number[] = []
635
+ for (const row of rows) {
636
+ const index = asNumber(row['idx'])
637
+ if (row['renewed'] === true) renewed.push(index)
638
+ else if (
639
+ asNumber(row['attempt_count']) > (attempts.get(index) ?? 0)
640
+ ) {
641
+ superseded.push(index)
642
+ }
643
+ }
644
+ return {
645
+ renewed: renewed.toSorted((a, b) => a - b),
646
+ superseded: superseded.toSorted((a, b) => a - b),
647
+ }
648
+ })
649
+ },
650
+
651
+ async completeAttempt({ sessionId, index, attempt, events }) {
652
+ return wrap(() =>
653
+ withTx(async (query) => {
654
+ const { rows: parentRows } = await query(
655
+ `select * from a2_events
656
+ where session_id = $1 and idx = $2
657
+ for update`,
658
+ [sessionId, index],
659
+ )
660
+ const parent = parentRows[0]
661
+ if (!parent) {
662
+ throw new TypeError(
663
+ `no event at index ${index} in session '${sessionId}'`,
664
+ )
665
+ }
666
+ const ids = events.map((event) => event.id)
667
+ if (new Set(ids).size !== ids.length) {
668
+ throw new A2Error(
669
+ 'PARTIAL_DUPLICATE_BATCH',
670
+ 'returned event batch contains the same event id more than once',
671
+ )
672
+ }
673
+ if (parent['processed_at'] !== null) {
674
+ if (asNumber(parent['processed_by_attempt']) !== attempt) {
675
+ return { outcome: 'superseded' }
676
+ }
677
+ const returnedIds =
678
+ parent['returned_event_ids'] === null ||
679
+ parent['returned_event_ids'] === undefined
680
+ ? null
681
+ : decodeReturnedEventIds(parent['returned_event_ids'])
682
+ if (
683
+ returnedIds === null ||
684
+ returnedIds.length !== ids.length ||
685
+ returnedIds.some((id, offset) => id !== ids[offset])
686
+ ) {
687
+ throw new A2Error(
688
+ 'PARTIAL_DUPLICATE_BATCH',
689
+ 'completed attempt does not match the returned event batch',
690
+ )
691
+ }
692
+ if (events.length === 0) {
693
+ return { outcome: 'completed', events: [] }
694
+ }
695
+ const { rows: existing } = await query(
696
+ `select * from a2_events
697
+ where event_id = any($1::text[])`,
698
+ [ids],
699
+ )
700
+ const byId = new Map(
701
+ existing.map((row) => [row['event_id'] as string, row]),
702
+ )
703
+ const ordered = ids.map((id) => byId.get(id))
704
+ if (
705
+ ordered.some((row) => {
706
+ const cause = row ? toCause(row['cause']) : null
707
+ return (
708
+ !row ||
709
+ row['session_id'] !== sessionId ||
710
+ cause?.index !== index ||
711
+ cause.attempt !== attempt
712
+ )
713
+ })
714
+ ) {
715
+ throw new A2Error(
716
+ 'PARTIAL_DUPLICATE_BATCH',
717
+ 'returned event retry does not match the committed batch',
718
+ )
719
+ }
720
+ return {
721
+ outcome: 'completed',
722
+ events: idempotentReplay(
723
+ events,
724
+ (ordered as Record<string, unknown>[]).map(toStored),
725
+ ),
726
+ }
727
+ }
728
+ if (
729
+ asNumber(parent['attempt_count']) !== attempt ||
730
+ parent['claim_holder'] === null ||
731
+ parent['claim_holder'] === undefined ||
732
+ parent['failed_at'] !== null
733
+ ) {
734
+ return { outcome: 'superseded' }
735
+ }
736
+
737
+ await query('select pg_advisory_xact_lock(hashtext($1))', [sessionId])
738
+ if (ids.length > 0) {
739
+ const { rows: existing } = await query(
740
+ 'select event_id from a2_events where event_id = any($1::text[])',
741
+ [ids],
742
+ )
743
+ if (existing.length > 0) {
744
+ throw new A2Error(
745
+ 'PARTIAL_DUPLICATE_BATCH',
746
+ 'returned event batch contains an already-appended event id',
747
+ )
748
+ }
749
+ }
750
+
751
+ const { rows: maxRows } = await query(
752
+ 'select coalesce(max(idx), 0) as max from a2_events where session_id = $1',
753
+ [sessionId],
754
+ )
755
+ const base = asNumber(maxRows[0]?.['max'])
756
+ const now = clock.now().getTime()
757
+ const inserted: StoredEvent[] = []
758
+ for (let offset = 0; offset < events.length; offset += 1) {
759
+ const event = events[offset]!
760
+ const childIndex = base + offset + 1
761
+ // oxlint-disable-next-line no-await-in-loop -- one tx, ordered inserts
762
+ const { rows } = await query(
763
+ `insert into a2_events
764
+ (session_id, idx, event_type, payload, event_id, created_at,
765
+ cause, lane, lane_ready, processed_at)
766
+ values (
767
+ $1, $2, $3, $4::jsonb, $5, $6, $7::jsonb, $8,
768
+ $8::text is null or not exists (
769
+ select 1 from a2_events
770
+ where session_id = $1 and lane = $8 and processed_at is null
771
+ ),
772
+ $9
773
+ )
774
+ returning *`,
775
+ [
776
+ sessionId,
777
+ childIndex,
778
+ event.type,
779
+ jsonb(event.payload),
780
+ event.id,
781
+ now,
782
+ jsonb({ index, attempt }),
783
+ event.lane ?? null,
784
+ event.settled ? now : null,
785
+ ],
786
+ )
787
+ inserted.push(toStored(rows[0]!))
788
+ }
789
+ await query(
790
+ `update a2_events set
791
+ processed_at = $1,
792
+ processed_by_attempt = $2,
793
+ returned_event_ids = $5::jsonb,
794
+ lane_ready = false,
795
+ claim_holder = null,
796
+ claim_expires_at = null
797
+ where session_id = $3 and idx = $4`,
798
+ [now, attempt, sessionId, index, jsonb(ids)],
799
+ )
800
+ const parentLane = (parent['lane'] as string | null) ?? null
801
+ if (parentLane !== null) {
802
+ await query(
803
+ `update a2_events set lane_ready = true
804
+ where session_id = $1
805
+ and idx = (
806
+ select idx from a2_events
807
+ where session_id = $1 and lane = $2 and processed_at is null
808
+ order by idx limit 1
809
+ )`,
810
+ [sessionId, parentLane],
811
+ )
812
+ }
813
+ return { outcome: 'completed', events: inserted }
814
+ }),
815
+ )
816
+ },
817
+
818
+ async failAttempt({ sessionId, index, attempt, error, maxFailures }) {
819
+ return wrap(async () => {
820
+ const c = await client()
821
+ const { rows } = await c.query(
822
+ `with current as materialized (
823
+ select e.* from a2_events e
824
+ where e.session_id = $1 and e.idx = $2
825
+ for update of e
826
+ ), updated as (
827
+ update a2_events e set
828
+ failure_count = e.failure_count + 1,
829
+ last_error = $4,
830
+ last_failed_at = $6,
831
+ last_failed_attempt = $3,
832
+ failed_at = case
833
+ when e.failure_count + 1 >= $5 then $6
834
+ else e.failed_at
835
+ end,
836
+ claim_holder = null,
837
+ claim_expires_at = null
838
+ from current
839
+ where e.session_id = $1
840
+ and e.idx = $2
841
+ and current.processed_at is null
842
+ and current.failed_at is null
843
+ and current.attempt_count = $3
844
+ and current.claim_holder is not null
845
+ returning e.failure_count, e.failed_at
846
+ )
847
+ select case
848
+ when not exists (select 1 from current) then 'missing'
849
+ when exists (
850
+ select 1 from updated where failed_at is not null
851
+ ) then 'dead_lettered'
852
+ when exists (select 1 from updated) then 'failed'
853
+ when exists (
854
+ select 1 from current
855
+ where processed_at is not null or attempt_count <> $3
856
+ ) then 'superseded'
857
+ when exists (
858
+ select 1 from current where failed_at is not null
859
+ ) then 'dead_lettered'
860
+ when exists (
861
+ select 1 from current
862
+ where claim_holder is null and last_failed_attempt = $3
863
+ ) then 'failed'
864
+ else 'superseded'
865
+ end as outcome,
866
+ coalesce(
867
+ (select failure_count from updated),
868
+ (select failure_count from current)
869
+ ) as failure_count`,
870
+ [
871
+ sessionId,
872
+ index,
873
+ attempt,
874
+ error,
875
+ maxFailures,
876
+ clock.now().getTime(),
877
+ ],
878
+ )
879
+ const row = rows[0]
880
+ if (!row || row['outcome'] === 'missing') {
881
+ throw new TypeError(
882
+ `no event at index ${index} in session '${sessionId}'`,
883
+ )
884
+ }
885
+ return {
886
+ outcome: row['outcome'] as 'failed' | 'dead_lettered' | 'superseded',
887
+ failureCount: asNumber(row['failure_count']),
888
+ }
889
+ })
890
+ },
891
+
892
+ async readState(sessionId, reducerName) {
893
+ return wrap(async () => {
894
+ const c = await client()
895
+ const { rows } = await c.query(
896
+ `with snapshot as materialized (
897
+ select up_to_index, state
898
+ from a2_snapshots
899
+ where session_id = $1 and reducer_name = $2
900
+ )
901
+ select 0 as row_order, 'snapshot' as row_kind,
902
+ snapshot.up_to_index as snapshot_index,
903
+ snapshot.state as snapshot_state,
904
+ null::text as session_id, null::bigint as idx,
905
+ null::text as event_type, null::jsonb as payload,
906
+ null::text as event_id, null::bigint as created_at
907
+ from snapshot
908
+ union all
909
+ select 1 as row_order, 'event' as row_kind,
910
+ null::bigint as snapshot_index,
911
+ null::jsonb as snapshot_state,
912
+ event.session_id, event.idx, event.event_type,
913
+ event.payload, event.event_id, event.created_at
914
+ from a2_events event
915
+ where event.session_id = $1
916
+ and event.idx > coalesce((select up_to_index from snapshot), 0)
917
+ order by row_order, idx`,
918
+ [sessionId, reducerName],
919
+ )
920
+ const snapshot = rows.find((row) => row['row_kind'] === 'snapshot')
921
+ return {
922
+ snapshot: snapshot
923
+ ? {
924
+ index: asNumber(snapshot['snapshot_index']),
925
+ state: snapshot['snapshot_state'],
926
+ }
927
+ : null,
928
+ events: rows
929
+ .filter((row) => row['row_kind'] === 'event')
930
+ .map(toEvent),
931
+ }
932
+ })
933
+ },
934
+
935
+ async readStates(sessionIds, reducerName) {
936
+ if (sessionIds.length === 0) return []
937
+ return wrap(async () => {
938
+ const c = await client()
939
+ // One statement, one MVCC snapshot — each session's tail is
940
+ // bounded by the same snapshot row the read returns.
941
+ const { rows } = await c.query(
942
+ `with snapshot as materialized (
943
+ select session_id, up_to_index, state
944
+ from a2_snapshots
945
+ where session_id = any($1::text[]) and reducer_name = $2
946
+ )
947
+ select 0 as row_order, 'snapshot' as row_kind,
948
+ snapshot.session_id,
949
+ snapshot.up_to_index as snapshot_index,
950
+ snapshot.state as snapshot_state,
951
+ null::bigint as idx, null::text as event_type,
952
+ null::jsonb as payload, null::text as event_id,
953
+ null::bigint as created_at
954
+ from snapshot
955
+ union all
956
+ select 1 as row_order, 'event' as row_kind,
957
+ event.session_id,
958
+ null::bigint as snapshot_index,
959
+ null::jsonb as snapshot_state,
960
+ event.idx, event.event_type, event.payload,
961
+ event.event_id, event.created_at
962
+ from a2_events event
963
+ left join snapshot on snapshot.session_id = event.session_id
964
+ where event.session_id = any($1::text[])
965
+ and event.idx > coalesce(snapshot.up_to_index, 0)
966
+ order by row_order, session_id, idx`,
967
+ [sessionIds, reducerName],
968
+ )
969
+ const bySession = new Map<string, StoreStateRead>()
970
+ const readOf = (sessionId: string): StoreStateRead => {
971
+ let read = bySession.get(sessionId)
972
+ if (!read) {
973
+ read = { snapshot: null, events: [] }
974
+ bySession.set(sessionId, read)
975
+ }
976
+ return read
977
+ }
978
+ for (const row of rows) {
979
+ const read = readOf(row['session_id'] as string)
980
+ if (row['row_kind'] === 'snapshot') {
981
+ read.snapshot = {
982
+ index: asNumber(row['snapshot_index']),
983
+ state: row['snapshot_state'],
984
+ }
985
+ } else {
986
+ read.events.push(toEvent(row))
987
+ }
988
+ }
989
+ return sessionIds.map(
990
+ (sessionId) =>
991
+ bySession.get(sessionId) ?? { snapshot: null, events: [] },
992
+ )
993
+ })
994
+ },
995
+
996
+ async putSnapshot(sessionId, reducerName, index, state) {
997
+ await wrap(async () => {
998
+ const c = await client()
999
+ // Guarded upsert: a slower concurrent writer must never clobber
1000
+ // a further-along snapshot (spec §3).
1001
+ await c.query(
1002
+ `insert into a2_snapshots (session_id, reducer_name, up_to_index, state, updated_at)
1003
+ values ($1, $2, $3, $4::jsonb, $5)
1004
+ on conflict (session_id, reducer_name) do update set
1005
+ up_to_index = excluded.up_to_index,
1006
+ state = excluded.state,
1007
+ updated_at = excluded.updated_at
1008
+ where excluded.up_to_index > a2_snapshots.up_to_index`,
1009
+ [sessionId, reducerName, index, jsonb(state), clock.now().getTime()],
1010
+ )
1011
+ })
1012
+ },
1013
+
1014
+ presence: {
1015
+ async set(ns, participant, values, meta) {
1016
+ const entries = Object.entries(values)
1017
+ if (entries.length === 0) return
1018
+ await wrap(async () => {
1019
+ const c = await client()
1020
+ const atMs = meta.at.getTime()
1021
+ // Expiry anchors on the storage clock — the sender's `at`
1022
+ // orders writes but never anchors their lifetime.
1023
+ const expiresAtMs = clock.now().getTime() + meta.ttlMs
1024
+ for (const [field, value] of entries) {
1025
+ // Field-wise LWW: a stored row with a strictly newer `at`
1026
+ // survives; ties go to the incoming write (memory parity).
1027
+ // oxlint-disable-next-line no-await-in-loop -- independent per-field registers
1028
+ await (value === null
1029
+ ? c.query(
1030
+ `delete from a2_presence
1031
+ where ns = $1 and participant = $2 and field = $3
1032
+ and at <= $4`,
1033
+ [ns, participant, field, atMs],
1034
+ )
1035
+ : c.query(
1036
+ `insert into a2_presence
1037
+ (ns, participant, field, value, seen, at, expires_at)
1038
+ values ($1, $2, $3, $4::jsonb, $5, $6, $7)
1039
+ on conflict (ns, participant, field) do update set
1040
+ value = excluded.value,
1041
+ seen = excluded.seen,
1042
+ at = excluded.at,
1043
+ expires_at = excluded.expires_at
1044
+ where a2_presence.at <= excluded.at`,
1045
+ [
1046
+ ns,
1047
+ participant,
1048
+ field,
1049
+ jsonb(value),
1050
+ meta.seen,
1051
+ atMs,
1052
+ expiresAtMs,
1053
+ ],
1054
+ ))
1055
+ }
1056
+ })
1057
+ },
1058
+
1059
+ async read(ns) {
1060
+ return wrap(async () => {
1061
+ const c = await client()
1062
+ const now = clock.now().getTime()
1063
+ // Sweep and read share one statement (one snapshot): the
1064
+ // delete is opportunistic cleanup; the filter alone keeps
1065
+ // expired rows out of this result.
1066
+ const { rows } = await c.query(
1067
+ `with swept as (
1068
+ delete from a2_presence
1069
+ where ns = $1 and expires_at <= $2
1070
+ )
1071
+ select participant, field, value, seen, at, expires_at
1072
+ from a2_presence
1073
+ where ns = $1 and expires_at > $2
1074
+ order by participant, field`,
1075
+ [ns, now],
1076
+ )
1077
+ return rows.map((row) => ({
1078
+ participant: row['participant'] as string,
1079
+ field: row['field'] as string,
1080
+ value: row['value'],
1081
+ seen: asNumber(row['seen']),
1082
+ at: new Date(asNumber(row['at'])),
1083
+ expiresAt: new Date(asNumber(row['expires_at'])),
1084
+ }))
1085
+ })
1086
+ },
1087
+ },
1088
+
1089
+ inspect: {
1090
+ async listSessions(inspectionOptions) {
1091
+ return wrap(async () => {
1092
+ const c = await client()
1093
+ const conditions = ['left(session_id, length($1)) = $1']
1094
+ const params: unknown[] = [inspectionOptions.prefix]
1095
+ if (inspectionOptions.cursor !== undefined) {
1096
+ params.push(inspectionOptions.cursor)
1097
+ conditions.push(`session_id > $${params.length}`)
1098
+ }
1099
+ params.push(inspectionOptions.limit + 1)
1100
+ const { rows } = await c.query(
1101
+ `select
1102
+ session_id,
1103
+ count(*) as event_count,
1104
+ count(*) filter (where processed_at is null and failed_at is null) as pending_count,
1105
+ count(*) filter (where failed_at is not null) as failed_count,
1106
+ sum(attempt_count) as attempt_count,
1107
+ sum(failure_count) as failure_count,
1108
+ min(created_at) as first_event_at,
1109
+ max(greatest(
1110
+ created_at,
1111
+ coalesce(first_claimed_at, 0),
1112
+ coalesce(last_claimed_at, 0),
1113
+ coalesce(last_failed_at, 0),
1114
+ coalesce(processed_at, 0),
1115
+ coalesce(failed_at, 0)
1116
+ )) as updated_at
1117
+ from a2_events
1118
+ where ${conditions.join(' and ')}
1119
+ group by session_id
1120
+ order by session_id
1121
+ limit $${params.length}`,
1122
+ params,
1123
+ )
1124
+ const hasMore = rows.length > inspectionOptions.limit
1125
+ const visible = rows.slice(0, inspectionOptions.limit)
1126
+ return {
1127
+ sessions: visible.map((row) => ({
1128
+ sessionId: row['session_id'] as string,
1129
+ eventCount: asNumber(row['event_count']),
1130
+ pendingCount: asNumber(row['pending_count']),
1131
+ failedCount: asNumber(row['failed_count']),
1132
+ attemptCount: asNumber(row['attempt_count']),
1133
+ failureCount: asNumber(row['failure_count']),
1134
+ firstEventAt: new Date(asNumber(row['first_event_at'])),
1135
+ updatedAt: new Date(asNumber(row['updated_at'])),
1136
+ })),
1137
+ cursor: hasMore
1138
+ ? ((visible.at(-1)?.['session_id'] as string | undefined) ?? null)
1139
+ : null,
1140
+ }
1141
+ })
1142
+ },
1143
+ async readEvents(sessionId, pageOptions) {
1144
+ return wrap(async () => {
1145
+ const c = await client()
1146
+ const currentFrontier = asNumber(
1147
+ (
1148
+ await c.query(
1149
+ 'select coalesce(max(idx), 0) as frontier from a2_events where session_id = $1',
1150
+ [sessionId],
1151
+ )
1152
+ ).rows[0]?.['frontier'] ?? 0,
1153
+ )
1154
+ const throughIndex = Math.min(
1155
+ pageOptions.throughIndex ?? currentFrontier,
1156
+ currentFrontier,
1157
+ )
1158
+ const { rows } = await c.query(
1159
+ `select * from a2_events
1160
+ where session_id = $1 and idx > $2 and idx <= $3
1161
+ order by idx limit $4`,
1162
+ [
1163
+ sessionId,
1164
+ pageOptions.afterIndex,
1165
+ throughIndex,
1166
+ pageOptions.limit,
1167
+ ],
1168
+ )
1169
+ return { events: rows.map(toStored), throughIndex }
1170
+ })
1171
+ },
1172
+ async listSnapshots(sessionId) {
1173
+ return wrap(async () => {
1174
+ const c = await client()
1175
+ const { rows } = await c.query(
1176
+ `select reducer_name, up_to_index, updated_at
1177
+ from a2_snapshots where session_id = $1 order by reducer_name`,
1178
+ [sessionId],
1179
+ )
1180
+ return rows.map((row) => ({
1181
+ reducerName: row['reducer_name'] as string,
1182
+ index: asNumber(row['up_to_index']),
1183
+ updatedAt: new Date(asNumber(row['updated_at'])),
1184
+ }))
1185
+ })
1186
+ },
1187
+ },
1188
+
1189
+ stream(sessionId, opts) {
1190
+ const readAfter = (afterIndex: number): Promise<StoredEvent[]> =>
1191
+ wrap(async () => {
1192
+ const c = await client()
1193
+ const { rows } = await c.query(
1194
+ 'select * from a2_events where session_id = $1 and idx > $2 order by idx',
1195
+ [sessionId, afterIndex],
1196
+ )
1197
+ return rows.map(toStored)
1198
+ })
1199
+ return pollingStream(
1200
+ readAfter,
1201
+ opts?.startAfter !== undefined ? { startAfter: opts.startAfter } : {},
1202
+ )
1203
+ },
1204
+
1205
+ async close() {
1206
+ const current = connection.peek()
1207
+ if (!current) return
1208
+ const c = await current
1209
+ await c.end?.()
1210
+ },
1211
+ }
1212
+ }