experimental-a2 0.3.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 (91) hide show
  1. package/CHANGELOG.md +156 -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-DZ1MiKLg.js → store-polling-DgrrAE3d.js} +5 -5
  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-core-CyJ5L8yR.js → store-redis-core-DWqx3F47.js} +269 -66
  56. package/dist/{log-redis-http.d.ts → store-redis-http.d.ts} +7 -7
  57. package/dist/{log-redis-http.js → store-redis-http.js} +20 -12
  58. package/dist/{log-redis.d.ts → store-redis.d.ts} +6 -6
  59. package/dist/{log-redis.js → store-redis.js} +99 -39
  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 +3 -3
  69. package/docs/concepts/01-contracts.mdx +18 -13
  70. package/docs/concepts/02-handlers.mdx +7 -7
  71. package/docs/concepts/03-durability.mdx +21 -21
  72. package/docs/concepts/04-state.mdx +18 -21
  73. package/docs/guides/01-timers.mdx +154 -49
  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 +310 -61
  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 +18 -14
  84. package/docs/reference/01-api.mdx +751 -103
  85. package/docs/reference/02-errors.mdx +10 -5
  86. package/package.json +23 -7
  87. package/dist/internal-gCd5qMry.js +0 -44
  88. package/dist/log-memory.d.ts +0 -11
  89. package/dist/recovery-vercel.js +0 -119
  90. package/dist/server-BcLa4RFL.js +0 -877
  91. package/dist/wire-BVsgR8o9.js +0 -62
@@ -1,31 +1,17 @@
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
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;
4
7
  /**
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,
8
+ * KEYS: store stream, counter, global id hash, session index, metadata,
23
9
  * pending, ready, watched.
24
10
  * ARGV: sessionId, nowMs, n, then per event:
25
11
  * id, type, payloadJson, causeJson, lane, settled.
26
12
  * Returns ['empty', hasPending] | ['ok', firstIndex, hasPending, watched] |
27
13
  * ['dup', hasPending, ...indexes] | ['partial', count] |
28
- * ['foreign', eventId].
14
+ * ['foreign', eventId] | ['no_parent', idx] | ['superseded', idx].
29
15
  */
30
16
  const APPEND_LUA = `
31
17
  local function lane_field(lane, suffix)
@@ -67,6 +53,22 @@ if #dups == n then
67
53
  return res
68
54
  end
69
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
70
72
  for i = 1, n do
71
73
  local id = ARGV[3 + (i - 1) * 6 + 1]
72
74
  local typ = ARGV[3 + (i - 1) * 6 + 2]
@@ -152,7 +154,7 @@ end
152
154
  return { 'failed', tostring(failures) }
153
155
  `;
154
156
  /**
155
- * KEYS: log, counter, meta, pending, ready.
157
+ * KEYS: store, counter, meta, pending, ready.
156
158
  * ARGV: holder, nowMs, expiresAtMs, excludedCount, excluded indexes.
157
159
  */
158
160
  const CLAIM_AVAILABLE_LUA = `
@@ -202,29 +204,42 @@ if retry_at then return { 'busy', tostring(retry_at) } end
202
204
  return { 'settled' }
203
205
  `;
204
206
  /**
205
- * 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].
206
210
  */
207
211
  const RENEW_CLAIMS_LUA = `
208
212
  local total = tonumber(redis.call('GET', KEYS[1]) or '0')
209
213
  local renewed = {}
214
+ local superseded = {}
210
215
  for i = 1, tonumber(ARGV[4]) do
211
- local field = ARGV[4 + i]
216
+ local field = ARGV[4 + (i - 1) * 2 + 1]
217
+ local attempt = tonumber(ARGV[4 + (i - 1) * 2 + 2])
212
218
  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
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
222
234
  end
223
235
  end
224
- 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
225
240
  `;
226
241
  /**
227
- * KEYS: log, counter, ids, session index, meta, pending, ready, watched.
242
+ * KEYS: store, counter, ids, session index, meta, pending, ready, watched.
228
243
  * ARGV: sessionId, nowMs, parent index, attempt, n, then per child:
229
244
  * id, type, payloadJson, lane, settled.
230
245
  */
@@ -360,21 +375,83 @@ end
360
375
  redis.call('SET', KEYS[1], ARGV[2])
