experimental-a2 0.7.0 → 0.8.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 (58) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/dist/ai-server.d.ts +1 -1
  3. package/dist/ai-server.d.ts.map +1 -1
  4. package/dist/ai-server.js +13 -11
  5. package/dist/ai-server.js.map +1 -1
  6. package/dist/ai.d.ts +1 -1
  7. package/dist/index.d.ts +1 -1
  8. package/dist/scheduler-qstash.d.ts +2 -2
  9. package/dist/scheduler-qstash.js +1 -1
  10. package/dist/scheduler-vercel.d.ts +2 -2
  11. package/dist/scheduler-vercel.js +1 -1
  12. package/dist/{server-286j79Mt.js → server-B2XNevQA.js} +123 -53
  13. package/dist/server-B2XNevQA.js.map +1 -0
  14. package/dist/{server-DgXmORIq.d.ts → server-DjPhHnbI.d.ts} +7 -4
  15. package/dist/server-DjPhHnbI.d.ts.map +1 -0
  16. package/dist/server.d.ts +3 -3
  17. package/dist/server.js +1 -1
  18. package/dist/store-N8PXxDAS.js.map +1 -1
  19. package/dist/{store-flRz1OWh.d.ts → store-RJO35BMj.d.ts} +25 -8
  20. package/dist/store-RJO35BMj.d.ts.map +1 -0
  21. package/dist/store-memory.d.ts +1 -1
  22. package/dist/store-memory.d.ts.map +1 -1
  23. package/dist/store-memory.js +79 -19
  24. package/dist/store-memory.js.map +1 -1
  25. package/dist/store-postgres.d.ts +1 -1
  26. package/dist/store-postgres.d.ts.map +1 -1
  27. package/dist/store-postgres.js +230 -101
  28. package/dist/store-postgres.js.map +1 -1
  29. package/dist/{store-redis-core-DEYO8Ryv.js → store-redis-core-DT01r4GZ.js} +167 -29
  30. package/dist/store-redis-core-DT01r4GZ.js.map +1 -0
  31. package/dist/store-redis-http.d.ts +1 -1
  32. package/dist/store-redis-http.js +2 -2
  33. package/dist/store-redis-http.js.map +1 -1
  34. package/dist/store-redis.d.ts +1 -1
  35. package/dist/store-redis.js +2 -2
  36. package/dist/store-redis.js.map +1 -1
  37. package/dist/store-sqlite.d.ts +1 -1
  38. package/dist/store-sqlite.d.ts.map +1 -1
  39. package/dist/store-sqlite.js +103 -19
  40. package/dist/store-sqlite.js.map +1 -1
  41. package/docs/concepts/02-handlers.mdx +4 -0
  42. package/docs/concepts/04-state.mdx +57 -9
  43. package/docs/guides/06-ai-agents.mdx +2 -1
  44. package/docs/reference/01-api.mdx +39 -16
  45. package/package.json +1 -1
  46. package/src/ai-server.ts +27 -10
  47. package/src/server.ts +242 -87
  48. package/src/store-memory.ts +138 -20
  49. package/src/store-postgres.ts +355 -138
  50. package/src/store-redis-core.ts +201 -27
  51. package/src/store-redis-http.ts +1 -1
  52. package/src/store-redis.ts +1 -1
  53. package/src/store-sqlite.ts +191 -34
  54. package/src/store.ts +27 -9
  55. package/dist/server-286j79Mt.js.map +0 -1
  56. package/dist/server-DgXmORIq.d.ts.map +0 -1
  57. package/dist/store-flRz1OWh.d.ts.map +0 -1
  58. package/dist/store-redis-core-DEYO8Ryv.js.map +0 -1
