experimental-a2 0.0.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 (68) hide show
  1. package/CHANGELOG.md +128 -0
  2. package/dist/ai-server.browser.d.ts +1 -0
  3. package/dist/ai-server.browser.js +4 -0
  4. package/dist/ai-server.d.ts +65 -0
  5. package/dist/ai-server.js +494 -0
  6. package/dist/ai.d.ts +282 -0
  7. package/dist/ai.js +922 -0
  8. package/dist/cache-indexeddb.d.ts +1 -0
  9. package/dist/cache-indexeddb.js +0 -0
  10. package/dist/client.d.ts +90 -0
  11. package/dist/client.js +410 -0
  12. package/dist/contract-B0kAXoaL.js +60 -0
  13. package/dist/contract-DL8btVd9.d.ts +161 -0
  14. package/dist/devtools-server.browser.d.ts +1 -0
  15. package/dist/devtools-server.browser.js +4 -0
  16. package/dist/devtools-server.d.ts +22 -0
  17. package/dist/devtools-server.js +1087 -0
  18. package/dist/errors-BJRMd-h6.js +23 -0
  19. package/dist/errors-xL_JTXsY.d.ts +20 -0
  20. package/dist/http.d.ts +44 -0
  21. package/dist/http.js +119 -0
  22. package/dist/index.d.ts +5 -0
  23. package/dist/index.js +3 -0
  24. package/dist/inspection-E7qbD0Xj.js +10 -0
  25. package/dist/internal-Dm8Ejnud.js +36 -0
  26. package/dist/log-Dg1I8NRr.d.ts +245 -0
  27. package/dist/log-memory.d.ts +11 -0
  28. package/dist/log-memory.js +345 -0
  29. package/dist/log-polling-RO7kclzR.js +83 -0
  30. package/dist/log-postgres.d.ts +40 -0
  31. package/dist/log-postgres.js +628 -0
  32. package/dist/log-redis.d.ts +31 -0
  33. package/dist/log-redis.js +711 -0
  34. package/dist/log-sqlite.d.ts +17 -0
  35. package/dist/log-sqlite.js +450 -0
  36. package/dist/log-yJbXUf72.js +5 -0
  37. package/dist/otel.d.ts +12 -0
  38. package/dist/otel.js +41 -0
  39. package/dist/react.d.ts +54 -0
  40. package/dist/react.js +85 -0
  41. package/dist/recovery-vercel.d.ts +60 -0
  42. package/dist/recovery-vercel.js +120 -0
  43. package/dist/retryable-lazy-DZWmHpii.js +19 -0
  44. package/dist/server-DYsnKTTy.js +780 -0
  45. package/dist/server.browser.d.ts +1 -0
  46. package/dist/server.browser.js +11 -0
  47. package/dist/server.d.ts +136 -0
  48. package/dist/server.js +2 -0
  49. package/dist/telemetry-C78al20p.d.ts +32 -0
  50. package/dist/validate-XKT4FSNn.js +28 -0
  51. package/dist/wire-2QpU1EtJ.js +62 -0
  52. package/docs/01-quickstart.mdx +214 -0
  53. package/docs/concepts/01-contracts.mdx +138 -0
  54. package/docs/concepts/02-handlers.mdx +146 -0
  55. package/docs/concepts/03-durability.mdx +230 -0
  56. package/docs/concepts/04-state.mdx +133 -0
  57. package/docs/guides/01-timers.mdx +85 -0
  58. package/docs/guides/02-cancellation.mdx +107 -0
  59. package/docs/guides/03-react.mdx +234 -0
  60. package/docs/guides/04-local-first.mdx +88 -0
  61. package/docs/guides/05-production.mdx +179 -0
  62. package/docs/guides/06-ai-agents.mdx +659 -0
  63. package/docs/guides/07-devtools.mdx +101 -0
  64. package/docs/guides/08-application-data.mdx +114 -0
  65. package/docs/index.mdx +282 -0
  66. package/docs/reference/01-api.mdx +637 -0
  67. package/docs/reference/02-errors.mdx +77 -0
  68. package/package.json +111 -0
