experimental-a2 0.2.0 → 0.4.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 (92) hide show
  1. package/CHANGELOG.md +169 -0
  2. package/dist/ai-B4YhEnfw.d.ts +333 -0
  3. package/dist/ai-server.d.ts +48 -9
  4. package/dist/ai-server.js +121 -49
  5. package/dist/ai.d.ts +2 -303
  6. package/dist/ai.js +231 -86
  7. package/dist/cli-B3VuxoDe.js +597 -0
  8. package/dist/cli-bin.d.ts +1 -0
  9. package/dist/cli-bin.js +5 -0
  10. package/dist/cli.d.ts +19 -0
  11. package/dist/cli.js +2 -0
  12. package/dist/client-BrfDXQ8A.d.ts +155 -0
  13. package/dist/client-Bt4tAKi9.js +798 -0
  14. package/dist/client.d.ts +2 -90
  15. package/dist/client.js +1 -409
  16. package/dist/{contract-CG_adnu_.js → contract-48bUMgcL.js} +10 -2
  17. package/dist/{contract-C_3dIIEU.d.ts → contract-jIfaR085.d.ts} +62 -8
  18. package/dist/devtools-J_jZ2vQf.d.ts +151 -0
  19. package/dist/devtools-kJJaORn-.js +338 -0
  20. package/dist/devtools-server.browser.js +1 -1
  21. package/dist/devtools-server.d.ts +2 -2
  22. package/dist/devtools-server.js +224 -43
  23. package/dist/devtools.d.ts +2 -0
  24. package/dist/devtools.js +2 -0
  25. package/dist/{errors-BJRMd-h6.js → errors-BQuJpe82.js} +4 -4
  26. package/dist/{errors-xL_JTXsY.d.ts → errors-W6nwJ-fm.d.ts} +1 -1
  27. package/dist/http.d.ts +71 -13
  28. package/dist/http.js +302 -41
  29. package/dist/{idempotent-replay-BMyHrP0L.js → idempotent-replay-DuqEkYA7.js} +2 -2
  30. package/dist/index.d.ts +5 -5
  31. package/dist/index.js +2 -2
  32. package/dist/{inspection-E7qbD0Xj.js → inspection-DaxB5jM2.js} +2 -1
  33. package/dist/internal-aEotMzu_.js +209 -0
  34. package/dist/otel.d.ts +1 -1
  35. package/dist/platform-B4TnJtWu.js +32 -0
  36. package/dist/react.d.ts +37 -14
  37. package/dist/react.js +26 -15
  38. package/dist/scheduler-qstash.d.ts +78 -0
  39. package/dist/scheduler-qstash.js +499 -0
  40. package/dist/scheduler-task-BpzhPnRS.js +54 -0
  41. package/dist/{recovery-vercel.d.ts → scheduler-vercel.d.ts} +17 -24
  42. package/dist/scheduler-vercel.js +226 -0
  43. package/dist/server-CcNnFnoW.js +1405 -0
  44. package/dist/server-YtPq7hjw.d.ts +260 -0
  45. package/dist/server.d.ts +4 -155
  46. package/dist/server.js +2 -2
  47. package/dist/{log-ldf5g8Cx.d.ts → store-C3sNAaBT.d.ts} +111 -35
  48. package/dist/{log-yJbXUf72.js → store-N8PXxDAS.js} +1 -1
  49. package/dist/store-codec-DTG0Ftek.js +8 -0
  50. package/dist/store-memory.d.ts +11 -0
  51. package/dist/{log-memory.js → store-memory.js} +127 -24
  52. package/dist/{log-polling-6COoN60V.js → store-polling-DgrrAE3d.js} +7 -6
  53. package/dist/{log-postgres.d.ts → store-postgres.d.ts} +6 -6
  54. package/dist/{log-postgres.js → store-postgres.js} +158 -24
  55. package/dist/{log-redis.js → store-redis-core-DWqx3F47.js} +294 -156
  56. package/dist/store-redis-http.d.ts +21 -0
  57. package/dist/store-redis-http.js +70 -0
  58. package/dist/store-redis.d.ts +37 -0
  59. package/dist/store-redis.js +298 -0
  60. package/dist/{log-sqlite.d.ts → store-sqlite.d.ts} +6 -6
  61. package/dist/{log-sqlite.js → store-sqlite.js} +116 -22
  62. package/dist/{telemetry-Cso0qyHQ.d.ts → telemetry-BjYHTfh2.d.ts} +1 -1
  63. package/dist/testing.browser.d.ts +1 -0
  64. package/dist/testing.browser.js +4 -0
  65. package/dist/testing.d.ts +31 -0
  66. package/dist/testing.js +101 -0
  67. package/dist/wire-DCUZBUlT.js +222 -0
  68. package/docs/01-quickstart.mdx +4 -5
  69. package/docs/concepts/01-contracts.mdx +21 -17
  70. package/docs/concepts/02-handlers.mdx +7 -7
  71. package/docs/concepts/03-durability.mdx +26 -29
  72. package/docs/concepts/04-state.mdx +18 -21
  73. package/docs/guides/01-timers.mdx +154 -54
  74. package/docs/guides/02-cancellation.mdx +30 -4
  75. package/docs/guides/03-react.mdx +20 -21
  76. package/docs/guides/04-local-first.mdx +1 -1
  77. package/docs/guides/05-production.mdx +321 -60
  78. package/docs/guides/06-ai-agents.mdx +249 -49
  79. package/docs/guides/07-devtools.mdx +137 -12
  80. package/docs/guides/08-application-data.mdx +1 -1
  81. package/docs/guides/09-presence.mdx +284 -0
  82. package/docs/guides/10-transports.mdx +131 -0
  83. package/docs/index.mdx +22 -46
  84. package/docs/reference/01-api.mdx +751 -102
  85. package/docs/reference/02-errors.mdx +10 -5
  86. package/package.json +23 -6
  87. package/dist/internal-D6wNxTck.js +0 -36
  88. package/dist/log-memory.d.ts +0 -11
  89. package/dist/log-redis.d.ts +0 -31
  90. package/dist/recovery-vercel.js +0 -119
  91. package/dist/server-DJgD2YWP.js +0 -877
  92. package/dist/wire-BVsgR8o9.js +0 -62