361
376
  return 1
362
377
  `;
363
- /** 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. */
364
430
  const READ_STATE_LUA = `
365
431
  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 }
432
+ local frontier = redis.call('GET', KEYS[2]) or '0'
433
+ return { snapshot or '', frontier }
372
434
  `;
373
435
  const fieldMap = (fields) => {
374
436
  const map = {};
375
437
  for (let i = 0; i + 1 < fields.length; i += 2) map[fields[i]] = fields[i + 1];
376
438
  return map;
377
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
+ ];
378
455
  const entryIndex = (entryId) => Number(entryId.slice(0, entryId.indexOf("-")));
379
456
  const toEvent = (sessionId, entry) => {
380
457
  const fields = fieldMap(entry[1]);
@@ -445,28 +522,43 @@ const wrap = async (fn) => {
445
522
  return await fn();
446
523
  } catch (err) {
447
524
  if (err instanceof A2Error || err instanceof TypeError) throw err;
448
- throw new A2Error("LOG_UNAVAILABLE", "redis log operation failed", { cause: err });
525
+ throw new A2Error("STORE_UNAVAILABLE", "redis store operation failed", { cause: err });
449
526
  }
450
527
  };
451
- function createRedisLogCore(options) {
528
+ function createRedisStoreCore(options) {
452
529
  const { call, clock, notify } = options;
453
530
  const generateId = options.ids;
454
531
  const prefix = options.keyPrefix;
455
- const logKey = (s) => `${prefix}:${s}:log`;
532
+ const storeKey = (s) => `${prefix}:${s}:log`;
456
533
  const countKey = (s) => `${prefix}:${s}:count`;
457
534
  const metaKey = (s) => `${prefix}:${s}:meta`;
458
535
  const pendingKey = (s) => `${prefix}:${s}:pending`;
459
536
  const readyKey = (s) => `${prefix}:${s}:ready`;
460
537
  const snapKey = (s, r) => `${prefix}:${s}:snap:${r}`;
461
538
  const watchedKey = (s) => `${prefix}:${s}:watched`;
539
+ const presenceKey = (s) => `${prefix}:${s}:presence`;
462
540
  const idsKey = `${prefix}:ids`;
463
541
  const sessionsKey = `${prefix}:sessions`;
464
542
  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`, "+");
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;
547
+ };
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);
468
551
  if (entries.length === 0) return [];
469
- const meta = fieldMap(await 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
+ }
470
562
  return entries.map((entry) => {
471
563
  const event = toEvent(sessionId, entry);
472
564
  const idx = event.index;
@@ -486,7 +578,7 @@ function createRedisLogCore(options) {
486
578
  lane: fields["lane"] === "" ? null : fields["lane"],
487
579
  processedAt: processedAt === void 0 ? null : new Date(Number(processedAt)),
488
580
  processedByAttempt: processedByAttempt === void 0 ? null : Number(processedByAttempt),
489
- returnedEventIds: returnedEventIds === void 0 ? null : JSON.parse(returnedEventIds),
581
+ returnedEventIds: returnedEventIds === void 0 ? null : decodeReturnedEventIds(returnedEventIds),
490
582
  firstClaimedAt: firstClaimedAt === void 0 ? null : new Date(Number(firstClaimedAt)),
491
583
  lastClaimedAt: lastClaimedAt === void 0 ? null : new Date(Number(lastClaimedAt)),
492
584
  attemptCount: Number(meta[`${idx}:d`] ?? 0),
@@ -500,6 +592,41 @@ function createRedisLogCore(options) {
500
592
  });
501
593
  });