@@ -0,0 +1 @@
1
+ {"version":3,"file":"store-redis-core-DT01r4GZ.js","names":[],"sources":["../src/store-redis-core.ts"],"sourcesContent":["/**\n * The shared Redis store core — every operation except `stream` and\n * `close`, expressed over one injected `call` function so both\n * transports reuse it: `store-redis` (Redis protocol via `ioredis`) and\n * `store-redis-http` (provider REST APIs).\n *\n * The stream IS the store: each event is a stream entry whose ID is its\n * dense a2 index (`${index}-0`), so `XRANGE` is the catch-up read.\n * Mutable drain/failure bookkeeping lives in a side hash (stream\n * entries are immutable); every write path is one atomic Lua script.\n * The conformance suite in test/conformance is the executable\n * contract, run against a real spawned `redis-server`.\n *\n * Timestamps and claim expiry come from the injected clock so tests\n * can time-travel against a real server. Cluster mode is out: the\n * atomic scripts span keys.\n */\n\nimport type { PresencePatch } from './contract.ts'\nimport { A2Error } from './errors.ts'\nimport { idempotentReplay } from './idempotent-replay.ts'\nimport { nullProtoRecord } from './internal.ts'\nimport { decodeReturnedEventIds } from './store-codec.ts'\nimport type {\n A2Store,\n Clock,\n CompleteAttemptResult,\n Event,\n EventCause,\n IdSource,\n StoreClaimAvailableResult,\n PresenceRow,\n StoredEvent,\n} from './store.ts'\n\n/** Issues any Redis command; the seam both transports implement. */\nexport type RedisCall = (\n command: string,\n ...args: Array<string | number>\n) => Promise<unknown>\n\nexport type RedisStoreCoreOptions = {\n call: RedisCall\n clock: Clock\n ids: IdSource\n keyPrefix: string\n /**\n * Disposable wake-up fired after a write appends new events to a\n * watched session — `count` is the session's new event count. The\n * watched check runs inside the write script, so unwatched sessions\n * cost no wake-up command at all. Losing one is harmless: live\n * feeds re-read on a safety cadence, so notifications only affect\n * latency, never delivery.\n */\n notify?: (sessionId: string, count: number) => void\n}\n\nexport type RedisStoreCore = Omit<A2Store, 'stream' | 'presence'> & {\n /**\n * The storage half of the presence capability, shared by both\n * transports. `set` returns the applied patch — null when every\n * field lost the LWW race — so a push-capable transport broadcasts\n * exactly what `read` now reflects; delivery is its business.\n */\n presence: {\n set(\n ns: string,\n participant: string,\n values: Record<string, unknown | null>,\n meta: { seen: number; at: Date; ttlMs: number },\n ): Promise<PresencePatch | null>\n read(ns: string): Promise<PresenceRow[]>\n }\n /** XRANGE + meta merge — the full-row catch-up read. */\n readRange(\n sessionId: string,\n afterIndex: number,\n throughIndex?: number,\n ): Promise<StoredEvent[]>\n /** XRANGE only — the light catch-up read live feeds use. */\n readEvents(sessionId: string, afterIndex: number): Promise<Event[]>\n /**\n * Declare the session watched for `ttlMs` — writes publish wake-ups\n * only while this presence marker lives. Live feeds refresh it\n * ahead of expiry; an expired marker degrades latency to the safety\n * cadence, never delivery.\n */\n markWatched(sessionId: string, ttlMs: number): Promise<void>\n}\n\nconst READ_PAGE_SIZE = 256\n\n// ── Lua scripts (each runs atomically) ───────────────────────────────\n\n/**\n * KEYS: store stream, counter, global id hash, metadata, pending, ready,\n * watched.\n * ARGV: sessionId, nowMs, n, then per event:\n * id, type, payloadJson, causeJson, lane, settled.\n * Returns ['empty', hasPending] | ['ok', firstIndex, hasPending, watched] |\n * ['dup', hasPending, ...indexes] | ['partial', count] |\n * ['foreign', eventId] | ['no_parent', idx] | ['superseded', idx].\n */\nconst APPEND_LUA = `\nlocal function lane_field(lane, suffix)\n return '__lane:' .. tostring(string.len(lane)) .. ':' .. lane .. ':' .. suffix\nend\nlocal function enqueue(field, idx, lane)\n redis.call('ZADD', KEYS[5], idx, field)\n if lane == '' then\n redis.call('ZADD', KEYS[6], idx, field)\n return\n end\n local tail_key = lane_field(lane, 'tail')\n local tail = redis.call('HGET', KEYS[4], tail_key)\n if tail then\n redis.call('HSET', KEYS[4], tail .. ':next', field)\n else\n redis.call('HSET', KEYS[4], lane_field(lane, 'head'), field)\n redis.call('ZADD', KEYS[6], idx, field)\n end\n redis.call('HSET', KEYS[4], tail_key, field)\nend\nlocal n = tonumber(ARGV[3])\nlocal base = tonumber(redis.call('GET', KEYS[2]) or '0')\nlocal has_pending = redis.call('ZCARD', KEYS[5]) > 0 and '1' or '0'\nif n == 0 then return { 'empty', has_pending } end\nlocal dups = {}\nfor i = 1, n do\n local id = ARGV[3 + (i - 1) * 6 + 1]\n local existing = redis.call('HGET', KEYS[3], id)\n if existing then\n local rec = cjson.decode(existing)\n if rec[1] ~= ARGV[1] then return { 'foreign', id } end\n dups[#dups + 1] = tostring(rec[2])\n end\nend\nif #dups == n then\n local res = { 'dup', has_pending }\n for i = 1, #dups do res[#res + 1] = dups[i] end\n return res\nend\nif #dups > 0 then return { 'partial', tostring(#dups) } end\nfor i = 1, n do\n local cause = ARGV[3 + (i - 1) * 6 + 4]\n if cause ~= '' then\n local parsed = cjson.decode(cause)\n local pidx = tonumber(parsed.index)\n if pidx < 1 or pidx > base then\n return { 'no_parent', tostring(parsed.index) }\n end\n local field = tostring(pidx)\n local dispatches = tonumber(redis.call('HGET', KEYS[4], field .. ':d') or '0')\n if dispatches ~= tonumber(parsed.attempt) or\n redis.call('HEXISTS', KEYS[4], field .. ':f') == 1 then\n return { 'superseded', field }\n end\n end\nend\nfor i = 1, n do\n local id = ARGV[3 + (i - 1) * 6 + 1]\n local typ = ARGV[3 + (i - 1) * 6 + 2]\n local payload = ARGV[3 + (i - 1) * 6 + 3]\n local cause = ARGV[3 + (i - 1) * 6 + 4]\n local lane = ARGV[3 + (i - 1) * 6 + 5]\n local settled = ARGV[3 + (i - 1) * 6 + 6]\n local idx = base + i\n redis.call('XADD', KEYS[1], tostring(idx) .. '-0',\n 'id', id, 'type', typ, 'payload', payload, 'created_at', ARGV[2],\n 'cause', cause, 'lane', lane)\n redis.call('HSET', KEYS[3], id, cjson.encode({ ARGV[1], idx }))\n redis.call('HSET', KEYS[4], tostring(idx) .. ':l', lane)\n if settled == '1' then\n redis.call('HSET', KEYS[4], tostring(idx) .. ':p', ARGV[2])\n else\n enqueue(tostring(idx), idx, lane)\n end\nend\nredis.call('SET', KEYS[2], tostring(base + n))\nhas_pending = redis.call('ZCARD', KEYS[5]) > 0 and '1' or '0'\nreturn { 'ok', tostring(base + 1), has_pending,\n tostring(redis.call('EXISTS', KEYS[7])) }\n`\n\n/**\n * KEYS: counter, meta, pending, ready, event snapshot pins, snapshot pin counts.\n * ARGV: idx, attempt, error, maxFailures, nowMs.\n */\nconst FAIL_ATTEMPT_LUA = `\nlocal function release_snapshot_pins()\n local refs = redis.call('SMEMBERS', KEYS[5])\n for _, ref in ipairs(refs) do\n local count = redis.call('HINCRBY', KEYS[6], ref, -1)\n if count <= 0 then\n redis.call('HDEL', KEYS[6], ref)\n local checkpoint = cjson.decode(ref)\n redis.call('ZREMRANGEBYSCORE', checkpoint[1], checkpoint[2], checkpoint[2])\n end\n end\n redis.call('DEL', KEYS[5])\n if redis.call('HLEN', KEYS[6]) == 0 then redis.call('DEL', KEYS[6]) end\nend\nlocal function lane_field(lane, suffix)\n return '__lane:' .. tostring(string.len(lane)) .. ':' .. lane .. ':' .. suffix\nend\nlocal function settle(field)\n redis.call('ZREM', KEYS[3], field)\n redis.call('ZREM', KEYS[4], field)\n local lane = redis.call('HGET', KEYS[2], field .. ':l') or ''\n if lane == '' then return end\n local head_key = lane_field(lane, 'head')\n if redis.call('HGET', KEYS[2], head_key) ~= field then return end\n local next_field = redis.call('HGET', KEYS[2], field .. ':next')\n while next_field and redis.call('HEXISTS', KEYS[2], next_field .. ':p') == 1 do\n next_field = redis.call('HGET', KEYS[2], next_field .. ':next')\n end\n if next_field then\n redis.call('HSET', KEYS[2], head_key, next_field)\n if redis.call('HEXISTS', KEYS[2], next_field .. ':f') == 0 then\n redis.call('ZADD', KEYS[4], tonumber(next_field), next_field)\n end\n else\n redis.call('HDEL', KEYS[2], head_key, lane_field(lane, 'tail'))\n end\nend\nlocal idx = tonumber(ARGV[1])\nif idx < 1 or idx > tonumber(redis.call('GET', KEYS[1]) or '0') then\n return { 'missing', '0' }\nend\nlocal field = ARGV[1]\nlocal failures = tonumber(redis.call('HGET', KEYS[2], field .. ':a') or '0')\nlocal dispatches = tonumber(redis.call('HGET', KEYS[2], field .. ':d') or '0')\nif redis.call('HEXISTS', KEYS[2], field .. ':p') == 1 or dispatches ~= tonumber(ARGV[2]) then\n return { 'superseded', tostring(failures) }\nend\nif redis.call('HEXISTS', KEYS[2], field .. ':f') == 1 then\n return { 'dead_lettered', tostring(failures) }\nend\nif redis.call('HEXISTS', KEYS[2], field .. ':lfa') == 1 and\n tonumber(redis.call('HGET', KEYS[2], field .. ':lfa')) == tonumber(ARGV[2]) and\n redis.call('HEXISTS', KEYS[2], field .. ':ch') == 0 then\n return { 'failed', tostring(failures) }\nend\nif redis.call('HEXISTS', KEYS[2], field .. ':ch') == 0 then\n return { 'superseded', tostring(failures) }\nend\nfailures = redis.call('HINCRBY', KEYS[2], field .. ':a', 1)\nredis.call('HSET', KEYS[2],\n field .. ':e', ARGV[3],\n field .. ':lf', ARGV[5],\n field .. ':lfa', ARGV[2])\nredis.call('HDEL', KEYS[2], field .. ':ch', field .. ':ce')\nif failures >= tonumber(ARGV[4]) then\n redis.call('HSET', KEYS[2], field .. ':f', ARGV[5])\n redis.call('ZREM', KEYS[4], field)\n release_snapshot_pins()\n return { 'dead_lettered', tostring(failures) }\nend\nreturn { 'failed', tostring(failures) }\n`\n\n/**\n * KEYS: store, counter, meta, pending, ready.\n * ARGV: holder, nowMs, expiresAtMs, excludedCount, excluded indexes.\n */\nconst CLAIM_AVAILABLE_LUA = `\nif redis.call('ZCARD', KEYS[4]) == 0 then return { 'settled' } end\nlocal ready = redis.call('ZRANGE', KEYS[5], 0, -1)\nlocal excluded = {}\nfor i = 1, tonumber(ARGV[4]) do excluded[ARGV[4 + i]] = true end\nlocal claimed = {}\nlocal retry_at = nil\nfor _, field in ipairs(ready) do\n if not excluded[field] then\n local expires = tonumber(redis.call('HGET', KEYS[3], field .. ':ce') or '0')\n if expires > tonumber(ARGV[2]) then\n if not retry_at or expires < retry_at then retry_at = expires end\n else\n local entries = redis.call('XRANGE', KEYS[1], field .. '-0', field .. '-0')\n if #entries == 0 then return { 'missing', field } end\n local attempt = redis.call('HINCRBY', KEYS[3], field .. ':d', 1)\n redis.call('HSETNX', KEYS[3], field .. ':fc', ARGV[2])\n redis.call('HSET', KEYS[3],\n field .. ':lc', ARGV[2],\n field .. ':ch', ARGV[1],\n field .. ':ce', ARGV[3])\n claimed[#claimed + 1] = {\n tostring(attempt),\n redis.call('HGET', KEYS[3], field .. ':a') or '0',\n redis.call('HGET', KEYS[3], field .. ':e') or false,\n redis.call('HGET', KEYS[3], field .. ':fc') or false,\n ARGV[2],\n redis.call('HGET', KEYS[3], field .. ':lf') or false,\n redis.call('HGET', KEYS[3], field .. ':lfa') or false,\n ARGV[1],\n ARGV[3],\n entries[1]\n }\n end\n end\nend\nif #claimed > 0 then\n local result = { 'claimed', tostring(#claimed) }\n for _, claim in ipairs(claimed) do\n for _, value in ipairs(claim) do result[#result + 1] = value end\n end\n return result\nend\nif retry_at then return { 'busy', tostring(retry_at) } end\nreturn { 'settled' }\n`\n\n/**\n * KEYS: counter, meta.\n * ARGV: holder, nowMs, expiresAtMs, n, then per claim: index, attempt.\n * Returns [renewedCount, ...renewed, ...superseded].\n */\nconst RENEW_CLAIMS_LUA = `\nlocal total = tonumber(redis.call('GET', KEYS[1]) or '0')\nlocal renewed = {}\nlocal superseded = {}\nfor i = 1, tonumber(ARGV[4]) do\n local field = ARGV[4 + (i - 1) * 2 + 1]\n local attempt = tonumber(ARGV[4 + (i - 1) * 2 + 2])\n local idx = tonumber(field)\n if idx >= 1 and idx <= total then\n local dispatches = tonumber(redis.call('HGET', KEYS[2], field .. ':d') or '0')\n if dispatches > attempt then\n superseded[#superseded + 1] = field\n else\n local expires = tonumber(redis.call('HGET', KEYS[2], field .. ':ce') or '0')\n if tonumber(ARGV[3]) > tonumber(ARGV[2]) and\n redis.call('HEXISTS', KEYS[2], field .. ':p') == 0 and\n redis.call('HEXISTS', KEYS[2], field .. ':f') == 0 and\n redis.call('HGET', KEYS[2], field .. ':ch') == ARGV[1] and\n expires > tonumber(ARGV[2]) then\n redis.call('HSET', KEYS[2], field .. ':ce', ARGV[3])\n renewed[#renewed + 1] = field\n end\n end\n end\nend\nlocal result = { tostring(#renewed) }\nfor i = 1, #renewed do result[#result + 1] = renewed[i] end\nfor i = 1, #superseded do result[#result + 1] = superseded[i] end\nreturn result\n`\n\n/**\n * KEYS: store, counter, ids, meta, pending, ready, watched,\n * event snapshot pins, snapshot pin counts.\n * ARGV: sessionId, nowMs, parent index, attempt, n, then per child:\n * id, type, payloadJson, lane, settled.\n */\nconst COMPLETE_ATTEMPT_LUA = `\nlocal function release_snapshot_pins()\n local refs = redis.call('SMEMBERS', KEYS[8])\n for _, ref in ipairs(refs) do\n local count = redis.call('HINCRBY', KEYS[9], ref, -1)\n if count <= 0 then\n redis.call('HDEL', KEYS[9], ref)\n local checkpoint = cjson.decode(ref)\n redis.call('ZREMRANGEBYSCORE', checkpoint[1], checkpoint[2], checkpoint[2])\n end\n end\n redis.call('DEL', KEYS[8])\n if redis.call('HLEN', KEYS[9]) == 0 then redis.call('DEL', KEYS[9]) end\nend\nlocal function lane_field(lane, suffix)\n return '__lane:' .. tostring(string.len(lane)) .. ':' .. lane .. ':' .. suffix\nend\nlocal function settle(field)\n redis.call('ZREM', KEYS[5], field)\n redis.call('ZREM', KEYS[6], field)\n local lane = redis.call('HGET', KEYS[4], field .. ':l') or ''\n if lane == '' then return end\n local head_key = lane_field(lane, 'head')\n if redis.call('HGET', KEYS[4], head_key) ~= field then return end\n local next_field = redis.call('HGET', KEYS[4], field .. ':next')\n while next_field and redis.call('HEXISTS', KEYS[4], next_field .. ':p') == 1 do\n next_field = redis.call('HGET', KEYS[4], next_field .. ':next')\n end\n if next_field then\n redis.call('HSET', KEYS[4], head_key, next_field)\n if redis.call('HEXISTS', KEYS[4], next_field .. ':f') == 0 then\n redis.call('ZADD', KEYS[6], tonumber(next_field), next_field)\n end\n else\n redis.call('HDEL', KEYS[4], head_key, lane_field(lane, 'tail'))\n end\nend\nlocal function enqueue(field, idx, lane)\n redis.call('ZADD', KEYS[5], idx, field)\n if lane == '' then\n redis.call('ZADD', KEYS[6], idx, field)\n return\n end\n local tail_key = lane_field(lane, 'tail')\n local tail = redis.call('HGET', KEYS[4], tail_key)\n if tail then\n redis.call('HSET', KEYS[4], tail .. ':next', field)\n else\n redis.call('HSET', KEYS[4], lane_field(lane, 'head'), field)\n redis.call('ZADD', KEYS[6], idx, field)\n end\n redis.call('HSET', KEYS[4], tail_key, field)\nend\nlocal parent = ARGV[3]\nlocal attempt = tonumber(ARGV[4])\nlocal n = tonumber(ARGV[5])\nlocal total = tonumber(redis.call('GET', KEYS[2]) or '0')\nif tonumber(parent) < 1 or tonumber(parent) > total then return { 'missing' } end\nlocal dispatches = tonumber(redis.call('HGET', KEYS[4], parent .. ':d') or '0')\nlocal processed = redis.call('HGET', KEYS[4], parent .. ':p')\nlocal processed_attempt = tonumber(redis.call('HGET', KEYS[4], parent .. ':pa') or '0')\nif processed then\n if processed_attempt ~= attempt then return { 'superseded' } end\n local raw_recorded_ids = redis.call('HGET', KEYS[4], parent .. ':ri')\n if not raw_recorded_ids then return { 'partial' } end\n local recorded_ids = cjson.decode(raw_recorded_ids)\n if #recorded_ids ~= n then return { 'partial' } end\n local indexes = {}\n for i = 1, n do\n local id = ARGV[5 + (i - 1) * 5 + 1]\n if recorded_ids[i] ~= id then return { 'partial' } end\n local existing = redis.call('HGET', KEYS[3], id)\n if not existing then return { 'partial' } end\n local rec = cjson.decode(existing)\n if rec[1] ~= ARGV[1] then return { 'partial' } end\n local entries = redis.call('XRANGE', KEYS[1], tostring(rec[2]) .. '-0', tostring(rec[2]) .. '-0')\n if #entries == 0 then return { 'partial' } end\n local values = entries[1][2]\n local cause = nil\n for j = 1, #values, 2 do\n if values[j] == 'cause' then cause = values[j + 1] break end\n end\n if not cause or cause == '' then return { 'partial' } end\n local decoded = cjson.decode(cause)\n if tonumber(decoded['index']) ~= tonumber(parent) or\n tonumber(decoded['attempt']) ~= attempt then return { 'partial' } end\n indexes[#indexes + 1] = tostring(rec[2])\n end\n local result = { 'duplicate' }\n for _, idx in ipairs(indexes) do result[#result + 1] = idx end\n return result\nend\nif dispatches ~= attempt then return { 'superseded' } end\nif redis.call('HEXISTS', KEYS[4], parent .. ':ch') == 0 or\n redis.call('HEXISTS', KEYS[4], parent .. ':f') == 1 or\n tonumber(redis.call('HGET', KEYS[4], parent .. ':lfa') or '0') == attempt then\n return { 'superseded' }\nend\nfor i = 1, n do\n local id = ARGV[5 + (i - 1) * 5 + 1]\n if redis.call('HEXISTS', KEYS[3], id) == 1 then return { 'partial' } end\nend\nlocal returned_ids = {}\nfor i = 1, n do returned_ids[i] = ARGV[5 + (i - 1) * 5 + 1] end\nlocal returned_json = '[]'\nif n > 0 then returned_json = cjson.encode(returned_ids) end\nredis.call('HSET', KEYS[4],\n parent .. ':p', ARGV[2],\n parent .. ':pa', ARGV[4],\n parent .. ':ri', returned_json)\nredis.call('HDEL', KEYS[4], parent .. ':ch', parent .. ':ce')\nsettle(parent)\nrelease_snapshot_pins()\nlocal cause = cjson.encode({ index = tonumber(parent), attempt = attempt })\nfor i = 1, n do\n local id = ARGV[5 + (i - 1) * 5 + 1]\n local typ = ARGV[5 + (i - 1) * 5 + 2]\n local payload = ARGV[5 + (i - 1) * 5 + 3]\n local lane = ARGV[5 + (i - 1) * 5 + 4]\n local settled = ARGV[5 + (i - 1) * 5 + 5]\n local idx = total + i\n redis.call('XADD', KEYS[1], tostring(idx) .. '-0',\n 'id', id, 'type', typ, 'payload', payload, 'created_at', ARGV[2],\n 'cause', cause, 'lane', lane)\n redis.call('HSET', KEYS[3], id, cjson.encode({ ARGV[1], idx }))\n redis.call('HSET', KEYS[4], tostring(idx) .. ':l', lane)\n if settled == '1' then\n redis.call('HSET', KEYS[4], tostring(idx) .. ':p', ARGV[2])\n else\n enqueue(tostring(idx), idx, lane)\n end\nend\nredis.call('SET', KEYS[2], tostring(total + n))\nreturn { 'ok', tostring(total + 1),\n tostring(redis.call('EXISTS', KEYS[7])) }\n`\n\n/**\n * KEYS: head, history, event counter, event meta, pin counts.\n * ARGV: nowMs, event pin key prefix, write count, then per write:\n * index, snapshot JSON, pin count, pin event indexes.\n */\nconst PUT_SNAPSHOTS_LUA = `\nlocal total = tonumber(redis.call('GET', KEYS[3]) or '0')\nlocal writes = {}\nlocal offset = 4\nfor i = 1, tonumber(ARGV[3]) do\n local index = tonumber(ARGV[offset])\n local snapshot = ARGV[offset + 1]\n local pin_count = tonumber(ARGV[offset + 2])\n local ref = cjson.encode({ KEYS[2], tostring(index) })\n for p = 1, pin_count do\n local event_index = tonumber(ARGV[offset + 2 + p])\n if event_index >= 1 and event_index <= total and\n redis.call('HEXISTS', KEYS[4], tostring(event_index) .. ':p') == 0 and\n redis.call('HEXISTS', KEYS[4], tostring(event_index) .. ':f') == 0 then\n local pin_key = ARGV[2] .. tostring(event_index)\n if redis.call('SADD', pin_key, ref) == 1 then\n redis.call('HINCRBY', KEYS[5], ref, 1)\n end\n end\n end\n writes[#writes + 1] = { index = index, snapshot = snapshot, ref = ref }\n offset = offset + 3 + pin_count\nend\n\nlocal head = redis.call('GET', KEYS[1])\nlocal head_index = nil\nif head then head_index = tonumber(cjson.decode(head)['index']) end\nlocal latest = writes[#writes]\nlocal writes_head = not head_index or latest.index >= head_index\nlocal advances = not head_index or latest.index > head_index\nlocal final_head_index = advances and latest.index or head_index\n\nif advances and head and tonumber(redis.call('HGET', KEYS[5], cjson.encode({ KEYS[2], tostring(head_index) })) or '0') > 0 then\n redis.call('ZREMRANGEBYSCORE', KEYS[2], head_index, head_index)\n redis.call('ZADD', KEYS[2], head_index, head)\nend\n\nfor _, write in ipairs(writes) do\n if write.index < final_head_index then\n redis.call('ZREMRANGEBYSCORE', KEYS[2], write.index, write.index)\n if tonumber(redis.call('HGET', KEYS[5], write.ref) or '0') > 0 then\n redis.call('ZADD', KEYS[2], write.index, write.snapshot)\n end\n end\nend\n\nif writes_head then\n redis.call('SET', KEYS[1], latest.snapshot)\n redis.call('ZREMRANGEBYSCORE', KEYS[2], latest.index, latest.index)\nend\nreturn 1\n`\n\n/**\n * KEYS: presence hash. ARGV: atMs, ttlMs, n, then per entry:\n * hashField, valueJson ('' deletes). Field-wise LWW by the sender's\n * `at`: a stored entry with a strictly newer stamp survives; ties go\n * to the incoming write (memory-tier parity). Returns the applied\n * 1-based entry positions. The whole-key PEXPIRE is the coarse bound,\n * sound because core sends one uniform ttlMs; the per-entry `e` stamp\n * is the fine bound (the injected storage clock at the applied set\n * plus ttlMs — never the sender stamp), pruned on read against the\n * same clock.\n */\nconst PRESENCE_SET_LUA = `\nlocal at = tonumber(ARGV[1])\nlocal applied = {}\nfor i = 1, tonumber(ARGV[3]) do\n local field = ARGV[3 + (i - 1) * 2 + 1]\n local value = ARGV[3 + (i - 1) * 2 + 2]\n local existing = redis.call('HGET', KEYS[1], field)\n if not existing or tonumber(cjson.decode(existing)['a']) <= at then\n if value == '' then\n redis.call('HDEL', KEYS[1], field)\n else\n redis.call('HSET', KEYS[1], field, value)\n end\n applied[#applied + 1] = tostring(i)\n end\nend\nif #applied > 0 then redis.call('PEXPIRE', KEYS[1], ARGV[2]) end\nreturn applied\n`\n\n/**\n * KEYS: presence hash. ARGV: nowMs (the injected clock, so tests can\n * time-travel expiry against a real server). Returns the live\n * [field, value, ...] pairs; entries at or past their fine-bound\n * expiry are HDELed in the same atomic step, so a stale reader can\n * never sweep a concurrently refreshed field.\n */\nconst PRESENCE_READ_LUA = `\nlocal now = tonumber(ARGV[1])\nlocal rows = redis.call('HGETALL', KEYS[1])\nlocal live = {}\nfor i = 1, #rows, 2 do\n if tonumber(cjson.decode(rows[i + 1])['e']) <= now then\n redis.call('HDEL', KEYS[1], rows[i])\n else\n live[#live + 1] = rows[i]\n live[#live + 1] = rows[i + 1]\n end\nend\nreturn live\n`\n\n/**\n * KEYS: head, history, event counter. ARGV: snapshot frontier or ''.\n * Returns one atomic multiversion-cache-plus-log-frontier read.\n */\nconst READ_STATE_LUA = `\nlocal head = redis.call('GET', KEYS[1])\nlocal cap = ARGV[1]\nlocal snapshot = nil\nif head and (cap == '' or tonumber(cjson.decode(head)['index']) <= tonumber(cap)) then\n snapshot = head\nend\nlocal upper = cap == '' and '+inf' or cap\nlocal historical = redis.call('ZREVRANGEBYSCORE', KEYS[2], upper, '-inf', 'LIMIT', 0, 1)\nif #historical > 0 then\n if not snapshot or tonumber(cjson.decode(historical[1])['index']) > tonumber(cjson.decode(snapshot)['index']) then\n snapshot = historical[1]\n end\nend\nlocal frontier = redis.call('GET', KEYS[3]) or '0'\nreturn { head or '', snapshot or '', frontier }\n`\n\n// ── wire helpers ─────────────────────────────────────────────────────\n\ntype StreamEntry = [id: string, fields: string[]]\n\nconst fieldMap = (fields: string[]): Record<string, string> => {\n const map: Record<string, string> = {}\n for (let i = 0; i + 1 < fields.length; i += 2) {\n map[fields[i]!] = fields[i + 1]!\n }\n return map\n}\n\nconst STORED_EVENT_META_SUFFIXES = [\n 'p',\n 'pa',\n 'ri',\n 'ch',\n 'ce',\n 'f',\n 'fc',\n 'lc',\n 'lf',\n 'lfa',\n 'd',\n 'a',\n 'e',\n] as const\n\nconst entryIndex = (entryId: string): number =>\n Number(entryId.slice(0, entryId.indexOf('-')))\n\nconst toEvent = (sessionId: string, entry: StreamEntry): Event => {\n const fields = fieldMap(entry[1])\n return {\n id: fields['id']!,\n type: fields['type']!,\n payload: JSON.parse(fields['payload']!),\n index: entryIndex(entry[0]),\n sessionId,\n createdAt: new Date(Number(fields['created_at'])),\n }\n}\n\nconst json = (value: unknown): string => JSON.stringify(value) ?? 'null'\n\nconst toCause = (raw: string | undefined): EventCause | null => {\n if (raw === '' || raw === undefined) return null\n const value = JSON.parse(raw) as {\n index?: unknown\n attempt?: unknown\n batchSize?: unknown\n }\n if (\n !Number.isInteger(value.index) ||\n Number(value.index) < 1 ||\n !Number.isInteger(value.attempt) ||\n Number(value.attempt) < 1\n ) {\n throw new TypeError('stored event has an invalid cause')\n }\n if (\n value.batchSize !== undefined &&\n (!Number.isInteger(value.batchSize) || Number(value.batchSize) < 1)\n ) {\n throw new TypeError('stored event has an invalid cause')\n }\n return {\n index: Number(value.index),\n attempt: Number(value.attempt),\n ...(value.batchSize === undefined\n ? {}\n : { batchSize: Number(value.batchSize) }),\n }\n}\n\nconst toClaimAvailableResult = (\n sessionId: string,\n reply: unknown[],\n): StoreClaimAvailableResult => {\n const outcome = String(reply[0])\n if (outcome === 'claimed') {\n const count = Number(reply[1])\n const events: StoredEvent[] = []\n for (let i = 0; i < count; i++) {\n const offset = 2 + i * 10\n const entry = reply[offset + 9] as StreamEntry\n const event = toEvent(sessionId, entry)\n const fields = fieldMap(entry[1])\n events.push(\n Object.assign(event, {\n cause: toCause(fields['cause']),\n lane: fields['lane'] === '' ? null : fields['lane']!,\n processedAt: null,\n processedByAttempt: null,\n returnedEventIds: null,\n firstClaimedAt:\n reply[offset + 3] === null\n ? null\n : new Date(Number(reply[offset + 3])),\n lastClaimedAt: new Date(Number(reply[offset + 4])),\n attemptCount: Number(reply[offset]),\n claimHolder: String(reply[offset + 7]),\n claimExpiresAt: new Date(Number(reply[offset + 8])),\n failureCount: Number(reply[offset + 1]),\n lastFailedAt:\n reply[offset + 5] === null\n ? null\n : new Date(Number(reply[offset + 5])),\n lastFailedAttempt:\n reply[offset + 6] === null ? null : Number(reply[offset + 6]),\n lastError:\n reply[offset + 2] === null ? null : String(reply[offset + 2]),\n failedAt: null,\n }),\n )\n }\n return {\n outcome: 'claimed',\n events,\n }\n }\n if (outcome === 'busy') {\n return { outcome, retryAt: new Date(Number(reply[1])) }\n }\n if (outcome === 'settled') return { outcome }\n throw new TypeError(\n `claim referenced a missing event in session '${sessionId}'`,\n )\n}\n\nconst wrap = async <T>(fn: () => Promise<T>): Promise<T> => {\n try {\n return await fn()\n } catch (err) {\n if (err instanceof A2Error || err instanceof TypeError) throw err\n throw new A2Error('STORE_UNAVAILABLE', 'redis store operation failed', {\n cause: err,\n })\n }\n}\n\nexport function createRedisStoreCore(\n options: RedisStoreCoreOptions,\n): RedisStoreCore {\n const { call, clock, notify } = options\n const generateId = options.ids\n const prefix = options.keyPrefix\n\n const storeKey = (s: string): string => `${prefix}:${s}:log`\n const countKey = (s: string): string => `${prefix}:${s}:count`\n const metaKey = (s: string): string => `${prefix}:${s}:meta`\n const pendingKey = (s: string): string => `${prefix}:${s}:pending`\n const readyKey = (s: string): string => `${prefix}:${s}:ready`\n const snapKey = (s: string, r: string): string => `${prefix}:${s}:snap:${r}`\n const snapHistoryKey = (s: string, r: string): string =>\n `${prefix}:${s}:snap-history:${r}`\n const snapshotEventPinsKey = (s: string, index: number): string =>\n `${prefix}:${s}:snapshot-pins:${index}`\n const snapshotEventPinsPrefix = (s: string): string =>\n `${prefix}:${s}:snapshot-pins:`\n const snapshotPinCountsKey = (s: string): string =>\n `${prefix}:${s}:snapshot-pin-counts`\n const watchedKey = (s: string): string => `${prefix}:${s}:watched`\n const presenceKey = (s: string): string => `${prefix}:${s}:presence`\n const idsKey = `${prefix}:ids`\n\n const evalScript = (\n script: string,\n keys: string[],\n args: Array<string | number>,\n ): Promise<unknown> => call('EVAL', script, keys.length, ...keys, ...args)\n\n const readFrontier = async (sessionId: string): Promise<number> => {\n const frontier = Number((await call('GET', countKey(sessionId))) ?? 0)\n if (!Number.isSafeInteger(frontier) || frontier < 0) {\n throw new A2Error(\n 'STORE_UNAVAILABLE',\n `redis event count for session '${sessionId}' is invalid`,\n )\n }\n return frontier\n }\n\n const readRangePage = async (\n sessionId: string,\n afterIndex: number,\n throughIndex: number,\n limit: number,\n ): Promise<StoredEvent[]> => {\n if (throughIndex <= afterIndex) return []\n const entries = (await call(\n 'XRANGE',\n storeKey(sessionId),\n `${afterIndex + 1}-0`,\n `${throughIndex}-0`,\n 'COUNT',\n limit,\n )) as StreamEntry[]\n if (entries.length === 0) return []\n\n const metaFields = entries.flatMap((entry) => {\n const index = entryIndex(entry[0])\n return STORED_EVENT_META_SUFFIXES.map((suffix) => `${index}:${suffix}`)\n })\n const metaValues =\n ((await call('HMGET', metaKey(sessionId), ...metaFields)) as Array<\n string | null\n > | null) ?? []\n const meta: Record<string, string> = {}\n for (let i = 0; i < metaFields.length; i += 1) {\n const value = metaValues[i]\n if (value !== null && value !== undefined) {\n meta[metaFields[i]!] = value\n }\n }\n return entries.map((entry) => {\n const event = toEvent(sessionId, entry)\n const idx = event.index\n const fields = fieldMap(entry[1])\n const processedAt = meta[`${idx}:p`]\n const processedByAttempt = meta[`${idx}:pa`]\n const returnedEventIds = meta[`${idx}:ri`]\n const claimHolder = meta[`${idx}:ch`]\n const claimExpiresAt = meta[`${idx}:ce`]\n const failedAt = meta[`${idx}:f`]\n const firstClaimedAt = meta[`${idx}:fc`]\n const lastClaimedAt = meta[`${idx}:lc`]\n const lastFailedAt = meta[`${idx}:lf`]\n const lastFailedAttempt = meta[`${idx}:lfa`]\n return Object.assign(event, {\n cause: toCause(fields['cause']),\n lane: fields['lane'] === '' ? null : fields['lane']!,\n processedAt:\n processedAt === undefined ? null : new Date(Number(processedAt)),\n processedByAttempt:\n processedByAttempt === undefined ? null : Number(processedByAttempt),\n returnedEventIds:\n returnedEventIds === undefined\n ? null\n : decodeReturnedEventIds(returnedEventIds),\n firstClaimedAt:\n firstClaimedAt === undefined\n ? null\n : new Date(Number(firstClaimedAt)),\n lastClaimedAt:\n lastClaimedAt === undefined ? null : new Date(Number(lastClaimedAt)),\n attemptCount: Number(meta[`${idx}:d`] ?? 0),\n claimHolder: claimHolder ?? null,\n claimExpiresAt:\n claimExpiresAt === undefined\n ? null\n : new Date(Number(claimExpiresAt)),\n failureCount: Number(meta[`${idx}:a`] ?? 0),\n lastFailedAt:\n lastFailedAt === undefined ? null : new Date(Number(lastFailedAt)),\n lastFailedAttempt:\n lastFailedAttempt === undefined ? null : Number(lastFailedAttempt),\n lastError: meta[`${idx}:e`] ?? null,\n failedAt: failedAt === undefined ? null : new Date(Number(failedAt)),\n })\n })\n }\n\n const readEventRange = async (\n sessionId: string,\n afterIndex: number,\n throughIndex: number,\n ): Promise<Event[]> => {\n if (throughIndex <= afterIndex) return []\n const events: Event[] = []\n let nextIndex = afterIndex + 1\n while (nextIndex <= throughIndex) {\n // oxlint-disable-next-line no-await-in-loop -- each page advances the dense frozen range\n const entries = (await call(\n 'XRANGE',\n storeKey(sessionId),\n `${nextIndex}-0`,\n `${throughIndex}-0`,\n 'COUNT',\n READ_PAGE_SIZE,\n )) as StreamEntry[]\n if (entries.length === 0) {\n throw new A2Error(\n 'STORE_UNAVAILABLE',\n `redis event range for session '${sessionId}' ended before index ${throughIndex}`,\n )\n }\n for (let position = 0; position < entries.length; position += 1) {\n const index = entryIndex(entries[position]![0])\n if (index !== nextIndex + position || index > throughIndex) {\n throw new A2Error(\n 'STORE_UNAVAILABLE',\n `redis event range for session '${sessionId}' is not contiguous`,\n )\n }\n }\n events.push(...entries.map((entry) => toEvent(sessionId, entry)))\n nextIndex = events.at(-1)!.index + 1\n }\n return events\n }\n\n /** Bounded XRANGE pages restore the full-read contract on capped providers. */\n const readRange = async (\n sessionId: string,\n afterIndex: number,\n throughIndex?: number,\n ): Promise<StoredEvent[]> => {\n if (throughIndex !== undefined && throughIndex <= afterIndex) return []\n const currentFrontier = await readFrontier(sessionId)\n const frontier = Math.min(throughIndex ?? currentFrontier, currentFrontier)\n if (frontier <= afterIndex) return []\n\n const rows: StoredEvent[] = []\n let nextIndex = afterIndex + 1\n while (nextIndex <= frontier) {\n // oxlint-disable-next-line no-await-in-loop -- each page advances the dense frozen range\n const page = await readRangePage(\n sessionId,\n nextIndex - 1,\n frontier,\n READ_PAGE_SIZE,\n )\n if (page.length === 0) {\n throw new A2Error(\n 'STORE_UNAVAILABLE',\n `redis event range for session '${sessionId}' ended before index ${frontier}`,\n )\n }\n for (let position = 0; position < page.length; position += 1) {\n if (page[position]!.index !== nextIndex + position) {\n throw new A2Error(\n 'STORE_UNAVAILABLE',\n `redis event range for session '${sessionId}' is not contiguous`,\n )\n }\n }\n const lastIndex = page.at(-1)!.index\n if (lastIndex < nextIndex || lastIndex > frontier) {\n throw new A2Error(\n 'STORE_UNAVAILABLE',\n `redis event range for session '${sessionId}' did not advance`,\n )\n }\n rows.push(...page)\n nextIndex = lastIndex + 1\n }\n return rows\n }\n\n return {\n async append(sessionId, events) {\n return wrap(async () => {\n const supplied = events.filter((e) => e.id !== undefined)\n const suppliedIds = supplied.map((e) => e.id as string)\n if (new Set(suppliedIds).size !== suppliedIds.length) {\n throw new A2Error(\n 'PARTIAL_DUPLICATE_BATCH',\n 'batch contains the same event id more than once',\n )\n }\n const now = clock.now().getTime()\n const withIds = events.map((e) => ({\n id: e.id ?? generateId(),\n type: e.type,\n payload: e.payload,\n cause: e.cause ? { ...e.cause } : null,\n lane: e.lane ?? null,\n settled: e.settled === true,\n }))\n const args: Array<string | number> = [\n sessionId,\n now,\n withIds.length,\n ...withIds.flatMap((e) => [\n e.id,\n e.type,\n json(e.payload),\n e.cause ? json(e.cause) : '',\n e.lane ?? '',\n e.settled ? '1' : '0',\n ]),\n ]\n const reply = (await evalScript(\n APPEND_LUA,\n [\n storeKey(sessionId),\n countKey(sessionId),\n idsKey,\n metaKey(sessionId),\n pendingKey(sessionId),\n readyKey(sessionId),\n watchedKey(sessionId),\n ],\n args,\n )) as string[]\n\n const [verdict] = reply\n if (verdict === 'empty') {\n return { events: [], hasPending: reply[1] === '1' }\n }\n if (verdict === 'foreign') {\n throw new A2Error(\n 'PARTIAL_DUPLICATE_BATCH',\n `event id '${reply[1]}' already exists in another session`,\n )\n }\n if (verdict === 'no_parent') {\n throw new TypeError(\n `no event at index ${reply[1]} in session '${sessionId}'`,\n )\n }\n if (verdict === 'superseded') {\n throw new A2Error(\n 'SUPERSEDED_ATTEMPT',\n `a newer attempt owns event ${reply[1]} in session '${sessionId}'`,\n )\n }\n if (verdict === 'partial') {\n throw new A2Error(\n 'PARTIAL_DUPLICATE_BATCH',\n `batch mixes ${reply[1]} already-appended and ${events.length - Number(reply[1])} fresh events`,\n )\n }\n if (verdict === 'dup') {\n // A retry of a committed batch whose ack was lost — return\n // the original rows, ordered by index like every adapter.\n const hasPending = reply[1] === '1'\n const wanted = new Set(reply.slice(2).map(Number))\n const min = Math.min(...wanted)\n const rows = await readRange(sessionId, min - 1)\n const existing = rows.filter((row) => wanted.has(row.index))\n return {\n events: idempotentReplay(events, existing),\n hasPending,\n }\n }\n const base = Number(reply[1]) - 1\n if (reply[3] === '1') notify?.(sessionId, base + withIds.length)\n return {\n events: withIds.map((e, i) => ({\n id: e.id,\n type: e.type,\n payload: structuredClone(e.payload),\n index: base + 1 + i,\n sessionId,\n createdAt: new Date(now),\n cause: e.cause,\n lane: e.lane,\n processedAt: e.settled ? new Date(now) : null,\n processedByAttempt: null,\n returnedEventIds: null,\n firstClaimedAt: null,\n lastClaimedAt: null,\n attemptCount: 0,\n claimHolder: null,\n claimExpiresAt: null,\n failureCount: 0,\n lastFailedAt: null,\n lastFailedAttempt: null,\n lastError: null,\n failedAt: null,\n })),\n hasPending: reply[2] === '1',\n }\n })\n },\n\n async read(sessionId, opts) {\n return wrap(async () => {\n const rows = await readRange(\n sessionId,\n opts?.afterIndex ?? 0,\n opts?.throughIndex,\n )\n return rows\n })\n },\n\n async claimAvailable({\n sessionId,\n holder,\n ttlMs,\n expiresAtMs,\n excludeIndexes,\n }) {\n return wrap(async () => {\n const now = clock.now().getTime()\n const expiresAt = expiresAtMs ?? now + ttlMs\n const excluded = excludeIndexes ?? []\n const reply = (await evalScript(\n CLAIM_AVAILABLE_LUA,\n [\n storeKey(sessionId),\n countKey(sessionId),\n metaKey(sessionId),\n pendingKey(sessionId),\n readyKey(sessionId),\n ],\n [holder, now, expiresAt, excluded.length, ...excluded],\n )) as unknown[]\n return toClaimAvailableResult(sessionId, reply)\n })\n },\n\n async renewClaims({ sessionId, holder, claims, ttlMs, expiresAtMs }) {\n return wrap(async () => {\n const now = clock.now().getTime()\n const expiresAt = expiresAtMs ?? now + ttlMs\n const reply = (await evalScript(\n RENEW_CLAIMS_LUA,\n [countKey(sessionId), metaKey(sessionId)],\n [\n holder,\n now,\n expiresAt,\n claims.length,\n ...claims.flatMap((claim) => [claim.index, claim.attempt]),\n ],\n )) as string[]\n const renewedCount = Number(reply[0])\n const values = reply.slice(1).map(Number)\n return {\n renewed: values.slice(0, renewedCount).toSorted((a, b) => a - b),\n superseded: values.slice(renewedCount).toSorted((a, b) => a - b),\n }\n })\n },\n\n async completeAttempt({ sessionId, index, attempt, events }) {\n return wrap(async () => {\n const ids = events.map((event) => event.id)\n if (new Set(ids).size !== ids.length) {\n throw new A2Error(\n 'PARTIAL_DUPLICATE_BATCH',\n 'batch contains the same event id more than once',\n )\n }\n const now = clock.now().getTime()\n const reply = (await evalScript(\n COMPLETE_ATTEMPT_LUA,\n [\n storeKey(sessionId),\n countKey(sessionId),\n idsKey,\n metaKey(sessionId),\n pendingKey(sessionId),\n readyKey(sessionId),\n watchedKey(sessionId),\n snapshotEventPinsKey(sessionId, index),\n snapshotPinCountsKey(sessionId),\n ],\n [\n sessionId,\n now,\n index,\n attempt,\n events.length,\n ...events.flatMap((event) => [\n event.id,\n event.type,\n json(event.payload),\n event.lane ?? '',\n event.settled === true ? '1' : '0',\n ]),\n ],\n )) as string[]\n const outcome = reply[0]\n if (outcome === 'missing') {\n throw new TypeError(\n `no event at index ${index} in session '${sessionId}'`,\n )\n }\n if (outcome === 'partial') {\n throw new A2Error(\n 'PARTIAL_DUPLICATE_BATCH',\n 'returned event ids do not match the completed attempt',\n )\n }\n if (outcome === 'superseded') return { outcome }\n\n const cause = { index, attempt }\n if (outcome === 'ok') {\n const base = Number(reply[1]) - 1\n if (events.length > 0 && reply[2] === '1') {\n notify?.(sessionId, base + events.length)\n }\n return {\n outcome: 'completed',\n events: events.map((event, i) => ({\n id: event.id,\n type: event.type,\n payload: structuredClone(event.payload),\n index: base + 1 + i,\n sessionId,\n createdAt: new Date(now),\n cause,\n lane: event.lane ?? null,\n processedAt: event.settled === true ? new Date(now) : null,\n processedByAttempt: null,\n returnedEventIds: null,\n firstClaimedAt: null,\n lastClaimedAt: null,\n attemptCount: 0,\n claimHolder: null,\n claimExpiresAt: null,\n failureCount: 0,\n lastFailedAt: null,\n lastFailedAttempt: null,\n lastError: null,\n failedAt: null,\n })),\n } satisfies CompleteAttemptResult\n }\n if (outcome === 'duplicate') {\n if (events.length === 0) {\n return { outcome: 'completed', events: [] }\n }\n const wanted = new Set(reply.slice(1).map(Number))\n const min = Math.min(...wanted)\n const rows = await readRange(sessionId, min - 1)\n const byIndex = new Map(rows.map((row) => [row.index, row]))\n const existing = reply\n .slice(1)\n .map((value) => byIndex.get(Number(value))!)\n return {\n outcome: 'completed',\n events: idempotentReplay(events, existing),\n }\n }\n throw new TypeError(`unexpected completion outcome '${outcome}'`)\n })\n },\n\n async failAttempt({ sessionId, index, attempt, error, maxFailures }) {\n return wrap(async () => {\n const reply = (await evalScript(\n FAIL_ATTEMPT_LUA,\n [\n countKey(sessionId),\n metaKey(sessionId),\n pendingKey(sessionId),\n readyKey(sessionId),\n snapshotEventPinsKey(sessionId, index),\n snapshotPinCountsKey(sessionId),\n ],\n [index, attempt, error, maxFailures, clock.now().getTime()],\n )) as string[]\n const outcome = reply[0]\n if (outcome === 'missing') {\n throw new TypeError(\n `no event at index ${index} in session '${sessionId}'`,\n )\n }\n return {\n outcome: outcome as 'failed' | 'dead_lettered' | 'superseded',\n failureCount: Number(reply[1]),\n }\n })\n },\n\n async readState(sessionId, reducerName, stateOptions) {\n return wrap(async () => {\n const reply = (await evalScript(\n READ_STATE_LUA,\n [\n snapKey(sessionId, reducerName),\n snapHistoryKey(sessionId, reducerName),\n countKey(sessionId),\n ],\n [\n stateOptions?.snapshotThroughIndex ??\n stateOptions?.throughIndex ??\n '',\n ],\n )) as [string, string, string]\n const rawHead = reply[0]\n const raw = reply[1]\n const head =\n rawHead === ''\n ? null\n : (JSON.parse(rawHead) as { index: number; state: unknown })\n const parsed =\n raw === ''\n ? null\n : (JSON.parse(raw) as { index: number; state: unknown })\n const currentFrontier = Number(reply[2])\n if (\n !Number.isSafeInteger(currentFrontier) ||\n currentFrontier < 0 ||\n (head !== null &&\n (!Number.isSafeInteger(head.index) || head.index < 0)) ||\n (parsed !== null &&\n (!Number.isSafeInteger(parsed.index) || parsed.index < 0))\n ) {\n throw new A2Error(\n 'STORE_UNAVAILABLE',\n `redis state frontier for session '${sessionId}' is invalid`,\n )\n }\n const frontier = Math.min(\n currentFrontier,\n stateOptions?.throughIndex ?? currentFrontier,\n )\n const snapshot = parsed\n const events = await readEventRange(\n sessionId,\n snapshot?.index ?? 0,\n frontier,\n )\n return {\n headIndex: head?.index ?? null,\n snapshot: snapshot\n ? { index: snapshot.index, state: snapshot.state }\n : null,\n events,\n }\n })\n },\n\n async putSnapshots(sessionId, reducerName, writes) {\n const byIndex = new Map<\n number,\n { index: number; state: unknown; pinEventIndexes: Set<number> }\n >()\n for (const write of writes) {\n const existing = byIndex.get(write.index)\n if (existing) {\n existing.state = write.state\n for (const eventIndex of write.pinEventIndexes ?? []) {\n existing.pinEventIndexes.add(eventIndex)\n }\n } else {\n byIndex.set(write.index, {\n index: write.index,\n state: write.state,\n pinEventIndexes: new Set(write.pinEventIndexes ?? []),\n })\n }\n }\n const normalized = [...byIndex.values()].toSorted(\n (a, b) => a.index - b.index,\n )\n if (normalized.length === 0) return\n await wrap(async () => {\n const args: Array<string | number> = [\n clock.now().getTime(),\n snapshotEventPinsPrefix(sessionId),\n normalized.length,\n ]\n for (const write of normalized) {\n args.push(\n write.index,\n json({\n index: write.index,\n state: write.state,\n updatedAt: clock.now().getTime(),\n }),\n write.pinEventIndexes.size,\n ...write.pinEventIndexes,\n )\n }\n await evalScript(\n PUT_SNAPSHOTS_LUA,\n [\n snapKey(sessionId, reducerName),\n snapHistoryKey(sessionId, reducerName),\n countKey(sessionId),\n metaKey(sessionId),\n snapshotPinCountsKey(sessionId),\n ],\n args,\n )\n })\n },\n\n presence: {\n async set(ns, participant, values, meta) {\n return wrap(async () => {\n const entries = Object.entries(values)\n if (entries.length === 0) return null\n const atMs = meta.at.getTime()\n // Expiry anchors on the injected storage clock — the sender's\n // `a` stamp orders writes, the `e` stamp bounds their\n // lifetime, and the two clocks never mix.\n const expiresAtMs = clock.now().getTime() + meta.ttlMs\n // The hash field is JSON `[participant, field]`: both are\n // caller strings, so no separator character is\n // collision-safe — the JSON array is.\n const applied = (await evalScript(\n PRESENCE_SET_LUA,\n [presenceKey(ns)],\n [\n atMs,\n meta.ttlMs,\n entries.length,\n ...entries.flatMap(([field, value]) => [\n JSON.stringify([participant, field]),\n value === null\n ? ''\n : json({\n v: value,\n s: meta.seen,\n a: atMs,\n e: expiresAtMs,\n }),\n ]),\n ],\n )) as string[]\n if (applied.length === 0) return null\n // Field-keyed and caller-named — null-prototype, like every\n // presence map (see `nullProtoRecord`).\n const appliedValues: Record<string, unknown> = nullProtoRecord()\n for (const position of applied) {\n const [field, value] = entries[Number(position) - 1]!\n appliedValues[field] = structuredClone(value)\n }\n return {\n participant,\n values: appliedValues,\n seen: meta.seen,\n at: new Date(atMs),\n }\n })\n },\n\n async read(ns) {\n return wrap(async () => {\n const reply = (await evalScript(\n PRESENCE_READ_LUA,\n [presenceKey(ns)],\n [clock.now().getTime()],\n )) as string[]\n const rows: PresenceRow[] = []\n for (let i = 0; i + 1 < reply.length; i += 2) {\n const [participant, field] = JSON.parse(reply[i]!) as [\n string,\n string,\n ]\n const entry = JSON.parse(reply[i + 1]!) as {\n v: unknown\n s: number\n a: number\n e: number\n }\n rows.push({\n participant,\n field,\n value: entry.v,\n seen: entry.s,\n at: new Date(entry.a),\n expiresAt: new Date(entry.e),\n })\n }\n return rows\n })\n },\n },\n\n async readRange(sessionId, afterIndex, throughIndex) {\n return wrap(() => readRange(sessionId, afterIndex, throughIndex))\n },\n\n async readEvents(sessionId, afterIndex) {\n return wrap(async () => {\n const entries = (await call(\n 'XRANGE',\n storeKey(sessionId),\n `${afterIndex + 1}-0`,\n '+',\n )) as StreamEntry[]\n return entries.map((entry) => toEvent(sessionId, entry))\n })\n },\n\n async markWatched(sessionId, ttlMs) {\n await wrap(() => call('SET', watchedKey(sessionId), '1', 'PX', ttlMs))\n },\n }\n}\n"],"mappings":"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"}
@@ -1,4 +1,4 @@
1
- import { r as Clock, s as IdSource, t as A2Store } from "./store-flRz1OWh.js";
1
+ import { r as Clock, s as IdSource, t as A2Store } from "./store-RJO35BMj.js";
2
2
  //#region src/store-redis-http.d.ts
