experimental-a2 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (182) hide show
  1. package/CHANGELOG.md +51 -0
  2. package/dist/{ai-B4YhEnfw.d.ts → ai-CFNeCrRl.d.ts} +3 -2
  3. package/dist/ai-CFNeCrRl.d.ts.map +1 -0
  4. package/dist/ai-server.browser.js +2 -0
  5. package/dist/ai-server.browser.js.map +1 -0
  6. package/dist/ai-server.d.ts +4 -3
  7. package/dist/ai-server.d.ts.map +1 -0
  8. package/dist/ai-server.js +4 -2
  9. package/dist/ai-server.js.map +1 -0
  10. package/dist/ai.d.ts +1 -1
  11. package/dist/ai.js +3 -1
  12. package/dist/ai.js.map +1 -0
  13. package/dist/cli-B3VuxoDe.js +2 -0
  14. package/dist/cli-B3VuxoDe.js.map +1 -0
  15. package/dist/cli-bin.js +2 -0
  16. package/dist/cli-bin.js.map +1 -0
  17. package/dist/cli.d.ts +2 -1
  18. package/dist/cli.d.ts.map +1 -0
  19. package/dist/{client-Bt4tAKi9.js → client-BKlyLiOU.js} +295 -85
  20. package/dist/client-BKlyLiOU.js.map +1 -0
  21. package/dist/{client-BrfDXQ8A.d.ts → client-D7mvIXrF.d.ts} +40 -4
  22. package/dist/client-D7mvIXrF.d.ts.map +1 -0
  23. package/dist/client.d.ts +2 -2
  24. package/dist/client.js +1 -1
  25. package/dist/contract-48bUMgcL.js +2 -0
  26. package/dist/contract-48bUMgcL.js.map +1 -0
  27. package/dist/contract-jIfaR085.d.ts +2 -1
  28. package/dist/contract-jIfaR085.d.ts.map +1 -0
  29. package/dist/devtools-J_jZ2vQf.d.ts +2 -1
  30. package/dist/devtools-J_jZ2vQf.d.ts.map +1 -0
  31. package/dist/devtools-kJJaORn-.js +2 -0
  32. package/dist/devtools-kJJaORn-.js.map +1 -0
  33. package/dist/devtools-server.browser.js +2 -0
  34. package/dist/devtools-server.browser.js.map +1 -0
  35. package/dist/devtools-server.d.ts +2 -1
  36. package/dist/devtools-server.d.ts.map +1 -0
  37. package/dist/devtools-server.js +2 -0
  38. package/dist/devtools-server.js.map +1 -0
  39. package/dist/errors-BQuJpe82.js +2 -0
  40. package/dist/errors-BQuJpe82.js.map +1 -0
  41. package/dist/errors-W6nwJ-fm.d.ts +2 -1
  42. package/dist/errors-W6nwJ-fm.d.ts.map +1 -0
  43. package/dist/http.d.ts +121 -72
  44. package/dist/http.d.ts.map +1 -0
  45. package/dist/http.js +503 -178
  46. package/dist/http.js.map +1 -0
  47. package/dist/idempotent-replay-DuqEkYA7.js +2 -0
  48. package/dist/idempotent-replay-DuqEkYA7.js.map +1 -0
  49. package/dist/index.d.ts +1 -1
  50. package/dist/inspection-DaxB5jM2.js +2 -0
  51. package/dist/inspection-DaxB5jM2.js.map +1 -0
  52. package/dist/{internal-aEotMzu_.js → internal-DstsI6Re.js} +3 -1
  53. package/dist/internal-DstsI6Re.js.map +1 -0
  54. package/dist/otel.d.ts +2 -1
  55. package/dist/otel.d.ts.map +1 -0
  56. package/dist/otel.js +2 -0
  57. package/dist/otel.js.map +1 -0
  58. package/dist/platform-B4TnJtWu.js +2 -0
  59. package/dist/platform-B4TnJtWu.js.map +1 -0
  60. package/dist/react.d.ts +12 -3
  61. package/dist/react.d.ts.map +1 -0
  62. package/dist/react.js +5 -1
  63. package/dist/react.js.map +1 -0
  64. package/dist/retryable-lazy-DZWmHpii.js +2 -0
  65. package/dist/retryable-lazy-DZWmHpii.js.map +1 -0
  66. package/dist/scheduler-qstash.d.ts +4 -3
  67. package/dist/scheduler-qstash.d.ts.map +1 -0
  68. package/dist/scheduler-qstash.js +4 -2
  69. package/dist/scheduler-qstash.js.map +1 -0
  70. package/dist/scheduler-task-BpzhPnRS.js +2 -0
  71. package/dist/scheduler-task-BpzhPnRS.js.map +1 -0
  72. package/dist/scheduler-vercel.d.ts +4 -3
  73. package/dist/scheduler-vercel.d.ts.map +1 -0
  74. package/dist/scheduler-vercel.js +4 -2
  75. package/dist/scheduler-vercel.js.map +1 -0
  76. package/dist/{server-CcNnFnoW.js → server-C72KOw51.js} +100 -48
  77. package/dist/server-C72KOw51.js.map +1 -0
  78. package/dist/{server-YtPq7hjw.d.ts → server-DUF9pjsx.d.ts} +5 -4
  79. package/dist/server-DUF9pjsx.d.ts.map +1 -0
  80. package/dist/server.browser.js +2 -0
  81. package/dist/server.browser.js.map +1 -0
  82. package/dist/server.d.ts +2 -2
  83. package/dist/server.js +1 -1
  84. package/dist/{store-C3sNAaBT.d.ts → store-DysUkTH3.d.ts} +10 -1
  85. package/dist/store-DysUkTH3.d.ts.map +1 -0
  86. package/dist/store-N8PXxDAS.js +2 -0
  87. package/dist/store-N8PXxDAS.js.map +1 -0
  88. package/dist/store-codec-DTG0Ftek.js +2 -0
  89. package/dist/store-codec-DTG0Ftek.js.map +1 -0
  90. package/dist/store-memory.d.ts +3 -2
  91. package/dist/store-memory.d.ts.map +1 -0
  92. package/dist/store-memory.js +19 -11
  93. package/dist/store-memory.js.map +1 -0
  94. package/dist/{store-polling-DgrrAE3d.js → store-polling-dSeLxzfb.js} +3 -1
  95. package/dist/store-polling-dSeLxzfb.js.map +1 -0
  96. package/dist/store-postgres.d.ts +3 -2
  97. package/dist/store-postgres.d.ts.map +1 -0
  98. package/dist/store-postgres.js +57 -1
  99. package/dist/store-postgres.js.map +1 -0
  100. package/dist/{store-redis-core-DWqx3F47.js → store-redis-core-BFLwz0Wj.js} +3 -1
  101. package/dist/store-redis-core-BFLwz0Wj.js.map +1 -0
  102. package/dist/store-redis-http.d.ts +3 -2
  103. package/dist/store-redis-http.d.ts.map +1 -0
  104. package/dist/store-redis-http.js +4 -2
  105. package/dist/store-redis-http.js.map +1 -0
  106. package/dist/store-redis.d.ts +3 -2
  107. package/dist/store-redis.d.ts.map +1 -0
  108. package/dist/store-redis.js +5 -3
  109. package/dist/store-redis.js.map +1 -0
  110. package/dist/store-sqlite.d.ts +3 -2
  111. package/dist/store-sqlite.d.ts.map +1 -0
  112. package/dist/store-sqlite.js +3 -1
  113. package/dist/store-sqlite.js.map +1 -0
  114. package/dist/telemetry-BjYHTfh2.d.ts +2 -1
  115. package/dist/telemetry-BjYHTfh2.d.ts.map +1 -0
  116. package/dist/testing.browser.js +2 -0
  117. package/dist/testing.browser.js.map +1 -0
  118. package/dist/testing.d.ts +2 -1
  119. package/dist/testing.d.ts.map +1 -0
  120. package/dist/testing.js +2 -0
  121. package/dist/testing.js.map +1 -0
  122. package/dist/validate-XKT4FSNn.js +2 -0
  123. package/dist/validate-XKT4FSNn.js.map +1 -0
  124. package/dist/{wire-DCUZBUlT.js → wire-BFQmSJ-9.js} +77 -15
  125. package/dist/wire-BFQmSJ-9.js.map +1 -0
  126. package/docs/guides/03-react.mdx +59 -39
  127. package/docs/guides/06-ai-agents.mdx +5 -27
  128. package/docs/guides/09-presence.mdx +19 -40
  129. package/docs/guides/10-transports.mdx +49 -40
  130. package/docs/reference/01-api.mdx +107 -26
  131. package/docs/reference/02-errors.mdx +4 -2
  132. package/package.json +2 -1
  133. package/src/ai-coordinator.ts +358 -0
  134. package/src/ai-projector.ts +524 -0
  135. package/src/ai-sdk-step.ts +261 -0
  136. package/src/ai-server.browser.ts +5 -0
  137. package/src/ai-server.ts +1719 -0
  138. package/src/ai.ts +2155 -0
  139. package/src/cache-indexeddb.ts +10 -0
  140. package/src/cli-bin.ts +5 -0
  141. package/src/cli.ts +1046 -0
  142. package/src/client.ts +1826 -0
  143. package/src/contract.ts +206 -0
  144. package/src/deterministic-id.ts +72 -0
  145. package/src/devtools-app.ts +989 -0
  146. package/src/devtools-server.browser.ts +5 -0
  147. package/src/devtools-server.ts +604 -0
  148. package/src/devtools.ts +716 -0
  149. package/src/errors.ts +50 -0
  150. package/src/http.ts +394 -0
  151. package/src/idempotent-replay.ts +53 -0
  152. package/src/index.ts +37 -0
  153. package/src/inspection.ts +39 -0
  154. package/src/internal.ts +426 -0
  155. package/src/otel.ts +59 -0
  156. package/src/platform.ts +60 -0
  157. package/src/push-envelope.ts +137 -0
  158. package/src/react.ts +284 -0
  159. package/src/reducer.ts +108 -0
  160. package/src/retryable-lazy.ts +27 -0
  161. package/src/scheduler-qstash.ts +915 -0
  162. package/src/scheduler-task.ts +106 -0
  163. package/src/scheduler-vercel.ts +437 -0
  164. package/src/server.browser.ts +12 -0
  165. package/src/server.ts +2700 -0
  166. package/src/session-socket.ts +548 -0
  167. package/src/sse.ts +141 -0
  168. package/src/standard-schema.ts +77 -0
  169. package/src/store-codec.ts +10 -0
  170. package/src/store-memory.ts +788 -0
  171. package/src/store-polling.ts +102 -0
  172. package/src/store-postgres.ts +1212 -0
  173. package/src/store-redis-core.ts +1494 -0
  174. package/src/store-redis-http.ts +116 -0
  175. package/src/store-redis.ts +458 -0
  176. package/src/store-sqlite.ts +1108 -0
  177. package/src/store.ts +385 -0
  178. package/src/telemetry.ts +47 -0
  179. package/src/testing.browser.ts +5 -0
  180. package/src/testing.ts +185 -0
  181. package/src/validate.ts +39 -0
  182. package/src/wire.ts +454 -0