@@ -1,37 +1,17 @@
1
- import { t as A2Error } from "./errors-BJRMd-h6.js";
2
- import { t as retryableLazy } from "./retryable-lazy-DZWmHpii.js";
3
- import { t as idempotentReplay } from "./idempotent-replay-BMyHrP0L.js";
4
- import { n as SYSTEM_CLOCK, t as RANDOM_IDS } from "./log-yJbXUf72.js";
5
- //#region src/log-redis.ts
1
+ import { t as A2Error } from "./errors-BQuJpe82.js";
2
+ import { g as nullProtoRecord } from "./internal-aEotMzu_.js";
3
+ import { t as idempotentReplay } from "./idempotent-replay-DuqEkYA7.js";
4
+ import { t as decodeReturnedEventIds } from "./store-codec-DTG0Ftek.js";
5
+ //#region src/store-redis-core.ts
6
+ const READ_PAGE_SIZE = 256;
6
7
  /**
7
- * experimental-a2/log-redis the push-native log backend, on Redis Streams.
8
- *
9
- * The stream IS the log: each event is a stream entry whose ID is its
10
- * dense a2 index (`${index}-0`), so `XRANGE` is the catch-up read and
11
- * `XREAD BLOCK` is a true blocking live feed — no polling anywhere.
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 can
18
- * time-travel against a real server.
19
- *
20
- * Works with any Redis-protocol server on a single instance or a
21
- * non-cluster provider (Upstash — durable by default — Redis, Valkey).
22
- * Cluster mode is out: the atomic scripts span keys. `ioredis` is an
23
- * optional peer dependency; pass `url`, or inject any client exposing
24
- * `call`/`duplicate`/`disconnect`.
25
- */
26
- /** How long each blocking read waits before re-checking for close(). */
27
- const XREAD_BLOCK_MS = 1e4;
28
- /**
29
- * KEYS: log stream, counter, global id hash, session index, metadata, pending, ready.
8
+ * KEYS: store stream, counter, global id hash, session index, metadata,
9
+ * pending, ready, watched.
30
10
  * ARGV: sessionId, nowMs, n, then per event:
31
11
  * id, type, payloadJson, causeJson, lane, settled.
32
- * Returns ['empty', hasPending] | ['ok', firstIndex, hasPending] |
12
+ * Returns ['empty', hasPending] | ['ok', firstIndex, hasPending, watched] |
33
13
  * ['dup', hasPending, ...indexes] | ['partial', count] |
34
- * ['foreign', eventId].
14
+ * ['foreign', eventId] | ['no_parent', idx] | ['superseded', idx].
35
15
  */
36
16
  const APPEND_LUA = `
37
17
  local function lane_field(lane, suffix)
@@ -73,6 +53,22 @@ if #dups == n then
73
53
  return res
74
54
  end
75
55
  if #dups > 0 then return { 'partial', tostring(#dups) } end
56
+ for i = 1, n do
57
+ local cause = ARGV[3 + (i - 1) * 6 + 4]
58
+ if cause ~= '' then
59
+ local parsed = cjson.decode(cause)
60
+ local pidx = tonumber(parsed.index)
61
+ if pidx < 1 or pidx > base then
62
+ return { 'no_parent', tostring(parsed.index) }
63
+ end
64
+ local field = tostring(pidx)
65
+ local dispatches = tonumber(redis.call('HGET', KEYS[5], field .. ':d') or '0')
66
+ if dispatches ~= tonumber(parsed.attempt) or
67
+ redis.call('HEXISTS', KEYS[5], field .. ':f') == 1 then
68
+ return { 'superseded', field }
69
+ end
70
+ end
71
+ end
76
72
  for i = 1, n do
77
73
  local id = ARGV[3 + (i - 1) * 6 + 1]
78
74
  local typ = ARGV[3 + (i - 1) * 6 + 2]
@@ -95,7 +91,8 @@ end
95
91
  redis.call('SET', KEYS[2], tostring(base + n))
96
92
  redis.call('ZADD', KEYS[4], 0, ARGV[1])
97
93
  has_pending = redis.call('ZCARD', KEYS[6]) > 0 and '1' or '0'
98
- return { 'ok', tostring(base + 1), has_pending }
94
+ return { 'ok', tostring(base + 1), has_pending,
95
+ tostring(redis.call('EXISTS', KEYS[8])) }
99
96
  `;