3
3
  type RedisHttpStoreOptions = {
4
4
  /** REST endpoint, e.g. `process.env.UPSTASH_REDIS_REST_URL`. */
@@ -1,7 +1,7 @@
1
1
  import { t as A2Error } from "./errors-BQuJpe82.js";
2
2
  import { n as pollingStream } from "./store-polling-6DW7F1DT.js";
3
3
  import { n as SYSTEM_CLOCK, t as RANDOM_IDS } from "./store-N8PXxDAS.js";
4
- import { t as createRedisStoreCore } from "./store-redis-core-DEYO8Ryv.js";
4
+ import { t as createRedisStoreCore } from "./store-redis-core-DT01r4GZ.js";
5
5
  //#region src/store-redis-http.ts
6
6
  /**
7
7
  * experimental-a2/store-redis-http — the same Redis Streams store over a
@@ -52,7 +52,7 @@ function redisHttp(options) {
52
52
  completeAttempt: core.completeAttempt,
53
53
  failAttempt: core.failAttempt,
54
54
  readState: core.readState,
55
- putSnapshot: core.putSnapshot,
55
+ putSnapshots: core.putSnapshots,
56
56
  presence: {
57
57
  async set(ns, participant, values, meta) {
58
58
  await core.presence.set(ns, participant, values, meta);
@@ -1 +1 @@
1
- {"version":3,"file":"store-redis-http.js","names":[],"sources":["../src/store-redis-http.ts"],"sourcesContent":["/**\n * experimental-a2/store-redis-http — the same Redis Streams store over a\n * provider REST API (Upstash-shaped: POST one command as a JSON array,\n * receive `{ result }` or `{ error }`). The storage semantics live in\n * store-redis-core.ts, shared with experimental-a2/store-redis.\n *\n * Fully connectionless: every command is one `fetch`, so it works\n * where a Redis-protocol connection cannot exist or cannot be spared.\n * Without a connection there is no push and no pub/sub, so `stream()`\n * is the shared activity-adaptive poll loop (25ms while events flow,\n * backing off to a 250ms idle ceiling), and presence is the degraded\n * tier: `set`/`read` from the shared core, no `subscribe`. No peer\n * dependencies.\n */\n\nimport { A2Error } from './errors.ts'\nimport { pollingStream } from './store-polling.ts'\nimport { createRedisStoreCore } from './store-redis-core.ts'\nimport {\n RANDOM_IDS,\n SYSTEM_CLOCK,\n type A2Store,\n type Clock,\n type IdSource,\n} from './store.ts'\n\nexport type RedisHttpStoreOptions = {\n /** REST endpoint, e.g. `process.env.UPSTASH_REDIS_REST_URL`. */\n url: string\n /** Bearer token, e.g. `process.env.UPSTASH_REDIS_REST_TOKEN`. */\n token: string\n /** Key prefix — isolates multiple apps on one Redis. Default `'a2'`. */\n keyPrefix?: string\n /** Injectable clock — every stored timestamp comes from here. */\n clock?: Clock\n /** Injectable id source for generated event ids. */\n ids?: IdSource\n}\n\nexport type RedisHttpStore = A2Store & {\n /** Nothing to disconnect; present so callers can treat stores uniformly. */\n close(): Promise<void>\n}\n\nexport function redisHttp(options: RedisHttpStoreOptions): RedisHttpStore {\n if (!options.url || !options.token) {\n throw new TypeError('redisHttp() needs a url and a token')\n }\n const { url, token } = options\n\n const call = async (\n command: string,\n ...args: Array<string | number>\n ): Promise<unknown> => {\n let response: Response\n try {\n response = await fetch(url, {\n method: 'POST',\n headers: {\n authorization: `Bearer ${token}`,\n 'content-type': 'application/json',\n },\n body: JSON.stringify([command, ...args.map(String)]),\n })\n } catch (err) {\n throw new A2Error('STORE_UNAVAILABLE', 'redis rest request failed', {\n cause: err,\n })\n }\n const body = (await response.json().catch(() => null)) as {\n result?: unknown\n error?: string\n } | null\n if (body === null || !response.ok || body.error !== undefined) {\n throw new A2Error(\n 'STORE_UNAVAILABLE',\n `redis rest command failed: ${body?.error ?? `status ${response.status}`}`,\n )\n }\n return body.result\n }\n\n const core = createRedisStoreCore({\n call,\n clock: options.clock ?? SYSTEM_CLOCK,\n ids: options.ids ?? RANDOM_IDS,\n keyPrefix: options.keyPrefix ?? 'a2',\n })\n\n return {\n append: core.append,\n read: core.read,\n claimAvailable: core.claimAvailable,\n renewClaims: core.renewClaims,\n completeAttempt: core.completeAttempt,\n failAttempt: core.failAttempt,\n readState: core.readState,\n putSnapshot: core.putSnapshot,\n presence: {\n async set(ns, participant, values, meta) {\n await core.presence.set(ns, participant, values, meta)\n },\n read: core.presence.read,\n },\n\n stream(sessionId, opts) {\n return pollingStream(\n (afterIndex) => core.readRange(sessionId, afterIndex),\n opts?.startAfter !== undefined ? { startAfter: opts.startAfter } : {},\n )\n },\n\n async close() {},\n }\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;AA4CA,SAAgB,UAAU,SAAgD;CACxE,IAAI,CAAC,QAAQ,OAAO,CAAC,QAAQ,OAC3B,MAAM,IAAI,UAAU,qCAAqC;CAE3D,MAAM,EAAE,KAAK,UAAU;CAEvB,MAAM,OAAO,OACX,SACA,GAAG,SACkB;EACrB,IAAI;EACJ,IAAI;GACF,WAAW,MAAM,MAAM,KAAK;IAC1B,QAAQ;IACR,SAAS;KACP,eAAe,UAAU;KACzB,gBAAgB;IAClB;IACA,MAAM,KAAK,UAAU,CAAC,SAAS,GAAG,KAAK,IAAI,MAAM,CAAC,CAAC;GACrD,CAAC;EACH,SAAS,KAAK;GACZ,MAAM,IAAI,QAAQ,qBAAqB,6BAA6B,EAClE,OAAO,IACT,CAAC;EACH;EACA,MAAM,OAAQ,MAAM,SAAS,KAAK,CAAC,CAAC,YAAY,IAAI;EAIpD,IAAI,SAAS,QAAQ,CAAC,SAAS,MAAM,KAAK,UAAU,KAAA,GAClD,MAAM,IAAI,QACR,qBACA,8BAA8B,MAAM,SAAS,UAAU,SAAS,UAClE;EAEF,OAAO,KAAK;CACd;CAEA,MAAM,OAAO,qBAAqB;EAChC;EACA,OAAO,QAAQ,SAAS;EACxB,KAAK,QAAQ,OAAO;EACpB,WAAW,QAAQ,aAAa;CAClC,CAAC;CAED,OAAO;EACL,QAAQ,KAAK;EACb,MAAM,KAAK;EACX,gBAAgB,KAAK;EACrB,aAAa,KAAK;EAClB,iBAAiB,KAAK;EACtB,aAAa,KAAK;EAClB,WAAW,KAAK;EAChB,aAAa,KAAK;EAClB,UAAU;GACR,MAAM,IAAI,IAAI,aAAa,QAAQ,MAAM;IACvC,MAAM,KAAK,SAAS,IAAI,IAAI,aAAa,QAAQ,IAAI;GACvD;GACA,MAAM,KAAK,SAAS;EACtB;EAEA,OAAO,WAAW,MAAM;GACtB,OAAO,eACJ,eAAe,KAAK,UAAU,WAAW,UAAU,GACpD,MAAM,eAAe,KAAA,IAAY,EAAE,YAAY,KAAK,WAAW,IAAI,CAAC,CACtE;EACF;EAEA,MAAM,QAAQ,CAAC;CACjB;AACF"}
1
+ {"version":3,"file":"store-redis-http.js","names":[],"sources":["../src/store-redis-http.ts"],"sourcesContent":["/**\n * experimental-a2/store-redis-http — the same Redis Streams store over a\n * provider REST API (Upstash-shaped: POST one command as a JSON array,\n * receive `{ result }` or `{ error }`). The storage semantics live in\n * store-redis-core.ts, shared with experimental-a2/store-redis.\n *\n * Fully connectionless: every command is one `fetch`, so it works\n * where a Redis-protocol connection cannot exist or cannot be spared.\n * Without a connection there is no push and no pub/sub, so `stream()`\n * is the shared activity-adaptive poll loop (25ms while events flow,\n * backing off to a 250ms idle ceiling), and presence is the degraded\n * tier: `set`/`read` from the shared core, no `subscribe`. No peer\n * dependencies.\n */\n\nimport { A2Error } from './errors.ts'\nimport { pollingStream } from './store-polling.ts'\nimport { createRedisStoreCore } from './store-redis-core.ts'\nimport {\n RANDOM_IDS,\n SYSTEM_CLOCK,\n type A2Store,\n type Clock,\n type IdSource,\n} from './store.ts'\n\nexport type RedisHttpStoreOptions = {\n /** REST endpoint, e.g. `process.env.UPSTASH_REDIS_REST_URL`. */\n url: string\n /** Bearer token, e.g. `process.env.UPSTASH_REDIS_REST_TOKEN`. */\n token: string\n /** Key prefix — isolates multiple apps on one Redis. Default `'a2'`. */\n keyPrefix?: string\n /** Injectable clock — every stored timestamp comes from here. */\n clock?: Clock\n /** Injectable id source for generated event ids. */\n ids?: IdSource\n}\n\nexport type RedisHttpStore = A2Store & {\n /** Nothing to disconnect; present so callers can treat stores uniformly. */\n close(): Promise<void>\n}\n\nexport function redisHttp(options: RedisHttpStoreOptions): RedisHttpStore {\n if (!options.url || !options.token) {\n throw new TypeError('redisHttp() needs a url and a token')\n }\n const { url, token } = options\n\n const call = async (\n command: string,\n ...args: Array<string | number>\n ): Promise<unknown> => {\n let response: Response\n try {\n response = await fetch(url, {\n method: 'POST',\n headers: {\n authorization: `Bearer ${token}`,\n 'content-type': 'application/json',\n },\n body: JSON.stringify([command, ...args.map(String)]),\n })\n } catch (err) {\n throw new A2Error('STORE_UNAVAILABLE', 'redis rest request failed', {\n cause: err,\n })\n }\n const body = (await response.json().catch(() => null)) as {\n result?: unknown\n error?: string\n } | null\n if (body === null || !response.ok || body.error !== undefined) {\n throw new A2Error(\n 'STORE_UNAVAILABLE',\n `redis rest command failed: ${body?.error ?? `status ${response.status}`}`,\n )\n }\n return body.result\n }\n\n const core = createRedisStoreCore({\n call,\n clock: options.clock ?? SYSTEM_CLOCK,\n ids: options.ids ?? RANDOM_IDS,\n keyPrefix: options.keyPrefix ?? 'a2',\n })\n\n return {\n append: core.append,\n read: core.read,\n claimAvailable: core.claimAvailable,\n renewClaims: core.renewClaims,\n completeAttempt: core.completeAttempt,\n failAttempt: core.failAttempt,\n readState: core.readState,\n putSnapshots: core.putSnapshots,\n presence: {\n async set(ns, participant, values, meta) {\n await core.presence.set(ns, participant, values, meta)\n },\n read: core.presence.read,\n },\n\n stream(sessionId, opts) {\n return pollingStream(\n (afterIndex) => core.readRange(sessionId, afterIndex),\n opts?.startAfter !== undefined ? { startAfter: opts.startAfter } : {},\n )\n },\n\n async close() {},\n }\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;AA4CA,SAAgB,UAAU,SAAgD;CACxE,IAAI,CAAC,QAAQ,OAAO,CAAC,QAAQ,OAC3B,MAAM,IAAI,UAAU,qCAAqC;CAE3D,MAAM,EAAE,KAAK,UAAU;CAEvB,MAAM,OAAO,OACX,SACA,GAAG,SACkB;EACrB,IAAI;EACJ,IAAI;GACF,WAAW,MAAM,MAAM,KAAK;IAC1B,QAAQ;IACR,SAAS;KACP,eAAe,UAAU;KACzB,gBAAgB;IAClB;IACA,MAAM,KAAK,UAAU,CAAC,SAAS,GAAG,KAAK,IAAI,MAAM,CAAC,CAAC;GACrD,CAAC;EACH,SAAS,KAAK;GACZ,MAAM,IAAI,QAAQ,qBAAqB,6BAA6B,EAClE,OAAO,IACT,CAAC;EACH;EACA,MAAM,OAAQ,MAAM,SAAS,KAAK,CAAC,CAAC,YAAY,IAAI;EAIpD,IAAI,SAAS,QAAQ,CAAC,SAAS,MAAM,KAAK,UAAU,KAAA,GAClD,MAAM,IAAI,QACR,qBACA,8BAA8B,MAAM,SAAS,UAAU,SAAS,UAClE;EAEF,OAAO,KAAK;CACd;CAEA,MAAM,OAAO,qBAAqB;EAChC;EACA,OAAO,QAAQ,SAAS;EACxB,KAAK,QAAQ,OAAO;EACpB,WAAW,QAAQ,aAAa;CAClC,CAAC;CAED,OAAO;EACL,QAAQ,KAAK;EACb,MAAM,KAAK;EACX,gBAAgB,KAAK;EACrB,aAAa,KAAK;EAClB,iBAAiB,KAAK;EACtB,aAAa,KAAK;EAClB,WAAW,KAAK;EAChB,cAAc,KAAK;EACnB,UAAU;GACR,MAAM,IAAI,IAAI,aAAa,QAAQ,MAAM;IACvC,MAAM,KAAK,SAAS,IAAI,IAAI,aAAa,QAAQ,IAAI;GACvD;GACA,MAAM,KAAK,SAAS;EACtB;EAEA,OAAO,WAAW,MAAM;GACtB,OAAO,eACJ,eAAe,KAAK,UAAU,WAAW,UAAU,GACpD,MAAM,eAAe,KAAA,IAAY,EAAE,YAAY,KAAK,WAAW,IAAI,CAAC,CACtE;EACF;EAEA,MAAM,QAAQ,CAAC;CACjB;AACF"}
@@ -1,4 +1,4 @@
1
- import { r as Clock, s as IdSource, t as A2Store } from "./store-flRz1OWh.js";
1
+ import { r as Clock, s as IdSource, t as A2Store } from "./store-RJO35BMj.js";
2
2
  //#region src/store-redis.d.ts
3
3
  /**
4
4
  * The minimal client this backend needs — `ioredis` matches it
@@ -3,7 +3,7 @@ import { r as NOTIFY_TIMINGS } from "./internal-DRXJ56EI.js";
3
3
  import { t as defaultSleep } from "./store-polling-6DW7F1DT.js";
4
4
  import { t as retryableLazy } from "./retryable-lazy-DZWmHpii.js";
5
5
  import { n as SYSTEM_CLOCK, t as RANDOM_IDS } from "./store-N8PXxDAS.js";
6
- import { t as createRedisStoreCore } from "./store-redis-core-DEYO8Ryv.js";
6
+ import { t as createRedisStoreCore } from "./store-redis-core-DT01r4GZ.js";
7
7
  //#region src/store-redis.ts
8
8
  function redis(options = {}) {
9
9
  const clock = options.clock ?? SYSTEM_CLOCK;
@@ -163,7 +163,7 @@ function redis(options = {}) {
163
163
  completeAttempt: core.completeAttempt,
164
164
  failAttempt: core.failAttempt,
165
165
  readState: core.readState,
166
- putSnapshot: core.putSnapshot,
166
+ putSnapshots: core.putSnapshots,
167
167
  presence: {
168
168
  async set(ns, participant, values, meta) {
169
169
  const patch = await core.presence.set(ns, participant, values, meta);
@@ -1 +1 @@
1
- {"version":3,"file":"store-redis.js","names":[],"sources":["../src/store-redis.ts"],"sourcesContent":["/**\n * experimental-a2/store-redis — the Redis-protocol store backend, on Redis\n * Streams. The storage semantics live in store-redis-core.ts (shared\n * with experimental-a2/store-redis-http); this module owns the connection and\n * the live feed.\n *\n * `stream()` is notify-driven: writes to watched sessions fire a\n * disposable PUBLISH wake-up (feeds hold a TTL'd presence marker the\n * write script checks, so unwatched sessions cost no wake-up), one\n * shared subscriber connection per backend serves every local feed,\n * and each wake triggers an `XRANGE` catch-up read. The notification\n * only decides when to read, never what — a lost one is healed by a\n * safety re-read (NOTIFY_TIMINGS), so delivery never depends on\n * pub/sub. Connections scale with processes (one command client plus\n * one subscriber), not with concurrent viewers.\n *\n * Works with any Redis-protocol server on a single instance or a\n * non-cluster provider (Upstash — durable by default — Redis, Valkey).\n * Cluster mode is out: the atomic scripts span keys. `ioredis` is an\n * optional peer dependency; pass `url`, or inject any client exposing\n * `call`/`duplicate`/`on`/`disconnect`.\n */\n\nimport type { PresencePatch } from './contract.ts'\nimport { A2Error } from './errors.ts'\nimport { NOTIFY_TIMINGS } from './internal.ts'\nimport { defaultSleep, type Sleeper } from './store-polling.ts'\nimport { createRedisStoreCore } from './store-redis-core.ts'\nimport { retryableLazy } from './retryable-lazy.ts'\nimport {\n RANDOM_IDS,\n SYSTEM_CLOCK,\n type A2Store,\n type Clock,\n type Event,\n type IdSource,\n} from './store.ts'\n\n/**\n * The minimal client this backend needs — `ioredis` matches it\n * structurally. `call` issues any command; `duplicate` opens the one\n * shared subscriber connection; `on` delivers its pub/sub messages.\n *\n * The client must restore its subscriptions after a reconnect\n * (`ioredis` does). One that doesn't stays correct — the safety\n * re-read delivers everything — but every live feed silently degrades\n * to safety-read latency from that point on.\n */\nexport type RedisConnection = {\n call(command: string, ...args: Array<string | number>): Promise<unknown>\n duplicate(): RedisConnection\n on(\n event: 'message',\n listener: (channel: string, message: string) => void,\n ): unknown\n disconnect(): void\n}\n\nexport type RedisStoreOptions = {\n /** Creates an `ioredis` client lazily (optional peer dep `ioredis`). */\n url?: string | undefined\n /** Bring your own client — anything `call`/`duplicate`/`on`/`disconnect`. */\n client?: RedisConnection\n /** Key prefix — isolates multiple apps on one Redis. Default `'a2'`. */\n keyPrefix?: string\n /** Injectable clock — every stored timestamp comes from here. */\n clock?: Clock\n /** Injectable id source for generated event ids. */\n ids?: IdSource\n}\n\nexport type RedisStore = A2Store & {\n /** Disconnect the command client and the shared subscriber. */\n close(): Promise<void>\n}\n\n/** A published presence patch: the patch fields plus the publishing\n * instance's tag, so its own loopback delivery is not repeated. */\ntype PresenceWireMessage = {\n src: string\n participant: string\n values: Record<string, unknown>\n seen: number\n at: number\n}\n\nexport function redis(options: RedisStoreOptions = {}): RedisStore {\n const clock = options.clock ?? SYSTEM_CLOCK\n const ids = options.ids ?? RANDOM_IDS\n const prefix = options.keyPrefix ?? 'a2'\n\n if (!options.client && !options.url) {\n throw new TypeError('redis() needs a url or an injected client')\n }\n\n // Lazy init — constructing the backend does no I/O.\n const connection = retryableLazy(async () => {\n if (options.client) return options.client\n const mod = await import('ioredis').catch(() => {\n throw new A2Error(\n 'STORE_NOT_CONFIGURED',\n \"experimental-a2/store-redis with a url needs the 'ioredis' package (optional peer dependency) — install it, or inject a client\",\n )\n })\n return new mod.default(options.url as string) as RedisConnection\n })\n const client = connection.get\n\n const notifyChannel = (sessionId: string): string =>\n `${prefix}:${sessionId}:notify`\n const presenceChannel = (ns: string): string => `${prefix}:${ns}:presence`\n\n const core = createRedisStoreCore({\n call: async (command, ...args) => {\n const c = await client()\n return c.call(command, ...args)\n },\n clock,\n ids,\n keyPrefix: prefix,\n // Fire-and-forget: the wake-up is disposable, so a failed PUBLISH\n // must never fail the write it follows.\n notify: (sessionId, count) => {\n void client()\n .then((c) => c.call('publish', notifyChannel(sessionId), count))\n .catch(() => {})\n },\n })\n\n // ── the shared subscriber ──────────────────────────────────────────\n // One duplicated connection serves every local feed: subscriber mode\n // monopolizes a connection, so it cannot be the command client.\n\n let severed = false\n let subscriber: Promise<RedisConnection> | null = null\n /** channel → parked-feed wake-ups, armed before each catch-up read. */\n const wakers = new Map<string, Set<() => void>>()\n /** channel → refcounted subscription held for each live iterator. */\n const subscriptions = new Map<\n string,\n { refs: number; ready: Promise<unknown> }\n >()\n /**\n * channel → last presence-marker refresh, throttling the SET.\n * Deliberately real time (`Date.now()`), not the injected clock:\n * the marker's PX expiry runs on server real time, and the throttle\n * must tick with the TTL it refreshes or time-traveling tests would\n * desync the two. Process-local and never stored, so the injected\n * clock's every-stored-timestamp pledge is untouched.\n */\n const watchedRefreshedAt = new Map<string, number>()\n /**\n * `:presence` channel → live listeners. Same-instance patches are\n * delivered here synchronously by `presence.set`, so subscribe/set\n * ordering never depends on the SUBSCRIBE command settling; the\n * channel carries only other instances' patches, with own echoes\n * skipped by `src`.\n */\n const presenceListeners = new Map<\n string,\n Set<(patch: PresencePatch) => void>\n >()\n const instanceId = crypto.randomUUID()\n\n const deliverRemotePresence = (\n listeners: Set<(patch: PresencePatch) => void>,\n message: string,\n ): void => {\n let parsed: PresenceWireMessage\n try {\n parsed = JSON.parse(message) as PresenceWireMessage\n } catch {\n return\n }\n if (parsed.src === instanceId) return\n const patch: PresencePatch = {\n participant: parsed.participant,\n values: parsed.values,\n seen: parsed.seen,\n at: new Date(parsed.at),\n }\n for (const listener of listeners) listener(patch)\n }\n\n const getSubscriber = (): Promise<RedisConnection> => {\n subscriber ??= (async () => {\n const conn = (await client()).duplicate()\n conn.on('message', (channel, message) => {\n // wake() only resolves a promise; removal happens later via\n // disarm(), so iterating the live set is safe.\n const set = wakers.get(channel)\n if (set) {\n for (const wake of set) wake()\n }\n const listeners = presenceListeners.get(channel)\n if (listeners) deliverRemotePresence(listeners, message)\n })\n return conn\n })()\n return subscriber\n }\n\n /**\n * The subscriber, gated on connection readiness — for channel\n * commands only (`close` uses the ungated promise, so it can never\n * hang on an unreachable server). SUBSCRIBE carries redis's\n * ok-loading flag, so ioredis writes it mid-handshake, ahead of the\n * ready check's INFO — which then finds the connection already in\n * subscriber mode and fails the check (a spurious error event plus\n * a reconnect). ECHO has no such flag: it resolves only once the\n * connection is ready, so every channel command chained behind it\n * lands after the check.\n */\n let subscriberGate: Promise<RedisConnection> | null = null\n const gatedSubscriber = (): Promise<RedisConnection> => {\n subscriberGate ??= getSubscriber().then(async (conn) => {\n await conn.call('echo', 'ready')\n return conn\n })\n return subscriberGate\n }\n\n const acquireChannel = async (channel: string): Promise<void> => {\n const existing = subscriptions.get(channel)\n if (existing) {\n existing.refs += 1\n try {\n await existing.ready\n } catch (err) {\n existing.refs -= 1\n throw err\n }\n return\n }\n const lease = {\n refs: 1,\n // Lowercase on purpose: ioredis keys its subscriber-mode\n // bookkeeping (and reconnect resubscription) on the exact\n // command name.\n ready: gatedSubscriber().then((conn) => conn.call('subscribe', channel)),\n }\n subscriptions.set(channel, lease)\n try {\n await lease.ready\n } catch (err) {\n if (subscriptions.get(channel) === lease) subscriptions.delete(channel)\n throw err\n }\n }\n\n const releaseChannel = (channel: string): void => {\n const lease = subscriptions.get(channel)\n if (!lease) return\n lease.refs -= 1\n if (lease.refs > 0) return\n subscriptions.delete(channel)\n watchedRefreshedAt.delete(channel)\n void gatedSubscriber()\n .then((conn) => conn.call('unsubscribe', channel))\n .catch(() => {})\n }\n\n const armWaker = (\n channel: string,\n ): { wakeup: Promise<void>; wake: () => void; disarm: () => void } => {\n let wake!: () => void\n const wakeup = new Promise<void>((resolve) => {\n wake = resolve // assigned synchronously by the executor\n })\n let set = wakers.get(channel)\n if (!set) {\n set = new Set()\n wakers.set(channel, set)\n }\n set.add(wake)\n return {\n wakeup,\n wake,\n disarm: () => {\n set.delete(wake)\n if (set.size === 0) wakers.delete(channel)\n },\n }\n }\n\n return {\n append: core.append,\n read: core.read,\n claimAvailable: core.claimAvailable,\n renewClaims: core.renewClaims,\n completeAttempt: core.completeAttempt,\n failAttempt: core.failAttempt,\n readState: core.readState,\n putSnapshot: core.putSnapshot,\n\n presence: {\n async set(ns, participant, values, meta) {\n const patch = await core.presence.set(ns, participant, values, meta)\n if (!patch) return\n const channel = presenceChannel(ns)\n const listeners = presenceListeners.get(channel)\n if (listeners) {\n for (const listener of listeners) listener(patch)\n }\n // Broadcast for other instances — best-effort like the notify\n // PUBLISH (a failed one must never fail the applied write),\n // but awaited so a patch can never reach a subscribe() that\n // starts after this set resolved.\n const message = JSON.stringify({\n src: instanceId,\n participant: patch.participant,\n values: patch.values,\n seen: patch.seen,\n at: patch.at.getTime(),\n } satisfies PresenceWireMessage)\n await client()\n .then((c) => c.call('publish', channel, message))\n .catch(() => {})\n },\n\n read: core.presence.read,\n\n subscribe(ns, onPatch) {\n const channel = presenceChannel(ns)\n let listeners = presenceListeners.get(channel)\n if (!listeners) {\n listeners = new Set()\n presenceListeners.set(channel, listeners)\n }\n listeners.add(onPatch)\n // Fire-and-forget on the shared refcounted subscription: it\n // only carries remote patches, so settling late (or failing)\n // degrades cross-instance latency, never same-process\n // delivery.\n void acquireChannel(channel).catch(() => {})\n let stopped = false\n return () => {\n if (stopped) return\n stopped = true\n listeners.delete(onPatch)\n if (listeners.size === 0) presenceListeners.delete(channel)\n releaseChannel(channel)\n }\n },\n },\n\n stream(sessionId, opts) {\n const startAfter = opts?.startAfter ?? 0\n const channel = notifyChannel(sessionId)\n\n return {\n [Symbol.asyncIterator](): AsyncIterator<Event> {\n let last = startAfter\n let buffer: Event[] = []\n let closed = false\n let subscribed = false\n let pending: Sleeper | null = null\n let interrupt: (() => void) | null = null\n\n const release = (): void => {\n if (!subscribed) return\n subscribed = false\n releaseChannel(channel)\n }\n\n return {\n async next(): Promise<IteratorResult<Event>> {\n for (;;) {\n if (closed) {\n release()\n return { value: undefined, done: true }\n }\n const row = buffer.shift()\n if (row) {\n last = row.index\n return { value: row, done: false }\n }\n if (severed) {\n release()\n throw new A2Error('STORE_UNAVAILABLE', 'the store was closed')\n }\n const waker = armWaker(channel)\n interrupt = waker.wake\n try {\n if (!subscribed) {\n // Subscribe before reading: anything appended\n // after the read lands as a wake-up, so nothing\n // can slip between catch-up and park.\n // oxlint-disable-next-line no-await-in-loop -- one-time setup\n await acquireChannel(channel)\n subscribed = true\n }\n // Mark watched before reading, for the same reason:\n // a write after this read sees the marker and\n // publishes. Throttled — one SET per safety period\n // per session per process, shared across feeds.\n const markedAt = watchedRefreshedAt.get(channel) ?? 0\n if (Date.now() - markedAt >= NOTIFY_TIMINGS.safetyReadMs) {\n watchedRefreshedAt.set(channel, Date.now())\n // oxlint-disable-next-line no-await-in-loop -- live-feed loop\n await core.markWatched(\n sessionId,\n NOTIFY_TIMINGS.safetyReadMs * 3,\n )\n }\n // oxlint-disable-next-line no-await-in-loop -- live-feed loop\n buffer = await core.readEvents(sessionId, last)\n if (buffer.length === 0 && !closed && !severed) {\n pending = defaultSleep(NOTIFY_TIMINGS.safetyReadMs)\n // oxlint-disable-next-line no-await-in-loop -- live-feed loop\n await Promise.race([waker.wakeup, pending.promise])\n pending.cancel()\n pending = null\n }\n } catch (err) {\n release()\n if (closed) return { value: undefined, done: true }\n if (err instanceof A2Error) throw err\n throw new A2Error(\n 'STORE_UNAVAILABLE',\n 'redis store operation failed',\n { cause: err },\n )\n } finally {\n waker.disarm()\n interrupt = null\n }\n }\n },\n async return(): Promise<IteratorResult<Event>> {\n closed = true\n pending?.cancel()\n interrupt?.() // wakes an in-flight next() immediately\n release()\n return { value: undefined, done: true }\n },\n }\n },\n }\n },\n\n async close() {\n severed = true\n for (const set of wakers.values()) {\n for (const wake of set) wake()\n }\n if (subscriber) {\n const sub = await subscriber.catch(() => null)\n sub?.disconnect()\n }\n const current = connection.peek()\n if (!current) return\n const c = await current.catch(() => null)\n c?.disconnect()\n },\n }\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"store-redis.js","names":[],"sources":["../src/store-redis.ts"],"sourcesContent":["/**\n * experimental-a2/store-redis — the Redis-protocol store backend, on Redis\n * Streams. The storage semantics live in store-redis-core.ts (shared\n * with experimental-a2/store-redis-http); this module owns the connection and\n * the live feed.\n *\n * `stream()` is notify-driven: writes to watched sessions fire a\n * disposable PUBLISH wake-up (feeds hold a TTL'd presence marker the\n * write script checks, so unwatched sessions cost no wake-up), one\n * shared subscriber connection per backend serves every local feed,\n * and each wake triggers an `XRANGE` catch-up read. The notification\n * only decides when to read, never what — a lost one is healed by a\n * safety re-read (NOTIFY_TIMINGS), so delivery never depends on\n * pub/sub. Connections scale with processes (one command client plus\n * one subscriber), not with concurrent viewers.\n *\n * Works with any Redis-protocol server on a single instance or a\n * non-cluster provider (Upstash — durable by default — Redis, Valkey).\n * Cluster mode is out: the atomic scripts span keys. `ioredis` is an\n * optional peer dependency; pass `url`, or inject any client exposing\n * `call`/`duplicate`/`on`/`disconnect`.\n */\n\nimport type { PresencePatch } from './contract.ts'\nimport { A2Error } from './errors.ts'\nimport { NOTIFY_TIMINGS } from './internal.ts'\nimport { defaultSleep, type Sleeper } from './store-polling.ts'\nimport { createRedisStoreCore } from './store-redis-core.ts'\nimport { retryableLazy } from './retryable-lazy.ts'\nimport {\n RANDOM_IDS,\n SYSTEM_CLOCK,\n type A2Store,\n type Clock,\n type Event,\n type IdSource,\n} from './store.ts'\n\n/**\n * The minimal client this backend needs — `ioredis` matches it\n * structurally. `call` issues any command; `duplicate` opens the one\n * shared subscriber connection; `on` delivers its pub/sub messages.\n *\n * The client must restore its subscriptions after a reconnect\n * (`ioredis` does). One that doesn't stays correct — the safety\n * re-read delivers everything — but every live feed silently degrades\n * to safety-read latency from that point on.\n */\nexport type RedisConnection = {\n call(command: string, ...args: Array<string | number>): Promise<unknown>\n duplicate(): RedisConnection\n on(\n event: 'message',\n listener: (channel: string, message: string) => void,\n ): unknown\n disconnect(): void\n}\n\nexport type RedisStoreOptions = {\n /** Creates an `ioredis` client lazily (optional peer dep `ioredis`). */\n url?: string | undefined\n /** Bring your own client — anything `call`/`duplicate`/`on`/`disconnect`. */\n client?: RedisConnection\n /** Key prefix — isolates multiple apps on one Redis. Default `'a2'`. */\n keyPrefix?: string\n /** Injectable clock — every stored timestamp comes from here. */\n clock?: Clock\n /** Injectable id source for generated event ids. */\n ids?: IdSource\n}\n\nexport type RedisStore = A2Store & {\n /** Disconnect the command client and the shared subscriber. */\n close(): Promise<void>\n}\n\n/** A published presence patch: the patch fields plus the publishing\n * instance's tag, so its own loopback delivery is not repeated. */\ntype PresenceWireMessage = {\n src: string\n participant: string\n values: Record<string, unknown>\n seen: number\n at: number\n}\n\nexport function redis(options: RedisStoreOptions = {}): RedisStore {\n const clock = options.clock ?? SYSTEM_CLOCK\n const ids = options.ids ?? RANDOM_IDS\n const prefix = options.keyPrefix ?? 'a2'\n\n if (!options.client && !options.url) {\n throw new TypeError('redis() needs a url or an injected client')\n }\n\n // Lazy init — constructing the backend does no I/O.\n const connection = retryableLazy(async () => {\n if (options.client) return options.client\n const mod = await import('ioredis').catch(() => {\n throw new A2Error(\n 'STORE_NOT_CONFIGURED',\n \"experimental-a2/store-redis with a url needs the 'ioredis' package (optional peer dependency) — install it, or inject a client\",\n )\n })\n return new mod.default(options.url as string) as RedisConnection\n })\n const client = connection.get\n\n const notifyChannel = (sessionId: string): string =>\n `${prefix}:${sessionId}:notify`\n const presenceChannel = (ns: string): string => `${prefix}:${ns}:presence`\n\n const core = createRedisStoreCore({\n call: async (command, ...args) => {\n const c = await client()\n return c.call(command, ...args)\n },\n clock,\n ids,\n keyPrefix: prefix,\n // Fire-and-forget: the wake-up is disposable, so a failed PUBLISH\n // must never fail the write it follows.\n notify: (sessionId, count) => {\n void client()\n .then((c) => c.call('publish', notifyChannel(sessionId), count))\n .catch(() => {})\n },\n })\n\n // ── the shared subscriber ──────────────────────────────────────────\n // One duplicated connection serves every local feed: subscriber mode\n // monopolizes a connection, so it cannot be the command client.\n\n let severed = false\n let subscriber: Promise<RedisConnection> | null = null\n /** channel → parked-feed wake-ups, armed before each catch-up read. */\n const wakers = new Map<string, Set<() => void>>()\n /** channel → refcounted subscription held for each live iterator. */\n const subscriptions = new Map<\n string,\n { refs: number; ready: Promise<unknown> }\n >()\n /**\n * channel → last presence-marker refresh, throttling the SET.\n * Deliberately real time (`Date.now()`), not the injected clock:\n * the marker's PX expiry runs on server real time, and the throttle\n * must tick with the TTL it refreshes or time-traveling tests would\n * desync the two. Process-local and never stored, so the injected\n * clock's every-stored-timestamp pledge is untouched.\n */\n const watchedRefreshedAt = new Map<string, number>()\n /**\n * `:presence` channel → live listeners. Same-instance patches are\n * delivered here synchronously by `presence.set`, so subscribe/set\n * ordering never depends on the SUBSCRIBE command settling; the\n * channel carries only other instances' patches, with own echoes\n * skipped by `src`.\n */\n const presenceListeners = new Map<\n string,\n Set<(patch: PresencePatch) => void>\n >()\n const instanceId = crypto.randomUUID()\n\n const deliverRemotePresence = (\n listeners: Set<(patch: PresencePatch) => void>,\n message: string,\n ): void => {\n let parsed: PresenceWireMessage\n try {\n parsed = JSON.parse(message) as PresenceWireMessage\n } catch {\n return\n }\n if (parsed.src === instanceId) return\n const patch: PresencePatch = {\n participant: parsed.participant,\n values: parsed.values,\n seen: parsed.seen,\n at: new Date(parsed.at),\n }\n for (const listener of listeners) listener(patch)\n }\n\n const getSubscriber = (): Promise<RedisConnection> => {\n subscriber ??= (async () => {\n const conn = (await client()).duplicate()\n conn.on('message', (channel, message) => {\n // wake() only resolves a promise; removal happens later via\n // disarm(), so iterating the live set is safe.\n const set = wakers.get(channel)\n if (set) {\n for (const wake of set) wake()\n }\n const listeners = presenceListeners.get(channel)\n if (listeners) deliverRemotePresence(listeners, message)\n })\n return conn\n })()\n return subscriber\n }\n\n /**\n * The subscriber, gated on connection readiness — for channel\n * commands only (`close` uses the ungated promise, so it can never\n * hang on an unreachable server). SUBSCRIBE carries redis's\n * ok-loading flag, so ioredis writes it mid-handshake, ahead of the\n * ready check's INFO — which then finds the connection already in\n * subscriber mode and fails the check (a spurious error event plus\n * a reconnect). ECHO has no such flag: it resolves only once the\n * connection is ready, so every channel command chained behind it\n * lands after the check.\n */\n let subscriberGate: Promise<RedisConnection> | null = null\n const gatedSubscriber = (): Promise<RedisConnection> => {\n subscriberGate ??= getSubscriber().then(async (conn) => {\n await conn.call('echo', 'ready')\n return conn\n })\n return subscriberGate\n }\n\n const acquireChannel = async (channel: string): Promise<void> => {\n const existing = subscriptions.get(channel)\n if (existing) {\n existing.refs += 1\n try {\n await existing.ready\n } catch (err) {\n existing.refs -= 1\n throw err\n }\n return\n }\n const lease = {\n refs: 1,\n // Lowercase on purpose: ioredis keys its subscriber-mode\n // bookkeeping (and reconnect resubscription) on the exact\n // command name.\n ready: gatedSubscriber().then((conn) => conn.call('subscribe', channel)),\n }\n subscriptions.set(channel, lease)\n try {\n await lease.ready\n } catch (err) {\n if (subscriptions.get(channel) === lease) subscriptions.delete(channel)\n throw err\n }\n }\n\n const releaseChannel = (channel: string): void => {\n const lease = subscriptions.get(channel)\n if (!lease) return\n lease.refs -= 1\n if (lease.refs > 0) return\n subscriptions.delete(channel)\n watchedRefreshedAt.delete(channel)\n void gatedSubscriber()\n .then((conn) => conn.call('unsubscribe', channel))\n .catch(() => {})\n }\n\n const armWaker = (\n channel: string,\n ): { wakeup: Promise<void>; wake: () => void; disarm: () => void } => {\n let wake!: () => void\n const wakeup = new Promise<void>((resolve) => {\n wake = resolve // assigned synchronously by the executor\n })\n let set = wakers.get(channel)\n if (!set) {\n set = new Set()\n wakers.set(channel, set)\n }\n set.add(wake)\n return {\n wakeup,\n wake,\n disarm: () => {\n set.delete(wake)\n if (set.size === 0) wakers.delete(channel)\n },\n }\n }\n\n return {\n append: core.append,\n read: core.read,\n claimAvailable: core.claimAvailable,\n renewClaims: core.renewClaims,\n completeAttempt: core.completeAttempt,\n failAttempt: core.failAttempt,\n readState: core.readState,\n putSnapshots: core.putSnapshots,\n\n presence: {\n async set(ns, participant, values, meta) {\n const patch = await core.presence.set(ns, participant, values, meta)\n if (!patch) return\n const channel = presenceChannel(ns)\n const listeners = presenceListeners.get(channel)\n if (listeners) {\n for (const listener of listeners) listener(patch)\n }\n // Broadcast for other instances — best-effort like the notify\n // PUBLISH (a failed one must never fail the applied write),\n // but awaited so a patch can never reach a subscribe() that\n // starts after this set resolved.\n const message = JSON.stringify({\n src: instanceId,\n participant: patch.participant,\n values: patch.values,\n seen: patch.seen,\n at: patch.at.getTime(),\n } satisfies PresenceWireMessage)\n await client()\n .then((c) => c.call('publish', channel, message))\n .catch(() => {})\n },\n\n read: core.presence.read,\n\n subscribe(ns, onPatch) {\n const channel = presenceChannel(ns)\n let listeners = presenceListeners.get(channel)\n if (!listeners) {\n listeners = new Set()\n presenceListeners.set(channel, listeners)\n }\n listeners.add(onPatch)\n // Fire-and-forget on the shared refcounted subscription: it\n // only carries remote patches, so settling late (or failing)\n // degrades cross-instance latency, never same-process\n // delivery.\n void acquireChannel(channel).catch(() => {})\n let stopped = false\n return () => {\n if (stopped) return\n stopped = true\n listeners.delete(onPatch)\n if (listeners.size === 0) presenceListeners.delete(channel)\n releaseChannel(channel)\n }\n },\n },\n\n stream(sessionId, opts) {\n const startAfter = opts?.startAfter ?? 0\n const channel = notifyChannel(sessionId)\n\n return {\n [Symbol.asyncIterator](): AsyncIterator<Event> {\n let last = startAfter\n let buffer: Event[] = []\n let closed = false\n let subscribed = false\n let pending: Sleeper | null = null\n let interrupt: (() => void) | null = null\n\n const release = (): void => {\n if (!subscribed) return\n subscribed = false\n releaseChannel(channel)\n }\n\n return {\n async next(): Promise<IteratorResult<Event>> {\n for (;;) {\n if (closed) {\n release()\n return { value: undefined, done: true }\n }\n const row = buffer.shift()\n if (row) {\n last = row.index\n return { value: row, done: false }\n }\n if (severed) {\n release()\n throw new A2Error('STORE_UNAVAILABLE', 'the store was closed')\n }\n const waker = armWaker(channel)\n interrupt = waker.wake\n try {\n if (!subscribed) {\n // Subscribe before reading: anything appended\n // after the read lands as a wake-up, so nothing\n // can slip between catch-up and park.\n // oxlint-disable-next-line no-await-in-loop -- one-time setup\n await acquireChannel(channel)\n subscribed = true\n }\n // Mark watched before reading, for the same reason:\n // a write after this read sees the marker and\n // publishes. Throttled — one SET per safety period\n // per session per process, shared across feeds.\n const markedAt = watchedRefreshedAt.get(channel) ?? 0\n if (Date.now() - markedAt >= NOTIFY_TIMINGS.safetyReadMs) {\n watchedRefreshedAt.set(channel, Date.now())\n // oxlint-disable-next-line no-await-in-loop -- live-feed loop\n await core.markWatched(\n sessionId,\n NOTIFY_TIMINGS.safetyReadMs * 3,\n )\n }\n // oxlint-disable-next-line no-await-in-loop -- live-feed loop\n buffer = await core.readEvents(sessionId, last)\n if (buffer.length === 0 && !closed && !severed) {\n pending = defaultSleep(NOTIFY_TIMINGS.safetyReadMs)\n // oxlint-disable-next-line no-await-in-loop -- live-feed loop\n await Promise.race([waker.wakeup, pending.promise])\n pending.cancel()\n pending = null\n }\n } catch (err) {\n release()\n if (closed) return { value: undefined, done: true }\n if (err instanceof A2Error) throw err\n throw new A2Error(\n 'STORE_UNAVAILABLE',\n 'redis store operation failed',\n { cause: err },\n )\n } finally {\n waker.disarm()\n interrupt = null\n }\n }\n },\n async return(): Promise<IteratorResult<Event>> {\n closed = true\n pending?.cancel()\n interrupt?.() // wakes an in-flight next() immediately\n release()\n return { value: undefined, done: true }\n },\n }\n },\n }\n },\n\n async close() {\n severed = true\n for (const set of wakers.values()) {\n for (const wake of set) wake()\n }\n if (subscriber) {\n const sub = await subscriber.catch(() => null)\n sub?.disconnect()\n }\n const current = connection.peek()\n if (!current) return\n const c = await current.catch(() => null)\n c?.disconnect()\n },\n }\n}\n"],"mappings":"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"}
@@ -1,4 +1,4 @@
1
- import { r as Clock, s as IdSource, t as A2Store } from "./store-flRz1OWh.js";
1
+ import { r as Clock, s as IdSource, t as A2Store } from "./store-RJO35BMj.js";
2
2
  //#region src/store-sqlite.d.ts
3
3
  type SqliteStoreOptions = {
4
4
  /** Database file path. Defaults to `.a2/dev.db`; `:memory:` works. */
@@ -1 +1 @@
1
- {"version":3,"file":"store-sqlite.d.ts","names":[],"sources":["../src/store-sqlite.ts"],"mappings":";;KAgCY;;EAEV;;EAEA,QAAQ;;EAER,MAAM;;KAGI,cAAc;;EAExB;;iBAsNc,OAAO,UAAS,qBAA0B"}
1
+ {"version":3,"file":"store-sqlite.d.ts","names":[],"sources":["../src/store-sqlite.ts"],"mappings":";;KAgCY;;EAEV;;EAEA,QAAQ;;EAER,MAAM;;KAGI,cAAc;;EAExB;;iBAiPc,OAAO,UAAS,qBAA0B"}
@@ -68,6 +68,26 @@ create table if not exists a2_snapshots (
68
68
  primary key (session_id, reducer_name)
69
69
  ) strict;
70
70
 
71
+ create table if not exists a2_snapshot_history (
72
+ session_id text not null,
73
+ reducer_name text not null,
74
+ up_to_index integer not null,
75
+ state text not null,
76
+ updated_at integer not null,
77
+ primary key (session_id, reducer_name, up_to_index)
78
+ ) strict;
79
+
80
+ create table if not exists a2_snapshot_pins (
81
+ session_id text not null,
82
+ event_index integer not null,
83
+ reducer_name text not null,
84
+ up_to_index integer not null,
85
+ primary key (session_id, event_index, reducer_name, up_to_index)
86
+ ) strict;
87
+
88
+ create index if not exists a2_snapshot_pins_checkpoint
89
+ on a2_snapshot_pins (session_id, reducer_name, up_to_index);
90
+
71
91
  create table if not exists a2_presence (
72
92
  ns text not null,
73
93
  participant text not null,
@@ -415,6 +435,15 @@ function sqlite(options = {}) {
415
435
  claim_holder = null,
416
436
  claim_expires_at = null
417
437
  where session_id = ? and idx = ?`).run(now, attempt, JSON.stringify(ids), sessionId, index);
438
+ db.prepare("delete from a2_snapshot_pins where session_id = ? and event_index = ?").run(sessionId, index);
439
+ db.prepare(`delete from a2_snapshot_history
440
+ where session_id = ?
441
+ and not exists (
442
+ select 1 from a2_snapshot_pins pin
443
+ where pin.session_id = a2_snapshot_history.session_id
444
+ and pin.reducer_name = a2_snapshot_history.reducer_name
445
+ and pin.up_to_index = a2_snapshot_history.up_to_index
446
+ )`).run(sessionId);
418
447
  if (parent.lane !== null) promoteLaneHead.run(sessionId, sessionId, parent.lane);
419
448
  return {
420
449
  outcome: "completed",
@@ -464,22 +493,46 @@ function sqlite(options = {}) {
464
493
  claim_holder = null,
465
494
  claim_expires_at = null
466
495
  where session_id = ? and idx = ?`).run(nextFailureCount, error, now, attempt, deadLettered ? now : null, sessionId, index);
496
+ if (deadLettered) {
497
+ db.prepare("delete from a2_snapshot_pins where session_id = ? and event_index = ?").run(sessionId, index);
498
+ db.prepare(`delete from a2_snapshot_history
499
+ where session_id = ?
500
+ and not exists (
501
+ select 1 from a2_snapshot_pins pin
502
+ where pin.session_id = a2_snapshot_history.session_id
503
+ and pin.reducer_name = a2_snapshot_history.reducer_name
504
+ and pin.up_to_index = a2_snapshot_history.up_to_index
505
+ )`).run(sessionId);
506
+ }
467
507
  return {
468
508
  outcome: deadLettered ? "dead_lettered" : "failed",
469
509
  failureCount: nextFailureCount
470
510
  };
471
511
  }));
472
512
  },
473
- async readState(sessionId, reducerName) {
513
+ async readState(sessionId, reducerName, stateOptions) {
474
514
  return wrap(() => {
475
- const rows = db.prepare(`with snapshot as materialized (
476
- select up_to_index, state
515
+ const throughIndex = stateOptions?.throughIndex ?? null;
516
+ const snapshotThroughIndex = stateOptions?.snapshotThroughIndex ?? throughIndex;
517
+ const rows = db.prepare(`with head as materialized (
518
+ select up_to_index, state, updated_at
477
519
  from a2_snapshots
478
- where session_id = ? and reducer_name = ?
520
+ where session_id = ? and reducer_name = ?
521
+ ), snapshot as materialized (
522
+ select up_to_index, state from (
523
+ select up_to_index, state from head
524
+ where (? is null or up_to_index <= ?)
525
+ union all
526
+ select up_to_index, state
527
+ from a2_snapshot_history
528
+ where session_id = ? and reducer_name = ?
529
+ and (? is null or up_to_index <= ?)
530
+ ) order by up_to_index desc limit 1
479
531
  )
480
532
  select 0 as row_order, 'snapshot' as row_kind,
481
- snapshot.up_to_index as snapshot_index,
482
- snapshot.state as snapshot_state,
533
+ (select up_to_index from head) as head_index,
534
+ (select up_to_index from snapshot) as snapshot_index,
535
+ (select state from snapshot) as snapshot_state,
483
536
  null as session_id, null as idx, null as event_type,
484
537
  null as payload, null as event_id, null as created_at,
485
538
  null as cause, null as lane, null as lane_ready,
@@ -491,18 +544,20 @@ function sqlite(options = {}) {
491
544
  null as last_failed_attempt, null as last_error,
492
545
  null as failed_at, null as claim_holder,
493
546
  null as claim_expires_at
494
- from snapshot
495
547
  union all
496
548
  select 1 as row_order, 'event' as row_kind,
549
+ (select up_to_index from head) as head_index,
497
550
  null as snapshot_index, null as snapshot_state,
498
551
  event.*
499
552
  from a2_events as event
500
553
  where event.session_id = ?
501
554
  and event.idx > coalesce((select up_to_index from snapshot), 0)
502
- order by row_order, idx`).all(sessionId, reducerName, sessionId);
555
+ and (? is null or event.idx <= ?)
556
+ order by row_order, idx`).all(sessionId, reducerName, snapshotThroughIndex, snapshotThroughIndex, sessionId, reducerName, snapshotThroughIndex, snapshotThroughIndex, sessionId, throughIndex, throughIndex);
503
557
  const snapshot = rows.find((row) => row.row_kind === "snapshot");
504
558
  return {
505
- snapshot: snapshot ? {
559
+ headIndex: snapshot?.head_index === null || snapshot?.head_index === void 0 ? null : Number(snapshot.head_index),
560
+ snapshot: snapshot?.snapshot_index !== null && snapshot?.snapshot_index !== void 0 ? {
506
561
  index: Number(snapshot.snapshot_index),
507
562
  state: JSON.parse(snapshot.snapshot_state)
508
563
  } : null,
@@ -510,16 +565,45 @@ function sqlite(options = {}) {
510
565
  };
511
566
  });
512
567
  },
513
- async putSnapshot(sessionId, reducerName, index, state) {
514
- wrap(() => {
515
- db.prepare(`insert into a2_snapshots (session_id, reducer_name, up_to_index, state, updated_at)
516
- values (?, ?, ?, ?, ?)
517
- on conflict (session_id, reducer_name) do update set
518
- up_to_index = excluded.up_to_index,
519
- state = excluded.state,
520
- updated_at = excluded.updated_at
521
- where excluded.up_to_index > a2_snapshots.up_to_index`).run(sessionId, reducerName, index, JSON.stringify(state) ?? "null", clock.now().getTime());
522
- });
568
+ async putSnapshots(sessionId, reducerName, writes) {
569
+ await wrap(() => tx(() => {
570
+ const selectHead = db.prepare(`select up_to_index, state, updated_at from a2_snapshots
571
+ where session_id = ? and reducer_name = ?`);
572
+ const unfinished = db.prepare(`select 1 from a2_events
573
+ where session_id = ? and idx = ?
574
+ and processed_at is null and failed_at is null`);
575
+ const insertPin = db.prepare(`insert or ignore into a2_snapshot_pins
576
+ (session_id, event_index, reducer_name, up_to_index)
577
+ values (?, ?, ?, ?)`);
578
+ const hasPin = db.prepare(`select 1 from a2_snapshot_pins
579
+ where session_id = ? and reducer_name = ? and up_to_index = ?
580
+ limit 1`);
581
+ const putHistory = db.prepare(`insert into a2_snapshot_history
582
+ (session_id, reducer_name, up_to_index, state, updated_at)
583
+ values (?, ?, ?, ?, ?)
584
+ on conflict (session_id, reducer_name, up_to_index) do update set
585
+ state = excluded.state, updated_at = excluded.updated_at`);
586
+ const putHead = db.prepare(`insert into a2_snapshots
587
+ (session_id, reducer_name, up_to_index, state, updated_at)
588
+ values (?, ?, ?, ?, ?)
589
+ on conflict (session_id, reducer_name) do update set
590
+ up_to_index = excluded.up_to_index,
591
+ state = excluded.state,
592
+ updated_at = excluded.updated_at
593
+ where excluded.up_to_index >= a2_snapshots.up_to_index`);
594
+ const deleteHistory = db.prepare(`delete from a2_snapshot_history
595
+ where session_id = ? and reducer_name = ? and up_to_index = ?`);
596
+ for (const write of writes.toSorted((a, b) => a.index - b.index)) {
597
+ for (const eventIndex of write.pinEventIndexes ?? []) if (unfinished.get(sessionId, eventIndex)) insertPin.run(sessionId, eventIndex, reducerName, write.index);
598
+ const head = selectHead.get(sessionId, reducerName);
599
+ const now = clock.now().getTime();
600
+ if (!head || write.index >= Number(head.up_to_index)) {
601
+ if (head && write.index > Number(head.up_to_index) && hasPin.get(sessionId, reducerName, Number(head.up_to_index))) putHistory.run(sessionId, reducerName, Number(head.up_to_index), head.state, Number(head.updated_at));
602
+ putHead.run(sessionId, reducerName, write.index, JSON.stringify(write.state) ?? "null", now);
603
+ deleteHistory.run(sessionId, reducerName, write.index);
604
+ } else if (write.index < Number(head.up_to_index) && hasPin.get(sessionId, reducerName, write.index)) putHistory.run(sessionId, reducerName, write.index, JSON.stringify(write.state) ?? "null", now);
605
+ }
606
+ }));
523
607
  },
524
608
  presence: {
525
609
  async set(ns, participant, values, meta) {