@@ -0,0 +1,1494 @@
1
+ /**
2
+ * The shared Redis store core — every operation except `stream` and
3
+ * `close`, expressed over one injected `call` function so both
4
+ * transports reuse it: `store-redis` (Redis protocol via `ioredis`) and
5
+ * `store-redis-http` (provider REST APIs).
6
+ *
7
+ * The stream IS the store: each event is a stream entry whose ID is its
8
+ * dense a2 index (`${index}-0`), so `XRANGE` is the catch-up read.
9
+ * Mutable drain/failure bookkeeping lives in a side hash (stream
10
+ * entries are immutable); every write path is one atomic Lua script.
11
+ * The conformance suite in test/conformance is the executable
12
+ * contract, run against a real spawned `redis-server`.
13
+ *
14
+ * Timestamps and claim expiry come from the injected clock so tests
15
+ * can time-travel against a real server. Cluster mode is out: the
16
+ * atomic scripts span keys.
17
+ */
18
+
19
+ import type { PresencePatch } from './contract.ts'
20
+ import { A2Error } from './errors.ts'
21
+ import { idempotentReplay } from './idempotent-replay.ts'
22
+ import { nullProtoRecord } from './internal.ts'
23
+ import { decodeReturnedEventIds } from './store-codec.ts'
24
+ import type {
25
+ A2Store,
26
+ A2StoreInspection,
27
+ Clock,
28
+ CompleteAttemptResult,
29
+ Event,
30
+ EventCause,
31
+ IdSource,
32
+ StoreClaimAvailableResult,
33
+ PresenceRow,
34
+ StoredEvent,
35
+ } from './store.ts'
36
+
37
+ /** Issues any Redis command; the seam both transports implement. */
38
+ export type RedisCall = (
39
+ command: string,
40
+ ...args: Array<string | number>
41
+ ) => Promise<unknown>
42
+
43
+ export type RedisStoreCoreOptions = {
44
+ call: RedisCall
45
+ clock: Clock
46
+ ids: IdSource
47
+ keyPrefix: string
48
+ /**
49
+ * Disposable wake-up fired after a write appends new events to a
50
+ * watched session — `count` is the session's new event count. The
51
+ * watched check runs inside the write script, so unwatched sessions
52
+ * cost no wake-up command at all. Losing one is harmless: live
53
+ * feeds re-read on a safety cadence, so notifications only affect
54
+ * latency, never delivery.
55
+ */
56
+ notify?: (sessionId: string, count: number) => void
57
+ }
58
+
59
+ export type RedisStoreCore = Omit<
60
+ A2Store,
61
+ 'stream' | 'inspect' | 'presence'
62
+ > & {
63
+ inspect: A2StoreInspection
64
+ /**
65
+ * The storage half of the presence capability, shared by both
66
+ * transports. `set` returns the applied patch — null when every
67
+ * field lost the LWW race — so a push-capable transport broadcasts
68
+ * exactly what `read` now reflects; delivery is its business.
69
+ */
70
+ presence: {
71
+ set(
72
+ ns: string,
73
+ participant: string,
74
+ values: Record<string, unknown | null>,
75
+ meta: { seen: number; at: Date; ttlMs: number },
76
+ ): Promise<PresencePatch | null>
77
+ read(ns: string): Promise<PresenceRow[]>
78
+ }
79
+ /** XRANGE + meta merge — the full-row catch-up read. */
80
+ readRange(
81
+ sessionId: string,
82
+ afterIndex: number,
83
+ throughIndex?: number,
84
+ ): Promise<StoredEvent[]>
85
+ /** XRANGE only — the light catch-up read live feeds use. */
86
+ readEvents(sessionId: string, afterIndex: number): Promise<Event[]>
87
+ /**
88
+ * Declare the session watched for `ttlMs` — writes publish wake-ups
89
+ * only while this presence marker lives. Live feeds refresh it
90
+ * ahead of expiry; an expired marker degrades latency to the safety
91
+ * cadence, never delivery.
92
+ */
93
+ markWatched(sessionId: string, ttlMs: number): Promise<void>
94
+ }
95
+
96
+ const READ_PAGE_SIZE = 256
97
+
98
+ // ── Lua scripts (each runs atomically) ───────────────────────────────
99
+
100
+ /**
101
+ * KEYS: store stream, counter, global id hash, session index, metadata,
102
+ * pending, ready, watched.
103
+ * ARGV: sessionId, nowMs, n, then per event:
104
+ * id, type, payloadJson, causeJson, lane, settled.
105
+ * Returns ['empty', hasPending] | ['ok', firstIndex, hasPending, watched] |
106
+ * ['dup', hasPending, ...indexes] | ['partial', count] |
107
+ * ['foreign', eventId] | ['no_parent', idx] | ['superseded', idx].
108
+ */
109
+ const APPEND_LUA = `
110
+ local function lane_field(lane, suffix)
111
+ return '__lane:' .. tostring(string.len(lane)) .. ':' .. lane .. ':' .. suffix
112
+ end
113
+ local function enqueue(field, idx, lane)
114
+ redis.call('ZADD', KEYS[6], idx, field)
115
+ if lane == '' then
116
+ redis.call('ZADD', KEYS[7], idx, field)
117
+ return
118
+ end
119
+ local tail_key = lane_field(lane, 'tail')
120
+ local tail = redis.call('HGET', KEYS[5], tail_key)
121
+ if tail then
122
+ redis.call('HSET', KEYS[5], tail .. ':next', field)
123
+ else
124
+ redis.call('HSET', KEYS[5], lane_field(lane, 'head'), field)
125
+ redis.call('ZADD', KEYS[7], idx, field)
126
+ end
127
+ redis.call('HSET', KEYS[5], tail_key, field)
128
+ end
129
+ local n = tonumber(ARGV[3])
130
+ local base = tonumber(redis.call('GET', KEYS[2]) or '0')
131
+ local has_pending = redis.call('ZCARD', KEYS[6]) > 0 and '1' or '0'
132
+ if n == 0 then return { 'empty', has_pending } end
133
+ local dups = {}
134
+ for i = 1, n do
135
+ local id = ARGV[3 + (i - 1) * 6 + 1]
136
+ local existing = redis.call('HGET', KEYS[3], id)
137
+ if existing then
138
+ local rec = cjson.decode(existing)
139
+ if rec[1] ~= ARGV[1] then return { 'foreign', id } end
140
+ dups[#dups + 1] = tostring(rec[2])
141
+ end
142
+ end
143
+ if #dups == n then
144
+ local res = { 'dup', has_pending }
145
+ for i = 1, #dups do res[#res + 1] = dups[i] end
146
+ return res
147
+ end
148
+ if #dups > 0 then return { 'partial', tostring(#dups) } end
149
+ for i = 1, n do
150
+ local cause = ARGV[3 + (i - 1) * 6 + 4]
151
+ if cause ~= '' then
152
+ local parsed = cjson.decode(cause)
153
+ local pidx = tonumber(parsed.index)
154
+ if pidx < 1 or pidx > base then
155
+ return { 'no_parent', tostring(parsed.index) }
156
+ end
157
+ local field = tostring(pidx)
158
+ local dispatches = tonumber(redis.call('HGET', KEYS[5], field .. ':d') or '0')
159
+ if dispatches ~= tonumber(parsed.attempt) or
160
+ redis.call('HEXISTS', KEYS[5], field .. ':f') == 1 then
161
+ return { 'superseded', field }
162
+ end
163
+ end
164
+ end
165
+ for i = 1, n do
166
+ local id = ARGV[3 + (i - 1) * 6 + 1]
167
+ local typ = ARGV[3 + (i - 1) * 6 + 2]
168
+ local payload = ARGV[3 + (i - 1) * 6 + 3]
169
+ local cause = ARGV[3 + (i - 1) * 6 + 4]
170
+ local lane = ARGV[3 + (i - 1) * 6 + 5]
171
+ local settled = ARGV[3 + (i - 1) * 6 + 6]
172
+ local idx = base + i
173
+ redis.call('XADD', KEYS[1], tostring(idx) .. '-0',
174
+ 'id', id, 'type', typ, 'payload', payload, 'created_at', ARGV[2],
175
+ 'cause', cause, 'lane', lane)
176
+ redis.call('HSET', KEYS[3], id, cjson.encode({ ARGV[1], idx }))
177
+ redis.call('HSET', KEYS[5], tostring(idx) .. ':l', lane)
178
+ if settled == '1' then
179
+ redis.call('HSET', KEYS[5], tostring(idx) .. ':p', ARGV[2])
180
+ else
181
+ enqueue(tostring(idx), idx, lane)
182
+ end
183
+ end
184
+ redis.call('SET', KEYS[2], tostring(base + n))
185
+ redis.call('ZADD', KEYS[4], 0, ARGV[1])
186
+ has_pending = redis.call('ZCARD', KEYS[6]) > 0 and '1' or '0'
187
+ return { 'ok', tostring(base + 1), has_pending,
188
+ tostring(redis.call('EXISTS', KEYS[8])) }
189
+ `
190
+
191
+ /** KEYS: counter, meta, pending, ready. ARGV: idx, attempt, error, maxFailures, nowMs. */
192
+ const FAIL_ATTEMPT_LUA = `
193
+ local function lane_field(lane, suffix)
194
+ return '__lane:' .. tostring(string.len(lane)) .. ':' .. lane .. ':' .. suffix
195
+ end
196
+ local function settle(field)
197
+ redis.call('ZREM', KEYS[3], field)
198
+ redis.call('ZREM', KEYS[4], field)
199
+ local lane = redis.call('HGET', KEYS[2], field .. ':l') or ''
200
+ if lane == '' then return end
201
+ local head_key = lane_field(lane, 'head')
202
+ if redis.call('HGET', KEYS[2], head_key) ~= field then return end
203
+ local next_field = redis.call('HGET', KEYS[2], field .. ':next')
204
+ while next_field and redis.call('HEXISTS', KEYS[2], next_field .. ':p') == 1 do
205
+ next_field = redis.call('HGET', KEYS[2], next_field .. ':next')
206
+ end
207
+ if next_field then
208
+ redis.call('HSET', KEYS[2], head_key, next_field)
209
+ if redis.call('HEXISTS', KEYS[2], next_field .. ':f') == 0 then
210
+ redis.call('ZADD', KEYS[4], tonumber(next_field), next_field)
211
+ end
212
+ else
213
+ redis.call('HDEL', KEYS[2], head_key, lane_field(lane, 'tail'))
214
+ end
215
+ end
216
+ local idx = tonumber(ARGV[1])
217
+ if idx < 1 or idx > tonumber(redis.call('GET', KEYS[1]) or '0') then
218
+ return { 'missing', '0' }
219
+ end
220
+ local field = ARGV[1]
221
+ local failures = tonumber(redis.call('HGET', KEYS[2], field .. ':a') or '0')
222
+ local dispatches = tonumber(redis.call('HGET', KEYS[2], field .. ':d') or '0')
223
+ if redis.call('HEXISTS', KEYS[2], field .. ':p') == 1 or dispatches ~= tonumber(ARGV[2]) then
224
+ return { 'superseded', tostring(failures) }
225
+ end
226
+ if redis.call('HEXISTS', KEYS[2], field .. ':f') == 1 then
227
+ return { 'dead_lettered', tostring(failures) }
228
+ end
229
+ if redis.call('HEXISTS', KEYS[2], field .. ':lfa') == 1 and
230
+ tonumber(redis.call('HGET', KEYS[2], field .. ':lfa')) == tonumber(ARGV[2]) and
231
+ redis.call('HEXISTS', KEYS[2], field .. ':ch') == 0 then
232
+ return { 'failed', tostring(failures) }
233
+ end
234
+ if redis.call('HEXISTS', KEYS[2], field .. ':ch') == 0 then
235
+ return { 'superseded', tostring(failures) }
236
+ end
237
+ failures = redis.call('HINCRBY', KEYS[2], field .. ':a', 1)
238
+ redis.call('HSET', KEYS[2],
239
+ field .. ':e', ARGV[3],
240
+ field .. ':lf', ARGV[5],
241
+ field .. ':lfa', ARGV[2])
242
+ redis.call('HDEL', KEYS[2], field .. ':ch', field .. ':ce')
243
+ if failures >= tonumber(ARGV[4]) then
244
+ redis.call('HSET', KEYS[2], field .. ':f', ARGV[5])
245
+ redis.call('ZREM', KEYS[4], field)
246
+ return { 'dead_lettered', tostring(failures) }
247
+ end
248
+ return { 'failed', tostring(failures) }
249
+ `
250
+
251
+ /**
252
+ * KEYS: store, counter, meta, pending, ready.
253
+ * ARGV: holder, nowMs, expiresAtMs, excludedCount, excluded indexes.
254
+ */
255
+ const CLAIM_AVAILABLE_LUA = `
256
+ if redis.call('ZCARD', KEYS[4]) == 0 then return { 'settled' } end
257
+ local ready = redis.call('ZRANGE', KEYS[5], 0, -1)
258
+ local excluded = {}
259
+ for i = 1, tonumber(ARGV[4]) do excluded[ARGV[4 + i]] = true end
260
+ local claimed = {}
261
+ local retry_at = nil
262
+ for _, field in ipairs(ready) do
263
+ if not excluded[field] then
264
+ local expires = tonumber(redis.call('HGET', KEYS[3], field .. ':ce') or '0')
265
+ if expires > tonumber(ARGV[2]) then
266
+ if not retry_at or expires < retry_at then retry_at = expires end
267
+ else
268
+ local entries = redis.call('XRANGE', KEYS[1], field .. '-0', field .. '-0')
269
+ if #entries == 0 then return { 'missing', field } end
270
+ local attempt = redis.call('HINCRBY', KEYS[3], field .. ':d', 1)
271
+ redis.call('HSETNX', KEYS[3], field .. ':fc', ARGV[2])
272
+ redis.call('HSET', KEYS[3],
273
+ field .. ':lc', ARGV[2],
274
+ field .. ':ch', ARGV[1],
275
+ field .. ':ce', ARGV[3])
276
+ claimed[#claimed + 1] = {
277
+ tostring(attempt),
278
+ redis.call('HGET', KEYS[3], field .. ':a') or '0',
279
+ redis.call('HGET', KEYS[3], field .. ':e') or false,
280
+ redis.call('HGET', KEYS[3], field .. ':fc') or false,
281
+ ARGV[2],
282
+ redis.call('HGET', KEYS[3], field .. ':lf') or false,
283
+ redis.call('HGET', KEYS[3], field .. ':lfa') or false,
284
+ ARGV[1],
285
+ ARGV[3],
286
+ entries[1]
287
+ }
288
+ end
289
+ end
290
+ end
291
+ if #claimed > 0 then
292
+ local result = { 'claimed', tostring(#claimed) }
293
+ for _, claim in ipairs(claimed) do
294
+ for _, value in ipairs(claim) do result[#result + 1] = value end
295
+ end
296
+ return result
297
+ end
298
+ if retry_at then return { 'busy', tostring(retry_at) } end
299
+ return { 'settled' }
300
+ `
301
+
302
+ /**
303
+ * KEYS: counter, meta.
304
+ * ARGV: holder, nowMs, expiresAtMs, n, then per claim: index, attempt.
305
+ * Returns [renewedCount, ...renewed, ...superseded].
306
+ */
307
+ const RENEW_CLAIMS_LUA = `
308
+ local total = tonumber(redis.call('GET', KEYS[1]) or '0')
309
+ local renewed = {}
310
+ local superseded = {}
311
+ for i = 1, tonumber(ARGV[4]) do
312
+ local field = ARGV[4 + (i - 1) * 2 + 1]
313
+ local attempt = tonumber(ARGV[4 + (i - 1) * 2 + 2])
314
+ local idx = tonumber(field)
315
+ if idx >= 1 and idx <= total then
316
+ local dispatches = tonumber(redis.call('HGET', KEYS[2], field .. ':d') or '0')
317
+ if dispatches > attempt then
318
+ superseded[#superseded + 1] = field
319
+ else
320
+ local expires = tonumber(redis.call('HGET', KEYS[2], field .. ':ce') or '0')
321
+ if tonumber(ARGV[3]) > tonumber(ARGV[2]) and
322
+ redis.call('HEXISTS', KEYS[2], field .. ':p') == 0 and
323
+ redis.call('HEXISTS', KEYS[2], field .. ':f') == 0 and
324
+ redis.call('HGET', KEYS[2], field .. ':ch') == ARGV[1] and
325
+ expires > tonumber(ARGV[2]) then
326
+ redis.call('HSET', KEYS[2], field .. ':ce', ARGV[3])
327
+ renewed[#renewed + 1] = field
328
+ end
329
+ end
330
+ end
331
+ end
332
+ local result = { tostring(#renewed) }
333
+ for i = 1, #renewed do result[#result + 1] = renewed[i] end
334
+ for i = 1, #superseded do result[#result + 1] = superseded[i] end
335
+ return result
336
+ `
337
+
338
+ /**
339
+ * KEYS: store, counter, ids, session index, meta, pending, ready, watched.
340
+ * ARGV: sessionId, nowMs, parent index, attempt, n, then per child:
341
+ * id, type, payloadJson, lane, settled.
342
+ */
343
+ const COMPLETE_ATTEMPT_LUA = `
344
+ local function lane_field(lane, suffix)
345
+ return '__lane:' .. tostring(string.len(lane)) .. ':' .. lane .. ':' .. suffix
346
+ end
347
+ local function settle(field)
348
+ redis.call('ZREM', KEYS[6], field)
349
+ redis.call('ZREM', KEYS[7], field)
350
+ local lane = redis.call('HGET', KEYS[5], field .. ':l') or ''
351
+ if lane == '' then return end
352
+ local head_key = lane_field(lane, 'head')
353
+ if redis.call('HGET', KEYS[5], head_key) ~= field then return end
354
+ local next_field = redis.call('HGET', KEYS[5], field .. ':next')
355
+ while next_field and redis.call('HEXISTS', KEYS[5], next_field .. ':p') == 1 do
356
+ next_field = redis.call('HGET', KEYS[5], next_field .. ':next')
357
+ end
358
+ if next_field then
359
+ redis.call('HSET', KEYS[5], head_key, next_field)
360
+ if redis.call('HEXISTS', KEYS[5], next_field .. ':f') == 0 then
361
+ redis.call('ZADD', KEYS[7], tonumber(next_field), next_field)
362
+ end
363
+ else
364
+ redis.call('HDEL', KEYS[5], head_key, lane_field(lane, 'tail'))
365
+ end
366
+ end
367
+ local function enqueue(field, idx, lane)
368
+ redis.call('ZADD', KEYS[6], idx, field)
369
+ if lane == '' then
370
+ redis.call('ZADD', KEYS[7], idx, field)
371
+ return
372
+ end
373
+ local tail_key = lane_field(lane, 'tail')
374
+ local tail = redis.call('HGET', KEYS[5], tail_key)
375
+ if tail then
376
+ redis.call('HSET', KEYS[5], tail .. ':next', field)
377
+ else
378
+ redis.call('HSET', KEYS[5], lane_field(lane, 'head'), field)
379
+ redis.call('ZADD', KEYS[7], idx, field)
380
+ end
381
+ redis.call('HSET', KEYS[5], tail_key, field)
382
+ end
383
+ local parent = ARGV[3]
384
+ local attempt = tonumber(ARGV[4])
385
+ local n = tonumber(ARGV[5])
386
+ local total = tonumber(redis.call('GET', KEYS[2]) or '0')
387
+ if tonumber(parent) < 1 or tonumber(parent) > total then return { 'missing' } end
388
+ local dispatches = tonumber(redis.call('HGET', KEYS[5], parent .. ':d') or '0')
389
+ local processed = redis.call('HGET', KEYS[5], parent .. ':p')
390
+ local processed_attempt = tonumber(redis.call('HGET', KEYS[5], parent .. ':pa') or '0')
391
+ if processed then
392
+ if processed_attempt ~= attempt then return { 'superseded' } end
393
+ local raw_recorded_ids = redis.call('HGET', KEYS[5], parent .. ':ri')
394
+ if not raw_recorded_ids then return { 'partial' } end
395
+ local recorded_ids = cjson.decode(raw_recorded_ids)
396
+ if #recorded_ids ~= n then return { 'partial' } end
397
+ local indexes = {}
398
+ for i = 1, n do
399
+ local id = ARGV[5 + (i - 1) * 5 + 1]
400
+ if recorded_ids[i] ~= id then return { 'partial' } end
401
+ local existing = redis.call('HGET', KEYS[3], id)
402
+ if not existing then return { 'partial' } end
403
+ local rec = cjson.decode(existing)
404
+ if rec[1] ~= ARGV[1] then return { 'partial' } end
405
+ local entries = redis.call('XRANGE', KEYS[1], tostring(rec[2]) .. '-0', tostring(rec[2]) .. '-0')
406
+ if #entries == 0 then return { 'partial' } end
407
+ local values = entries[1][2]
408
+ local cause = nil
409
+ for j = 1, #values, 2 do
410
+ if values[j] == 'cause' then cause = values[j + 1] break end
411
+ end
412
+ if not cause or cause == '' then return { 'partial' } end
413
+ local decoded = cjson.decode(cause)
414
+ if tonumber(decoded['index']) ~= tonumber(parent) or
415
+ tonumber(decoded['attempt']) ~= attempt then return { 'partial' } end
416
+ indexes[#indexes + 1] = tostring(rec[2])
417
+ end
418
+ local result = { 'duplicate' }
419
+ for _, idx in ipairs(indexes) do result[#result + 1] = idx end
420
+ return result
421
+ end
422
+ if dispatches ~= attempt then return { 'superseded' } end
423
+ if redis.call('HEXISTS', KEYS[5], parent .. ':ch') == 0 or
424
+ redis.call('HEXISTS', KEYS[5], parent .. ':f') == 1 or
425
+ tonumber(redis.call('HGET', KEYS[5], parent .. ':lfa') or '0') == attempt then
426
+ return { 'superseded' }
427
+ end
428
+ for i = 1, n do
429
+ local id = ARGV[5 + (i - 1) * 5 + 1]
430
+ if redis.call('HEXISTS', KEYS[3], id) == 1 then return { 'partial' } end
431
+ end
432
+ local returned_ids = {}
433
+ for i = 1, n do returned_ids[i] = ARGV[5 + (i - 1) * 5 + 1] end
434
+ local returned_json = '[]'
435
+ if n > 0 then returned_json = cjson.encode(returned_ids) end
436
+ redis.call('HSET', KEYS[5],
437
+ parent .. ':p', ARGV[2],
438
+ parent .. ':pa', ARGV[4],
439
+ parent .. ':ri', returned_json)
440
+ redis.call('HDEL', KEYS[5], parent .. ':ch', parent .. ':ce')
441
+ settle(parent)
442
+ local cause = cjson.encode({ index = tonumber(parent), attempt = attempt })
443
+ for i = 1, n do
444
+ local id = ARGV[5 + (i - 1) * 5 + 1]
445
+ local typ = ARGV[5 + (i - 1) * 5 + 2]
446
+ local payload = ARGV[5 + (i - 1) * 5 + 3]
447
+ local lane = ARGV[5 + (i - 1) * 5 + 4]
448
+ local settled = ARGV[5 + (i - 1) * 5 + 5]
449
+ local idx = total + i
450
+ redis.call('XADD', KEYS[1], tostring(idx) .. '-0',
451
+ 'id', id, 'type', typ, 'payload', payload, 'created_at', ARGV[2],
452
+ 'cause', cause, 'lane', lane)
453
+ redis.call('HSET', KEYS[3], id, cjson.encode({ ARGV[1], idx }))
454
+ redis.call('HSET', KEYS[5], tostring(idx) .. ':l', lane)
455
+ if settled == '1' then
456
+ redis.call('HSET', KEYS[5], tostring(idx) .. ':p', ARGV[2])
457
+ else
458
+ enqueue(tostring(idx), idx, lane)
459
+ end
460
+ end
461
+ redis.call('SET', KEYS[2], tostring(total + n))
462
+ redis.call('ZADD', KEYS[4], 0, ARGV[1])
463
+ return { 'ok', tostring(total + 1),
464
+ tostring(redis.call('EXISTS', KEYS[8])) }
465
+ `
466
+
467
+ /** KEYS: snapshot. ARGV: upToIndex, json. Guarded: never move backward. */
468
+ const PUT_SNAPSHOT_LUA = `
469
+ local cur = redis.call('GET', KEYS[1])
470
+ if cur and tonumber(cjson.decode(cur)['index']) >= tonumber(ARGV[1]) then
471
+ return 0
472
+ end
473
+ redis.call('SET', KEYS[1], ARGV[2])
474
+ return 1
475
+ `
476
+
477
+ /**
478
+ * KEYS: presence hash. ARGV: atMs, ttlMs, n, then per entry:
479
+ * hashField, valueJson ('' deletes). Field-wise LWW by the sender's
480
+ * `at`: a stored entry with a strictly newer stamp survives; ties go
481
+ * to the incoming write (memory-tier parity). Returns the applied
482
+ * 1-based entry positions. The whole-key PEXPIRE is the coarse bound,
483
+ * sound because core sends one uniform ttlMs; the per-entry `e` stamp
484
+ * is the fine bound (the injected storage clock at the applied set
485
+ * plus ttlMs — never the sender stamp), pruned on read against the
486
+ * same clock.
487
+ */
488
+ const PRESENCE_SET_LUA = `
489
+ local at = tonumber(ARGV[1])
490
+ local applied = {}
491
+ for i = 1, tonumber(ARGV[3]) do
492
+ local field = ARGV[3 + (i - 1) * 2 + 1]
493
+ local value = ARGV[3 + (i - 1) * 2 + 2]
494
+ local existing = redis.call('HGET', KEYS[1], field)
495
+ if not existing or tonumber(cjson.decode(existing)['a']) <= at then
496
+ if value == '' then
497
+ redis.call('HDEL', KEYS[1], field)
498
+ else
499
+ redis.call('HSET', KEYS[1], field, value)
500
+ end
501
+ applied[#applied + 1] = tostring(i)
502
+ end
503
+ end
504
+ if #applied > 0 then redis.call('PEXPIRE', KEYS[1], ARGV[2]) end
505
+ return applied
506
+ `
507
+
508
+ /**
509
+ * KEYS: presence hash. ARGV: nowMs (the injected clock, so tests can
510
+ * time-travel expiry against a real server). Returns the live
511
+ * [field, value, ...] pairs; entries at or past their fine-bound
512
+ * expiry are HDELed in the same atomic step, so a stale reader can
513
+ * never sweep a concurrently refreshed field.
514
+ */
515
+ const PRESENCE_READ_LUA = `
516
+ local now = tonumber(ARGV[1])
517
+ local rows = redis.call('HGETALL', KEYS[1])
518
+ local live = {}
519
+ for i = 1, #rows, 2 do
520
+ if tonumber(cjson.decode(rows[i + 1])['e']) <= now then
521
+ redis.call('HDEL', KEYS[1], rows[i])
522
+ else
523
+ live[#live + 1] = rows[i]
524
+ live[#live + 1] = rows[i + 1]
525
+ end
526
+ end
527
+ return live
528
+ `
529
+
530
+ /** KEYS: snapshot, event counter. Returns one atomic cache-plus-frontier read. */
531
+ const READ_STATE_LUA = `
532
+ local snapshot = redis.call('GET', KEYS[1])
533
+ local frontier = redis.call('GET', KEYS[2]) or '0'
534
+ return { snapshot or '', frontier }
535
+ `
536
+
537
+ // ── wire helpers ─────────────────────────────────────────────────────
538
+
539
+ type StreamEntry = [id: string, fields: string[]]
540
+
541
+ const fieldMap = (fields: string[]): Record<string, string> => {
542
+ const map: Record<string, string> = {}
543
+ for (let i = 0; i + 1 < fields.length; i += 2) {
544
+ map[fields[i]!] = fields[i + 1]!
545
+ }
546
+ return map
547
+ }
548
+
549
+ const STORED_EVENT_META_SUFFIXES = [
550
+ 'p',
551
+ 'pa',
552
+ 'ri',
553
+ 'ch',
554
+ 'ce',
555
+ 'f',
556
+ 'fc',
557
+ 'lc',
558
+ 'lf',
559
+ 'lfa',
560
+ 'd',
561
+ 'a',
562
+ 'e',
563
+ ] as const
564
+
565
+ const entryIndex = (entryId: string): number =>
566
+ Number(entryId.slice(0, entryId.indexOf('-')))
567
+
568
+ const toEvent = (sessionId: string, entry: StreamEntry): Event => {
569
+ const fields = fieldMap(entry[1])
570
+ return {
571
+ id: fields['id']!,
572
+ type: fields['type']!,
573
+ payload: JSON.parse(fields['payload']!),
574
+ index: entryIndex(entry[0]),
575
+ sessionId,
576
+ createdAt: new Date(Number(fields['created_at'])),
577
+ }
578
+ }
579
+
580
+ const json = (value: unknown): string => JSON.stringify(value) ?? 'null'
581
+
582
+ const escapeGlob = (value: string): string =>
583
+ value
584
+ .replaceAll('\\', '\\\\')
585
+ .replaceAll('*', '\\*')
586
+ .replaceAll('?', '\\?')
587
+ .replaceAll('[', '\\[')
588
+ .replaceAll(']', '\\]')
589
+
590
+ const toCause = (raw: string | undefined): EventCause | null => {
591
+ if (raw === '' || raw === undefined) return null
592
+ const value = JSON.parse(raw) as {
593
+ index?: unknown
594
+ attempt?: unknown
595
+ batchSize?: unknown
596
+ }
597
+ if (
598
+ !Number.isInteger(value.index) ||
599
+ Number(value.index) < 1 ||
600
+ !Number.isInteger(value.attempt) ||
601
+ Number(value.attempt) < 1
602
+ ) {
603
+ throw new TypeError('stored event has an invalid cause')
604
+ }
605
+ if (
606
+ value.batchSize !== undefined &&
607
+ (!Number.isInteger(value.batchSize) || Number(value.batchSize) < 1)
608
+ ) {
609
+ throw new TypeError('stored event has an invalid cause')
610
+ }
611
+ return {
612
+ index: Number(value.index),
613
+ attempt: Number(value.attempt),
614
+ ...(value.batchSize === undefined
615
+ ? {}
616
+ : { batchSize: Number(value.batchSize) }),
617
+ }
618
+ }
619
+
620
+ const toClaimAvailableResult = (
621
+ sessionId: string,
622
+ reply: unknown[],
623
+ ): StoreClaimAvailableResult => {
624
+ const outcome = String(reply[0])
625
+ if (outcome === 'claimed') {
626
+ const count = Number(reply[1])
627
+ const events: StoredEvent[] = []
628
+ for (let i = 0; i < count; i++) {
629
+ const offset = 2 + i * 10
630
+ const entry = reply[offset + 9] as StreamEntry
631
+ const event = toEvent(sessionId, entry)
632
+ const fields = fieldMap(entry[1])
633
+ events.push(
634
+ Object.assign(event, {
635
+ cause: toCause(fields['cause']),
636
+ lane: fields['lane'] === '' ? null : fields['lane']!,
637
+ processedAt: null,
638
+ processedByAttempt: null,
639
+ returnedEventIds: null,
640
+ firstClaimedAt:
641
+ reply[offset + 3] === null
642
+ ? null
643
+ : new Date(Number(reply[offset + 3])),
644
+ lastClaimedAt: new Date(Number(reply[offset + 4])),
645
+ attemptCount: Number(reply[offset]),
646
+ claimHolder: String(reply[offset + 7]),
647
+ claimExpiresAt: new Date(Number(reply[offset + 8])),
648
+ failureCount: Number(reply[offset + 1]),
649
+ lastFailedAt:
650
+ reply[offset + 5] === null
651
+ ? null
652
+ : new Date(Number(reply[offset + 5])),
653
+ lastFailedAttempt:
654
+ reply[offset + 6] === null ? null : Number(reply[offset + 6]),
655
+ lastError:
656
+ reply[offset + 2] === null ? null : String(reply[offset + 2]),
657
+ failedAt: null,
658
+ }),
659
+ )
660
+ }
661
+ return {
662
+ outcome: 'claimed',
663
+ events,
664
+ }
665
+ }
666
+ if (outcome === 'busy') {
667
+ return { outcome, retryAt: new Date(Number(reply[1])) }
668
+ }
669
+ if (outcome === 'settled') return { outcome }
670
+ throw new TypeError(
671
+ `claim referenced a missing event in session '${sessionId}'`,
672
+ )
673
+ }
674
+
675
+ const wrap = async <T>(fn: () => Promise<T>): Promise<T> => {
676
+ try {
677
+ return await fn()
678
+ } catch (err) {
679
+ if (err instanceof A2Error || err instanceof TypeError) throw err
680
+ throw new A2Error('STORE_UNAVAILABLE', 'redis store operation failed', {
681
+ cause: err,
682
+ })
683
+ }
684
+ }
685
+
686
+ export function createRedisStoreCore(
687
+ options: RedisStoreCoreOptions,
688
+ ): RedisStoreCore {
689
+ const { call, clock, notify } = options
690
+ const generateId = options.ids
691
+ const prefix = options.keyPrefix
692
+
693
+ const storeKey = (s: string): string => `${prefix}:${s}:log`
694
+ const countKey = (s: string): string => `${prefix}:${s}:count`
695
+ const metaKey = (s: string): string => `${prefix}:${s}:meta`
696
+ const pendingKey = (s: string): string => `${prefix}:${s}:pending`
697
+ const readyKey = (s: string): string => `${prefix}:${s}:ready`
698
+ const snapKey = (s: string, r: string): string => `${prefix}:${s}:snap:${r}`
699
+ const watchedKey = (s: string): string => `${prefix}:${s}:watched`
700
+ const presenceKey = (s: string): string => `${prefix}:${s}:presence`
701
+ const idsKey = `${prefix}:ids`
702
+ const sessionsKey = `${prefix}:sessions`
703
+
704
+ const evalScript = (
705
+ script: string,
706
+ keys: string[],
707
+ args: Array<string | number>,
708
+ ): Promise<unknown> => call('EVAL', script, keys.length, ...keys, ...args)
709
+
710
+ const readFrontier = async (sessionId: string): Promise<number> => {
711
+ const frontier = Number((await call('GET', countKey(sessionId))) ?? 0)
712
+ if (!Number.isSafeInteger(frontier) || frontier < 0) {
713
+ throw new A2Error(
714
+ 'STORE_UNAVAILABLE',
715
+ `redis event count for session '${sessionId}' is invalid`,
716
+ )
717
+ }
718
+ return frontier
719
+ }
720
+
721
+ const readRangePage = async (
722
+ sessionId: string,
723
+ afterIndex: number,
724
+ throughIndex: number,
725
+ limit: number,
726
+ ): Promise<StoredEvent[]> => {
727
+ if (throughIndex <= afterIndex) return []
728
+ const entries = (await call(
729
+ 'XRANGE',
730
+ storeKey(sessionId),
731
+ `${afterIndex + 1}-0`,
732
+ `${throughIndex}-0`,
733
+ 'COUNT',
734
+ limit,
735
+ )) as StreamEntry[]
736
+ if (entries.length === 0) return []
737
+
738
+ const metaFields = entries.flatMap((entry) => {
739
+ const index = entryIndex(entry[0])
740
+ return STORED_EVENT_META_SUFFIXES.map((suffix) => `${index}:${suffix}`)
741
+ })
742
+ const metaValues =
743
+ ((await call('HMGET', metaKey(sessionId), ...metaFields)) as Array<
744
+ string | null
745
+ > | null) ?? []
746
+ const meta: Record<string, string> = {}
747
+ for (let i = 0; i < metaFields.length; i += 1) {
748
+ const value = metaValues[i]
749
+ if (value !== null && value !== undefined) {
750
+ meta[metaFields[i]!] = value
751
+ }
752
+ }
753
+ return entries.map((entry) => {
754
+ const event = toEvent(sessionId, entry)
755
+ const idx = event.index
756
+ const fields = fieldMap(entry[1])
757
+ const processedAt = meta[`${idx}:p`]
758
+ const processedByAttempt = meta[`${idx}:pa`]
759
+ const returnedEventIds = meta[`${idx}:ri`]
760
+ const claimHolder = meta[`${idx}:ch`]
761
+ const claimExpiresAt = meta[`${idx}:ce`]
762
+ const failedAt = meta[`${idx}:f`]
763
+ const firstClaimedAt = meta[`${idx}:fc`]
764
+ const lastClaimedAt = meta[`${idx}:lc`]
765
+ const lastFailedAt = meta[`${idx}:lf`]
766
+ const lastFailedAttempt = meta[`${idx}:lfa`]
767
+ return Object.assign(event, {
768
+ cause: toCause(fields['cause']),
769
+ lane: fields['lane'] === '' ? null : fields['lane']!,
770
+ processedAt:
771
+ processedAt === undefined ? null : new Date(Number(processedAt)),
772
+ processedByAttempt:
773
+ processedByAttempt === undefined ? null : Number(processedByAttempt),
774
+ returnedEventIds:
775
+ returnedEventIds === undefined
776
+ ? null
777
+ : decodeReturnedEventIds(returnedEventIds),
778
+ firstClaimedAt:
779
+ firstClaimedAt === undefined
780
+ ? null
781
+ : new Date(Number(firstClaimedAt)),
782
+ lastClaimedAt:
783
+ lastClaimedAt === undefined ? null : new Date(Number(lastClaimedAt)),
784
+ attemptCount: Number(meta[`${idx}:d`] ?? 0),
785
+ claimHolder: claimHolder ?? null,
786
+ claimExpiresAt:
787
+ claimExpiresAt === undefined
788
+ ? null
789
+ : new Date(Number(claimExpiresAt)),
790
+ failureCount: Number(meta[`${idx}:a`] ?? 0),
791
+ lastFailedAt:
792
+ lastFailedAt === undefined ? null : new Date(Number(lastFailedAt)),
793
+ lastFailedAttempt:
794
+ lastFailedAttempt === undefined ? null : Number(lastFailedAttempt),
795
+ lastError: meta[`${idx}:e`] ?? null,
796
+ failedAt: failedAt === undefined ? null : new Date(Number(failedAt)),
797
+ })
798
+ })
799
+ }
800
+
801
+ const readEventRange = async (
802
+ sessionId: string,
803
+ afterIndex: number,
804
+ throughIndex: number,
805
+ ): Promise<Event[]> => {
806
+ if (throughIndex <= afterIndex) return []
807
+ const events: Event[] = []
808
+ let nextIndex = afterIndex + 1
809
+ while (nextIndex <= throughIndex) {
810
+ // oxlint-disable-next-line no-await-in-loop -- each page advances the dense frozen range
811
+ const entries = (await call(
812
+ 'XRANGE',
813
+ storeKey(sessionId),
814
+ `${nextIndex}-0`,
815
+ `${throughIndex}-0`,
816
+ 'COUNT',
817
+ READ_PAGE_SIZE,
818
+ )) as StreamEntry[]
819
+ if (entries.length === 0) {
820
+ throw new A2Error(
821
+ 'STORE_UNAVAILABLE',
822
+ `redis event range for session '${sessionId}' ended before index ${throughIndex}`,
823
+ )
824
+ }
825
+ for (let position = 0; position < entries.length; position += 1) {
826
+ const index = entryIndex(entries[position]![0])
827
+ if (index !== nextIndex + position || index > throughIndex) {
828
+ throw new A2Error(
829
+ 'STORE_UNAVAILABLE',
830
+ `redis event range for session '${sessionId}' is not contiguous`,
831
+ )
832
+ }
833
+ }
834
+ events.push(...entries.map((entry) => toEvent(sessionId, entry)))
835
+ nextIndex = events.at(-1)!.index + 1
836
+ }
837
+ return events
838
+ }
839
+
840
+ /** Bounded XRANGE pages restore the full-read contract on capped providers. */
841
+ const readRange = async (
842
+ sessionId: string,
843
+ afterIndex: number,
844
+ throughIndex?: number,
845
+ ): Promise<StoredEvent[]> => {
846
+ if (throughIndex !== undefined && throughIndex <= afterIndex) return []
847
+ const currentFrontier = await readFrontier(sessionId)
848
+ const frontier = Math.min(throughIndex ?? currentFrontier, currentFrontier)
849
+ if (frontier <= afterIndex) return []
850
+
851
+ const rows: StoredEvent[] = []
852
+ let nextIndex = afterIndex + 1
853
+ while (nextIndex <= frontier) {
854
+ // oxlint-disable-next-line no-await-in-loop -- each page advances the dense frozen range
855
+ const page = await readRangePage(
856
+ sessionId,
857
+ nextIndex - 1,
858
+ frontier,
859
+ READ_PAGE_SIZE,
860
+ )
861
+ if (page.length === 0) {
862
+ throw new A2Error(
863
+ 'STORE_UNAVAILABLE',
864
+ `redis event range for session '${sessionId}' ended before index ${frontier}`,
865
+ )
866
+ }
867
+ for (let position = 0; position < page.length; position += 1) {
868
+ if (page[position]!.index !== nextIndex + position) {
869
+ throw new A2Error(
870
+ 'STORE_UNAVAILABLE',
871
+ `redis event range for session '${sessionId}' is not contiguous`,
872
+ )
873
+ }
874
+ }
875
+ const lastIndex = page.at(-1)!.index
876
+ if (lastIndex < nextIndex || lastIndex > frontier) {
877
+ throw new A2Error(
878
+ 'STORE_UNAVAILABLE',
879
+ `redis event range for session '${sessionId}' did not advance`,
880
+ )
881
+ }
882
+ rows.push(...page)
883
+ nextIndex = lastIndex + 1
884
+ }
885
+ return rows
886
+ }
887
+
888
+ return {
889
+ async append(sessionId, events) {
890
+ return wrap(async () => {
891
+ const supplied = events.filter((e) => e.id !== undefined)
892
+ const suppliedIds = supplied.map((e) => e.id as string)
893
+ if (new Set(suppliedIds).size !== suppliedIds.length) {
894
+ throw new A2Error(
895
+ 'PARTIAL_DUPLICATE_BATCH',
896
+ 'batch contains the same event id more than once',
897
+ )
898
+ }
899
+ const now = clock.now().getTime()
900
+ const withIds = events.map((e) => ({
901
+ id: e.id ?? generateId(),
902
+ type: e.type,
903
+ payload: e.payload,
904
+ cause: e.cause ? { ...e.cause } : null,
905
+ lane: e.lane ?? null,
906
+ settled: e.settled === true,
907
+ }))
908
+ const args: Array<string | number> = [
909
+ sessionId,
910
+ now,
911
+ withIds.length,
912
+ ...withIds.flatMap((e) => [
913
+ e.id,
914
+ e.type,
915
+ json(e.payload),
916
+ e.cause ? json(e.cause) : '',
917
+ e.lane ?? '',
918
+ e.settled ? '1' : '0',
919
+ ]),
920
+ ]
921
+ const reply = (await evalScript(
922
+ APPEND_LUA,
923
+ [
924
+ storeKey(sessionId),
925
+ countKey(sessionId),
926
+ idsKey,
927
+ sessionsKey,
928
+ metaKey(sessionId),
929
+ pendingKey(sessionId),
930
+ readyKey(sessionId),
931
+ watchedKey(sessionId),
932
+ ],
933
+ args,
934
+ )) as string[]
935
+
936
+ const [verdict] = reply
937
+ if (verdict === 'empty') {
938
+ return { events: [], hasPending: reply[1] === '1' }
939
+ }
940
+ if (verdict === 'foreign') {
941
+ throw new A2Error(
942
+ 'PARTIAL_DUPLICATE_BATCH',
943
+ `event id '${reply[1]}' already exists in another session`,
944
+ )
945
+ }
946
+ if (verdict === 'no_parent') {
947
+ throw new TypeError(
948
+ `no event at index ${reply[1]} in session '${sessionId}'`,
949
+ )
950
+ }
951
+ if (verdict === 'superseded') {
952
+ throw new A2Error(
953
+ 'SUPERSEDED_ATTEMPT',
954
+ `a newer attempt owns event ${reply[1]} in session '${sessionId}'`,
955
+ )
956
+ }
957
+ if (verdict === 'partial') {
958
+ throw new A2Error(
959
+ 'PARTIAL_DUPLICATE_BATCH',
960
+ `batch mixes ${reply[1]} already-appended and ${events.length - Number(reply[1])} fresh events`,
961
+ )
962
+ }
963
+ if (verdict === 'dup') {
964
+ // A retry of a committed batch whose ack was lost — return
965
+ // the original rows, ordered by index like every adapter.
966
+ const hasPending = reply[1] === '1'
967
+ const wanted = new Set(reply.slice(2).map(Number))
968
+ const min = Math.min(...wanted)
969
+ const rows = await readRange(sessionId, min - 1)
970
+ const existing = rows.filter((row) => wanted.has(row.index))
971
+ return {
972
+ events: idempotentReplay(events, existing),
973
+ hasPending,
974
+ }
975
+ }
976
+ const base = Number(reply[1]) - 1
977
+ if (reply[3] === '1') notify?.(sessionId, base + withIds.length)
978
+ return {
979
+ events: withIds.map((e, i) => ({
980
+ id: e.id,
981
+ type: e.type,
982
+ payload: structuredClone(e.payload),
983
+ index: base + 1 + i,
984
+ sessionId,
985
+ createdAt: new Date(now),
986
+ cause: e.cause,
987
+ lane: e.lane,
988
+ processedAt: e.settled ? new Date(now) : null,
989
+ processedByAttempt: null,
990
+ returnedEventIds: null,
991
+ firstClaimedAt: null,
992
+ lastClaimedAt: null,
993
+ attemptCount: 0,
994
+ claimHolder: null,
995
+ claimExpiresAt: null,
996
+ failureCount: 0,
997
+ lastFailedAt: null,
998
+ lastFailedAttempt: null,
999
+ lastError: null,
1000
+ failedAt: null,
1001
+ })),
1002
+ hasPending: reply[2] === '1',
1003
+ }
1004
+ })
1005
+ },
1006
+
1007
+ async read(sessionId, opts) {
1008
+ return wrap(async () => {
1009
+ const rows = await readRange(
1010
+ sessionId,
1011
+ opts?.afterIndex ?? 0,
1012
+ opts?.throughIndex,
1013
+ )
1014
+ return rows
1015
+ })
1016
+ },
1017
+
1018
+ async claimAvailable({
1019
+ sessionId,
1020
+ holder,
1021
+ ttlMs,
1022
+ expiresAtMs,
1023
+ excludeIndexes,
1024
+ }) {
1025
+ return wrap(async () => {
1026
+ const now = clock.now().getTime()
1027
+ const expiresAt = expiresAtMs ?? now + ttlMs
1028
+ const excluded = excludeIndexes ?? []
1029
+ const reply = (await evalScript(
1030
+ CLAIM_AVAILABLE_LUA,
1031
+ [
1032
+ storeKey(sessionId),
1033
+ countKey(sessionId),
1034
+ metaKey(sessionId),
1035
+ pendingKey(sessionId),
1036
+ readyKey(sessionId),
1037
+ ],
1038
+ [holder, now, expiresAt, excluded.length, ...excluded],
1039
+ )) as unknown[]
1040
+ return toClaimAvailableResult(sessionId, reply)
1041
+ })
1042
+ },
1043
+
1044
+ async renewClaims({ sessionId, holder, claims, ttlMs, expiresAtMs }) {
1045
+ return wrap(async () => {
1046
+ const now = clock.now().getTime()
1047
+ const expiresAt = expiresAtMs ?? now + ttlMs
1048
+ const reply = (await evalScript(
1049
+ RENEW_CLAIMS_LUA,
1050
+ [countKey(sessionId), metaKey(sessionId)],
1051
+ [
1052
+ holder,
1053
+ now,
1054
+ expiresAt,
1055
+ claims.length,
1056
+ ...claims.flatMap((claim) => [claim.index, claim.attempt]),
1057
+ ],
1058
+ )) as string[]
1059
+ const renewedCount = Number(reply[0])
1060
+ const values = reply.slice(1).map(Number)
1061
+ return {
1062
+ renewed: values.slice(0, renewedCount).toSorted((a, b) => a - b),
1063
+ superseded: values.slice(renewedCount).toSorted((a, b) => a - b),
1064
+ }
1065
+ })
1066
+ },
1067
+
1068
+ async completeAttempt({ sessionId, index, attempt, events }) {
1069
+ return wrap(async () => {
1070
+ const ids = events.map((event) => event.id)
1071
+ if (new Set(ids).size !== ids.length) {
1072
+ throw new A2Error(
1073
+ 'PARTIAL_DUPLICATE_BATCH',
1074
+ 'batch contains the same event id more than once',
1075
+ )
1076
+ }
1077
+ const now = clock.now().getTime()
1078
+ const reply = (await evalScript(
1079
+ COMPLETE_ATTEMPT_LUA,
1080
+ [
1081
+ storeKey(sessionId),
1082
+ countKey(sessionId),
1083
+ idsKey,
1084
+ sessionsKey,
1085
+ metaKey(sessionId),
1086
+ pendingKey(sessionId),
1087
+ readyKey(sessionId),
1088
+ watchedKey(sessionId),
1089
+ ],
1090
+ [
1091
+ sessionId,
1092
+ now,
1093
+ index,
1094
+ attempt,
1095
+ events.length,
1096
+ ...events.flatMap((event) => [
1097
+ event.id,
1098
+ event.type,
1099
+ json(event.payload),
1100
+ event.lane ?? '',
1101
+ event.settled === true ? '1' : '0',
1102
+ ]),
1103
+ ],
1104
+ )) as string[]
1105
+ const outcome = reply[0]
1106
+ if (outcome === 'missing') {
1107
+ throw new TypeError(
1108
+ `no event at index ${index} in session '${sessionId}'`,
1109
+ )
1110
+ }
1111
+ if (outcome === 'partial') {
1112
+ throw new A2Error(
1113
+ 'PARTIAL_DUPLICATE_BATCH',
1114
+ 'returned event ids do not match the completed attempt',
1115
+ )
1116
+ }
1117
+ if (outcome === 'superseded') return { outcome }
1118
+
1119
+ const cause = { index, attempt }
1120
+ if (outcome === 'ok') {
1121
+ const base = Number(reply[1]) - 1
1122
+ if (events.length > 0 && reply[2] === '1') {
1123
+ notify?.(sessionId, base + events.length)
1124
+ }
1125
+ return {
1126
+ outcome: 'completed',
1127
+ events: events.map((event, i) => ({
1128
+ id: event.id,
1129
+ type: event.type,
1130
+ payload: structuredClone(event.payload),
1131
+ index: base + 1 + i,
1132
+ sessionId,
1133
+ createdAt: new Date(now),
1134
+ cause,
1135
+ lane: event.lane ?? null,
1136
+ processedAt: event.settled === true ? new Date(now) : null,
1137
+ processedByAttempt: null,
1138
+ returnedEventIds: null,
1139
+ firstClaimedAt: null,
1140
+ lastClaimedAt: null,
1141
+ attemptCount: 0,
1142
+ claimHolder: null,
1143
+ claimExpiresAt: null,
1144
+ failureCount: 0,
1145
+ lastFailedAt: null,
1146
+ lastFailedAttempt: null,
1147
+ lastError: null,
1148
+ failedAt: null,
1149
+ })),
1150
+ } satisfies CompleteAttemptResult
1151
+ }
1152
+ if (outcome === 'duplicate') {
1153
+ if (events.length === 0) {
1154
+ return { outcome: 'completed', events: [] }
1155
+ }
1156
+ const wanted = new Set(reply.slice(1).map(Number))
1157
+ const min = Math.min(...wanted)
1158
+ const rows = await readRange(sessionId, min - 1)
1159
+ const byIndex = new Map(rows.map((row) => [row.index, row]))
1160
+ const existing = reply
1161
+ .slice(1)
1162
+ .map((value) => byIndex.get(Number(value))!)
1163
+ return {
1164
+ outcome: 'completed',
1165
+ events: idempotentReplay(events, existing),
1166
+ }
1167
+ }
1168
+ throw new TypeError(`unexpected completion outcome '${outcome}'`)
1169
+ })
1170
+ },
1171
+
1172
+ async failAttempt({ sessionId, index, attempt, error, maxFailures }) {
1173
+ return wrap(async () => {
1174
+ const reply = (await evalScript(
1175
+ FAIL_ATTEMPT_LUA,
1176
+ [
1177
+ countKey(sessionId),
1178
+ metaKey(sessionId),
1179
+ pendingKey(sessionId),
1180
+ readyKey(sessionId),
1181
+ ],
1182
+ [index, attempt, error, maxFailures, clock.now().getTime()],
1183
+ )) as string[]
1184
+ const outcome = reply[0]
1185
+ if (outcome === 'missing') {
1186
+ throw new TypeError(
1187
+ `no event at index ${index} in session '${sessionId}'`,
1188
+ )
1189
+ }
1190
+ return {
1191
+ outcome: outcome as 'failed' | 'dead_lettered' | 'superseded',
1192
+ failureCount: Number(reply[1]),
1193
+ }
1194
+ })
1195
+ },
1196
+
1197
+ async readState(sessionId, reducerName) {
1198
+ return wrap(async () => {
1199
+ const reply = (await evalScript(
1200
+ READ_STATE_LUA,
1201
+ [snapKey(sessionId, reducerName), countKey(sessionId)],
1202
+ [],
1203
+ )) as [string, string]
1204
+ const raw = reply[0]
1205
+ const parsed =
1206
+ raw === ''
1207
+ ? null
1208
+ : (JSON.parse(raw) as { index: number; state: unknown })
1209
+ const frontier = Number(reply[1])
1210
+ if (
1211
+ !Number.isSafeInteger(frontier) ||
1212
+ frontier < 0 ||
1213
+ (parsed !== null &&
1214
+ (!Number.isSafeInteger(parsed.index) || parsed.index < 0))
1215
+ ) {
1216
+ throw new A2Error(
1217
+ 'STORE_UNAVAILABLE',
1218
+ `redis state frontier for session '${sessionId}' is invalid`,
1219
+ )
1220
+ }
1221
+ const events = await readEventRange(
1222
+ sessionId,
1223
+ parsed?.index ?? 0,
1224
+ frontier,
1225
+ )
1226
+ return {
1227
+ snapshot: parsed
1228
+ ? { index: parsed.index, state: parsed.state }
1229
+ : null,
1230
+ events,
1231
+ }
1232
+ })
1233
+ },
1234
+
1235
+ async putSnapshot(sessionId, reducerName, index, state) {
1236
+ await wrap(async () => {
1237
+ await evalScript(
1238
+ PUT_SNAPSHOT_LUA,
1239
+ [snapKey(sessionId, reducerName)],
1240
+ [index, json({ index, state, updatedAt: clock.now().getTime() })],
1241
+ )
1242
+ })
1243
+ },
1244
+
1245
+ presence: {
1246
+ async set(ns, participant, values, meta) {
1247
+ return wrap(async () => {
1248
+ const entries = Object.entries(values)
1249
+ if (entries.length === 0) return null
1250
+ const atMs = meta.at.getTime()
1251
+ // Expiry anchors on the injected storage clock — the sender's
1252
+ // `a` stamp orders writes, the `e` stamp bounds their
1253
+ // lifetime, and the two clocks never mix.
1254
+ const expiresAtMs = clock.now().getTime() + meta.ttlMs
1255
+ // The hash field is JSON `[participant, field]`: both are
1256
+ // caller strings, so no separator character is
1257
+ // collision-safe — the JSON array is.
1258
+ const applied = (await evalScript(
1259
+ PRESENCE_SET_LUA,
1260
+ [presenceKey(ns)],
1261
+ [
1262
+ atMs,
1263
+ meta.ttlMs,
1264
+ entries.length,
1265
+ ...entries.flatMap(([field, value]) => [
1266
+ JSON.stringify([participant, field]),
1267
+ value === null
1268
+ ? ''
1269
+ : json({
1270
+ v: value,
1271
+ s: meta.seen,
1272
+ a: atMs,
1273
+ e: expiresAtMs,
1274
+ }),
1275
+ ]),
1276
+ ],
1277
+ )) as string[]
1278
+ if (applied.length === 0) return null
1279
+ // Field-keyed and caller-named — null-prototype, like every
1280
+ // presence map (see `nullProtoRecord`).
1281
+ const appliedValues: Record<string, unknown> = nullProtoRecord()
1282
+ for (const position of applied) {
1283
+ const [field, value] = entries[Number(position) - 1]!
1284
+ appliedValues[field] = structuredClone(value)
1285
+ }
1286
+ return {
1287
+ participant,
1288
+ values: appliedValues,
1289
+ seen: meta.seen,
1290
+ at: new Date(atMs),
1291
+ }
1292
+ })
1293
+ },
1294
+
1295
+ async read(ns) {
1296
+ return wrap(async () => {
1297
+ const reply = (await evalScript(
1298
+ PRESENCE_READ_LUA,
1299
+ [presenceKey(ns)],
1300
+ [clock.now().getTime()],
1301
+ )) as string[]
1302
+ const rows: PresenceRow[] = []
1303
+ for (let i = 0; i + 1 < reply.length; i += 2) {
1304
+ const [participant, field] = JSON.parse(reply[i]!) as [
1305
+ string,
1306
+ string,
1307
+ ]
1308
+ const entry = JSON.parse(reply[i + 1]!) as {
1309
+ v: unknown
1310
+ s: number
1311
+ a: number
1312
+ e: number
1313
+ }
1314
+ rows.push({
1315
+ participant,
1316
+ field,
1317
+ value: entry.v,
1318
+ seen: entry.s,
1319
+ at: new Date(entry.a),
1320
+ expiresAt: new Date(entry.e),
1321
+ })
1322
+ }
1323
+ return rows
1324
+ })
1325
+ },
1326
+ },
1327
+
1328
+ inspect: {
1329
+ async listSessions(inspectionOptions) {
1330
+ return wrap(async () => {
1331
+ const minimum =
1332
+ inspectionOptions.cursor === undefined
1333
+ ? `[${inspectionOptions.prefix}`
1334
+ : `(${inspectionOptions.cursor}`
1335
+ const candidates = (await call(
1336
+ 'ZRANGEBYLEX',
1337
+ sessionsKey,
1338
+ minimum,
1339
+ '+',
1340
+ 'LIMIT',
1341
+ 0,
1342
+ inspectionOptions.limit + 1,
1343
+ )) as string[]
1344
+ const sessionIds = candidates
1345
+ .filter((id) => id.startsWith(inspectionOptions.prefix))
1346
+ .slice(0, inspectionOptions.limit)
1347
+
1348
+ const sessions = await Promise.all(
1349
+ sessionIds.map(async (sessionId) => {
1350
+ const rows = await readRange(sessionId, 0)
1351
+ const first = rows[0]!
1352
+ let updatedAt = first.createdAt
1353
+ let pendingCount = 0
1354
+ let failedCount = 0
1355
+ let attemptCount = 0
1356
+ let failureCount = 0
1357
+ for (const row of rows) {
1358
+ attemptCount += row.attemptCount
1359
+ failureCount += row.failureCount
1360
+ if (row.processedAt === null && row.failedAt === null) {
1361
+ pendingCount += 1
1362
+ }
1363
+ if (row.failedAt !== null) failedCount += 1
1364
+ for (const timestamp of [
1365
+ row.createdAt,
1366
+ row.firstClaimedAt,
1367
+ row.lastClaimedAt,
1368
+ row.lastFailedAt,
1369
+ row.processedAt,
1370
+ row.failedAt,
1371
+ ]) {
1372
+ if (timestamp && timestamp > updatedAt) updatedAt = timestamp
1373
+ }
1374
+ }
1375
+ return {
1376
+ sessionId,
1377
+ eventCount: rows.length,
1378
+ pendingCount,
1379
+ failedCount,
1380
+ attemptCount,
1381
+ failureCount,
1382
+ firstEventAt: first.createdAt,
1383
+ updatedAt,
1384
+ }
1385
+ }),
1386
+ )
1387
+ return {
1388
+ sessions: sessions.toSorted((a, b) =>
1389
+ a.sessionId.localeCompare(b.sessionId),
1390
+ ),
1391
+ cursor:
1392
+ candidates.length > inspectionOptions.limit &&
1393
+ candidates[inspectionOptions.limit]?.startsWith(
1394
+ inspectionOptions.prefix,
1395
+ )
1396
+ ? (sessionIds.at(-1) ?? null)
1397
+ : null,
1398
+ }
1399
+ })
1400
+ },
1401
+ async readEvents(sessionId, pageOptions) {
1402
+ return wrap(async () => {
1403
+ const currentFrontier = await readFrontier(sessionId)
1404
+ const throughIndex = Math.min(
1405
+ pageOptions.throughIndex ?? currentFrontier,
1406
+ currentFrontier,
1407
+ )
1408
+ const events = await readRangePage(
1409
+ sessionId,
1410
+ pageOptions.afterIndex,
1411
+ throughIndex,
1412
+ pageOptions.limit,
1413
+ )
1414
+ if (throughIndex > pageOptions.afterIndex && events.length === 0) {
1415
+ throw new A2Error(
1416
+ 'STORE_UNAVAILABLE',
1417
+ `redis event range for session '${sessionId}' ended before index ${throughIndex}`,
1418
+ )
1419
+ }
1420
+ for (let position = 0; position < events.length; position += 1) {
1421
+ if (
1422
+ events[position]!.index !==
1423
+ pageOptions.afterIndex + position + 1
1424
+ ) {
1425
+ throw new A2Error(
1426
+ 'STORE_UNAVAILABLE',
1427
+ `redis event range for session '${sessionId}' is not contiguous`,
1428
+ )
1429
+ }
1430
+ }
1431
+ return { events, throughIndex }
1432
+ })
1433
+ },
1434
+ async listSnapshots(sessionId) {
1435
+ return wrap(async () => {
1436
+ const start = `${prefix}:${sessionId}:snap:`
1437
+ let cursor = '0'
1438
+ const keys: string[] = []
1439
+ do {
1440
+ // oxlint-disable-next-line no-await-in-loop -- each SCAN cursor comes from the prior reply
1441
+ const reply = (await call(
1442
+ 'SCAN',
1443
+ cursor,
1444
+ 'MATCH',
1445
+ `${escapeGlob(start)}*`,
1446
+ 'COUNT',
1447
+ 100,
1448
+ )) as [string, string[]]
1449
+ cursor = reply[0]
1450
+ keys.push(...reply[1].filter((key) => key.startsWith(start)))
1451
+ } while (cursor !== '0')
1452
+ const snapshots = await Promise.all(
1453
+ keys.map(async (key) => {
1454
+ const raw = (await call('GET', key)) as string | null
1455
+ if (raw === null) return null
1456
+ const parsed = JSON.parse(raw) as {
1457
+ index: number
1458
+ updatedAt?: number
1459
+ }
1460
+ return {
1461
+ reducerName: key.slice(start.length),
1462
+ index: parsed.index,
1463
+ updatedAt: new Date(parsed.updatedAt ?? 0),
1464
+ }
1465
+ }),
1466
+ )
1467
+ return snapshots
1468
+ .filter((snapshot) => snapshot !== null)
1469
+ .toSorted((a, b) => a.reducerName.localeCompare(b.reducerName))
1470
+ })
1471
+ },
1472
+ },
1473
+
1474
+ async readRange(sessionId, afterIndex, throughIndex) {
1475
+ return wrap(() => readRange(sessionId, afterIndex, throughIndex))
1476
+ },
1477
+
1478
+ async readEvents(sessionId, afterIndex) {
1479
+ return wrap(async () => {
1480
+ const entries = (await call(
1481
+ 'XRANGE',
1482
+ storeKey(sessionId),
1483
+ `${afterIndex + 1}-0`,
1484
+ '+',
1485
+ )) as StreamEntry[]
1486
+ return entries.map((entry) => toEvent(sessionId, entry))
1487
+ })
1488
+ },
1489
+
1490
+ async markWatched(sessionId, ttlMs) {
1491
+ await wrap(() => call('SET', watchedKey(sessionId), '1', 'PX', ttlMs))
1492
+ },
1493
+ }
1494
+ }