100
97
  /** KEYS: counter, meta, pending, ready. ARGV: idx, attempt, error, maxFailures, nowMs. */
101
98
  const FAIL_ATTEMPT_LUA = `
@@ -157,7 +154,7 @@ end
157
154
  return { 'failed', tostring(failures) }
158
155
  `;
159
156
  /**
160
- * KEYS: log, counter, meta, pending, ready.
157
+ * KEYS: store, counter, meta, pending, ready.
161
158
  * ARGV: holder, nowMs, expiresAtMs, excludedCount, excluded indexes.
162
159
  */
163
160
  const CLAIM_AVAILABLE_LUA = `
@@ -207,29 +204,42 @@ if retry_at then return { 'busy', tostring(retry_at) } end
207
204
  return { 'settled' }
208
205
  `;
209
206
  /**
210
- * KEYS: counter, meta. ARGV: holder, nowMs, expiresAtMs, n, indexes.
207
+ * KEYS: counter, meta.
208
+ * ARGV: holder, nowMs, expiresAtMs, n, then per claim: index, attempt.
209
+ * Returns [renewedCount, ...renewed, ...superseded].
211
210
  */
212
211
  const RENEW_CLAIMS_LUA = `
213
212
  local total = tonumber(redis.call('GET', KEYS[1]) or '0')
214
213
  local renewed = {}
214
+ local superseded = {}
215
215
  for i = 1, tonumber(ARGV[4]) do
216
- local field = ARGV[4 + i]
216
+ local field = ARGV[4 + (i - 1) * 2 + 1]
217
+ local attempt = tonumber(ARGV[4 + (i - 1) * 2 + 2])
217
218
  local idx = tonumber(field)
218
- local expires = tonumber(redis.call('HGET', KEYS[2], field .. ':ce') or '0')
219
- if idx >= 1 and idx <= total and
220
- tonumber(ARGV[3]) > tonumber(ARGV[2]) and
221
- redis.call('HEXISTS', KEYS[2], field .. ':p') == 0 and
222
- redis.call('HEXISTS', KEYS[2], field .. ':f') == 0 and
223
- redis.call('HGET', KEYS[2], field .. ':ch') == ARGV[1] and
224
- expires > tonumber(ARGV[2]) then
225
- redis.call('HSET', KEYS[2], field .. ':ce', ARGV[3])
226
- renewed[#renewed + 1] = field
219
+ if idx >= 1 and idx <= total then
220
+ local dispatches = tonumber(redis.call('HGET', KEYS[2], field .. ':d') or '0')
221
+ if dispatches > attempt then
222
+ superseded[#superseded + 1] = field
223
+ else
224
+ local expires = tonumber(redis.call('HGET', KEYS[2], field .. ':ce') or '0')
225
+ if tonumber(ARGV[3]) > tonumber(ARGV[2]) and
226
+ redis.call('HEXISTS', KEYS[2], field .. ':p') == 0 and
227
+ redis.call('HEXISTS', KEYS[2], field .. ':f') == 0 and
228
+ redis.call('HGET', KEYS[2], field .. ':ch') == ARGV[1] and
229
+ expires > tonumber(ARGV[2]) then
230
+ redis.call('HSET', KEYS[2], field .. ':ce', ARGV[3])
231
+ renewed[#renewed + 1] = field
232
+ end
233
+ end
227
234
  end
228
235
  end
229
- return renewed
236
+ local result = { tostring(#renewed) }
237
+ for i = 1, #renewed do result[#result + 1] = renewed[i] end
238
+ for i = 1, #superseded do result[#result + 1] = superseded[i] end
239
+ return result
230
240
  `;
231
241
  /**
232
- * KEYS: log, counter, ids, session index, meta, pending, ready.
242
+ * KEYS: store, counter, ids, session index, meta, pending, ready, watched.
233
243
  * ARGV: sessionId, nowMs, parent index, attempt, n, then per child:
234
244
  * id, type, payloadJson, lane, settled.
235
245
  */
@@ -353,7 +363,8 @@ for i = 1, n do
353
363
  end
354
364
  redis.call('SET', KEYS[2], tostring(total + n))
355
365
  redis.call('ZADD', KEYS[4], 0, ARGV[1])
356
- return { 'ok', tostring(total + 1) }
366
+ return { 'ok', tostring(total + 1),
367
+ tostring(redis.call('EXISTS', KEYS[8])) }
357
368
  `;
358
369
  /** KEYS: snapshot. ARGV: upToIndex, json. Guarded: never move backward. */
359
370
  const PUT_SNAPSHOT_LUA = `
@@ -364,21 +375,83 @@ end
364
375
  redis.call('SET', KEYS[1], ARGV[2])
365
376
  return 1
