experimental-a2 0.2.0 → 0.3.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.
@@ -0,0 +1,836 @@
1
+ import { t as A2Error } from "./errors-BJRMd-h6.js";
2
+ import { t as idempotentReplay } from "./idempotent-replay-BMyHrP0L.js";
3
+ //#region src/log-redis-core.ts
4
+ /**
5
+ * The shared Redis log core — every operation except `stream` and
6
+ * `close`, expressed over one injected `call` function so both
7
+ * transports reuse it: `log-redis` (Redis protocol via `ioredis`) and
8
+ * `log-redis-http` (provider REST APIs).
9
+ *
10
+ * The stream IS the log: each event is a stream entry whose ID is its
11
+ * dense a2 index (`${index}-0`), so `XRANGE` is the catch-up read.
12
+ * Mutable drain/failure bookkeeping lives in a side hash (stream
13
+ * entries are immutable); every write path is one atomic Lua script.
14
+ * The conformance suite in test/conformance is the executable
15
+ * contract, run against a real spawned `redis-server`.
16
+ *
17
+ * Timestamps and claim expiry come from the injected clock so tests
18
+ * can time-travel against a real server. Cluster mode is out: the
19
+ * atomic scripts span keys.
20
+ */
21
+ /**
22
+ * KEYS: log stream, counter, global id hash, session index, metadata,
23
+ * pending, ready, watched.
24
+ * ARGV: sessionId, nowMs, n, then per event:
25
+ * id, type, payloadJson, causeJson, lane, settled.
26
+ * Returns ['empty', hasPending] | ['ok', firstIndex, hasPending, watched] |
27
+ * ['dup', hasPending, ...indexes] | ['partial', count] |
28
+ * ['foreign', eventId].
29
+ */
30
+ const APPEND_LUA = `
31
+ local function lane_field(lane, suffix)
32
+ return '__lane:' .. tostring(string.len(lane)) .. ':' .. lane .. ':' .. suffix
33
+ end
34
+ local function enqueue(field, idx, lane)
35
+ redis.call('ZADD', KEYS[6], idx, field)
36
+ if lane == '' then
37
+ redis.call('ZADD', KEYS[7], idx, field)
38
+ return
39
+ end
40
+ local tail_key = lane_field(lane, 'tail')
41
+ local tail = redis.call('HGET', KEYS[5], tail_key)
42
+ if tail then
43
+ redis.call('HSET', KEYS[5], tail .. ':next', field)
44
+ else
45
+ redis.call('HSET', KEYS[5], lane_field(lane, 'head'), field)
46
+ redis.call('ZADD', KEYS[7], idx, field)
47
+ end
48
+ redis.call('HSET', KEYS[5], tail_key, field)
49
+ end
50
+ local n = tonumber(ARGV[3])
51
+ local base = tonumber(redis.call('GET', KEYS[2]) or '0')
52
+ local has_pending = redis.call('ZCARD', KEYS[6]) > 0 and '1' or '0'
53
+ if n == 0 then return { 'empty', has_pending } end
54
+ local dups = {}
55
+ for i = 1, n do
56
+ local id = ARGV[3 + (i - 1) * 6 + 1]
57
+ local existing = redis.call('HGET', KEYS[3], id)
58
+ if existing then
59
+ local rec = cjson.decode(existing)
60
+ if rec[1] ~= ARGV[1] then return { 'foreign', id } end
61
+ dups[#dups + 1] = tostring(rec[2])
62
+ end
63
+ end
64
+ if #dups == n then
65
+ local res = { 'dup', has_pending }
66
+ for i = 1, #dups do res[#res + 1] = dups[i] end
67
+ return res
68
+ end
69
+ if #dups > 0 then return { 'partial', tostring(#dups) } end
70
+ for i = 1, n do
71
+ local id = ARGV[3 + (i - 1) * 6 + 1]
72
+ local typ = ARGV[3 + (i - 1) * 6 + 2]
73
+ local payload = ARGV[3 + (i - 1) * 6 + 3]
74
+ local cause = ARGV[3 + (i - 1) * 6 + 4]
75
+ local lane = ARGV[3 + (i - 1) * 6 + 5]
76
+ local settled = ARGV[3 + (i - 1) * 6 + 6]
77
+ local idx = base + i
78
+ redis.call('XADD', KEYS[1], tostring(idx) .. '-0',
79
+ 'id', id, 'type', typ, 'payload', payload, 'created_at', ARGV[2],
80
+ 'cause', cause, 'lane', lane)
81
+ redis.call('HSET', KEYS[3], id, cjson.encode({ ARGV[1], idx }))
82
+ redis.call('HSET', KEYS[5], tostring(idx) .. ':l', lane)
83
+ if settled == '1' then
84
+ redis.call('HSET', KEYS[5], tostring(idx) .. ':p', ARGV[2])
85
+ else
86
+ enqueue(tostring(idx), idx, lane)
87
+ end
88
+ end
89
+ redis.call('SET', KEYS[2], tostring(base + n))
90
+ redis.call('ZADD', KEYS[4], 0, ARGV[1])
91
+ has_pending = redis.call('ZCARD', KEYS[6]) > 0 and '1' or '0'
92
+ return { 'ok', tostring(base + 1), has_pending,
93
+ tostring(redis.call('EXISTS', KEYS[8])) }
94
+ `;
95
+ /** KEYS: counter, meta, pending, ready. ARGV: idx, attempt, error, maxFailures, nowMs. */
96
+ const FAIL_ATTEMPT_LUA = `
97
+ local function lane_field(lane, suffix)
98
+ return '__lane:' .. tostring(string.len(lane)) .. ':' .. lane .. ':' .. suffix
99
+ end
100
+ local function settle(field)
101
+ redis.call('ZREM', KEYS[3], field)
102
+ redis.call('ZREM', KEYS[4], field)
103
+ local lane = redis.call('HGET', KEYS[2], field .. ':l') or ''
104
+ if lane == '' then return end
105
+ local head_key = lane_field(lane, 'head')
106
+ if redis.call('HGET', KEYS[2], head_key) ~= field then return end
107
+ local next_field = redis.call('HGET', KEYS[2], field .. ':next')
108
+ while next_field and redis.call('HEXISTS', KEYS[2], next_field .. ':p') == 1 do
109
+ next_field = redis.call('HGET', KEYS[2], next_field .. ':next')
110
+ end
111
+ if next_field then
112
+ redis.call('HSET', KEYS[2], head_key, next_field)
113
+ if redis.call('HEXISTS', KEYS[2], next_field .. ':f') == 0 then
114
+ redis.call('ZADD', KEYS[4], tonumber(next_field), next_field)
115
+ end
116
+ else
117
+ redis.call('HDEL', KEYS[2], head_key, lane_field(lane, 'tail'))
118
+ end
119
+ end
120
+ local idx = tonumber(ARGV[1])
121
+ if idx < 1 or idx > tonumber(redis.call('GET', KEYS[1]) or '0') then
122
+ return { 'missing', '0' }
123
+ end
124
+ local field = ARGV[1]
125
+ local failures = tonumber(redis.call('HGET', KEYS[2], field .. ':a') or '0')
126
+ local dispatches = tonumber(redis.call('HGET', KEYS[2], field .. ':d') or '0')
127
+ if redis.call('HEXISTS', KEYS[2], field .. ':p') == 1 or dispatches ~= tonumber(ARGV[2]) then
128
+ return { 'superseded', tostring(failures) }
129
+ end
130
+ if redis.call('HEXISTS', KEYS[2], field .. ':f') == 1 then
131
+ return { 'dead_lettered', tostring(failures) }
132
+ end
133
+ if redis.call('HEXISTS', KEYS[2], field .. ':lfa') == 1 and
134
+ tonumber(redis.call('HGET', KEYS[2], field .. ':lfa')) == tonumber(ARGV[2]) and
135
+ redis.call('HEXISTS', KEYS[2], field .. ':ch') == 0 then
136
+ return { 'failed', tostring(failures) }
137
+ end
138
+ if redis.call('HEXISTS', KEYS[2], field .. ':ch') == 0 then
139
+ return { 'superseded', tostring(failures) }
140
+ end
141
+ failures = redis.call('HINCRBY', KEYS[2], field .. ':a', 1)
142
+ redis.call('HSET', KEYS[2],
143
+ field .. ':e', ARGV[3],
144
+ field .. ':lf', ARGV[5],
145
+ field .. ':lfa', ARGV[2])
146
+ redis.call('HDEL', KEYS[2], field .. ':ch', field .. ':ce')
147
+ if failures >= tonumber(ARGV[4]) then
148
+ redis.call('HSET', KEYS[2], field .. ':f', ARGV[5])
149
+ redis.call('ZREM', KEYS[4], field)
150
+ return { 'dead_lettered', tostring(failures) }
151
+ end
152
+ return { 'failed', tostring(failures) }
153
+ `;
154
+ /**
155
+ * KEYS: log, counter, meta, pending, ready.
156
+ * ARGV: holder, nowMs, expiresAtMs, excludedCount, excluded indexes.
157
+ */
158
+ const CLAIM_AVAILABLE_LUA = `
159
+ if redis.call('ZCARD', KEYS[4]) == 0 then return { 'settled' } end
160
+ local ready = redis.call('ZRANGE', KEYS[5], 0, -1)
161
+ local excluded = {}
162
+ for i = 1, tonumber(ARGV[4]) do excluded[ARGV[4 + i]] = true end
163
+ local claimed = {}
164
+ local retry_at = nil
165
+ for _, field in ipairs(ready) do
166
+ if not excluded[field] then
167
+ local expires = tonumber(redis.call('HGET', KEYS[3], field .. ':ce') or '0')
168
+ if expires > tonumber(ARGV[2]) then
169
+ if not retry_at or expires < retry_at then retry_at = expires end
170
+ else
171
+ local entries = redis.call('XRANGE', KEYS[1], field .. '-0', field .. '-0')
172
+ if #entries == 0 then return { 'missing', field } end
173
+ local attempt = redis.call('HINCRBY', KEYS[3], field .. ':d', 1)
174
+ redis.call('HSETNX', KEYS[3], field .. ':fc', ARGV[2])
175
+ redis.call('HSET', KEYS[3],
176
+ field .. ':lc', ARGV[2],
177
+ field .. ':ch', ARGV[1],
178
+ field .. ':ce', ARGV[3])
179
+ claimed[#claimed + 1] = {
180
+ tostring(attempt),
181
+ redis.call('HGET', KEYS[3], field .. ':a') or '0',
182
+ redis.call('HGET', KEYS[3], field .. ':e') or false,
183
+ redis.call('HGET', KEYS[3], field .. ':fc') or false,
184
+ ARGV[2],
185
+ redis.call('HGET', KEYS[3], field .. ':lf') or false,
186
+ redis.call('HGET', KEYS[3], field .. ':lfa') or false,
187
+ ARGV[1],
188
+ ARGV[3],
189
+ entries[1]
190
+ }
191
+ end
192
+ end
193
+ end
194
+ if #claimed > 0 then
195
+ local result = { 'claimed', tostring(#claimed) }
196
+ for _, claim in ipairs(claimed) do
197
+ for _, value in ipairs(claim) do result[#result + 1] = value end
198
+ end
199
+ return result
200
+ end
201
+ if retry_at then return { 'busy', tostring(retry_at) } end
202
+ return { 'settled' }
203
+ `;
204
+ /**
205
+ * KEYS: counter, meta. ARGV: holder, nowMs, expiresAtMs, n, indexes.
206
+ */
207
+ const RENEW_CLAIMS_LUA = `
208
+ local total = tonumber(redis.call('GET', KEYS[1]) or '0')
209
+ local renewed = {}
210
+ for i = 1, tonumber(ARGV[4]) do
211
+ local field = ARGV[4 + i]
212
+ local idx = tonumber(field)
213
+ local expires = tonumber(redis.call('HGET', KEYS[2], field .. ':ce') or '0')
214
+ if idx >= 1 and idx <= total and
215
+ tonumber(ARGV[3]) > tonumber(ARGV[2]) and
216
+ redis.call('HEXISTS', KEYS[2], field .. ':p') == 0 and
217
+ redis.call('HEXISTS', KEYS[2], field .. ':f') == 0 and
218
+ redis.call('HGET', KEYS[2], field .. ':ch') == ARGV[1] and
219
+ expires > tonumber(ARGV[2]) then
220
+ redis.call('HSET', KEYS[2], field .. ':ce', ARGV[3])
221
+ renewed[#renewed + 1] = field
222
+ end
223
+ end
224
+ return renewed
225
+ `;
226
+ /**
227
+ * KEYS: log, counter, ids, session index, meta, pending, ready, watched.
228
+ * ARGV: sessionId, nowMs, parent index, attempt, n, then per child:
229
+ * id, type, payloadJson, lane, settled.
230
+ */
231
+ const COMPLETE_ATTEMPT_LUA = `
232
+ local function lane_field(lane, suffix)
233
+ return '__lane:' .. tostring(string.len(lane)) .. ':' .. lane .. ':' .. suffix
234
+ end
235
+ local function settle(field)
236
+ redis.call('ZREM', KEYS[6], field)
237
+ redis.call('ZREM', KEYS[7], field)
238
+ local lane = redis.call('HGET', KEYS[5], field .. ':l') or ''
239
+ if lane == '' then return end
240
+ local head_key = lane_field(lane, 'head')
241
+ if redis.call('HGET', KEYS[5], head_key) ~= field then return end
242
+ local next_field = redis.call('HGET', KEYS[5], field .. ':next')
243
+ while next_field and redis.call('HEXISTS', KEYS[5], next_field .. ':p') == 1 do
244
+ next_field = redis.call('HGET', KEYS[5], next_field .. ':next')
245
+ end
246
+ if next_field then
247
+ redis.call('HSET', KEYS[5], head_key, next_field)
248
+ if redis.call('HEXISTS', KEYS[5], next_field .. ':f') == 0 then
249
+ redis.call('ZADD', KEYS[7], tonumber(next_field), next_field)
250
+ end
251
+ else
252
+ redis.call('HDEL', KEYS[5], head_key, lane_field(lane, 'tail'))
253
+ end
254
+ end
255
+ local function enqueue(field, idx, lane)
256
+ redis.call('ZADD', KEYS[6], idx, field)
257
+ if lane == '' then
258
+ redis.call('ZADD', KEYS[7], idx, field)
259
+ return
260
+ end
261
+ local tail_key = lane_field(lane, 'tail')
262
+ local tail = redis.call('HGET', KEYS[5], tail_key)
263
+ if tail then
264
+ redis.call('HSET', KEYS[5], tail .. ':next', field)
265
+ else
266
+ redis.call('HSET', KEYS[5], lane_field(lane, 'head'), field)
267
+ redis.call('ZADD', KEYS[7], idx, field)
268
+ end
269
+ redis.call('HSET', KEYS[5], tail_key, field)
270
+ end
271
+ local parent = ARGV[3]
272
+ local attempt = tonumber(ARGV[4])
273
+ local n = tonumber(ARGV[5])
274
+ local total = tonumber(redis.call('GET', KEYS[2]) or '0')
275
+ if tonumber(parent) < 1 or tonumber(parent) > total then return { 'missing' } end
276
+ local dispatches = tonumber(redis.call('HGET', KEYS[5], parent .. ':d') or '0')
277
+ local processed = redis.call('HGET', KEYS[5], parent .. ':p')
278
+ local processed_attempt = tonumber(redis.call('HGET', KEYS[5], parent .. ':pa') or '0')
279
+ if processed then
280
+ if processed_attempt ~= attempt then return { 'superseded' } end
281
+ local raw_recorded_ids = redis.call('HGET', KEYS[5], parent .. ':ri')
282
+ if not raw_recorded_ids then return { 'partial' } end
283
+ local recorded_ids = cjson.decode(raw_recorded_ids)
284
+ if #recorded_ids ~= n then return { 'partial' } end
285
+ local indexes = {}
286
+ for i = 1, n do
287
+ local id = ARGV[5 + (i - 1) * 5 + 1]
288
+ if recorded_ids[i] ~= id then return { 'partial' } end
289
+ local existing = redis.call('HGET', KEYS[3], id)
290
+ if not existing then return { 'partial' } end
291
+ local rec = cjson.decode(existing)
292
+ if rec[1] ~= ARGV[1] then return { 'partial' } end
293
+ local entries = redis.call('XRANGE', KEYS[1], tostring(rec[2]) .. '-0', tostring(rec[2]) .. '-0')
294
+ if #entries == 0 then return { 'partial' } end
295
+ local values = entries[1][2]
296
+ local cause = nil
297
+ for j = 1, #values, 2 do
298
+ if values[j] == 'cause' then cause = values[j + 1] break end
299
+ end
300
+ if not cause or cause == '' then return { 'partial' } end
301
+ local decoded = cjson.decode(cause)
302
+ if tonumber(decoded['index']) ~= tonumber(parent) or
303
+ tonumber(decoded['attempt']) ~= attempt then return { 'partial' } end
304
+ indexes[#indexes + 1] = tostring(rec[2])
305
+ end
306
+ local result = { 'duplicate' }
307
+ for _, idx in ipairs(indexes) do result[#result + 1] = idx end
308
+ return result
309
+ end
310
+ if dispatches ~= attempt then return { 'superseded' } end
311
+ if redis.call('HEXISTS', KEYS[5], parent .. ':ch') == 0 or
312
+ redis.call('HEXISTS', KEYS[5], parent .. ':f') == 1 or
313
+ tonumber(redis.call('HGET', KEYS[5], parent .. ':lfa') or '0') == attempt then
314
+ return { 'superseded' }
315
+ end
316
+ for i = 1, n do
317
+ local id = ARGV[5 + (i - 1) * 5 + 1]
318
+ if redis.call('HEXISTS', KEYS[3], id) == 1 then return { 'partial' } end
319
+ end
320
+ local returned_ids = {}
321
+ for i = 1, n do returned_ids[i] = ARGV[5 + (i - 1) * 5 + 1] end
322
+ local returned_json = '[]'
323
+ if n > 0 then returned_json = cjson.encode(returned_ids) end
324
+ redis.call('HSET', KEYS[5],
325
+ parent .. ':p', ARGV[2],
326
+ parent .. ':pa', ARGV[4],
327
+ parent .. ':ri', returned_json)
328
+ redis.call('HDEL', KEYS[5], parent .. ':ch', parent .. ':ce')
329
+ settle(parent)
330
+ local cause = cjson.encode({ index = tonumber(parent), attempt = attempt })
331
+ for i = 1, n do
332
+ local id = ARGV[5 + (i - 1) * 5 + 1]
333
+ local typ = ARGV[5 + (i - 1) * 5 + 2]
334
+ local payload = ARGV[5 + (i - 1) * 5 + 3]
335
+ local lane = ARGV[5 + (i - 1) * 5 + 4]
336
+ local settled = ARGV[5 + (i - 1) * 5 + 5]
337
+ local idx = total + i
338
+ redis.call('XADD', KEYS[1], tostring(idx) .. '-0',
339
+ 'id', id, 'type', typ, 'payload', payload, 'created_at', ARGV[2],
340
+ 'cause', cause, 'lane', lane)
341
+ redis.call('HSET', KEYS[3], id, cjson.encode({ ARGV[1], idx }))
342
+ redis.call('HSET', KEYS[5], tostring(idx) .. ':l', lane)
343
+ if settled == '1' then
344
+ redis.call('HSET', KEYS[5], tostring(idx) .. ':p', ARGV[2])
345
+ else
346
+ enqueue(tostring(idx), idx, lane)
347
+ end
348
+ end
349
+ redis.call('SET', KEYS[2], tostring(total + n))
350
+ redis.call('ZADD', KEYS[4], 0, ARGV[1])
351
+ return { 'ok', tostring(total + 1),
352
+ tostring(redis.call('EXISTS', KEYS[8])) }
353
+ `;
354
+ /** KEYS: snapshot. ARGV: upToIndex, json. Guarded: never move backward. */
355
+ const PUT_SNAPSHOT_LUA = `
356
+ local cur = redis.call('GET', KEYS[1])
357
+ if cur and tonumber(cjson.decode(cur)['index']) >= tonumber(ARGV[1]) then
358
+ return 0
359
+ end
360
+ redis.call('SET', KEYS[1], ARGV[2])
361
+ return 1
362
+ `;
363
+ /** KEYS: snapshot, log stream. Returns one atomic cache-plus-tail read. */
364
+ const READ_STATE_LUA = `
365
+ local snapshot = redis.call('GET', KEYS[1])
366
+ local after = 0
367
+ if snapshot then
368
+ after = tonumber(cjson.decode(snapshot)['index'])
369
+ end
370
+ local events = redis.call('XRANGE', KEYS[2], tostring(after + 1) .. '-0', '+')
371
+ return { snapshot or '', events }
372
+ `;
373
+ const fieldMap = (fields) => {
374
+ const map = {};
375
+ for (let i = 0; i + 1 < fields.length; i += 2) map[fields[i]] = fields[i + 1];
376
+ return map;
377
+ };
378
+ const entryIndex = (entryId) => Number(entryId.slice(0, entryId.indexOf("-")));
379
+ const toEvent = (sessionId, entry) => {
380
+ const fields = fieldMap(entry[1]);
381
+ return {
382
+ id: fields["id"],
383
+ type: fields["type"],
384
+ payload: JSON.parse(fields["payload"]),
385
+ index: entryIndex(entry[0]),
386
+ sessionId,
387
+ createdAt: new Date(Number(fields["created_at"]))
388
+ };
389
+ };
390
+ const json = (value) => JSON.stringify(value) ?? "null";
391
+ const escapeGlob = (value) => value.replaceAll("\\", "\\\\").replaceAll("*", "\\*").replaceAll("?", "\\?").replaceAll("[", "\\[").replaceAll("]", "\\]");
392
+ const toCause = (raw) => {
393
+ if (raw === "" || raw === void 0) return null;
394
+ const value = JSON.parse(raw);
395
+ 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");
396
+ if (value.batchSize !== void 0 && (!Number.isInteger(value.batchSize) || Number(value.batchSize) < 1)) throw new TypeError("stored event has an invalid cause");
397
+ return {
398
+ index: Number(value.index),
399
+ attempt: Number(value.attempt),
400
+ ...value.batchSize === void 0 ? {} : { batchSize: Number(value.batchSize) }
401
+ };
402
+ };
403
+ const toClaimAvailableResult = (sessionId, reply) => {
404
+ const outcome = String(reply[0]);
405
+ if (outcome === "claimed") {
406
+ const count = Number(reply[1]);
407
+ const events = [];
408
+ for (let i = 0; i < count; i++) {
409
+ const offset = 2 + i * 10;
410
+ const entry = reply[offset + 9];
411
+ const event = toEvent(sessionId, entry);
412
+ const fields = fieldMap(entry[1]);
413
+ events.push(Object.assign(event, {
414
+ cause: toCause(fields["cause"]),
415
+ lane: fields["lane"] === "" ? null : fields["lane"],
416
+ processedAt: null,
417
+ processedByAttempt: null,
418
+ returnedEventIds: null,
419
+ firstClaimedAt: reply[offset + 3] === null ? null : new Date(Number(reply[offset + 3])),
420
+ lastClaimedAt: new Date(Number(reply[offset + 4])),
421
+ attemptCount: Number(reply[offset]),
422
+ claimHolder: String(reply[offset + 7]),
423
+ claimExpiresAt: new Date(Number(reply[offset + 8])),
424
+ failureCount: Number(reply[offset + 1]),
425
+ lastFailedAt: reply[offset + 5] === null ? null : new Date(Number(reply[offset + 5])),
426
+ lastFailedAttempt: reply[offset + 6] === null ? null : Number(reply[offset + 6]),
427
+ lastError: reply[offset + 2] === null ? null : String(reply[offset + 2]),
428
+ failedAt: null
429
+ }));
430
+ }
431
+ return {
432
+ outcome: "claimed",
433
+ events
434
+ };
435
+ }
436
+ if (outcome === "busy") return {
437
+ outcome,
438
+ retryAt: new Date(Number(reply[1]))
439
+ };
440
+ if (outcome === "settled") return { outcome };
441
+ throw new TypeError(`claim referenced a missing event in session '${sessionId}'`);
442
+ };
443
+ const wrap = async (fn) => {
444
+ try {
445
+ return await fn();
446
+ } catch (err) {
447
+ if (err instanceof A2Error || err instanceof TypeError) throw err;
448
+ throw new A2Error("LOG_UNAVAILABLE", "redis log operation failed", { cause: err });
449
+ }
450
+ };
451
+ function createRedisLogCore(options) {
452
+ const { call, clock, notify } = options;
453
+ const generateId = options.ids;
454
+ const prefix = options.keyPrefix;
455
+ const logKey = (s) => `${prefix}:${s}:log`;
456
+ const countKey = (s) => `${prefix}:${s}:count`;
457
+ const metaKey = (s) => `${prefix}:${s}:meta`;
458
+ const pendingKey = (s) => `${prefix}:${s}:pending`;
459
+ const readyKey = (s) => `${prefix}:${s}:ready`;
460
+ const snapKey = (s, r) => `${prefix}:${s}:snap:${r}`;
461
+ const watchedKey = (s) => `${prefix}:${s}:watched`;
462
+ const idsKey = `${prefix}:ids`;
463
+ const sessionsKey = `${prefix}:sessions`;
464
+ const evalScript = (script, keys, args) => call("EVAL", script, keys.length, ...keys, ...args);
465
+ /** XRANGE + meta merge — the raw read both `read` and dup-returns use. */
466
+ const readRange = async (sessionId, afterIndex) => {
467
+ const entries = await call("XRANGE", logKey(sessionId), `${afterIndex + 1}-0`, "+");
468
+ if (entries.length === 0) return [];
469
+ const meta = fieldMap(await call("HGETALL", metaKey(sessionId)) ?? []);
470
+ return entries.map((entry) => {
471
+ const event = toEvent(sessionId, entry);
472
+ const idx = event.index;
473
+ const fields = fieldMap(entry[1]);
474
+ const processedAt = meta[`${idx}:p`];
475
+ const processedByAttempt = meta[`${idx}:pa`];
476
+ const returnedEventIds = meta[`${idx}:ri`];
477
+ const claimHolder = meta[`${idx}:ch`];
478
+ const claimExpiresAt = meta[`${idx}:ce`];
479
+ const failedAt = meta[`${idx}:f`];
480
+ const firstClaimedAt = meta[`${idx}:fc`];
481
+ const lastClaimedAt = meta[`${idx}:lc`];
482
+ const lastFailedAt = meta[`${idx}:lf`];
483
+ const lastFailedAttempt = meta[`${idx}:lfa`];
484
+ return Object.assign(event, {
485
+ cause: toCause(fields["cause"]),
486
+ lane: fields["lane"] === "" ? null : fields["lane"],
487
+ processedAt: processedAt === void 0 ? null : new Date(Number(processedAt)),
488
+ processedByAttempt: processedByAttempt === void 0 ? null : Number(processedByAttempt),
489
+ returnedEventIds: returnedEventIds === void 0 ? null : JSON.parse(returnedEventIds),
490
+ firstClaimedAt: firstClaimedAt === void 0 ? null : new Date(Number(firstClaimedAt)),
491
+ lastClaimedAt: lastClaimedAt === void 0 ? null : new Date(Number(lastClaimedAt)),
492
+ attemptCount: Number(meta[`${idx}:d`] ?? 0),
493
+ claimHolder: claimHolder ?? null,
494
+ claimExpiresAt: claimExpiresAt === void 0 ? null : new Date(Number(claimExpiresAt)),
495
+ failureCount: Number(meta[`${idx}:a`] ?? 0),
496
+ lastFailedAt: lastFailedAt === void 0 ? null : new Date(Number(lastFailedAt)),
497
+ lastFailedAttempt: lastFailedAttempt === void 0 ? null : Number(lastFailedAttempt),
498
+ lastError: meta[`${idx}:e`] ?? null,
499
+ failedAt: failedAt === void 0 ? null : new Date(Number(failedAt))
500
+ });
501
+ });
502
+ };
503
+ return {
504
+ async append(sessionId, events) {
505
+ return wrap(async () => {
506
+ const suppliedIds = events.filter((e) => e.id !== void 0).map((e) => e.id);
507
+ if (new Set(suppliedIds).size !== suppliedIds.length) throw new A2Error("PARTIAL_DUPLICATE_BATCH", "batch contains the same event id more than once");
508
+ const now = clock.now().getTime();
509
+ const withIds = events.map((e) => ({
510
+ id: e.id ?? generateId(),
511
+ type: e.type,
512
+ payload: e.payload,
513
+ cause: e.cause ? { ...e.cause } : null,
514
+ lane: e.lane ?? null,
515
+ settled: e.settled === true
516
+ }));
517
+ const args = [
518
+ sessionId,
519
+ now,
520
+ withIds.length,
521
+ ...withIds.flatMap((e) => [
522
+ e.id,
523
+ e.type,
524
+ json(e.payload),
525
+ e.cause ? json(e.cause) : "",
526
+ e.lane ?? "",
527
+ e.settled ? "1" : "0"
528
+ ])
529
+ ];
530
+ const reply = await evalScript(APPEND_LUA, [
531
+ logKey(sessionId),
532
+ countKey(sessionId),
533
+ idsKey,
534
+ sessionsKey,
535
+ metaKey(sessionId),
536
+ pendingKey(sessionId),
537
+ readyKey(sessionId),
538
+ watchedKey(sessionId)
539
+ ], args);
540
+ const [verdict] = reply;
541
+ if (verdict === "empty") return {
542
+ events: [],
543
+ hasPending: reply[1] === "1"
544
+ };
545
+ if (verdict === "foreign") throw new A2Error("PARTIAL_DUPLICATE_BATCH", `event id '${reply[1]}' already exists in another session`);
546
+ if (verdict === "partial") throw new A2Error("PARTIAL_DUPLICATE_BATCH", `batch mixes ${reply[1]} already-appended and ${events.length - Number(reply[1])} fresh events`);
547
+ if (verdict === "dup") {
548
+ const hasPending = reply[1] === "1";
549
+ const wanted = new Set(reply.slice(2).map(Number));
550
+ const min = Math.min(...wanted);
551
+ const existing = (await readRange(sessionId, min - 1)).filter((row) => wanted.has(row.index));
552
+ return {
553
+ events: idempotentReplay(events, existing),
554
+ hasPending
555
+ };
556
+ }
557
+ const base = Number(reply[1]) - 1;
558
+ if (reply[3] === "1") notify?.(sessionId, base + withIds.length);
559
+ return {
560
+ events: withIds.map((e, i) => ({
561
+ id: e.id,
562
+ type: e.type,
563
+ payload: structuredClone(e.payload),
564
+ index: base + 1 + i,
565
+ sessionId,
566
+ createdAt: new Date(now),
567
+ cause: e.cause,
568
+ lane: e.lane,
569
+ processedAt: e.settled ? new Date(now) : null,
570
+ processedByAttempt: null,
571
+ returnedEventIds: null,
572
+ firstClaimedAt: null,
573
+ lastClaimedAt: null,
574
+ attemptCount: 0,
575
+ claimHolder: null,
576
+ claimExpiresAt: null,
577
+ failureCount: 0,
578
+ lastFailedAt: null,
579
+ lastFailedAttempt: null,
580
+ lastError: null,
581
+ failedAt: null
582
+ })),
583
+ hasPending: reply[2] === "1"
584
+ };
585
+ });
586
+ },
587
+ async read(sessionId, opts) {
588
+ return wrap(async () => {
589
+ return await readRange(sessionId, opts?.afterIndex ?? 0);
590
+ });
591
+ },
592
+ async claimAvailable({ sessionId, holder, ttlMs, expiresAtMs, excludeIndexes }) {
593
+ return wrap(async () => {
594
+ const now = clock.now().getTime();
595
+ const expiresAt = expiresAtMs ?? now + ttlMs;
596
+ const excluded = excludeIndexes ?? [];
597
+ const reply = await evalScript(CLAIM_AVAILABLE_LUA, [
598
+ logKey(sessionId),
599
+ countKey(sessionId),
600
+ metaKey(sessionId),
601
+ pendingKey(sessionId),
602
+ readyKey(sessionId)
603
+ ], [
604
+ holder,
605
+ now,
606
+ expiresAt,
607
+ excluded.length,
608
+ ...excluded
609
+ ]);
610
+ return toClaimAvailableResult(sessionId, reply);
611
+ });
612
+ },
613
+ async renewClaims({ sessionId, holder, indexes, ttlMs, expiresAtMs }) {
614
+ return wrap(async () => {
615
+ const now = clock.now().getTime();
616
+ const expiresAt = expiresAtMs ?? now + ttlMs;
617
+ return (await evalScript(RENEW_CLAIMS_LUA, [countKey(sessionId), metaKey(sessionId)], [
618
+ holder,
619
+ now,
620
+ expiresAt,
621
+ indexes.length,
622
+ ...indexes
623
+ ])).map(Number);
624
+ });
625
+ },
626
+ async completeAttempt({ sessionId, index, attempt, events }) {
627
+ return wrap(async () => {
628
+ const ids = events.map((event) => event.id);
629
+ if (new Set(ids).size !== ids.length) throw new A2Error("PARTIAL_DUPLICATE_BATCH", "batch contains the same event id more than once");
630
+ const now = clock.now().getTime();
631
+ const reply = await evalScript(COMPLETE_ATTEMPT_LUA, [
632
+ logKey(sessionId),
633
+ countKey(sessionId),
634
+ idsKey,
635
+ sessionsKey,
636
+ metaKey(sessionId),
637
+ pendingKey(sessionId),
638
+ readyKey(sessionId),
639
+ watchedKey(sessionId)
640
+ ], [
641
+ sessionId,
642
+ now,
643
+ index,
644
+ attempt,
645
+ events.length,
646
+ ...events.flatMap((event) => [
647
+ event.id,
648
+ event.type,
649
+ json(event.payload),
650
+ event.lane ?? "",
651
+ event.settled === true ? "1" : "0"
652
+ ])
653
+ ]);
654
+ const outcome = reply[0];
655
+ if (outcome === "missing") throw new TypeError(`no event at index ${index} in session '${sessionId}'`);
656
+ if (outcome === "partial") throw new A2Error("PARTIAL_DUPLICATE_BATCH", "returned event ids do not match the completed attempt");
657
+ if (outcome === "superseded") return { outcome };
658
+ const cause = {
659
+ index,
660
+ attempt
661
+ };
662
+ if (outcome === "ok") {
663
+ const base = Number(reply[1]) - 1;
664
+ if (events.length > 0 && reply[2] === "1") notify?.(sessionId, base + events.length);
665
+ return {
666
+ outcome: "completed",
667
+ events: events.map((event, i) => ({
668
+ id: event.id,
669
+ type: event.type,
670
+ payload: structuredClone(event.payload),
671
+ index: base + 1 + i,
672
+ sessionId,
673
+ createdAt: new Date(now),
674
+ cause,
675
+ lane: event.lane ?? null,
676
+ processedAt: event.settled === true ? new Date(now) : null,
677
+ processedByAttempt: null,
678
+ returnedEventIds: null,
679
+ firstClaimedAt: null,
680
+ lastClaimedAt: null,
681
+ attemptCount: 0,
682
+ claimHolder: null,
683
+ claimExpiresAt: null,
684
+ failureCount: 0,
685
+ lastFailedAt: null,
686
+ lastFailedAttempt: null,
687
+ lastError: null,
688
+ failedAt: null
689
+ }))
690
+ };
691
+ }
692
+ if (outcome === "duplicate") {
693
+ if (events.length === 0) return {
694
+ outcome: "completed",
695
+ events: []
696
+ };
697
+ const wanted = new Set(reply.slice(1).map(Number));
698
+ const min = Math.min(...wanted);
699
+ const rows = await readRange(sessionId, min - 1);
700
+ const byIndex = new Map(rows.map((row) => [row.index, row]));
701
+ const existing = reply.slice(1).map((value) => byIndex.get(Number(value)));
702
+ return {
703
+ outcome: "completed",
704
+ events: idempotentReplay(events, existing)
705
+ };
706
+ }
707
+ throw new TypeError(`unexpected completion outcome '${outcome}'`);
708
+ });
709
+ },
710
+ async failAttempt({ sessionId, index, attempt, error, maxFailures }) {
711
+ return wrap(async () => {
712
+ const reply = await evalScript(FAIL_ATTEMPT_LUA, [
713
+ countKey(sessionId),
714
+ metaKey(sessionId),
715
+ pendingKey(sessionId),
716
+ readyKey(sessionId)
717
+ ], [
718
+ index,
719
+ attempt,
720
+ error,
721
+ maxFailures,
722
+ clock.now().getTime()
723
+ ]);
724
+ const outcome = reply[0];
725
+ if (outcome === "missing") throw new TypeError(`no event at index ${index} in session '${sessionId}'`);
726
+ return {
727
+ outcome,
728
+ failureCount: Number(reply[1])
729
+ };
730
+ });
731
+ },
732
+ async readState(sessionId, reducerName) {
733
+ return wrap(async () => {
734
+ const reply = await evalScript(READ_STATE_LUA, [snapKey(sessionId, reducerName), logKey(sessionId)], []);
735
+ const raw = reply[0];
736
+ const parsed = raw === "" ? null : JSON.parse(raw);
737
+ return {
738
+ snapshot: parsed ? {
739
+ index: parsed.index,
740
+ state: parsed.state
741
+ } : null,
742
+ events: reply[1].map((entry) => toEvent(sessionId, entry))
743
+ };
744
+ });
745
+ },
746
+ async putSnapshot(sessionId, reducerName, index, state) {
747
+ await wrap(async () => {
748
+ await evalScript(PUT_SNAPSHOT_LUA, [snapKey(sessionId, reducerName)], [index, json({
749
+ index,
750
+ state,
751
+ updatedAt: clock.now().getTime()
752
+ })]);
753
+ });
754
+ },
755
+ inspect: {
756
+ async listSessions(inspectionOptions) {
757
+ return wrap(async () => {
758
+ const minimum = inspectionOptions.cursor === void 0 ? `[${inspectionOptions.prefix}` : `(${inspectionOptions.cursor}`;
759
+ const candidates = await call("ZRANGEBYLEX", sessionsKey, minimum, "+", "LIMIT", 0, inspectionOptions.limit + 1);
760
+ const sessionIds = candidates.filter((id) => id.startsWith(inspectionOptions.prefix)).slice(0, inspectionOptions.limit);
761
+ return {
762
+ sessions: (await Promise.all(sessionIds.map(async (sessionId) => {
763
+ const rows = await readRange(sessionId, 0);
764
+ const first = rows[0];
765
+ let updatedAt = first.createdAt;
766
+ let pendingCount = 0;
767
+ let failedCount = 0;
768
+ let attemptCount = 0;
769
+ let failureCount = 0;
770
+ for (const row of rows) {
771
+ attemptCount += row.attemptCount;
772
+ failureCount += row.failureCount;
773
+ if (row.processedAt === null && row.failedAt === null) pendingCount += 1;
774
+ if (row.failedAt !== null) failedCount += 1;
775
+ for (const timestamp of [
776
+ row.createdAt,
777
+ row.firstClaimedAt,
778
+ row.lastClaimedAt,
779
+ row.lastFailedAt,
780
+ row.processedAt,
781
+ row.failedAt
782
+ ]) if (timestamp && timestamp > updatedAt) updatedAt = timestamp;
783
+ }
784
+ return {
785
+ sessionId,
786
+ eventCount: rows.length,
787
+ pendingCount,
788
+ failedCount,
789
+ attemptCount,
790
+ failureCount,
791
+ firstEventAt: first.createdAt,
792
+ updatedAt
793
+ };
794
+ }))).toSorted((a, b) => a.sessionId.localeCompare(b.sessionId)),
795
+ cursor: candidates.length > inspectionOptions.limit && candidates[inspectionOptions.limit]?.startsWith(inspectionOptions.prefix) ? sessionIds.at(-1) ?? null : null
796
+ };
797
+ });
798
+ },
799
+ async listSnapshots(sessionId) {
800
+ return wrap(async () => {
801
+ const start = `${prefix}:${sessionId}:snap:`;
802
+ let cursor = "0";
803
+ const keys = [];
804
+ do {
805
+ const reply = await call("SCAN", cursor, "MATCH", `${escapeGlob(start)}*`, "COUNT", 100);
806
+ cursor = reply[0];
807
+ keys.push(...reply[1].filter((key) => key.startsWith(start)));
808
+ } while (cursor !== "0");
809
+ return (await Promise.all(keys.map(async (key) => {
810
+ const raw = await call("GET", key);
811
+ if (raw === null) return null;
812
+ const parsed = JSON.parse(raw);
813
+ return {
814
+ reducerName: key.slice(start.length),
815
+ index: parsed.index,
816
+ updatedAt: new Date(parsed.updatedAt ?? 0)
817
+ };
818
+ }))).filter((snapshot) => snapshot !== null).toSorted((a, b) => a.reducerName.localeCompare(b.reducerName));
819
+ });
820
+ }
821
+ },
822
+ async readRange(sessionId, afterIndex) {
823
+ return wrap(() => readRange(sessionId, afterIndex));
824
+ },
825
+ async readEvents(sessionId, afterIndex) {
826
+ return wrap(async () => {
827
+ return (await call("XRANGE", logKey(sessionId), `${afterIndex + 1}-0`, "+")).map((entry) => toEvent(sessionId, entry));
828
+ });
829
+ },
830
+ async markWatched(sessionId, ttlMs) {
831
+ await wrap(() => call("SET", watchedKey(sessionId), "1", "PX", ttlMs));
832
+ }
833
+ };
834
+ }
835
+ //#endregion
836
+ export { createRedisLogCore as t };