@@ -0,0 +1,711 @@
1
+ import { t as A2Error } from "./errors-BJRMd-h6.js";
2
+ import { t as retryableLazy } from "./retryable-lazy-DZWmHpii.js";
3
+ import { n as SYSTEM_CLOCK, t as RANDOM_IDS } from "./log-yJbXUf72.js";
4
+ //#region src/log-redis.ts
5
+ /**
6
+ * a2/log-redis — the push-native log backend, on Redis Streams.
7
+ *
8
+ * The stream IS the log: each event is a stream entry whose ID is its
9
+ * dense a2 index (`${index}-0`), so `XRANGE` is the catch-up read and
10
+ * `XREAD BLOCK` is a true blocking live feed — no polling anywhere.
11
+ * Mutable drain/failure bookkeeping lives in a side hash (stream
12
+ * entries are immutable); every write path is one atomic Lua script.
13
+ * The conformance suite in test/conformance is the executable
14
+ * contract, run against a real spawned `redis-server`.
15
+ *
16
+ * Timestamps and lease expiry come from the injected clock — the lease
17
+ * stores its logical `expiresAt` and compares against `clock.now()`,
18
+ * with a generous real-time `PX` purely as garbage collection — so
19
+ * tests can time-travel against a real server.
20
+ *
21
+ * Works with any Redis-protocol server on a single instance or a
22
+ * non-cluster provider (Upstash — durable by default — Redis, Valkey).
23
+ * Cluster mode is out: the atomic scripts span keys. `ioredis` is an
24
+ * optional peer dependency; pass `url`, or inject any client exposing
25
+ * `call`/`duplicate`/`disconnect`.
26
+ */
27
+ /** How long each blocking read waits before re-checking for close(). */
28
+ const XREAD_BLOCK_MS = 1e4;
29
+ /**
30
+ * KEYS: log stream, counter, global id hash, session index.
31
+ * ARGV: sessionId, nowMs, n, then per event:
32
+ * id, type, payloadJson, causeJson (empty when no causal edge is known).
33
+ * Returns ['ok', firstIndex] | ['dup', ...indexes] | ['partial', count]
34
+ * | ['foreign', eventId].
35
+ */
36
+ const APPEND_LUA = `
37
+ local n = tonumber(ARGV[3])
38
+ local dups = {}
39
+ for i = 1, n do
40
+ local id = ARGV[3 + (i - 1) * 4 + 1]
41
+ local existing = redis.call('HGET', KEYS[3], id)
42
+ if existing then
43
+ local rec = cjson.decode(existing)
44
+ if rec[1] ~= ARGV[1] then return { 'foreign', id } end
45
+ dups[#dups + 1] = tostring(rec[2])
46
+ end
47
+ end
48
+ if #dups == n then
49
+ local res = { 'dup' }
50
+ for i = 1, #dups do res[#res + 1] = dups[i] end
51
+ return res
52
+ end
53
+ if #dups > 0 then return { 'partial', tostring(#dups) } end
54
+ local base = tonumber(redis.call('GET', KEYS[2]) or '0')
55
+ for i = 1, n do
56
+ local id = ARGV[3 + (i - 1) * 4 + 1]
57
+ local typ = ARGV[3 + (i - 1) * 4 + 2]
58
+ local payload = ARGV[3 + (i - 1) * 4 + 3]
59
+ local cause = ARGV[3 + (i - 1) * 4 + 4]
60
+ local idx = base + i
61
+ redis.call('XADD', KEYS[1], tostring(idx) .. '-0',
62
+ 'id', id, 'type', typ, 'payload', payload, 'created_at', ARGV[2],
63
+ 'cause', cause)
64
+ redis.call('HSET', KEYS[3], id, cjson.encode({ ARGV[1], idx }))
65
+ end
66
+ redis.call('SET', KEYS[2], tostring(base + n))
67
+ redis.call('ZADD', KEYS[4], 0, ARGV[1])
68
+ return { 'ok', tostring(base + 1) }
69
+ `;
70
+ /** KEYS: counter, meta. ARGV: idx, value, field suffix. Returns 0|1. */
71
+ const MARK_LUA = `
72
+ local idx = tonumber(ARGV[1])
73
+ local total = tonumber(redis.call('GET', KEYS[1]) or '0')
74
+ if idx < 1 or idx > total then
75
+ return 0
76
+ end
77
+ local target = ARGV[1] .. ':' .. ARGV[3]
78
+ if ARGV[3] ~= 'p' or redis.call('HEXISTS', KEYS[2], target) == 0 then
79
+ redis.call('HSET', KEYS[2], target, ARGV[2])
80
+ end
81
+ if ARGV[3] == 'p' then
82
+ local head = tonumber(redis.call('HGET', KEYS[2], '__head') or '1')
83
+ while head <= total and redis.call('HEXISTS', KEYS[2], tostring(head) .. ':p') == 1 do
84
+ head = head + 1
85
+ end
86
+ redis.call('HSET', KEYS[2], '__head', tostring(head))
87
+ end
88
+ return 1
89
+ `;
90
+ /** KEYS: counter, meta. ARGV: idx, attempt, error, maxFailures, nowMs. */
91
+ const FAIL_ATTEMPT_LUA = `
92
+ local idx = tonumber(ARGV[1])
93
+ if idx < 1 or idx > tonumber(redis.call('GET', KEYS[1]) or '0') then
94
+ return { 'missing', '0' }
95
+ end
96
+ local field = ARGV[1]
97
+ local failures = tonumber(redis.call('HGET', KEYS[2], field .. ':a') or '0')
98
+ local dispatches = tonumber(redis.call('HGET', KEYS[2], field .. ':d') or '0')
99
+ if redis.call('HEXISTS', KEYS[2], field .. ':p') == 1 or dispatches ~= tonumber(ARGV[2]) then
100
+ return { 'superseded', tostring(failures) }
101
+ end
102
+ if redis.call('HEXISTS', KEYS[2], field .. ':f') == 1 then
103
+ return { 'dead_lettered', tostring(failures) }
104
+ end
105
+ failures = redis.call('HINCRBY', KEYS[2], field .. ':a', 1)
106
+ redis.call('HSET', KEYS[2],
107
+ field .. ':e', ARGV[3],
108
+ field .. ':lf', ARGV[5],
109
+ field .. ':lfa', ARGV[2])
110
+ if failures >= tonumber(ARGV[4]) then
111
+ redis.call('HSET', KEYS[2], field .. ':f', ARGV[5])
112
+ return { 'dead_lettered', tostring(failures) }
113
+ end
114
+ return { 'failed', tostring(failures) }
115
+ `;
116
+ /**
117
+ * KEYS: log, counter, meta, lease.
118
+ * ARGV: holder, nowMs, expiresAtMs, gcPxMs, maxIndex (-1 = unbounded).
119
+ */
120
+ const CLAIM_NEXT_LUA = `
121
+ local total = tonumber(redis.call('GET', KEYS[2]) or '0')
122
+ local rawHead = redis.call('HGET', KEYS[3], '__head')
123
+ local head = tonumber(rawHead or '1')
124
+ while head <= total and redis.call('HEXISTS', KEYS[3], tostring(head) .. ':p') == 1 do
125
+ head = head + 1
126
+ end
127
+ if total > 0 or rawHead then
128
+ redis.call('HSET', KEYS[3], '__head', tostring(head))
129
+ end
130
+ if head > total then return { 'settled' } end
131
+ local field = tostring(head)
132
+ local maxIndex = tonumber(ARGV[5])
133
+ if redis.call('HEXISTS', KEYS[3], field .. ':f') == 1 or
134
+ (maxIndex >= 0 and head > maxIndex) then
135
+ return { 'settled' }
136
+ end
137
+
138
+ local cur = redis.call('GET', KEYS[4])
139
+ if cur then
140
+ local rec = cjson.decode(cur)
141
+ if rec[2] > tonumber(ARGV[2]) and rec[1] ~= ARGV[1] then
142
+ return { 'busy' }
143
+ end
144
+ end
145
+ local lease = cjson.encode({ ARGV[1], tonumber(ARGV[3]) })
146
+ redis.call('SET', KEYS[4], lease, 'PX', ARGV[4])
147
+
148
+ local failures = tonumber(redis.call('HGET', KEYS[3], field .. ':a') or '0')
149
+ local dispatches = tonumber(redis.call('HGET', KEYS[3], field .. ':d') or '0')
150
+ local attempt = dispatches + 1
151
+ redis.call('HSETNX', KEYS[3], field .. ':fc', ARGV[2])
152
+ redis.call('HSET', KEYS[3], field .. ':d', tostring(attempt), field .. ':lc', ARGV[2])
153
+ local entries = redis.call('XRANGE', KEYS[1], field .. '-0', field .. '-0')
154
+ if #entries == 0 then return { 'missing' } end
155
+ return {
156
+ 'claimed', tostring(attempt), tostring(failures),
157
+ redis.call('HGET', KEYS[3], field .. ':e') or false,
158
+ redis.call('HGET', KEYS[3], field .. ':fc') or false,
159
+ redis.call('HGET', KEYS[3], field .. ':lc') or false,
160
+ redis.call('HGET', KEYS[3], field .. ':lf') or false,
161
+ redis.call('HGET', KEYS[3], field .. ':lfa') or false,
162
+ entries[1]
163
+ }
164
+ `;
165
+ /**
166
+ * KEYS: log, counter, meta, lease.
167
+ * ARGV: holder, nowMs, completedIndex, attempt, maxIndex (-1 = unbounded).
168
+ */
169
+ const COMPLETE_AND_CLAIM_NEXT_LUA = `
170
+ local total = tonumber(redis.call('GET', KEYS[2]) or '0')
171
+ local completed = tonumber(ARGV[3])
172
+ if completed < 1 or completed > total then return { 'missing' } end
173
+ local completedField = ARGV[3]
174
+ local completedDispatches = tonumber(redis.call('HGET', KEYS[3], completedField .. ':d') or '0')
175
+ if redis.call('HEXISTS', KEYS[3], completedField .. ':p') == 1 or
176
+ completedDispatches ~= tonumber(ARGV[4]) then
177
+ return { 'superseded' }
178
+ end
179
+ redis.call('HSET', KEYS[3],
180
+ completedField .. ':p', ARGV[2],
181
+ completedField .. ':pa', ARGV[4])
182
+
183
+ local head = tonumber(redis.call('HGET', KEYS[3], '__head') or '1')
184
+ while head <= total and redis.call('HEXISTS', KEYS[3], tostring(head) .. ':p') == 1 do
185
+ head = head + 1
186
+ end
187
+ redis.call('HSET', KEYS[3], '__head', tostring(head))
188
+ if head > total then return { 'settled' } end
189
+ local field = tostring(head)
190
+ local maxIndex = tonumber(ARGV[5])
191
+ if redis.call('HEXISTS', KEYS[3], field .. ':f') == 1 or
192
+ (maxIndex >= 0 and head > maxIndex) then
193
+ return { 'settled' }
194
+ end
195
+
196
+ local cur = redis.call('GET', KEYS[4])
197
+ if not cur then return { 'busy' } end
198
+ local lease = cjson.decode(cur)
199
+ if lease[1] ~= ARGV[1] or lease[2] <= tonumber(ARGV[2]) then
200
+ return { 'busy' }
201
+ end
202
+
203
+ local failures = tonumber(redis.call('HGET', KEYS[3], field .. ':a') or '0')
204
+ local dispatches = tonumber(redis.call('HGET', KEYS[3], field .. ':d') or '0')
205
+ local attempt = dispatches + 1
206
+ redis.call('HSETNX', KEYS[3], field .. ':fc', ARGV[2])
207
+ redis.call('HSET', KEYS[3], field .. ':d', tostring(attempt), field .. ':lc', ARGV[2])
208
+ local entries = redis.call('XRANGE', KEYS[1], field .. '-0', field .. '-0')
209
+ if #entries == 0 then return { 'missing' } end
210
+ return {
211
+ 'claimed', tostring(attempt), tostring(failures),
212
+ redis.call('HGET', KEYS[3], field .. ':e') or false,
213
+ redis.call('HGET', KEYS[3], field .. ':fc') or false,
214
+ redis.call('HGET', KEYS[3], field .. ':lc') or false,
215
+ redis.call('HGET', KEYS[3], field .. ':lf') or false,
216
+ redis.call('HGET', KEYS[3], field .. ':lfa') or false,
217
+ entries[1]
218
+ }
219
+ `;
220
+ /**
221
+ * KEYS: lease. ARGV: holder, nowMs, expiresAtMs, gcPxMs. Logical expiry is
222
+ * the stored expiresAt vs now (exclusive: expiresAt <= now is free) —
223
+ * the PX is garbage collection only, never the semantics.
224
+ */
225
+ const LEASE_ACQUIRE_LUA = `
226
+ local cur = redis.call('GET', KEYS[1])
227
+ if cur then
228
+ local rec = cjson.decode(cur)
229
+ if rec[2] > tonumber(ARGV[2]) and rec[1] ~= ARGV[1] then return 0 end
230
+ end
231
+ local rec = cjson.encode({ ARGV[1], tonumber(ARGV[3]) })
232
+ redis.call('SET', KEYS[1], rec, 'PX', ARGV[4])
233
+ return 1
234
+ `;
235
+ /** KEYS: lease. ARGV: holder. Only the holder may release. */
236
+ const LEASE_RELEASE_LUA = `
237
+ local cur = redis.call('GET', KEYS[1])
238
+ if cur and cjson.decode(cur)[1] == ARGV[1] then
239
+ redis.call('DEL', KEYS[1])
240
+ end
241
+ return 1
242
+ `;
243
+ /** KEYS: snapshot. ARGV: upToIndex, json. Guarded: never move backward. */
244
+ const PUT_SNAPSHOT_LUA = `
245
+ local cur = redis.call('GET', KEYS[1])
246
+ if cur and tonumber(cjson.decode(cur)['index']) >= tonumber(ARGV[1]) then
247
+ return 0
248
+ end
249
+ redis.call('SET', KEYS[1], ARGV[2])
250
+ return 1
251
+ `;
252
+ /** KEYS: snapshot, log stream. Returns one atomic cache-plus-tail read. */
253
+ const READ_STATE_LUA = `
254
+ local snapshot = redis.call('GET', KEYS[1])
255
+ local after = 0
256
+ if snapshot then
257
+ after = tonumber(cjson.decode(snapshot)['index'])
258
+ end
259
+ local events = redis.call('XRANGE', KEYS[2], tostring(after + 1) .. '-0', '+')
260
+ return { snapshot or '', events }
261
+ `;
262
+ const fieldMap = (fields) => {
263
+ const map = {};
264
+ for (let i = 0; i + 1 < fields.length; i += 2) map[fields[i]] = fields[i + 1];
265
+ return map;
266
+ };
267
+ const entryIndex = (entryId) => Number(entryId.slice(0, entryId.indexOf("-")));
268
+ const toEvent = (sessionId, entry) => {
269
+ const fields = fieldMap(entry[1]);
270
+ return {
271
+ id: fields["id"],
272
+ type: fields["type"],
273
+ payload: JSON.parse(fields["payload"]),
274
+ index: entryIndex(entry[0]),
275
+ sessionId,
276
+ createdAt: new Date(Number(fields["created_at"]))
277
+ };
278
+ };
279
+ const json = (value) => JSON.stringify(value) ?? "null";
280
+ const escapeGlob = (value) => value.replaceAll("\\", "\\\\").replaceAll("*", "\\*").replaceAll("?", "\\?").replaceAll("[", "\\[").replaceAll("]", "\\]");
281
+ const toCause = (raw) => {
282
+ if (raw === "" || raw === void 0) return null;
283
+ const value = JSON.parse(raw);
284
+ if (!Number.isInteger(value.index) || Number(value.index) < 1 || !Number.isInteger(value.attempt) || Number(value.attempt) < 1) throw new TypeError("stored event has an invalid cause");
285
+ return {
286
+ index: Number(value.index),
287
+ attempt: Number(value.attempt)
288
+ };
289
+ };
290
+ const toClaimResult = (sessionId, reply) => {
291
+ const outcome = String(reply[0]);
292
+ if (outcome === "claimed") {
293
+ const entry = reply[8];
294
+ const event = toEvent(sessionId, entry);
295
+ const fields = fieldMap(entry[1]);
296
+ return {
297
+ outcome: "claimed",
298
+ event: Object.assign(event, {
299
+ cause: toCause(fields["cause"]),
300
+ processedAt: null,
301
+ processedByAttempt: null,
302
+ firstClaimedAt: reply[4] === null ? null : new Date(Number(reply[4])),
303
+ lastClaimedAt: reply[5] === null ? null : new Date(Number(reply[5])),
304
+ attemptCount: Number(reply[1]),
305
+ failureCount: Number(reply[2]),
306
+ lastFailedAt: reply[6] === null ? null : new Date(Number(reply[6])),
307
+ lastFailedAttempt: reply[7] === null ? null : Number(reply[7]),
308
+ lastError: reply[3] === null ? null : String(reply[3]),
309
+ failedAt: null
310
+ })
311
+ };
312
+ }
313
+ if (outcome === "busy" || outcome === "settled") return { outcome };
314
+ throw new TypeError(`claim referenced a missing event in session '${sessionId}'`);
315
+ };
316
+ const toHandoffResult = (sessionId, reply) => String(reply[0]) === "superseded" ? { outcome: "superseded" } : toClaimResult(sessionId, reply);
317
+ const wrap = async (fn) => {
318
+ try {
319
+ return await fn();
320
+ } catch (err) {
321
+ if (err instanceof A2Error || err instanceof TypeError) throw err;
322
+ throw new A2Error("LOG_UNAVAILABLE", "redis log operation failed", { cause: err });
323
+ }
324
+ };
325
+ function redis(options = {}) {
326
+ const clock = options.clock ?? SYSTEM_CLOCK;
327
+ const generateId = options.ids ?? RANDOM_IDS;
328
+ const prefix = options.keyPrefix ?? "a2";
329
+ if (!options.client && !options.url) throw new TypeError("redis() needs a url or an injected client");
330
+ const logKey = (s) => `${prefix}:${s}:log`;
331
+ const countKey = (s) => `${prefix}:${s}:count`;
332
+ const metaKey = (s) => `${prefix}:${s}:meta`;
333
+ const leaseKey = (s) => `${prefix}:${s}:lease`;
334
+ const snapKey = (s, r) => `${prefix}:${s}:snap:${r}`;
335
+ const idsKey = `${prefix}:ids`;
336
+ const sessionsKey = `${prefix}:sessions`;
337
+ const connection = retryableLazy(async () => {
338
+ if (options.client) return options.client;
339
+ return new (await (import("ioredis").catch(() => {
340
+ throw new A2Error("LOG_NOT_CONFIGURED", "a2/log-redis with a url needs the 'ioredis' package (optional peer dependency) — install it, or inject a client");
341
+ }))).default(options.url);
342
+ });
343
+ const client = connection.get;
344
+ const evalScript = async (script, keys, args) => {
345
+ return (await client()).call("EVAL", script, keys.length, ...keys, ...args);
346
+ };
347
+ /** Every live-feed connection, so close() can sever them. */
348
+ const streamConnections = /* @__PURE__ */ new Set();
349
+ const ensureSessionIndex = retryableLazy(() => wrap(async () => {
350
+ const c = await client();
351
+ let cursor = "0";
352
+ const keyStart = `${prefix}:`;
353
+ const keyEnd = ":count";
354
+ do {
355
+ const reply = await c.call("SCAN", cursor, "MATCH", `${escapeGlob(prefix)}:*:count`, "COUNT", 200);
356
+ cursor = reply[0];
357
+ const ids = reply[1].filter((key) => key.startsWith(keyStart) && key.endsWith(keyEnd)).map((key) => key.slice(keyStart.length, -6));
358
+ if (ids.length > 0) await c.call("ZADD", sessionsKey, ...ids.flatMap((id) => [0, id]));
359
+ } while (cursor !== "0");
360
+ })).get;
361
+ /** XRANGE + meta merge — the raw read both `read` and dup-returns use. */
362
+ const readRange = async (sessionId, afterIndex) => {
363
+ const c = await client();
364
+ const entries = await c.call("XRANGE", logKey(sessionId), `${afterIndex + 1}-0`, "+");
365
+ if (entries.length === 0) return [];
366
+ const meta = fieldMap(await c.call("HGETALL", metaKey(sessionId)) ?? []);
367
+ return entries.map((entry) => {
368
+ const event = toEvent(sessionId, entry);
369
+ const idx = event.index;
370
+ const fields = fieldMap(entry[1]);
371
+ const processedAt = meta[`${idx}:p`];
372
+ const processedByAttempt = meta[`${idx}:pa`];
373
+ const failedAt = meta[`${idx}:f`];
374
+ const firstClaimedAt = meta[`${idx}:fc`];
375
+ const lastClaimedAt = meta[`${idx}:lc`];
376
+ const lastFailedAt = meta[`${idx}:lf`];
377
+ const lastFailedAttempt = meta[`${idx}:lfa`];
378
+ return Object.assign(event, {
379
+ cause: toCause(fields["cause"]),
380
+ processedAt: processedAt === void 0 ? null : new Date(Number(processedAt)),
381
+ processedByAttempt: processedByAttempt === void 0 ? null : Number(processedByAttempt),
382
+ firstClaimedAt: firstClaimedAt === void 0 ? null : new Date(Number(firstClaimedAt)),
383
+ lastClaimedAt: lastClaimedAt === void 0 ? null : new Date(Number(lastClaimedAt)),
384
+ attemptCount: Number(meta[`${idx}:d`] ?? 0),
385
+ failureCount: Number(meta[`${idx}:a`] ?? 0),
386
+ lastFailedAt: lastFailedAt === void 0 ? null : new Date(Number(lastFailedAt)),
387
+ lastFailedAttempt: lastFailedAttempt === void 0 ? null : Number(lastFailedAttempt),
388
+ lastError: meta[`${idx}:e`] ?? null,
389
+ failedAt: failedAt === void 0 ? null : new Date(Number(failedAt))
390
+ });
391
+ });
392
+ };
393
+ return {
394
+ async append(sessionId, events) {
395
+ if (events.length === 0) return [];
396
+ return wrap(async () => {
397
+ const suppliedIds = events.filter((e) => e.id !== void 0).map((e) => e.id);
398
+ if (new Set(suppliedIds).size !== suppliedIds.length) throw new A2Error("PARTIAL_DUPLICATE_BATCH", "batch contains the same event id more than once");
399
+ const now = clock.now().getTime();
400
+ const withIds = events.map((e) => ({
401
+ id: e.id ?? generateId(),
402
+ type: e.type,
403
+ payload: e.payload,
404
+ cause: e.cause ? { ...e.cause } : null
405
+ }));
406
+ const args = [
407
+ sessionId,
408
+ now,
409
+ withIds.length,
410
+ ...withIds.flatMap((e) => [
411
+ e.id,
412
+ e.type,
413
+ json(e.payload),
414
+ e.cause ? json(e.cause) : ""
415
+ ])
416
+ ];
417
+ const reply = await evalScript(APPEND_LUA, [
418
+ logKey(sessionId),
419
+ countKey(sessionId),
420
+ idsKey,
421
+ sessionsKey
422
+ ], args);
423
+ const [verdict] = reply;
424
+ if (verdict === "foreign") throw new A2Error("PARTIAL_DUPLICATE_BATCH", `event id '${reply[1]}' already exists in another session`);
425
+ if (verdict === "partial") throw new A2Error("PARTIAL_DUPLICATE_BATCH", `batch mixes ${reply[1]} already-appended and ${events.length - Number(reply[1])} fresh events`);
426
+ if (verdict === "dup") {
427
+ const wanted = new Set(reply.slice(1).map(Number));
428
+ const min = Math.min(...wanted);
429
+ return (await readRange(sessionId, min - 1)).filter((r) => wanted.has(r.index));
430
+ }
431
+ const base = Number(reply[1]) - 1;
432
+ return withIds.map((e, i) => ({
433
+ id: e.id,
434
+ type: e.type,
435
+ payload: structuredClone(e.payload),
436
+ index: base + 1 + i,
437
+ sessionId,
438
+ createdAt: new Date(now),
439
+ cause: e.cause,
440
+ processedAt: null,
441
+ processedByAttempt: null,
442
+ firstClaimedAt: null,
443
+ lastClaimedAt: null,
444
+ attemptCount: 0,
445
+ failureCount: 0,
446
+ lastFailedAt: null,
447
+ lastFailedAttempt: null,
448
+ lastError: null,
449
+ failedAt: null
450
+ }));
451
+ });
452
+ },
453
+ async read(sessionId, opts) {
454
+ return wrap(async () => {
455
+ const rows = await readRange(sessionId, opts?.afterIndex ?? 0);
456
+ return opts?.unprocessedOnly ? rows.filter((r) => r.processedAt === null) : rows;
457
+ });
458
+ },
459
+ async claimNext({ sessionId, holder, ttlMs, expiresAtMs, maxIndex }) {
460
+ return wrap(async () => {
461
+ const now = clock.now().getTime();
462
+ const expiresAt = expiresAtMs ?? now + ttlMs;
463
+ const effectiveTtlMs = Math.max(1, expiresAt - now);
464
+ const gcPx = Math.max(effectiveTtlMs * 10, 6e4);
465
+ const reply = await evalScript(CLAIM_NEXT_LUA, [
466
+ logKey(sessionId),
467
+ countKey(sessionId),
468
+ metaKey(sessionId),
469
+ leaseKey(sessionId)
470
+ ], [
471
+ holder,
472
+ now,
473
+ expiresAt,
474
+ gcPx,
475
+ maxIndex ?? -1
476
+ ]);
477
+ return toClaimResult(sessionId, reply);
478
+ });
479
+ },
480
+ async completeAndClaimNext({ sessionId, holder, completedIndex, attempt, maxIndex }) {
481
+ return wrap(async () => {
482
+ const reply = await evalScript(COMPLETE_AND_CLAIM_NEXT_LUA, [
483
+ logKey(sessionId),
484
+ countKey(sessionId),
485
+ metaKey(sessionId),
486
+ leaseKey(sessionId)
487
+ ], [
488
+ holder,
489
+ clock.now().getTime(),
490
+ completedIndex,
491
+ attempt,
492
+ maxIndex ?? -1
493
+ ]);
494
+ return toHandoffResult(sessionId, reply);
495
+ });
496
+ },
497
+ async markProcessed(sessionId, index) {
498
+ await wrap(async () => {
499
+ if (await evalScript(MARK_LUA, [countKey(sessionId), metaKey(sessionId)], [
500
+ index,
501
+ clock.now().getTime(),
502
+ "p"
503
+ ]) === 0) throw new TypeError(`no event at index ${index} in session '${sessionId}'`);
504
+ });
505
+ },
506
+ async failAttempt({ sessionId, index, attempt, error, maxFailures }) {
507
+ return wrap(async () => {
508
+ const reply = await evalScript(FAIL_ATTEMPT_LUA, [countKey(sessionId), metaKey(sessionId)], [
509
+ index,
510
+ attempt,
511
+ error,
512
+ maxFailures,
513
+ clock.now().getTime()
514
+ ]);
515
+ const outcome = reply[0];
516
+ if (outcome === "missing") throw new TypeError(`no event at index ${index} in session '${sessionId}'`);
517
+ return {
518
+ outcome,
519
+ failureCount: Number(reply[1])
520
+ };
521
+ });
522
+ },
523
+ async markFailed(sessionId, index) {
524
+ await wrap(async () => {
525
+ if (await evalScript(MARK_LUA, [countKey(sessionId), metaKey(sessionId)], [
526
+ index,
527
+ clock.now().getTime(),
528
+ "f"
529
+ ]) === 0) throw new TypeError(`no event at index ${index} in session '${sessionId}'`);
530
+ });
531
+ },
532
+ async readState(sessionId, reducerName) {
533
+ return wrap(async () => {
534
+ const reply = await evalScript(READ_STATE_LUA, [snapKey(sessionId, reducerName), logKey(sessionId)], []);
535
+ const raw = reply[0];
536
+ const parsed = raw === "" ? null : JSON.parse(raw);
537
+ return {
538
+ snapshot: parsed ? {
539
+ index: parsed.index,
540
+ state: parsed.state
541
+ } : null,
542
+ events: reply[1].map((entry) => toEvent(sessionId, entry))
543
+ };
544
+ });
545
+ },
546
+ async putSnapshot(sessionId, reducerName, index, state) {
547
+ await wrap(async () => {
548
+ await evalScript(PUT_SNAPSHOT_LUA, [snapKey(sessionId, reducerName)], [index, json({
549
+ index,
550
+ state,
551
+ updatedAt: clock.now().getTime()
552
+ })]);
553
+ });
554
+ },
555
+ inspect: {
556
+ async listSessions(inspectionOptions) {
557
+ return wrap(async () => {
558
+ await ensureSessionIndex();
559
+ const c = await client();
560
+ const minimum = inspectionOptions.cursor === void 0 ? `[${inspectionOptions.prefix}` : `(${inspectionOptions.cursor}`;
561
+ const candidates = await c.call("ZRANGEBYLEX", sessionsKey, minimum, "+", "LIMIT", 0, inspectionOptions.limit + 1);
562
+ const sessionIds = candidates.filter((id) => id.startsWith(inspectionOptions.prefix)).slice(0, inspectionOptions.limit);
563
+ return {
564
+ sessions: (await Promise.all(sessionIds.map(async (sessionId) => {
565
+ const rows = await readRange(sessionId, 0);
566
+ const first = rows[0];
567
+ let updatedAt = first.createdAt;
568
+ let pendingCount = 0;
569
+ let failedCount = 0;
570
+ let attemptCount = 0;
571
+ let failureCount = 0;
572
+ for (const row of rows) {
573
+ attemptCount += row.attemptCount;
574
+ failureCount += row.failureCount;
575
+ if (row.processedAt === null && row.failedAt === null) pendingCount += 1;
576
+ if (row.failedAt !== null) failedCount += 1;
577
+ for (const timestamp of [
578
+ row.createdAt,
579
+ row.firstClaimedAt,
580
+ row.lastClaimedAt,
581
+ row.lastFailedAt,
582
+ row.processedAt,
583
+ row.failedAt
584
+ ]) if (timestamp && timestamp > updatedAt) updatedAt = timestamp;
585
+ }
586
+ return {
587
+ sessionId,
588
+ eventCount: rows.length,
589
+ pendingCount,
590
+ failedCount,
591
+ attemptCount,
592
+ failureCount,
593
+ firstEventAt: first.createdAt,
594
+ updatedAt
595
+ };
596
+ }))).toSorted((a, b) => a.sessionId.localeCompare(b.sessionId)),
597
+ cursor: candidates.length > inspectionOptions.limit && candidates[inspectionOptions.limit]?.startsWith(inspectionOptions.prefix) ? sessionIds.at(-1) ?? null : null
598
+ };
599
+ });
600
+ },
601
+ async listSnapshots(sessionId) {
602
+ return wrap(async () => {
603
+ const c = await client();
604
+ const start = `${prefix}:${sessionId}:snap:`;
605
+ let cursor = "0";
606
+ const keys = [];
607
+ do {
608
+ const reply = await c.call("SCAN", cursor, "MATCH", `${escapeGlob(start)}*`, "COUNT", 100);
609
+ cursor = reply[0];
610
+ keys.push(...reply[1].filter((key) => key.startsWith(start)));
611
+ } while (cursor !== "0");
612
+ return (await Promise.all(keys.map(async (key) => {
613
+ const raw = await c.call("GET", key);
614
+ if (raw === null) return null;
615
+ const parsed = JSON.parse(raw);
616
+ return {
617
+ reducerName: key.slice(start.length),
618
+ index: parsed.index,
619
+ updatedAt: new Date(parsed.updatedAt ?? 0)
620
+ };
621
+ }))).filter((snapshot) => snapshot !== null).toSorted((a, b) => a.reducerName.localeCompare(b.reducerName));
622
+ });
623
+ }
624
+ },
625
+ stream(sessionId, opts) {
626
+ const startAt = opts?.startAt ?? 0;
627
+ return { [Symbol.asyncIterator]() {
628
+ let last = startAt;
629
+ let buffer = [];
630
+ let closed = false;
631
+ let conn = null;
632
+ return {
633
+ async next() {
634
+ for (;;) {
635
+ if (closed) return {
636
+ value: void 0,
637
+ done: true
638
+ };
639
+ const row = buffer.shift();
640
+ if (row) {
641
+ last = row.index;
642
+ return {
643
+ value: row,
644
+ done: false
645
+ };
646
+ }
647
+ if (!conn) {
648
+ conn = (await client()).duplicate();
649
+ streamConnections.add(conn);
650
+ }
651
+ let reply;
652
+ try {
653
+ reply = await conn.call("XREAD", "BLOCK", XREAD_BLOCK_MS, "STREAMS", logKey(sessionId), `${last}-0`);
654
+ } catch (err) {
655
+ if (closed) return {
656
+ value: void 0,
657
+ done: true
658
+ };
659
+ throw new A2Error("LOG_UNAVAILABLE", "redis log operation failed", { cause: err });
660
+ }
661
+ if (reply === null) continue;
662
+ const [[, entries]] = reply;
663
+ buffer = entries.map((entry) => toEvent(sessionId, entry));
664
+ }
665
+ },
666
+ async return() {
667
+ closed = true;
668
+ if (conn) {
669
+ streamConnections.delete(conn);
670
+ conn.disconnect();
671
+ }
672
+ return {
673
+ value: void 0,
674
+ done: true
675
+ };
676
+ }
677
+ };
678
+ } };
679
+ },
680
+ lease: {
681
+ async acquire({ sessionId, holder, ttlMs, expiresAtMs }) {
682
+ return wrap(async () => {
683
+ const now = clock.now().getTime();
684
+ const expiresAt = expiresAtMs ?? now + ttlMs;
685
+ const effectiveTtlMs = Math.max(1, expiresAt - now);
686
+ const gcPx = Math.max(effectiveTtlMs * 10, 6e4);
687
+ return await evalScript(LEASE_ACQUIRE_LUA, [leaseKey(sessionId)], [
688
+ holder,
689
+ now,
690
+ expiresAt,
691
+ gcPx
692
+ ]) === 1;
693
+ });
694
+ },
695
+ async release({ sessionId, holder }) {
696
+ await wrap(async () => {
697
+ await evalScript(LEASE_RELEASE_LUA, [leaseKey(sessionId)], [holder]);
698
+ });
699
+ }
700
+ },
701
+ async close() {
702
+ for (const conn of streamConnections) conn.disconnect();
703
+ streamConnections.clear();
704
+ const current = connection.peek();
705
+ if (!current) return;
706
+ (await current).disconnect();
707
+ }
708
+ };
709
+ }
710
+ //#endregion
711
+ export { redis };