366
377
  `;
367
- /** KEYS: snapshot, log stream. Returns one atomic cache-plus-tail read. */
378
+ /**
379
+ * KEYS: presence hash. ARGV: atMs, ttlMs, n, then per entry:
380
+ * hashField, valueJson ('' deletes). Field-wise LWW by the sender's
381
+ * `at`: a stored entry with a strictly newer stamp survives; ties go
382
+ * to the incoming write (memory-tier parity). Returns the applied
383
+ * 1-based entry positions. The whole-key PEXPIRE is the coarse bound,
384
+ * sound because core sends one uniform ttlMs; the per-entry `e` stamp
385
+ * is the fine bound (the injected storage clock at the applied set
386
+ * plus ttlMs — never the sender stamp), pruned on read against the
387
+ * same clock.
388
+ */
389
+ const PRESENCE_SET_LUA = `
390
+ local at = tonumber(ARGV[1])
391
+ local applied = {}
392
+ for i = 1, tonumber(ARGV[3]) do
393
+ local field = ARGV[3 + (i - 1) * 2 + 1]
394
+ local value = ARGV[3 + (i - 1) * 2 + 2]
395
+ local existing = redis.call('HGET', KEYS[1], field)
396
+ if not existing or tonumber(cjson.decode(existing)['a']) <= at then
397
+ if value == '' then
398
+ redis.call('HDEL', KEYS[1], field)
399
+ else
400
+ redis.call('HSET', KEYS[1], field, value)
401
+ end
402
+ applied[#applied + 1] = tostring(i)
403
+ end
404
+ end
405
+ if #applied > 0 then redis.call('PEXPIRE', KEYS[1], ARGV[2]) end
406
+ return applied
407
+ `;
408
+ /**
409
+ * KEYS: presence hash. ARGV: nowMs (the injected clock, so tests can
410
+ * time-travel expiry against a real server). Returns the live
411
+ * [field, value, ...] pairs; entries at or past their fine-bound
412
+ * expiry are HDELed in the same atomic step, so a stale reader can
413
+ * never sweep a concurrently refreshed field.
414
+ */
415
+ const PRESENCE_READ_LUA = `
416
+ local now = tonumber(ARGV[1])
417
+ local rows = redis.call('HGETALL', KEYS[1])
418
+ local live = {}
419
+ for i = 1, #rows, 2 do
420
+ if tonumber(cjson.decode(rows[i + 1])['e']) <= now then
421
+ redis.call('HDEL', KEYS[1], rows[i])
422
+ else
423
+ live[#live + 1] = rows[i]
424
+ live[#live + 1] = rows[i + 1]
425
+ end
426
+ end
427
+ return live
428
+ `;
429
+ /** KEYS: snapshot, event counter. Returns one atomic cache-plus-frontier read. */
368
430
  const READ_STATE_LUA = `
369
431
  local snapshot = redis.call('GET', KEYS[1])
370
- local after = 0
371
- if snapshot then
372
- after = tonumber(cjson.decode(snapshot)['index'])
373
- end
374
- local events = redis.call('XRANGE', KEYS[2], tostring(after + 1) .. '-0', '+')
375
- return { snapshot or '', events }
432
+ local frontier = redis.call('GET', KEYS[2]) or '0'
433
+ return { snapshot or '', frontier }
376
434
  `;
377
435
  const fieldMap = (fields) => {
378
436
  const map = {};
379
437
  for (let i = 0; i + 1 < fields.length; i += 2) map[fields[i]] = fields[i + 1];
380
438
  return map;
381
439
  };
440
+ const STORED_EVENT_META_SUFFIXES = [
441
+ "p",
442
+ "pa",
443
+ "ri",
444
+ "ch",
445
+ "ce",
446
+ "f",
447
+ "fc",
448
+ "lc",
449
+ "lf",
450
+ "lfa",
451
+ "d",
452
+ "a",
453
+ "e"
454
+ ];
382
455
  const entryIndex = (entryId) => Number(entryId.slice(0, entryId.indexOf("-")));
383
456
  const toEvent = (sessionId, entry) => {
384
457
  const fields = fieldMap(entry[1]);
@@ -449,40 +522,43 @@ const wrap = async (fn) => {
449
522
  return await fn();
450
523
  } catch (err) {
451
524
  if (err instanceof A2Error || err instanceof TypeError) throw err;
452
- throw new A2Error("LOG_UNAVAILABLE", "redis log operation failed", { cause: err });
525
+ throw new A2Error("STORE_UNAVAILABLE", "redis store operation failed", { cause: err });
453
526
  }
454
527
  };
455
- function redis(options = {}) {
456
- const clock = options.clock ?? SYSTEM_CLOCK;
457
- const generateId = options.ids ?? RANDOM_IDS;
458
- const prefix = options.keyPrefix ?? "a2";
459
- if (!options.client && !options.url) throw new TypeError("redis() needs a url or an injected client");
460
- const logKey = (s) => `${prefix}:${s}:log`;
528
+ function createRedisStoreCore(options) {
529
+ const { call, clock, notify } = options;
530
+ const generateId = options.ids;
531
+ const prefix = options.keyPrefix;
532
+ const storeKey = (s) => `${prefix}:${s}:log`;
461
533
  const countKey = (s) => `${prefix}:${s}:count`;
462
534
  const metaKey = (s) => `${prefix}:${s}:meta`;
463
535
  const pendingKey = (s) => `${prefix}:${s}:pending`;
464
536
  const readyKey = (s) => `${prefix}:${s}:ready`;
465
537
  const snapKey = (s, r) => `${prefix}:${s}:snap:${r}`;
538
+ const watchedKey = (s) => `${prefix}:${s}:watched`;
539
+ const presenceKey = (s) => `${prefix}:${s}:presence`;
466
540
  const idsKey = `${prefix}:ids`;
467
541
  const sessionsKey = `${prefix}:sessions`;
468
- const connection = retryableLazy(async () => {
469
- if (options.client) return options.client;
470
- return new (await (import("ioredis").catch(() => {
471
- throw new A2Error("LOG_NOT_CONFIGURED", "experimental-a2/log-redis with a url needs the 'ioredis' package (optional peer dependency) — install it, or inject a client");
472
- }))).default(options.url);
473
- });
474
- const client = connection.get;
475
- const evalScript = async (script, keys, args) => {
476
- return (await client()).call("EVAL", script, keys.length, ...keys, ...args);
542
+ const evalScript = (script, keys, args) => call("EVAL", script, keys.length, ...keys, ...args);
543
+ const readFrontier = async (sessionId) => {
544
+ const frontier = Number(await call("GET", countKey(sessionId)) ?? 0);
545
+ if (!Number.isSafeInteger(frontier) || frontier < 0) throw new A2Error("STORE_UNAVAILABLE", `redis event count for session '${sessionId}' is invalid`);
546
+ return frontier;
477
547
  };
478
- /** Every live-feed connection, so close() can sever them. */
479
- const streamConnections = /* @__PURE__ */ new Set();
480
- /** XRANGE + meta merge the raw read both `read` and dup-returns use. */
481
- const readRange = async (sessionId, afterIndex) => {
482
- const c = await client();
483
- const entries = await c.call("XRANGE", logKey(sessionId), `${afterIndex + 1}-0`, "+");
548
+ const readRangePage = async (sessionId, afterIndex, throughIndex, limit) => {
549
+ if (throughIndex <= afterIndex) return [];
550
+ const entries = await call("XRANGE", storeKey(sessionId), `${afterIndex + 1}-0`, `${throughIndex}-0`, "COUNT", limit);
484
551
  if (entries.length === 0) return [];
485
- const meta = fieldMap(await c.call("HGETALL", metaKey(sessionId)) ?? []);
552
+ const metaFields = entries.flatMap((entry) => {
553
+ const index = entryIndex(entry[0]);
554
+ return STORED_EVENT_META_SUFFIXES.map((suffix) => `${index}:${suffix}`);
555
+ });
556
+ const metaValues = await call("HMGET", metaKey(sessionId), ...metaFields) ?? [];
557
+ const meta = {};
558
+ for (let i = 0; i < metaFields.length; i += 1) {
559
+ const value = metaValues[i];
560
+ if (value !== null && value !== void 0) meta[metaFields[i]] = value;
561
+ }
486
562
  return entries.map((entry) => {
487
563
  const event = toEvent(sessionId, entry);
488
564
  const idx = event.index;
@@ -502,7 +578,7 @@ function redis(options = {}) {
502
578
  lane: fields["lane"] === "" ? null : fields["lane"],
503
579
  processedAt: processedAt === void 0 ? null : new Date(Number(processedAt)),
504
580
  processedByAttempt: processedByAttempt === void 0 ? null : Number(processedByAttempt),
505
- returnedEventIds: returnedEventIds === void 0 ? null : JSON.parse(returnedEventIds),
581
+ returnedEventIds: returnedEventIds === void 0 ? null : decodeReturnedEventIds(returnedEventIds),
506
582
  firstClaimedAt: firstClaimedAt === void 0 ? null : new Date(Number(firstClaimedAt)),
507
583
  lastClaimedAt: lastClaimedAt === void 0 ? null : new Date(Number(lastClaimedAt)),
508
584
  attemptCount: Number(meta[`${idx}:d`] ?? 0),
@@ -516,6 +592,41 @@ function redis(options = {}) {
516
592
  });
517
593
  });
518
594
  };
595
+ const readEventRange = async (sessionId, afterIndex, throughIndex) => {
596
+ if (throughIndex <= afterIndex) return [];
597
+ const events = [];
598
+ let nextIndex = afterIndex + 1;
599
+ while (nextIndex <= throughIndex) {
600
+ const entries = await call("XRANGE", storeKey(sessionId), `${nextIndex}-0`, `${throughIndex}-0`, "COUNT", READ_PAGE_SIZE);
601
+ if (entries.length === 0) throw new A2Error("STORE_UNAVAILABLE", `redis event range for session '${sessionId}' ended before index ${throughIndex}`);
602
+ for (let position = 0; position < entries.length; position += 1) {
603
+ const index = entryIndex(entries[position][0]);
604
+ if (index !== nextIndex + position || index > throughIndex) throw new A2Error("STORE_UNAVAILABLE", `redis event range for session '${sessionId}' is not contiguous`);
605
+ }
606
+ events.push(...entries.map((entry) => toEvent(sessionId, entry)));
607
+ nextIndex = events.at(-1).index + 1;
608
+ }
609
+ return events;
610
+ };
611
+ /** Bounded XRANGE pages restore the full-read contract on capped providers. */
612
+ const readRange = async (sessionId, afterIndex, throughIndex) => {
613
+ if (throughIndex !== void 0 && throughIndex <= afterIndex) return [];
614
+ const currentFrontier = await readFrontier(sessionId);
615
+ const frontier = Math.min(throughIndex ?? currentFrontier, currentFrontier);
616
+ if (frontier <= afterIndex) return [];
617
+ const rows = [];
618
+ let nextIndex = afterIndex + 1;
619
+ while (nextIndex <= frontier) {
620
+ const page = await readRangePage(sessionId, nextIndex - 1, frontier, READ_PAGE_SIZE);
621
+ if (page.length === 0) throw new A2Error("STORE_UNAVAILABLE", `redis event range for session '${sessionId}' ended before index ${frontier}`);
622
+ for (let position = 0; position < page.length; position += 1) if (page[position].index !== nextIndex + position) throw new A2Error("STORE_UNAVAILABLE", `redis event range for session '${sessionId}' is not contiguous`);
623
+ const lastIndex = page.at(-1).index;
624
+ if (lastIndex < nextIndex || lastIndex > frontier) throw new A2Error("STORE_UNAVAILABLE", `redis event range for session '${sessionId}' did not advance`);
625
+ rows.push(...page);
626
+ nextIndex = lastIndex + 1;
627
+ }
628
+ return rows;
629
+ };
519
630
  return {
520
631
  async append(sessionId, events) {
521
632
  return wrap(async () => {
@@ -544,13 +655,14 @@ function redis(options = {}) {
544
655
  ])
545
656
  ];
546
657
  const reply = await evalScript(APPEND_LUA, [
547
- logKey(sessionId),
658
+ storeKey(sessionId),
548
659
  countKey(sessionId),
549
660
  idsKey,
550
661
  sessionsKey,
551
662
  metaKey(sessionId),
552
663
  pendingKey(sessionId),
553
- readyKey(sessionId)
664
+ readyKey(sessionId),
665
+ watchedKey(sessionId)
554
666
  ], args);
555
667
  const [verdict] = reply;
556
668
  if (verdict === "empty") return {
@@ -558,6 +670,8 @@ function redis(options = {}) {
558
670
  hasPending: reply[1] === "1"
559
671
  };
560
672
  if (verdict === "foreign") throw new A2Error("PARTIAL_DUPLICATE_BATCH", `event id '${reply[1]}' already exists in another session`);
673
+ if (verdict === "no_parent") throw new TypeError(`no event at index ${reply[1]} in session '${sessionId}'`);
674
+ if (verdict === "superseded") throw new A2Error("SUPERSEDED_ATTEMPT", `a newer attempt owns event ${reply[1]} in session '${sessionId}'`);
561
675
  if (verdict === "partial") throw new A2Error("PARTIAL_DUPLICATE_BATCH", `batch mixes ${reply[1]} already-appended and ${events.length - Number(reply[1])} fresh events`);
562
676
  if (verdict === "dup") {
563
677
  const hasPending = reply[1] === "1";
@@ -570,6 +684,7 @@ function redis(options = {}) {
570
684
  };
571
685
  }
572
686
  const base = Number(reply[1]) - 1;
687
+ if (reply[3] === "1") notify?.(sessionId, base + withIds.length);
573
688
  return {
574
689
  events: withIds.map((e, i) => ({
575
690
  id: e.id,
@@ -600,7 +715,7 @@ function redis(options = {}) {
600
715
  },
601
716
  async read(sessionId, opts) {
602
717
  return wrap(async () => {
603
- return await readRange(sessionId, opts?.afterIndex ?? 0);
718
+ return await readRange(sessionId, opts?.afterIndex ?? 0, opts?.throughIndex);
604
719
  });
605
720
  },
606
721
  async claimAvailable({ sessionId, holder, ttlMs, expiresAtMs, excludeIndexes }) {
@@ -609,7 +724,7 @@ function redis(options = {}) {
609
724
  const expiresAt = expiresAtMs ?? now + ttlMs;
610
725
  const excluded = excludeIndexes ?? [];
611
726
  const reply = await evalScript(CLAIM_AVAILABLE_LUA, [
612
- logKey(sessionId),
727
+ storeKey(sessionId),
613
728
  countKey(sessionId),
614
729
  metaKey(sessionId),
615
730
  pendingKey(sessionId),
@@ -624,17 +739,23 @@ function redis(options = {}) {
624
739
  return toClaimAvailableResult(sessionId, reply);
625
740
  });
626
741
  },
627
- async renewClaims({ sessionId, holder, indexes, ttlMs, expiresAtMs }) {
742
+ async renewClaims({ sessionId, holder, claims, ttlMs, expiresAtMs }) {
628
743
  return wrap(async () => {
629
744
  const now = clock.now().getTime();
630
745
  const expiresAt = expiresAtMs ?? now + ttlMs;
631
- return (await evalScript(RENEW_CLAIMS_LUA, [countKey(sessionId), metaKey(sessionId)], [
746
+ const reply = await evalScript(RENEW_CLAIMS_LUA, [countKey(sessionId), metaKey(sessionId)], [
632
747
  holder,
633
748
  now,
634
749
  expiresAt,
635
- indexes.length,
636
- ...indexes
637
- ])).map(Number);
750
+ claims.length,
751
+ ...claims.flatMap((claim) => [claim.index, claim.attempt])
752
+ ]);
753
+ const renewedCount = Number(reply[0]);
754
+ const values = reply.slice(1).map(Number);
755
+ return {
756
+ renewed: values.slice(0, renewedCount).toSorted((a, b) => a - b),
757
+ superseded: values.slice(renewedCount).toSorted((a, b) => a - b)
758
+ };
638
759
  });
639
760
  },
640
761
  async completeAttempt({ sessionId, index, attempt, events }) {
@@ -643,13 +764,14 @@ function redis(options = {}) {
643
764
  if (new Set(ids).size !== ids.length) throw new A2Error("PARTIAL_DUPLICATE_BATCH", "batch contains the same event id more than once");
644
765
  const now = clock.now().getTime();
645
766
  const reply = await evalScript(COMPLETE_ATTEMPT_LUA, [
646
- logKey(sessionId),
767
+ storeKey(sessionId),
647
768
  countKey(sessionId),
648
769
  idsKey,
649
770
  sessionsKey,
650
771
  metaKey(sessionId),
651
772
  pendingKey(sessionId),
652
- readyKey(sessionId)
773
+ readyKey(sessionId),
774
+ watchedKey(sessionId)
653
775
  ], [
654
776
  sessionId,
655
777
  now,
@@ -674,6 +796,7 @@ function redis(options = {}) {
674
796
  };
675
797
  if (outcome === "ok") {
676
798
  const base = Number(reply[1]) - 1;
799
+ if (events.length > 0 && reply[2] === "1") notify?.(sessionId, base + events.length);
677
800
  return {
678
801
  outcome: "completed",
679
802
  events: events.map((event, i) => ({
@@ -743,15 +866,18 @@ function redis(options = {}) {
743
866
  },
744
867
  async readState(sessionId, reducerName) {
745
868
  return wrap(async () => {
746
- const reply = await evalScript(READ_STATE_LUA, [snapKey(sessionId, reducerName), logKey(sessionId)], []);
869
+ const reply = await evalScript(READ_STATE_LUA, [snapKey(sessionId, reducerName), countKey(sessionId)], []);
747
870
  const raw = reply[0];
748
871
  const parsed = raw === "" ? null : JSON.parse(raw);
872
+ const frontier = Number(reply[1]);
873
+ if (!Number.isSafeInteger(frontier) || frontier < 0 || parsed !== null && (!Number.isSafeInteger(parsed.index) || parsed.index < 0)) throw new A2Error("STORE_UNAVAILABLE", `redis state frontier for session '${sessionId}' is invalid`);
874
+ const events = await readEventRange(sessionId, parsed?.index ?? 0, frontier);
749
875
  return {
750
876
  snapshot: parsed ? {
751
877
  index: parsed.index,
752
878
  state: parsed.state
753
879
  } : null,
754
- events: reply[1].map((entry) => toEvent(sessionId, entry))
880
+ events
755
881
  };
756
882
  });
757
883
  },
@@ -764,12 +890,63 @@ function redis(options = {}) {
764
890
  })]);
765
891
  });
766
892
  },
893
+ presence: {
894
+ async set(ns, participant, values, meta) {
895
+ return wrap(async () => {
896
+ const entries = Object.entries(values);
897
+ if (entries.length === 0) return null;
898
+ const atMs = meta.at.getTime();
899
+ const expiresAtMs = clock.now().getTime() + meta.ttlMs;
900
+ const applied = await evalScript(PRESENCE_SET_LUA, [presenceKey(ns)], [
901
+ atMs,
902
+ meta.ttlMs,
903
+ entries.length,
904
+ ...entries.flatMap(([field, value]) => [JSON.stringify([participant, field]), value === null ? "" : json({
905
+ v: value,
906
+ s: meta.seen,
907
+ a: atMs,
908
+ e: expiresAtMs
909
+ })])
910
+ ]);
911
+ if (applied.length === 0) return null;
912
+ const appliedValues = nullProtoRecord();
913
+ for (const position of applied) {
914
+ const [field, value] = entries[Number(position) - 1];
915
+ appliedValues[field] = structuredClone(value);
916
+ }
917
+ return {
918
+ participant,
919
+ values: appliedValues,
920
+ seen: meta.seen,
921
+ at: new Date(atMs)
922
+ };
923
+ });
924
+ },
925
+ async read(ns) {
926
+ return wrap(async () => {
927
+ const reply = await evalScript(PRESENCE_READ_LUA, [presenceKey(ns)], [clock.now().getTime()]);
928
+ const rows = [];
929
+ for (let i = 0; i + 1 < reply.length; i += 2) {
930
+ const [participant, field] = JSON.parse(reply[i]);
931
+ const entry = JSON.parse(reply[i + 1]);
932
+ rows.push({
933
+ participant,
934
+ field,
935
+ value: entry.v,
936
+ seen: entry.s,
937
+ at: new Date(entry.a),
938
+ expiresAt: new Date(entry.e)
939
+ });
940
+ }
941
+ return rows;
942
+ });
943
+ }
944
+ },
767
945
  inspect: {
768
946
  async listSessions(inspectionOptions) {
769
947
  return wrap(async () => {
770
- const c = await client();
771
948
  const minimum = inspectionOptions.cursor === void 0 ? `[${inspectionOptions.prefix}` : `(${inspectionOptions.cursor}`;
772
- const candidates = await c.call("ZRANGEBYLEX", sessionsKey, minimum, "+", "LIMIT", 0, inspectionOptions.limit + 1);
949
+ const candidates = await call("ZRANGEBYLEX", sessionsKey, minimum, "+", "LIMIT", 0, inspectionOptions.limit + 1);
773
950
  const sessionIds = candidates.filter((id) => id.startsWith(inspectionOptions.prefix)).slice(0, inspectionOptions.limit);
774
951
  return {
775
952
  sessions: (await Promise.all(sessionIds.map(async (sessionId) => {
@@ -809,19 +986,31 @@ function redis(options = {}) {
809
986
  };
810
987
  });
811
988
  },
989
+ async readEvents(sessionId, pageOptions) {
990
+ return wrap(async () => {
991
+ const currentFrontier = await readFrontier(sessionId);
992
+ const throughIndex = Math.min(pageOptions.throughIndex ?? currentFrontier, currentFrontier);
993
+ const events = await readRangePage(sessionId, pageOptions.afterIndex, throughIndex, pageOptions.limit);
994
+ if (throughIndex > pageOptions.afterIndex && events.length === 0) throw new A2Error("STORE_UNAVAILABLE", `redis event range for session '${sessionId}' ended before index ${throughIndex}`);
995
+ for (let position = 0; position < events.length; position += 1) if (events[position].index !== pageOptions.afterIndex + position + 1) throw new A2Error("STORE_UNAVAILABLE", `redis event range for session '${sessionId}' is not contiguous`);
996
+ return {
997
+ events,
998
+ throughIndex
999
+ };
1000
+ });
1001
+ },
812
1002
  async listSnapshots(sessionId) {
813
1003
  return wrap(async () => {
814
- const c = await client();
815
1004
  const start = `${prefix}:${sessionId}:snap:`;
816
1005
  let cursor = "0";
817
1006
  const keys = [];
818
1007
  do {
819
- const reply = await c.call("SCAN", cursor, "MATCH", `${escapeGlob(start)}*`, "COUNT", 100);
1008
+ const reply = await call("SCAN", cursor, "MATCH", `${escapeGlob(start)}*`, "COUNT", 100);
820
1009
  cursor = reply[0];
821
1010
  keys.push(...reply[1].filter((key) => key.startsWith(start)));
822
1011
  } while (cursor !== "0");
823
1012
  return (await Promise.all(keys.map(async (key) => {
824
- const raw = await c.call("GET", key);
1013
+ const raw = await call("GET", key);
825
1014
  if (raw === null) return null;
826
1015
  const parsed = JSON.parse(raw);
827
1016
  return {
@@ -833,69 +1022,18 @@ function redis(options = {}) {
833
1022
  });
834
1023
  }
835
1024
  },
836
- stream(sessionId, opts) {
837
- const startAt = opts?.startAt ?? 0;
838
- return { [Symbol.asyncIterator]() {
839
- let last = startAt;
840
- let buffer = [];
841
- let closed = false;
842
- let conn = null;
843
- return {
844
- async next() {
845
- for (;;) {
846
- if (closed) return {
847
- value: void 0,
848
- done: true
849
- };
850
- const row = buffer.shift();
851
- if (row) {
852
- last = row.index;
853
- return {
854
- value: row,
855
- done: false
856
- };
857
- }
858
- if (!conn) {
859
- conn = (await client()).duplicate();
860
- streamConnections.add(conn);
861
- }
862
- let reply;
863
- try {
864
- reply = await conn.call("XREAD", "BLOCK", XREAD_BLOCK_MS, "STREAMS", logKey(sessionId), `${last}-0`);
865
- } catch (err) {
866
- if (closed) return {
867
- value: void 0,
868
- done: true
869
- };
870
- throw new A2Error("LOG_UNAVAILABLE", "redis log operation failed", { cause: err });
871
- }
872
- if (reply === null) continue;
873
- const [[, entries]] = reply;
874
- buffer = entries.map((entry) => toEvent(sessionId, entry));
875
- }
876
- },
877
- async return() {
878
- closed = true;
879
- if (conn) {
880
- streamConnections.delete(conn);
881
- conn.disconnect();
882
- }
883
- return {
884
- value: void 0,
885
- done: true
886
- };
887
- }
888
- };
889
- } };
1025
+ async readRange(sessionId, afterIndex, throughIndex) {
1026
+ return wrap(() => readRange(sessionId, afterIndex, throughIndex));
1027
+ },
1028
+ async readEvents(sessionId, afterIndex) {
1029
+ return wrap(async () => {
1030
+ return (await call("XRANGE", storeKey(sessionId), `${afterIndex + 1}-0`, "+")).map((entry) => toEvent(sessionId, entry));
1031
+ });
890
1032
  },
891
- async close() {
892
- for (const conn of streamConnections) conn.disconnect();
893
- streamConnections.clear();
894
- const current = connection.peek();
895
- if (!current) return;
896
- (await current).disconnect();
1033
+ async markWatched(sessionId, ttlMs) {
1034
+ await wrap(() => call("SET", watchedKey(sessionId), "1", "PX", ttlMs));
897
1035
  }
898
1036
  };
899
1037
  }
900
1038
  //#endregion
901
- export { redis };
1039
+ export { createRedisStoreCore as t };