502
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
+ };
503
630
  return {
504
631
  async append(sessionId, events) {
505
632
  return wrap(async () => {
@@ -528,7 +655,7 @@ function createRedisLogCore(options) {
528
655
  ])
529
656
  ];
530
657
  const reply = await evalScript(APPEND_LUA, [
531
- logKey(sessionId),
658
+ storeKey(sessionId),
532
659
  countKey(sessionId),
533
660
  idsKey,
534
661
  sessionsKey,
@@ -543,6 +670,8 @@ function createRedisLogCore(options) {
543
670
  hasPending: reply[1] === "1"
544
671
  };
545
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}'`);
546
675
  if (verdict === "partial") throw new A2Error("PARTIAL_DUPLICATE_BATCH", `batch mixes ${reply[1]} already-appended and ${events.length - Number(reply[1])} fresh events`);
547
676
  if (verdict === "dup") {
548
677
  const hasPending = reply[1] === "1";
@@ -586,7 +715,7 @@ function createRedisLogCore(options) {
586
715
  },
587
716
  async read(sessionId, opts) {
588
717
  return wrap(async () => {
589
- return await readRange(sessionId, opts?.afterIndex ?? 0);
718
+ return await readRange(sessionId, opts?.afterIndex ?? 0, opts?.throughIndex);
590
719
  });
591
720
  },
592
721
  async claimAvailable({ sessionId, holder, ttlMs, expiresAtMs, excludeIndexes }) {
@@ -595,7 +724,7 @@ function createRedisLogCore(options) {
595
724
  const expiresAt = expiresAtMs ?? now + ttlMs;
596
725
  const excluded = excludeIndexes ?? [];
597
726
  const reply = await evalScript(CLAIM_AVAILABLE_LUA, [
598
- logKey(sessionId),
727
+ storeKey(sessionId),
599
728
  countKey(sessionId),
600
729
  metaKey(sessionId),
601
730
  pendingKey(sessionId),
@@ -610,17 +739,23 @@ function createRedisLogCore(options) {
610
739
  return toClaimAvailableResult(sessionId, reply);
611
740
  });
612
741
  },
613
- async renewClaims({ sessionId, holder, indexes, ttlMs, expiresAtMs }) {
742
+ async renewClaims({ sessionId, holder, claims, ttlMs, expiresAtMs }) {
614
743
  return wrap(async () => {
615
744
  const now = clock.now().getTime();
616
745
  const expiresAt = expiresAtMs ?? now + ttlMs;
617
- return (await evalScript(RENEW_CLAIMS_LUA, [countKey(sessionId), metaKey(sessionId)], [
746
+ const reply = await evalScript(RENEW_CLAIMS_LUA, [countKey(sessionId), metaKey(sessionId)], [
618
747
  holder,
619
748
  now,
620
749
  expiresAt,
621
- indexes.length,
622
- ...indexes
623
- ])).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
+ };
624
759
  });
625
760
  },
626
761
  async completeAttempt({ sessionId, index, attempt, events }) {
@@ -629,7 +764,7 @@ function createRedisLogCore(options) {
629
764
  if (new Set(ids).size !== ids.length) throw new A2Error("PARTIAL_DUPLICATE_BATCH", "batch contains the same event id more than once");
630
765
  const now = clock.now().getTime();
631
766
  const reply = await evalScript(COMPLETE_ATTEMPT_LUA, [
632
- logKey(sessionId),
767
+ storeKey(sessionId),
633
768
  countKey(sessionId),
634
769
  idsKey,
635
770
  sessionsKey,
@@ -731,15 +866,18 @@ function createRedisLogCore(options) {
731
866
  },
732
867
  async readState(sessionId, reducerName) {
733
868
  return wrap(async () => {
734
- 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)], []);
735
870
  const raw = reply[0];
736
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);
737
875
  return {
738
876
  snapshot: parsed ? {
739
877
  index: parsed.index,
740
878
  state: parsed.state
741
879
  } : null,
742
- events: reply[1].map((entry) => toEvent(sessionId, entry))
880
+ events
743
881
  };
744
882
  });
745
883
  },
@@ -752,6 +890,58 @@ function createRedisLogCore(options) {
752
890
  })]);
753
891
  });
754
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
+ },
755
945
  inspect: {
756
946
  async listSessions(inspectionOptions) {
757
947
  return wrap(async () => {
@@ -796,6 +986,19 @@ function createRedisLogCore(options) {
796
986
  };
797
987
  });
798
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
+ },
799
1002
  async listSnapshots(sessionId) {
800
1003
  return wrap(async () => {
801
1004
  const start = `${prefix}:${sessionId}:snap:`;
@@ -819,12 +1022,12 @@ function createRedisLogCore(options) {
819
1022
  });
820
1023
  }
821
1024
  },
822
- async readRange(sessionId, afterIndex) {
823
- return wrap(() => readRange(sessionId, afterIndex));
1025
+ async readRange(sessionId, afterIndex, throughIndex) {
1026
+ return wrap(() => readRange(sessionId, afterIndex, throughIndex));
824
1027
  },
825
1028
  async readEvents(sessionId, afterIndex) {
826
1029
  return wrap(async () => {
827
- return (await call("XRANGE", logKey(sessionId), `${afterIndex + 1}-0`, "+")).map((entry) => toEvent(sessionId, entry));
1030
+ return (await call("XRANGE", storeKey(sessionId), `${afterIndex + 1}-0`, "+")).map((entry) => toEvent(sessionId, entry));
828
1031
  });
829
1032
  },
830
1033
  async markWatched(sessionId, ttlMs) {
@@ -833,4 +1036,4 @@ function createRedisLogCore(options) {
833
1036
  };
834
1037
  }
835
1038
  //#endregion
836
- export { createRedisLogCore as t };
1039
+ export { createRedisStoreCore as t };
@@ -1,6 +1,6 @@
1
- import { c as IdSource, i as Clock, t as A2Log } from "./log-ldf5g8Cx.js";
2
- //#region src/log-redis-http.d.ts
3
- type RedisHttpLogOptions = {
1
+ import { c as IdSource, i as Clock, t as A2Store } from "./store-C3sNAaBT.js";
2
+ //#region src/store-redis-http.d.ts
3
+ type RedisHttpStoreOptions = {
4
4
  /** REST endpoint, e.g. `process.env.UPSTASH_REDIS_REST_URL`. */
5
5
  url: string;
6
6
  /** Bearer token, e.g. `process.env.UPSTASH_REDIS_REST_TOKEN`. */
@@ -12,10 +12,10 @@ type RedisHttpLogOptions = {
12
12
  /** Injectable id source for generated event ids. */
13
13
  ids?: IdSource;
14
14
  };
15
- type RedisHttpLog = A2Log & {
16
- /** Nothing to disconnect; present so callers can treat logs uniformly. */
15
+ type RedisHttpStore = A2Store & {
16
+ /** Nothing to disconnect; present so callers can treat stores uniformly. */
17
17
  close(): Promise<void>;
18
18
  };
19
- declare function redisHttp(options: RedisHttpLogOptions): RedisHttpLog;
19
+ declare function redisHttp(options: RedisHttpStoreOptions): RedisHttpStore;
20
20
  //#endregion
21
- export { RedisHttpLog, RedisHttpLogOptions, redisHttp };
21
+ export { RedisHttpStore, RedisHttpStoreOptions, redisHttp };
@@ -1,19 +1,21 @@
1
- import { t as A2Error } from "./errors-BJRMd-h6.js";
2
- import { n as SYSTEM_CLOCK, t as RANDOM_IDS } from "./log-yJbXUf72.js";
3
- import { n as pollingStream } from "./log-polling-DZ1MiKLg.js";
4
- import { t as createRedisLogCore } from "./log-redis-core-CyJ5L8yR.js";
5
- //#region src/log-redis-http.ts
1
+ import { t as A2Error } from "./errors-BQuJpe82.js";
2
+ import { n as pollingStream } from "./store-polling-DgrrAE3d.js";
3
+ import { n as SYSTEM_CLOCK, t as RANDOM_IDS } from "./store-N8PXxDAS.js";
4
+ import { t as createRedisStoreCore } from "./store-redis-core-DWqx3F47.js";
5
+ //#region src/store-redis-http.ts
6
6
  /**
7
- * experimental-a2/log-redis-http — the same Redis Streams log over a
7
+ * experimental-a2/store-redis-http — the same Redis Streams store over a
8
8
  * provider REST API (Upstash-shaped: POST one command as a JSON array,
9
9
  * receive `{ result }` or `{ error }`). The storage semantics live in
10
- * log-redis-core.ts, shared with experimental-a2/log-redis.
10
+ * store-redis-core.ts, shared with experimental-a2/store-redis.
11
11
  *
12
12
  * Fully connectionless: every command is one `fetch`, so it works
13
13
  * where a Redis-protocol connection cannot exist or cannot be spared.
14
14
  * Without a connection there is no push and no pub/sub, so `stream()`
15
15
  * is the shared activity-adaptive poll loop (25ms while events flow,
16
- * backing off to a 250ms idle ceiling). No peer dependencies.
16
+ * backing off to a 250ms idle ceiling), and presence is the degraded
17
+ * tier: `set`/`read` from the shared core, no `subscribe`. No peer
18
+ * dependencies.
17
19
  */
18
20
  function redisHttp(options) {
19
21
  if (!options.url || !options.token) throw new TypeError("redisHttp() needs a url and a token");
@@ -30,13 +32,13 @@ function redisHttp(options) {
30
32
  body: JSON.stringify([command, ...args.map(String)])
31
33
  });
32
34
  } catch (err) {
33
- throw new A2Error("LOG_UNAVAILABLE", "redis rest request failed", { cause: err });
35
+ throw new A2Error("STORE_UNAVAILABLE", "redis rest request failed", { cause: err });
34
36
  }
35
37
  const body = await response.json().catch(() => null);
36
- if (body === null || !response.ok || body.error !== void 0) throw new A2Error("LOG_UNAVAILABLE", `redis rest command failed: ${body?.error ?? `status ${response.status}`}`);
38
+ if (body === null || !response.ok || body.error !== void 0) throw new A2Error("STORE_UNAVAILABLE", `redis rest command failed: ${body?.error ?? `status ${response.status}`}`);
37
39
  return body.result;
38
40
  };
39
- const core = createRedisLogCore({
41
+ const core = createRedisStoreCore({
40
42
  call,
41
43
  clock: options.clock ?? SYSTEM_CLOCK,
42
44
  ids: options.ids ?? RANDOM_IDS,
@@ -51,9 +53,15 @@ function redisHttp(options) {
51
53
  failAttempt: core.failAttempt,
52
54
  readState: core.readState,
53
55
  putSnapshot: core.putSnapshot,
56
+ presence: {
57
+ async set(ns, participant, values, meta) {
58
+ await core.presence.set(ns, participant, values, meta);
59
+ },
60
+ read: core.presence.read
61
+ },
54
62
  inspect: core.inspect,
55
63
  stream(sessionId, opts) {
56
- return pollingStream((afterIndex) => core.readRange(sessionId, afterIndex), opts?.startAt !== void 0 ? { startAt: opts.startAt } : {});
64
+ return pollingStream((afterIndex) => core.readRange(sessionId, afterIndex), opts?.startAfter !== void 0 ? { startAfter: opts.startAfter } : {});
57
65
  },
58
66
  async close() {}
59
67
  };
@@ -1,5 +1,5 @@
1
- import { c as IdSource, i as Clock, t as A2Log } from "./log-ldf5g8Cx.js";
2
- //#region src/log-redis.d.ts
1
+ import { c as IdSource, i as Clock, t as A2Store } from "./store-C3sNAaBT.js";
2
+ //#region src/store-redis.d.ts
3
3
  /**
4
4
  * The minimal client this backend needs — `ioredis` matches it
5
5
  * structurally. `call` issues any command; `duplicate` opens the one
@@ -16,7 +16,7 @@ type RedisConnection = {
16
16
  on(event: "message", listener: (channel: string, message: string) => void): unknown;
17
17
  disconnect(): void;
18
18
  };
19
- type RedisLogOptions = {
19
+ type RedisStoreOptions = {
20
20
  /** Creates an `ioredis` client lazily (optional peer dep `ioredis`). */
21
21
  url?: string | undefined;
22
22
  /** Bring your own client — anything `call`/`duplicate`/`on`/`disconnect`. */
@@ -28,10 +28,10 @@ type RedisLogOptions = {
28
28
  /** Injectable id source for generated event ids. */
29
29
  ids?: IdSource;
30
30
  };
31
- type RedisLog = A2Log & {
31
+ type RedisStore = A2Store & {
32
32
  /** Disconnect the command client and the shared subscriber. */
33
33
  close(): Promise<void>;
34
34
  };
35
- declare function redis(options?: RedisLogOptions): RedisLog;
35
+ declare function redis(options?: RedisStoreOptions): RedisStore;
36
36
  //#endregion
37
- export { RedisConnection, RedisLog, RedisLogOptions, redis };
37
+ export { RedisConnection, RedisStore, RedisStoreOptions, redis };