experimental-a2 0.16.1 → 0.17.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.
- package/CHANGELOG.md +352 -0
- package/dist/{actor-ohPC-81x.d.ts → actor-DBbe5xeE.d.ts} +21 -11
- package/dist/actor-DBbe5xeE.d.ts.map +1 -0
- package/dist/actor-client.d.ts +2 -5
- package/dist/actor-client.d.ts.map +1 -1
- package/dist/actor-client.js +13 -4
- package/dist/actor-client.js.map +1 -1
- package/dist/actor-react.d.ts +5 -3
- package/dist/actor-react.d.ts.map +1 -1
- package/dist/actor-react.js +20 -10
- package/dist/actor-react.js.map +1 -1
- package/dist/{actor-shared-B5tJfzt-.js → actor-shared-t7w8xIKP.js} +12 -7
- package/dist/actor-shared-t7w8xIKP.js.map +1 -0
- package/dist/actor.d.ts +1 -1
- package/dist/actor.js +42 -23
- package/dist/actor.js.map +1 -1
- package/dist/{ai-DgOBltJ_.js → ai-UrWfKeuv.js} +621 -216
- package/dist/ai-UrWfKeuv.js.map +1 -0
- package/dist/ai-client.d.ts +27 -0
- package/dist/ai-client.d.ts.map +1 -0
- package/dist/ai-client.js +29 -0
- package/dist/ai-client.js.map +1 -0
- package/dist/ai-react.d.ts +96 -0
- package/dist/ai-react.d.ts.map +1 -0
- package/dist/ai-react.js +142 -0
- package/dist/ai-react.js.map +1 -0
- package/dist/ai-server.browser.js +2 -2
- package/dist/ai-server.browser.js.map +1 -1
- package/dist/ai-server.d.ts +80 -36
- package/dist/ai-server.d.ts.map +1 -1
- package/dist/ai-server.js +432 -258
- package/dist/ai-server.js.map +1 -1
- package/dist/{ai-CrEf6p_W.d.ts → ai-vpGtcaCB.d.ts} +114 -100
- package/dist/ai-vpGtcaCB.d.ts.map +1 -0
- package/dist/ai.d.ts +2 -2
- package/dist/ai.js +2 -2
- package/dist/{client-BYzHjkwU.js → client-COaiaDub.js} +447 -80
- package/dist/client-COaiaDub.js.map +1 -0
- package/dist/{client-CzyacQpJ.d.ts → client-qRnRUzBS.d.ts} +14 -13
- package/dist/client-qRnRUzBS.d.ts.map +1 -0
- package/dist/client.d.ts +4 -2
- package/dist/client.js +1 -1
- package/dist/{contract-CKRg_E4q.js → contract-BYKASlCV.js} +12 -21
- package/dist/contract-BYKASlCV.js.map +1 -0
- package/dist/{idempotent-replay-DVOlyYbx.js → idempotent-replay-JdgMQRRm.js} +2 -2
- package/dist/{idempotent-replay-DVOlyYbx.js.map → idempotent-replay-JdgMQRRm.js.map} +1 -1
- package/dist/index.d.ts +6 -5
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -3
- package/dist/{errors-DvhSXnxk.d.ts → initial-snapshot-BrLso5x_.d.ts} +7 -3
- package/dist/initial-snapshot-BrLso5x_.d.ts.map +1 -0
- package/dist/{internal-Dq2qYxou.js → internal-CbVvEwad.js} +23 -2
- package/dist/internal-CbVvEwad.js.map +1 -0
- package/dist/otel.d.ts +1 -1
- package/dist/react-BbJ-g6Uu.d.ts +86 -0
- package/dist/react-BbJ-g6Uu.d.ts.map +1 -0
- package/dist/react.d.ts +2 -122
- package/dist/react.js +107 -100
- package/dist/react.js.map +1 -1
- package/dist/reducer-BAXc8JyF.js +87 -0
- package/dist/reducer-BAXc8JyF.js.map +1 -0
- package/dist/{reducer-otzHjuJj.d.ts → reducer-BYu6DT7h.d.ts} +45 -29
- package/dist/reducer-BYu6DT7h.d.ts.map +1 -0
- package/dist/reducer-runtime-DQwz_J59.js +28 -0
- package/dist/reducer-runtime-DQwz_J59.js.map +1 -0
- package/dist/scheduler-qstash.d.ts +3 -3
- package/dist/scheduler-qstash.d.ts.map +1 -1
- package/dist/scheduler-qstash.js +33 -10
- package/dist/scheduler-qstash.js.map +1 -1
- package/dist/{scheduler-task-BpzhPnRS.js → scheduler-task-X1SToDjP.js} +18 -3
- package/dist/scheduler-task-X1SToDjP.js.map +1 -0
- package/dist/scheduler-vercel.d.ts +4 -9
- package/dist/scheduler-vercel.d.ts.map +1 -1
- package/dist/scheduler-vercel.js +25 -44
- package/dist/scheduler-vercel.js.map +1 -1
- package/dist/{server-DbD7IJVK.d.ts → server-CGM8WzRG.d.ts} +87 -26
- package/dist/server-CGM8WzRG.d.ts.map +1 -0
- package/dist/{server-BD5ckxZb.js → server-Rj64fNvX.js} +854 -202
- package/dist/server-Rj64fNvX.js.map +1 -0
- package/dist/server.d.ts +4 -4
- package/dist/server.js +2 -2
- package/dist/{errors-DCk6ch5n.js → session-clear-DGI4jTQ5.js} +17 -2
- package/dist/session-clear-DGI4jTQ5.js.map +1 -0
- package/dist/session-clear-Dshpuhff.d.ts +12 -0
- package/dist/session-clear-Dshpuhff.d.ts.map +1 -0
- package/dist/{store-DyZM6fS5.d.ts → store-Gpkq7WBo.d.ts} +44 -3
- package/dist/store-Gpkq7WBo.d.ts.map +1 -0
- package/dist/store-N8PXxDAS.js.map +1 -1
- package/dist/store-memory.d.ts +1 -1
- package/dist/store-memory.d.ts.map +1 -1
- package/dist/store-memory.js +182 -69
- package/dist/store-memory.js.map +1 -1
- package/dist/{store-polling-CmxUbV93.js → store-polling-4_y8lETm.js} +21 -4
- package/dist/store-polling-4_y8lETm.js.map +1 -0
- package/dist/store-postgres.d.ts +1 -1
- package/dist/store-postgres.d.ts.map +1 -1
- package/dist/store-postgres.js +399 -108
- package/dist/store-postgres.js.map +1 -1
- package/dist/{store-presence-polling-C7-XZyW9.js → store-presence-polling-Q1C-k2dd.js} +3 -3
- package/dist/{store-presence-polling-C7-XZyW9.js.map → store-presence-polling-Q1C-k2dd.js.map} +1 -1
- package/dist/store-redis-http.d.ts +1 -1
- package/dist/store-redis-http.d.ts.map +1 -1
- package/dist/store-redis-http.js +293 -11
- package/dist/store-redis-http.js.map +1 -1
- package/dist/store-redis-notify-tqWNnMRu.js +2374 -0
- package/dist/store-redis-notify-tqWNnMRu.js.map +1 -0
- package/dist/store-redis.d.ts +1 -1
- package/dist/store-redis.d.ts.map +1 -1
- package/dist/store-redis.js +11 -11
- package/dist/store-redis.js.map +1 -1
- package/dist/store-sqlite.d.ts +1 -1
- package/dist/store-sqlite.d.ts.map +1 -1
- package/dist/store-sqlite.js +166 -32
- package/dist/store-sqlite.js.map +1 -1
- package/dist/stream-buffer-Cx7AAPGq.js +23 -0
- package/dist/stream-buffer-Cx7AAPGq.js.map +1 -0
- package/dist/{telemetry-B5jzpy6y.d.ts → telemetry-cZH-cibj.d.ts} +2 -2
- package/dist/telemetry-cZH-cibj.d.ts.map +1 -0
- package/dist/{wire-BO5wWCb1.js → wire-DjfHOvdW.js} +132 -10
- package/dist/wire-DjfHOvdW.js.map +1 -0
- package/docs/01-quickstart.mdx +2 -2
- package/docs/actors/01-introduction.mdx +4 -0
- package/docs/actors/04-routes.mdx +14 -1
- package/docs/concepts/01-contracts.mdx +95 -0
- package/docs/concepts/04-state.mdx +3 -3
- package/docs/guides/01-timers.mdx +22 -14
- package/docs/guides/02-cancellation.mdx +1 -1
- package/docs/guides/03-react.mdx +268 -328
- package/docs/guides/04-local-first.mdx +32 -73
- package/docs/guides/05-production.mdx +40 -27
- package/docs/guides/06-ai-agents.mdx +671 -581
- package/docs/guides/09-presence.mdx +3 -7
- package/docs/guides/10-transports.mdx +5 -5
- package/docs/index.mdx +10 -18
- package/docs/reference/01-api.mdx +656 -194
- package/docs/reference/02-errors.mdx +2 -1
- package/examples/playground/DEPLOY.md +52 -6
- package/examples/playground/app/a0/[appId]/loading.tsx +16 -0
- package/examples/playground/app/a0/[appId]/page.tsx +48 -0
- package/examples/playground/app/a0/a0.css +818 -0
- package/examples/playground/app/a0/app-list.tsx +211 -0
- package/examples/playground/app/a0/app-view.tsx +55 -0
- package/examples/playground/app/a0/apps/[appId]/preview/route.ts +27 -0
- package/examples/playground/app/a0/apps/[appId]/route.ts +23 -0
- package/examples/playground/app/a0/apps.test.ts +116 -0
- package/examples/playground/app/a0/apps.ts +87 -0
- package/examples/playground/app/a0/chat.tsx +364 -0
- package/examples/playground/app/a0/columns.tsx +109 -0
- package/examples/playground/app/a0/context-menu.tsx +116 -0
- package/examples/playground/app/a0/cookies.ts +46 -0
- package/examples/playground/app/a0/crash-on-request.ts +72 -0
- package/examples/playground/app/a0/crashes/route.ts +3 -0
- package/examples/playground/app/a0/crashes.test.ts +33 -0
- package/examples/playground/app/a0/crashes.ts +28 -0
- package/examples/playground/app/a0/devtools.tsx +164 -0
- package/examples/playground/app/a0/events/route.ts +6 -0
- package/examples/playground/app/a0/flags.ts +30 -0
- package/examples/playground/app/a0/layout.tsx +38 -0
- package/examples/playground/app/a0/list/route.ts +10 -0
- package/examples/playground/app/a0/model.test.ts +56 -0
- package/examples/playground/app/a0/model.ts +58 -0
- package/examples/playground/app/a0/page.tsx +14 -0
- package/examples/playground/app/a0/preview-pane.tsx +145 -0
- package/examples/playground/app/a0/preview.test.ts +53 -0
- package/examples/playground/app/a0/preview.ts +26 -0
- package/examples/playground/app/a0/sandbox-log.ts +69 -0
- package/examples/playground/app/a0/sandbox.ts +191 -0
- package/examples/playground/app/a0/sandboxes/scheduler/route.ts +6 -0
- package/examples/playground/app/a0/scheduler/route.ts +7 -0
- package/examples/playground/app/a0/server.ts +158 -0
- package/examples/playground/app/a0/session.ts +47 -0
- package/examples/playground/app/a0/shell.tsx +150 -0
- package/examples/playground/app/a0/short/events/route.ts +9 -0
- package/examples/playground/app/a0/splitter.tsx +120 -0
- package/examples/playground/app/agent/[agentId]/agent-client.tsx +144 -174
- package/examples/playground/app/agent/[agentId]/command-receipt.test.tsx +65 -0
- package/examples/playground/app/agent/[agentId]/command-receipt.tsx +27 -0
- package/examples/playground/app/agent/[agentId]/compaction-event.tsx +25 -24
- package/examples/playground/app/agent/[agentId]/compaction-panel.test.tsx +119 -0
- package/examples/playground/app/agent/[agentId]/compaction-panel.tsx +208 -165
- package/examples/playground/app/agent/[agentId]/page.tsx +2 -16
- package/examples/playground/app/agent/events/route.ts +5 -2
- package/examples/playground/app/agent/ingress.test.ts +98 -0
- package/examples/playground/app/agent/model-input.test.tsx +58 -0
- package/examples/playground/app/agent/model-input.tsx +115 -0
- package/examples/playground/app/agent/model.test.ts +73 -0
- package/examples/playground/app/agent/model.ts +58 -26
- package/examples/playground/app/agent/new-agent-session.tsx +37 -19
- package/examples/playground/app/agent/page.tsx +4 -4
- package/examples/playground/app/agent/server.ts +15 -14
- package/examples/playground/app/agent/session.ts +5 -6
- package/examples/playground/app/agent/telemetry.test.ts +168 -0
- package/examples/playground/app/agent/telemetry.ts +30 -0
- package/examples/playground/app/agent/trace.test.ts +23 -0
- package/examples/playground/app/agent/trace.ts +18 -0
- package/examples/playground/app/canvas/[canvasId]/canvas-client.tsx +11 -11
- package/examples/playground/app/canvas/[canvasId]/page.tsx +1 -2
- package/examples/playground/app/canvas/[canvasId]/session.ts +2 -2
- package/examples/playground/app/canvas/model.ts +53 -53
- package/examples/playground/app/chat/[chatId]/agent-state.test.ts +176 -0
- package/examples/playground/app/chat/[chatId]/agent-stream-drawer.tsx +16 -1
- package/examples/playground/app/chat/[chatId]/chat-client.tsx +55 -77
- package/examples/playground/app/chat/[chatId]/chat-view.ts +37 -3
- package/examples/playground/app/chat/[chatId]/page.tsx +7 -15
- package/examples/playground/app/chat/[chatId]/session.ts +2 -19
- package/examples/playground/app/chat/agent/route.ts +9 -0
- package/examples/playground/app/chat/model.test.ts +46 -119
- package/examples/playground/app/chat/model.ts +82 -128
- package/examples/playground/app/chat/scheduler/route.ts +2 -2
- package/examples/playground/app/chat/server.test.ts +108 -0
- package/examples/playground/app/chat/server.ts +69 -73
- package/examples/playground/app/components/activity-feed.tsx +30 -1
- package/examples/playground/app/components/activity-history.test.tsx +35 -0
- package/examples/playground/app/components/demo-icons.tsx +89 -0
- package/examples/playground/app/components/demos.tsx +33 -0
- package/examples/playground/app/components/fast-link.test.tsx +58 -0
- package/examples/playground/app/components/fast-link.tsx +55 -0
- package/examples/playground/app/components/site-header.tsx +96 -0
- package/examples/playground/app/counter/counter-client.tsx +3 -3
- package/examples/playground/app/counter/model.test.ts +3 -3
- package/examples/playground/app/counter/model.ts +11 -21
- package/examples/playground/app/counter/page.tsx +2 -15
- package/examples/playground/app/counter/session.ts +2 -2
- package/examples/playground/app/documents/[documentId]/code-editor.tsx +12 -1
- package/examples/playground/app/documents/[documentId]/document-client.tsx +10 -11
- package/examples/playground/app/documents/[documentId]/page.tsx +1 -2
- package/examples/playground/app/documents/[documentId]/session.ts +2 -2
- package/examples/playground/app/documents/model.ts +27 -27
- package/examples/playground/app/globals.css +420 -207
- package/examples/playground/app/initial-state.test.tsx +54 -0
- package/examples/playground/app/layout.tsx +7 -23
- package/examples/playground/app/orders/[orderId]/page.tsx +1 -2
- package/examples/playground/app/orders/[orderId]/session.ts +2 -2
- package/examples/playground/app/orders/model.ts +49 -49
- package/examples/playground/app/page.tsx +13 -102
- package/examples/playground/app/recovery/[recoveryId]/page.tsx +1 -2
- package/examples/playground/app/recovery/[recoveryId]/session.ts +2 -2
- package/examples/playground/app/recovery/model.ts +30 -30
- package/examples/playground/instrumentation.ts +5 -0
- package/examples/playground/package.json +10 -2
- package/examples/playground/scripts/a0-template.ts +77 -0
- package/examples/playground/vercel.json +18 -0
- package/package.json +3 -1
- package/src/actor-client.ts +16 -4
- package/src/actor-react.ts +25 -11
- package/src/actor-shared.ts +21 -6
- package/src/actor.ts +76 -31
- package/src/ai-agent-actions.ts +219 -0
- package/src/ai-agent.ts +2343 -0
- package/src/ai-app-state.ts +91 -0
- package/src/ai-client-state.ts +6 -3
- package/src/ai-client.ts +62 -0
- package/src/ai-context-schema.ts +25 -5
- package/src/ai-context.ts +90 -40
- package/src/ai-control-server.ts +92 -72
- package/src/ai-coordinator.ts +23 -22
- package/src/ai-message-base.ts +209 -0
- package/src/ai-message-projection.ts +262 -64
- package/src/ai-progress-batches.ts +26 -0
- package/src/ai-projector.ts +95 -14
- package/src/ai-react.ts +146 -0
- package/src/ai-server.browser.ts +2 -2
- package/src/ai-server.ts +20 -1964
- package/src/ai-stored-state.ts +48 -163
- package/src/ai.ts +339 -155
- package/src/bootstrap-stream.ts +72 -0
- package/src/bounded-cache.ts +55 -0
- package/src/client-render-session.ts +49 -0
- package/src/client.ts +688 -151
- package/src/contract.ts +92 -42
- package/src/deterministic-id.ts +37 -0
- package/src/equal-json.ts +32 -0
- package/src/errors.ts +13 -1
- package/src/index.ts +5 -1
- package/src/initial-snapshot.ts +39 -0
- package/src/internal.ts +45 -0
- package/src/postgres-notification-scope.ts +14 -3
- package/src/react.ts +171 -206
- package/src/redis-http-reply.ts +256 -0
- package/src/redis-http-stream.ts +73 -0
- package/src/redis-http-subscriptions.ts +13 -5
- package/src/reducer-runtime.ts +52 -0
- package/src/reducer.ts +92 -10
- package/src/scheduler-qstash.ts +49 -20
- package/src/scheduler-task.ts +34 -2
- package/src/scheduler-vercel.ts +32 -84
- package/src/server-fetch.ts +139 -35
- package/src/server.ts +957 -237
- package/src/session-clear.ts +23 -0
- package/src/session-socket.ts +154 -49
- package/src/sse.ts +71 -4
- package/src/store-memory.ts +220 -78
- package/src/store-polling.ts +18 -4
- package/src/store-postgres.ts +461 -114
- package/src/store-redis-core.ts +1260 -434
- package/src/store-redis-http.ts +18 -2
- package/src/store-redis-notify.ts +260 -85
- package/src/store-redis.ts +19 -9
- package/src/store-sqlite.ts +259 -45
- package/src/store.ts +46 -1
- package/src/stream-buffer.ts +43 -0
- package/src/telemetry.ts +13 -1
- package/src/wire.ts +241 -13
- package/dist/actor-ohPC-81x.d.ts.map +0 -1
- package/dist/actor-shared-B5tJfzt-.js.map +0 -1
- package/dist/ai-CrEf6p_W.d.ts.map +0 -1
- package/dist/ai-DgOBltJ_.js.map +0 -1
- package/dist/client-BYzHjkwU.js.map +0 -1
- package/dist/client-CzyacQpJ.d.ts.map +0 -1
- package/dist/contract-CKRg_E4q.js.map +0 -1
- package/dist/errors-DCk6ch5n.js.map +0 -1
- package/dist/errors-DvhSXnxk.d.ts.map +0 -1
- package/dist/internal-Dq2qYxou.js.map +0 -1
- package/dist/react.d.ts.map +0 -1
- package/dist/reducer-DEMjEY_O.js +0 -29
- package/dist/reducer-DEMjEY_O.js.map +0 -1
- package/dist/reducer-otzHjuJj.d.ts.map +0 -1
- package/dist/scheduler-task-BpzhPnRS.js.map +0 -1
- package/dist/server-BD5ckxZb.js.map +0 -1
- package/dist/server-DbD7IJVK.d.ts.map +0 -1
- package/dist/store-DyZM6fS5.d.ts.map +0 -1
- package/dist/store-polling-CmxUbV93.js.map +0 -1
- package/dist/store-redis-notify-BVLUfI3j.js +0 -1583
- package/dist/store-redis-notify-BVLUfI3j.js.map +0 -1
- package/dist/telemetry-B5jzpy6y.d.ts.map +0 -1
- package/dist/validate-XKT4FSNn.js +0 -30
- package/dist/validate-XKT4FSNn.js.map +0 -1
- package/dist/wire-BO5wWCb1.js.map +0 -1
- package/examples/playground/app/agent/[agentId]/compaction/route.ts +0 -14
- package/examples/playground/app/agent/compaction-settings.test.ts +0 -144
- package/examples/playground/app/agent/compaction-settings.ts +0 -49
- package/examples/playground/app/agent/compaction-timeline.test.ts +0 -377
- package/examples/playground/app/agent/compaction-timeline.ts +0 -208
|
@@ -1,1583 +0,0 @@
|
|
|
1
|
-
import { t as A2Error } from "./errors-DCk6ch5n.js";
|
|
2
|
-
import { h as nullProtoRecord, r as NOTIFY_TIMINGS } from "./internal-Dq2qYxou.js";
|
|
3
|
-
import { i as streamActivities, n as pollingStream, r as createStreamActivity, t as defaultSleep } from "./store-polling-CmxUbV93.js";
|
|
4
|
-
import { t as idempotentReplay } from "./idempotent-replay-DVOlyYbx.js";
|
|
5
|
-
import { t as decodeReturnedEventIds } from "./store-codec-DTG0Ftek.js";
|
|
6
|
-
import { t as pollPresence } from "./store-presence-polling-C7-XZyW9.js";
|
|
7
|
-
//#region src/presence-recovery.ts
|
|
8
|
-
const key = ({ participant, field }) => JSON.stringify([participant, field]);
|
|
9
|
-
function presenceRecovery({ read, deliver, isActive }) {
|
|
10
|
-
const fields = /* @__PURE__ */ new Map();
|
|
11
|
-
const reading = /* @__PURE__ */ new Set();
|
|
12
|
-
let generation = 0;
|
|
13
|
-
let latest;
|
|
14
|
-
const remember = ({ participant, field, seen, at }) => {
|
|
15
|
-
const id = key({
|
|
16
|
-
participant,
|
|
17
|
-
field
|
|
18
|
-
});
|
|
19
|
-
const previous = fields.get(id);
|
|
20
|
-
if (!previous || previous.at.getTime() < at.getTime() || previous.at.getTime() === at.getTime() && previous.seen !== seen) fields.set(id, {
|
|
21
|
-
participant,
|
|
22
|
-
field,
|
|
23
|
-
seen,
|
|
24
|
-
at
|
|
25
|
-
});
|
|
26
|
-
};
|
|
27
|
-
const snapshot = async ({ reconcile }) => {
|
|
28
|
-
const run = generation;
|
|
29
|
-
const before = new Map(fields);
|
|
30
|
-
const changed = /* @__PURE__ */ new Set();
|
|
31
|
-
reading.add(changed);
|
|
32
|
-
try {
|
|
33
|
-
const rows = await read();
|
|
34
|
-
if (!isActive()) return rows;
|
|
35
|
-
if (run !== generation) return latest;
|
|
36
|
-
const live = /* @__PURE__ */ new Set();
|
|
37
|
-
for (const row of rows) {
|
|
38
|
-
const id = key(row);
|
|
39
|
-
live.add(id);
|
|
40
|
-
if (changed.has(id)) continue;
|
|
41
|
-
remember(row);
|
|
42
|
-
if (reconcile) deliver({
|
|
43
|
-
participant: row.participant,
|
|
44
|
-
values: { [row.field]: row.value },
|
|
45
|
-
seen: row.seen,
|
|
46
|
-
at: row.at
|
|
47
|
-
});
|
|
48
|
-
}
|
|
49
|
-
if (reconcile) for (const [id, prior] of before) {
|
|
50
|
-
if (live.has(id) || changed.has(id) || fields.get(id) !== prior) continue;
|
|
51
|
-
fields.delete(id);
|
|
52
|
-
deliver({
|
|
53
|
-
participant: prior.participant,
|
|
54
|
-
values: { [prior.field]: null },
|
|
55
|
-
seen: prior.seen,
|
|
56
|
-
at: prior.at
|
|
57
|
-
});
|
|
58
|
-
}
|
|
59
|
-
return rows;
|
|
60
|
-
} finally {
|
|
61
|
-
reading.delete(changed);
|
|
62
|
-
if (reading.size === 0) latest = void 0;
|
|
63
|
-
}
|
|
64
|
-
};
|
|
65
|
-
return {
|
|
66
|
-
async read() {
|
|
67
|
-
let pending = latest;
|
|
68
|
-
while (pending) {
|
|
69
|
-
await pending;
|
|
70
|
-
if (latest === pending) break;
|
|
71
|
-
pending = latest;
|
|
72
|
-
}
|
|
73
|
-
return snapshot({ reconcile: false });
|
|
74
|
-
},
|
|
75
|
-
async reconnect() {
|
|
76
|
-
generation += 1;
|
|
77
|
-
latest = snapshot({ reconcile: true });
|
|
78
|
-
await latest;
|
|
79
|
-
},
|
|
80
|
-
update(patch) {
|
|
81
|
-
for (const [field, value] of Object.entries(patch.values)) {
|
|
82
|
-
if (value === void 0) continue;
|
|
83
|
-
const id = key({
|
|
84
|
-
participant: patch.participant,
|
|
85
|
-
field
|
|
86
|
-
});
|
|
87
|
-
const previous = fields.get(id);
|
|
88
|
-
if (previous && previous.at.getTime() > patch.at.getTime()) continue;
|
|
89
|
-
for (const changed of reading) changed.add(id);
|
|
90
|
-
if (value === null) fields.delete(id);
|
|
91
|
-
else remember({
|
|
92
|
-
participant: patch.participant,
|
|
93
|
-
field,
|
|
94
|
-
seen: patch.seen,
|
|
95
|
-
at: patch.at
|
|
96
|
-
});
|
|
97
|
-
}
|
|
98
|
-
}
|
|
99
|
-
};
|
|
100
|
-
}
|
|
101
|
-
//#endregion
|
|
102
|
-
//#region src/store-redis-core.ts
|
|
103
|
-
const READ_PAGE_SIZE = 256;
|
|
104
|
-
const READ_CONCURRENCY = 16;
|
|
105
|
-
/**
|
|
106
|
-
* KEYS: store stream, counter, global id hash, metadata, pending, ready,
|
|
107
|
-
* watched.
|
|
108
|
-
* ARGV: sessionId, nowMs, n, then per event:
|
|
109
|
-
* id, type, payloadJson, causeJson, lane, settled; finally notify channel.
|
|
110
|
-
* Returns ['empty', hasPending] | ['ok', firstIndex, hasPending] |
|
|
111
|
-
* ['dup', hasPending, ...indexes] | ['partial', count] |
|
|
112
|
-
* ['foreign', eventId] | ['no_parent', idx] | ['superseded', idx].
|
|
113
|
-
*/
|
|
114
|
-
const APPEND_LUA = `
|
|
115
|
-
local function lane_field(lane, suffix)
|
|
116
|
-
return '__lane:' .. tostring(string.len(lane)) .. ':' .. lane .. ':' .. suffix
|
|
117
|
-
end
|
|
118
|
-
local function enqueue(field, idx, lane)
|
|
119
|
-
redis.call('ZADD', KEYS[5], idx, field)
|
|
120
|
-
if lane == '' then
|
|
121
|
-
redis.call('ZADD', KEYS[6], idx, field)
|
|
122
|
-
return
|
|
123
|
-
end
|
|
124
|
-
local tail_key = lane_field(lane, 'tail')
|
|
125
|
-
local tail = redis.call('HGET', KEYS[4], tail_key)
|
|
126
|
-
if tail then
|
|
127
|
-
redis.call('HSET', KEYS[4], tail .. ':next', field)
|
|
128
|
-
else
|
|
129
|
-
redis.call('HSET', KEYS[4], lane_field(lane, 'head'), field)
|
|
130
|
-
redis.call('ZADD', KEYS[6], idx, field)
|
|
131
|
-
end
|
|
132
|
-
redis.call('HSET', KEYS[4], tail_key, field)
|
|
133
|
-
end
|
|
134
|
-
local n = tonumber(ARGV[3])
|
|
135
|
-
local base = tonumber(redis.call('GET', KEYS[2]) or '0')
|
|
136
|
-
local has_pending = redis.call('ZCARD', KEYS[5]) > 0 and '1' or '0'
|
|
137
|
-
if n == 0 then return { 'empty', has_pending } end
|
|
138
|
-
local dups = {}
|
|
139
|
-
for i = 1, n do
|
|
140
|
-
local id = ARGV[3 + (i - 1) * 6 + 1]
|
|
141
|
-
local existing = redis.call('HGET', KEYS[3], id)
|
|
142
|
-
if existing then
|
|
143
|
-
local rec = cjson.decode(existing)
|
|
144
|
-
if rec[1] ~= ARGV[1] then return { 'foreign', id } end
|
|
145
|
-
dups[#dups + 1] = tostring(rec[2])
|
|
146
|
-
end
|
|
147
|
-
end
|
|
148
|
-
if #dups == n then
|
|
149
|
-
local res = { 'dup', has_pending }
|
|
150
|
-
for i = 1, #dups do res[#res + 1] = dups[i] end
|
|
151
|
-
return res
|
|
152
|
-
end
|
|
153
|
-
if #dups > 0 then return { 'partial', tostring(#dups) } end
|
|
154
|
-
for i = 1, n do
|
|
155
|
-
local cause = ARGV[3 + (i - 1) * 6 + 4]
|
|
156
|
-
if cause ~= '' then
|
|
157
|
-
local parsed = cjson.decode(cause)
|
|
158
|
-
local pidx = tonumber(parsed.index)
|
|
159
|
-
if pidx < 1 or pidx > base then
|
|
160
|
-
return { 'no_parent', tostring(parsed.index) }
|
|
161
|
-
end
|
|
162
|
-
local field = tostring(pidx)
|
|
163
|
-
local dispatches = tonumber(redis.call('HGET', KEYS[4], field .. ':d') or '0')
|
|
164
|
-
if dispatches ~= tonumber(parsed.attempt) or
|
|
165
|
-
redis.call('HEXISTS', KEYS[4], field .. ':f') == 1 then
|
|
166
|
-
return { 'superseded', field }
|
|
167
|
-
end
|
|
168
|
-
end
|
|
169
|
-
end
|
|
170
|
-
for i = 1, n do
|
|
171
|
-
local id = ARGV[3 + (i - 1) * 6 + 1]
|
|
172
|
-
local typ = ARGV[3 + (i - 1) * 6 + 2]
|
|
173
|
-
local payload = ARGV[3 + (i - 1) * 6 + 3]
|
|
174
|
-
local cause = ARGV[3 + (i - 1) * 6 + 4]
|
|
175
|
-
local lane = ARGV[3 + (i - 1) * 6 + 5]
|
|
176
|
-
local settled = ARGV[3 + (i - 1) * 6 + 6]
|
|
177
|
-
local idx = base + i
|
|
178
|
-
redis.call('XADD', KEYS[1], tostring(idx) .. '-0',
|
|
179
|
-
'id', id, 'type', typ, 'payload', payload, 'created_at', ARGV[2],
|
|
180
|
-
'cause', cause, 'lane', lane)
|
|
181
|
-
redis.call('HSET', KEYS[3], id, cjson.encode({ ARGV[1], idx }))
|
|
182
|
-
redis.call('HSET', KEYS[4], tostring(idx) .. ':l', lane)
|
|
183
|
-
if settled == '1' then
|
|
184
|
-
redis.call('HSET', KEYS[4], tostring(idx) .. ':p', ARGV[2])
|
|
185
|
-
else
|
|
186
|
-
enqueue(tostring(idx), idx, lane)
|
|
187
|
-
end
|
|
188
|
-
end
|
|
189
|
-
redis.call('SET', KEYS[2], tostring(base + n))
|
|
190
|
-
has_pending = redis.call('ZCARD', KEYS[5]) > 0 and '1' or '0'
|
|
191
|
-
if redis.call('EXISTS', KEYS[7]) == 1 then
|
|
192
|
-
redis.pcall('PUBLISH', ARGV[4 + n * 6], tostring(base + n))
|
|
193
|
-
end
|
|
194
|
-
return { 'ok', tostring(base + 1), has_pending }
|
|
195
|
-
`;
|
|
196
|
-
/**
|
|
197
|
-
* KEYS: counter, meta, pending, ready, event snapshot pins, snapshot pin counts.
|
|
198
|
-
* ARGV: idx, attempt, error, maxFailures, nowMs.
|
|
199
|
-
*/
|
|
200
|
-
const FAIL_ATTEMPT_LUA = `
|
|
201
|
-
local function release_snapshot_pins()
|
|
202
|
-
local refs = redis.call('SMEMBERS', KEYS[5])
|
|
203
|
-
for _, ref in ipairs(refs) do
|
|
204
|
-
local count = redis.call('HINCRBY', KEYS[6], ref, -1)
|
|
205
|
-
if count <= 0 then
|
|
206
|
-
redis.call('HDEL', KEYS[6], ref)
|
|
207
|
-
local checkpoint = cjson.decode(ref)
|
|
208
|
-
redis.call('ZREMRANGEBYSCORE', checkpoint[1], checkpoint[2], checkpoint[2])
|
|
209
|
-
end
|
|
210
|
-
end
|
|
211
|
-
redis.call('DEL', KEYS[5])
|
|
212
|
-
if redis.call('HLEN', KEYS[6]) == 0 then redis.call('DEL', KEYS[6]) end
|
|
213
|
-
end
|
|
214
|
-
local function lane_field(lane, suffix)
|
|
215
|
-
return '__lane:' .. tostring(string.len(lane)) .. ':' .. lane .. ':' .. suffix
|
|
216
|
-
end
|
|
217
|
-
local function settle(field)
|
|
218
|
-
redis.call('ZREM', KEYS[3], field)
|
|
219
|
-
redis.call('ZREM', KEYS[4], field)
|
|
220
|
-
local lane = redis.call('HGET', KEYS[2], field .. ':l') or ''
|
|
221
|
-
if lane == '' then return end
|
|
222
|
-
local head_key = lane_field(lane, 'head')
|
|
223
|
-
if redis.call('HGET', KEYS[2], head_key) ~= field then return end
|
|
224
|
-
local next_field = redis.call('HGET', KEYS[2], field .. ':next')
|
|
225
|
-
while next_field and redis.call('HEXISTS', KEYS[2], next_field .. ':p') == 1 do
|
|
226
|
-
next_field = redis.call('HGET', KEYS[2], next_field .. ':next')
|
|
227
|
-
end
|
|
228
|
-
if next_field then
|
|
229
|
-
redis.call('HSET', KEYS[2], head_key, next_field)
|
|
230
|
-
if redis.call('HEXISTS', KEYS[2], next_field .. ':f') == 0 then
|
|
231
|
-
redis.call('ZADD', KEYS[4], tonumber(next_field), next_field)
|
|
232
|
-
end
|
|
233
|
-
else
|
|
234
|
-
redis.call('HDEL', KEYS[2], head_key, lane_field(lane, 'tail'))
|
|
235
|
-
end
|
|
236
|
-
end
|
|
237
|
-
local idx = tonumber(ARGV[1])
|
|
238
|
-
if idx < 1 or idx > tonumber(redis.call('GET', KEYS[1]) or '0') then
|
|
239
|
-
return { 'missing', '0' }
|
|
240
|
-
end
|
|
241
|
-
local field = ARGV[1]
|
|
242
|
-
local failures = tonumber(redis.call('HGET', KEYS[2], field .. ':a') or '0')
|
|
243
|
-
local dispatches = tonumber(redis.call('HGET', KEYS[2], field .. ':d') or '0')
|
|
244
|
-
if redis.call('HEXISTS', KEYS[2], field .. ':p') == 1 or dispatches ~= tonumber(ARGV[2]) then
|
|
245
|
-
return { 'superseded', tostring(failures) }
|
|
246
|
-
end
|
|
247
|
-
if redis.call('HEXISTS', KEYS[2], field .. ':f') == 1 then
|
|
248
|
-
return { 'dead_lettered', tostring(failures) }
|
|
249
|
-
end
|
|
250
|
-
if redis.call('HEXISTS', KEYS[2], field .. ':lfa') == 1 and
|
|
251
|
-
tonumber(redis.call('HGET', KEYS[2], field .. ':lfa')) == tonumber(ARGV[2]) and
|
|
252
|
-
redis.call('HEXISTS', KEYS[2], field .. ':ch') == 0 then
|
|
253
|
-
return { 'failed', tostring(failures) }
|
|
254
|
-
end
|
|
255
|
-
if redis.call('HEXISTS', KEYS[2], field .. ':ch') == 0 then
|
|
256
|
-
return { 'superseded', tostring(failures) }
|
|
257
|
-
end
|
|
258
|
-
failures = redis.call('HINCRBY', KEYS[2], field .. ':a', 1)
|
|
259
|
-
redis.call('HSET', KEYS[2],
|
|
260
|
-
field .. ':e', ARGV[3],
|
|
261
|
-
field .. ':lf', ARGV[5],
|
|
262
|
-
field .. ':lfa', ARGV[2])
|
|
263
|
-
redis.call('HDEL', KEYS[2], field .. ':ch', field .. ':ce')
|
|
264
|
-
if failures >= tonumber(ARGV[4]) then
|
|
265
|
-
redis.call('HSET', KEYS[2], field .. ':f', ARGV[5])
|
|
266
|
-
redis.call('ZREM', KEYS[4], field)
|
|
267
|
-
release_snapshot_pins()
|
|
268
|
-
return { 'dead_lettered', tostring(failures) }
|
|
269
|
-
end
|
|
270
|
-
return { 'failed', tostring(failures) }
|
|
271
|
-
`;
|
|
272
|
-
/**
|
|
273
|
-
* KEYS: store, counter, meta, pending, ready.
|
|
274
|
-
* ARGV: holder, nowMs, expiresAtMs, excludedCount, excluded indexes.
|
|
275
|
-
*/
|
|
276
|
-
const CLAIM_AVAILABLE_LUA = `
|
|
277
|
-
if redis.call('ZCARD', KEYS[4]) == 0 then return { 'settled' } end
|
|
278
|
-
local ready = redis.call('ZRANGE', KEYS[5], 0, -1)
|
|
279
|
-
local excluded = {}
|
|
280
|
-
for i = 1, tonumber(ARGV[4]) do excluded[ARGV[4 + i]] = true end
|
|
281
|
-
local claimed = {}
|
|
282
|
-
local retry_at = nil
|
|
283
|
-
for _, field in ipairs(ready) do
|
|
284
|
-
if not excluded[field] then
|
|
285
|
-
local expires = tonumber(redis.call('HGET', KEYS[3], field .. ':ce') or '0')
|
|
286
|
-
if expires > tonumber(ARGV[2]) then
|
|
287
|
-
if not retry_at or expires < retry_at then retry_at = expires end
|
|
288
|
-
else
|
|
289
|
-
local entries = redis.call('XRANGE', KEYS[1], field .. '-0', field .. '-0')
|
|
290
|
-
if #entries == 0 then return { 'missing', field } end
|
|
291
|
-
local attempt = redis.call('HINCRBY', KEYS[3], field .. ':d', 1)
|
|
292
|
-
redis.call('HSETNX', KEYS[3], field .. ':fc', ARGV[2])
|
|
293
|
-
redis.call('HSET', KEYS[3],
|
|
294
|
-
field .. ':lc', ARGV[2],
|
|
295
|
-
field .. ':ch', ARGV[1],
|
|
296
|
-
field .. ':ce', ARGV[3])
|
|
297
|
-
claimed[#claimed + 1] = {
|
|
298
|
-
tostring(attempt),
|
|
299
|
-
redis.call('HGET', KEYS[3], field .. ':a') or '0',
|
|
300
|
-
redis.call('HGET', KEYS[3], field .. ':e') or false,
|
|
301
|
-
redis.call('HGET', KEYS[3], field .. ':fc') or false,
|
|
302
|
-
ARGV[2],
|
|
303
|
-
redis.call('HGET', KEYS[3], field .. ':lf') or false,
|
|
304
|
-
redis.call('HGET', KEYS[3], field .. ':lfa') or false,
|
|
305
|
-
ARGV[1],
|
|
306
|
-
ARGV[3],
|
|
307
|
-
entries[1]
|
|
308
|
-
}
|
|
309
|
-
end
|
|
310
|
-
end
|
|
311
|
-
end
|
|
312
|
-
if #claimed > 0 then
|
|
313
|
-
local result = { 'claimed', tostring(#claimed) }
|
|
314
|
-
for _, claim in ipairs(claimed) do
|
|
315
|
-
for _, value in ipairs(claim) do result[#result + 1] = value end
|
|
316
|
-
end
|
|
317
|
-
return result
|
|
318
|
-
end
|
|
319
|
-
if retry_at then return { 'busy', tostring(retry_at) } end
|
|
320
|
-
return { 'settled' }
|
|
321
|
-
`;
|
|
322
|
-
/**
|
|
323
|
-
* KEYS: counter, meta.
|
|
324
|
-
* ARGV: holder, nowMs, expiresAtMs, n, then per claim: index, attempt.
|
|
325
|
-
* Returns [renewedCount, ...renewed, ...superseded].
|
|
326
|
-
*/
|
|
327
|
-
const RENEW_CLAIMS_LUA = `
|
|
328
|
-
local total = tonumber(redis.call('GET', KEYS[1]) or '0')
|
|
329
|
-
local renewed = {}
|
|
330
|
-
local superseded = {}
|
|
331
|
-
for i = 1, tonumber(ARGV[4]) do
|
|
332
|
-
local field = ARGV[4 + (i - 1) * 2 + 1]
|
|
333
|
-
local attempt = tonumber(ARGV[4 + (i - 1) * 2 + 2])
|
|
334
|
-
local idx = tonumber(field)
|
|
335
|
-
if idx >= 1 and idx <= total then
|
|
336
|
-
local dispatches = tonumber(redis.call('HGET', KEYS[2], field .. ':d') or '0')
|
|
337
|
-
if dispatches > attempt then
|
|
338
|
-
superseded[#superseded + 1] = field
|
|
339
|
-
else
|
|
340
|
-
local expires = tonumber(redis.call('HGET', KEYS[2], field .. ':ce') or '0')
|
|
341
|
-
if tonumber(ARGV[3]) > tonumber(ARGV[2]) and
|
|
342
|
-
redis.call('HEXISTS', KEYS[2], field .. ':p') == 0 and
|
|
343
|
-
redis.call('HEXISTS', KEYS[2], field .. ':f') == 0 and
|
|
344
|
-
redis.call('HGET', KEYS[2], field .. ':ch') == ARGV[1] and
|
|
345
|
-
expires > tonumber(ARGV[2]) then
|
|
346
|
-
redis.call('HSET', KEYS[2], field .. ':ce', ARGV[3])
|
|
347
|
-
renewed[#renewed + 1] = field
|
|
348
|
-
end
|
|
349
|
-
end
|
|
350
|
-
end
|
|
351
|
-
end
|
|
352
|
-
local result = { tostring(#renewed) }
|
|
353
|
-
for i = 1, #renewed do result[#result + 1] = renewed[i] end
|
|
354
|
-
for i = 1, #superseded do result[#result + 1] = superseded[i] end
|
|
355
|
-
return result
|
|
356
|
-
`;
|
|
357
|
-
/**
|
|
358
|
-
* KEYS: store, counter, ids, meta, pending, ready, watched,
|
|
359
|
-
* event snapshot pins, snapshot pin counts.
|
|
360
|
-
* ARGV: sessionId, nowMs, parent index, attempt, n, then per child:
|
|
361
|
-
* id, type, payloadJson, lane, settled; finally notify channel.
|
|
362
|
-
*/
|
|
363
|
-
const COMPLETE_ATTEMPT_LUA = `
|
|
364
|
-
local function release_snapshot_pins()
|
|
365
|
-
local refs = redis.call('SMEMBERS', KEYS[8])
|
|
366
|
-
for _, ref in ipairs(refs) do
|
|
367
|
-
local count = redis.call('HINCRBY', KEYS[9], ref, -1)
|
|
368
|
-
if count <= 0 then
|
|
369
|
-
redis.call('HDEL', KEYS[9], ref)
|
|
370
|
-
local checkpoint = cjson.decode(ref)
|
|
371
|
-
redis.call('ZREMRANGEBYSCORE', checkpoint[1], checkpoint[2], checkpoint[2])
|
|
372
|
-
end
|
|
373
|
-
end
|
|
374
|
-
redis.call('DEL', KEYS[8])
|
|
375
|
-
if redis.call('HLEN', KEYS[9]) == 0 then redis.call('DEL', KEYS[9]) end
|
|
376
|
-
end
|
|
377
|
-
local function lane_field(lane, suffix)
|
|
378
|
-
return '__lane:' .. tostring(string.len(lane)) .. ':' .. lane .. ':' .. suffix
|
|
379
|
-
end
|
|
380
|
-
local function settle(field)
|
|
381
|
-
redis.call('ZREM', KEYS[5], field)
|
|
382
|
-
redis.call('ZREM', KEYS[6], field)
|
|
383
|
-
local lane = redis.call('HGET', KEYS[4], field .. ':l') or ''
|
|
384
|
-
if lane == '' then return end
|
|
385
|
-
local head_key = lane_field(lane, 'head')
|
|
386
|
-
if redis.call('HGET', KEYS[4], head_key) ~= field then return end
|
|
387
|
-
local next_field = redis.call('HGET', KEYS[4], field .. ':next')
|
|
388
|
-
while next_field and redis.call('HEXISTS', KEYS[4], next_field .. ':p') == 1 do
|
|
389
|
-
next_field = redis.call('HGET', KEYS[4], next_field .. ':next')
|
|
390
|
-
end
|
|
391
|
-
if next_field then
|
|
392
|
-
redis.call('HSET', KEYS[4], head_key, next_field)
|
|
393
|
-
if redis.call('HEXISTS', KEYS[4], next_field .. ':f') == 0 then
|
|
394
|
-
redis.call('ZADD', KEYS[6], tonumber(next_field), next_field)
|
|
395
|
-
end
|
|
396
|
-
else
|
|
397
|
-
redis.call('HDEL', KEYS[4], head_key, lane_field(lane, 'tail'))
|
|
398
|
-
end
|
|
399
|
-
end
|
|
400
|
-
local function enqueue(field, idx, lane)
|
|
401
|
-
redis.call('ZADD', KEYS[5], idx, field)
|
|
402
|
-
if lane == '' then
|
|
403
|
-
redis.call('ZADD', KEYS[6], idx, field)
|
|
404
|
-
return
|
|
405
|
-
end
|
|
406
|
-
local tail_key = lane_field(lane, 'tail')
|
|
407
|
-
local tail = redis.call('HGET', KEYS[4], tail_key)
|
|
408
|
-
if tail then
|
|
409
|
-
redis.call('HSET', KEYS[4], tail .. ':next', field)
|
|
410
|
-
else
|
|
411
|
-
redis.call('HSET', KEYS[4], lane_field(lane, 'head'), field)
|
|
412
|
-
redis.call('ZADD', KEYS[6], idx, field)
|
|
413
|
-
end
|
|
414
|
-
redis.call('HSET', KEYS[4], tail_key, field)
|
|
415
|
-
end
|
|
416
|
-
local parent = ARGV[3]
|
|
417
|
-
local attempt = tonumber(ARGV[4])
|
|
418
|
-
local n = tonumber(ARGV[5])
|
|
419
|
-
local total = tonumber(redis.call('GET', KEYS[2]) or '0')
|
|
420
|
-
if tonumber(parent) < 1 or tonumber(parent) > total then return { 'missing' } end
|
|
421
|
-
local dispatches = tonumber(redis.call('HGET', KEYS[4], parent .. ':d') or '0')
|
|
422
|
-
local processed = redis.call('HGET', KEYS[4], parent .. ':p')
|
|
423
|
-
local processed_attempt = tonumber(redis.call('HGET', KEYS[4], parent .. ':pa') or '0')
|
|
424
|
-
if processed then
|
|
425
|
-
if processed_attempt ~= attempt then return { 'superseded' } end
|
|
426
|
-
local raw_recorded_ids = redis.call('HGET', KEYS[4], parent .. ':ri')
|
|
427
|
-
if not raw_recorded_ids then return { 'partial' } end
|
|
428
|
-
local recorded_ids = cjson.decode(raw_recorded_ids)
|
|
429
|
-
if #recorded_ids ~= n then return { 'partial' } end
|
|
430
|
-
local indexes = {}
|
|
431
|
-
for i = 1, n do
|
|
432
|
-
local id = ARGV[5 + (i - 1) * 5 + 1]
|
|
433
|
-
if recorded_ids[i] ~= id then return { 'partial' } end
|
|
434
|
-
local existing = redis.call('HGET', KEYS[3], id)
|
|
435
|
-
if not existing then return { 'partial' } end
|
|
436
|
-
local rec = cjson.decode(existing)
|
|
437
|
-
if rec[1] ~= ARGV[1] then return { 'partial' } end
|
|
438
|
-
local entries = redis.call('XRANGE', KEYS[1], tostring(rec[2]) .. '-0', tostring(rec[2]) .. '-0')
|
|
439
|
-
if #entries == 0 then return { 'partial' } end
|
|
440
|
-
local values = entries[1][2]
|
|
441
|
-
local cause = nil
|
|
442
|
-
for j = 1, #values, 2 do
|
|
443
|
-
if values[j] == 'cause' then cause = values[j + 1] break end
|
|
444
|
-
end
|
|
445
|
-
if not cause or cause == '' then return { 'partial' } end
|
|
446
|
-
local decoded = cjson.decode(cause)
|
|
447
|
-
if tonumber(decoded['index']) ~= tonumber(parent) or
|
|
448
|
-
tonumber(decoded['attempt']) ~= attempt then return { 'partial' } end
|
|
449
|
-
indexes[#indexes + 1] = tostring(rec[2])
|
|
450
|
-
end
|
|
451
|
-
local result = { 'duplicate' }
|
|
452
|
-
for _, idx in ipairs(indexes) do result[#result + 1] = idx end
|
|
453
|
-
return result
|
|
454
|
-
end
|
|
455
|
-
if dispatches ~= attempt then return { 'superseded' } end
|
|
456
|
-
if redis.call('HEXISTS', KEYS[4], parent .. ':ch') == 0 or
|
|
457
|
-
redis.call('HEXISTS', KEYS[4], parent .. ':f') == 1 or
|
|
458
|
-
tonumber(redis.call('HGET', KEYS[4], parent .. ':lfa') or '0') == attempt then
|
|
459
|
-
return { 'superseded' }
|
|
460
|
-
end
|
|
461
|
-
for i = 1, n do
|
|
462
|
-
local id = ARGV[5 + (i - 1) * 5 + 1]
|
|
463
|
-
if redis.call('HEXISTS', KEYS[3], id) == 1 then return { 'partial' } end
|
|
464
|
-
end
|
|
465
|
-
local returned_ids = {}
|
|
466
|
-
for i = 1, n do returned_ids[i] = ARGV[5 + (i - 1) * 5 + 1] end
|
|
467
|
-
local returned_json = '[]'
|
|
468
|
-
if n > 0 then returned_json = cjson.encode(returned_ids) end
|
|
469
|
-
redis.call('HSET', KEYS[4],
|
|
470
|
-
parent .. ':p', ARGV[2],
|
|
471
|
-
parent .. ':pa', ARGV[4],
|
|
472
|
-
parent .. ':ri', returned_json)
|
|
473
|
-
redis.call('HDEL', KEYS[4], parent .. ':ch', parent .. ':ce')
|
|
474
|
-
settle(parent)
|
|
475
|
-
release_snapshot_pins()
|
|
476
|
-
local cause = cjson.encode({ index = tonumber(parent), attempt = attempt })
|
|
477
|
-
for i = 1, n do
|
|
478
|
-
local id = ARGV[5 + (i - 1) * 5 + 1]
|
|
479
|
-
local typ = ARGV[5 + (i - 1) * 5 + 2]
|
|
480
|
-
local payload = ARGV[5 + (i - 1) * 5 + 3]
|
|
481
|
-
local lane = ARGV[5 + (i - 1) * 5 + 4]
|
|
482
|
-
local settled = ARGV[5 + (i - 1) * 5 + 5]
|
|
483
|
-
local idx = total + i
|
|
484
|
-
redis.call('XADD', KEYS[1], tostring(idx) .. '-0',
|
|
485
|
-
'id', id, 'type', typ, 'payload', payload, 'created_at', ARGV[2],
|
|
486
|
-
'cause', cause, 'lane', lane)
|
|
487
|
-
redis.call('HSET', KEYS[3], id, cjson.encode({ ARGV[1], idx }))
|
|
488
|
-
redis.call('HSET', KEYS[4], tostring(idx) .. ':l', lane)
|
|
489
|
-
if settled == '1' then
|
|
490
|
-
redis.call('HSET', KEYS[4], tostring(idx) .. ':p', ARGV[2])
|
|
491
|
-
else
|
|
492
|
-
enqueue(tostring(idx), idx, lane)
|
|
493
|
-
end
|
|
494
|
-
end
|
|
495
|
-
redis.call('SET', KEYS[2], tostring(total + n))
|
|
496
|
-
if n > 0 and redis.call('EXISTS', KEYS[7]) == 1 then
|
|
497
|
-
redis.pcall('PUBLISH', ARGV[6 + n * 5], tostring(total + n))
|
|
498
|
-
end
|
|
499
|
-
return { 'ok', tostring(total + 1) }
|
|
500
|
-
`;
|
|
501
|
-
/**
|
|
502
|
-
* KEYS: head, history, event counter, event meta, pin counts.
|
|
503
|
-
* ARGV: nowMs, event pin key prefix, write count, then per write:
|
|
504
|
-
* index, snapshot JSON, pin count, pin event indexes.
|
|
505
|
-
*/
|
|
506
|
-
const PUT_SNAPSHOTS_LUA = `
|
|
507
|
-
local total = tonumber(redis.call('GET', KEYS[3]) or '0')
|
|
508
|
-
local writes = {}
|
|
509
|
-
local offset = 4
|
|
510
|
-
for i = 1, tonumber(ARGV[3]) do
|
|
511
|
-
local index = tonumber(ARGV[offset])
|
|
512
|
-
local snapshot = ARGV[offset + 1]
|
|
513
|
-
local pin_count = tonumber(ARGV[offset + 2])
|
|
514
|
-
local ref = cjson.encode({ KEYS[2], tostring(index) })
|
|
515
|
-
for p = 1, pin_count do
|
|
516
|
-
local event_index = tonumber(ARGV[offset + 2 + p])
|
|
517
|
-
if event_index >= 1 and event_index <= total and
|
|
518
|
-
redis.call('HEXISTS', KEYS[4], tostring(event_index) .. ':p') == 0 and
|
|
519
|
-
redis.call('HEXISTS', KEYS[4], tostring(event_index) .. ':f') == 0 then
|
|
520
|
-
local pin_key = ARGV[2] .. tostring(event_index)
|
|
521
|
-
if redis.call('SADD', pin_key, ref) == 1 then
|
|
522
|
-
redis.call('HINCRBY', KEYS[5], ref, 1)
|
|
523
|
-
end
|
|
524
|
-
end
|
|
525
|
-
end
|
|
526
|
-
writes[#writes + 1] = { index = index, snapshot = snapshot, ref = ref }
|
|
527
|
-
offset = offset + 3 + pin_count
|
|
528
|
-
end
|
|
529
|
-
|
|
530
|
-
local head = redis.call('GET', KEYS[1])
|
|
531
|
-
local head_index = nil
|
|
532
|
-
if head then head_index = tonumber(cjson.decode(head)['index']) end
|
|
533
|
-
local latest = writes[#writes]
|
|
534
|
-
local writes_head = not head_index or latest.index >= head_index
|
|
535
|
-
local advances = not head_index or latest.index > head_index
|
|
536
|
-
local final_head_index = advances and latest.index or head_index
|
|
537
|
-
|
|
538
|
-
if advances and head and tonumber(redis.call('HGET', KEYS[5], cjson.encode({ KEYS[2], tostring(head_index) })) or '0') > 0 then
|
|
539
|
-
redis.call('ZREMRANGEBYSCORE', KEYS[2], head_index, head_index)
|
|
540
|
-
redis.call('ZADD', KEYS[2], head_index, head)
|
|
541
|
-
end
|
|
542
|
-
|
|
543
|
-
for _, write in ipairs(writes) do
|
|
544
|
-
if write.index < final_head_index then
|
|
545
|
-
redis.call('ZREMRANGEBYSCORE', KEYS[2], write.index, write.index)
|
|
546
|
-
if tonumber(redis.call('HGET', KEYS[5], write.ref) or '0') > 0 then
|
|
547
|
-
redis.call('ZADD', KEYS[2], write.index, write.snapshot)
|
|
548
|
-
end
|
|
549
|
-
end
|
|
550
|
-
end
|
|
551
|
-
|
|
552
|
-
if writes_head then
|
|
553
|
-
redis.call('SET', KEYS[1], latest.snapshot)
|
|
554
|
-
redis.call('ZREMRANGEBYSCORE', KEYS[2], latest.index, latest.index)
|
|
555
|
-
end
|
|
556
|
-
return 1
|
|
557
|
-
`;
|
|
558
|
-
/**
|
|
559
|
-
* KEYS: presence hash. ARGV: atMs, ttlMs, n, then per entry:
|
|
560
|
-
* hashField, valueJson ('' deletes), patchFieldJson; finally channel and
|
|
561
|
-
* message prefix. Wire fragments stay JSON text to preserve arrays
|
|
562
|
-
* and numeric precision through Lua. Field-wise LWW by the sender's
|
|
563
|
-
* `at`: a stored entry with a strictly newer stamp survives; ties go
|
|
564
|
-
* to the incoming write (memory-tier parity). Returns the applied
|
|
565
|
-
* 1-based entry positions. The whole-key PEXPIRE is the coarse bound,
|
|
566
|
-
* sound because core sends one uniform ttlMs; the per-entry `e` stamp
|
|
567
|
-
* is the fine bound (the injected storage clock at the applied set
|
|
568
|
-
* plus ttlMs — never the sender stamp), pruned on read against the
|
|
569
|
-
* same clock.
|
|
570
|
-
*/
|
|
571
|
-
const PRESENCE_SET_LUA = `
|
|
572
|
-
local at = tonumber(ARGV[1])
|
|
573
|
-
local applied = {}
|
|
574
|
-
local published = {}
|
|
575
|
-
local n = tonumber(ARGV[3])
|
|
576
|
-
for i = 1, n do
|
|
577
|
-
local field = ARGV[3 + (i - 1) * 3 + 1]
|
|
578
|
-
local value = ARGV[3 + (i - 1) * 3 + 2]
|
|
579
|
-
local existing = redis.call('HGET', KEYS[1], field)
|
|
580
|
-
if not existing or tonumber(cjson.decode(existing)['a']) <= at then
|
|
581
|
-
if value == '' then
|
|
582
|
-
redis.call('HDEL', KEYS[1], field)
|
|
583
|
-
else
|
|
584
|
-
redis.call('HSET', KEYS[1], field, value)
|
|
585
|
-
end
|
|
586
|
-
applied[#applied + 1] = tostring(i)
|
|
587
|
-
published[#published + 1] = ARGV[3 + (i - 1) * 3 + 3]
|
|
588
|
-
end
|
|
589
|
-
end
|
|
590
|
-
if #applied > 0 then
|
|
591
|
-
redis.call('PEXPIRE', KEYS[1], ARGV[2])
|
|
592
|
-
redis.pcall('PUBLISH', ARGV[4 + n * 3], ARGV[5 + n * 3] .. table.concat(published, ',') .. '}}')
|
|
593
|
-
end
|
|
594
|
-
return applied
|
|
595
|
-
`;
|
|
596
|
-
/**
|
|
597
|
-
* KEYS: presence hash. ARGV: nowMs (the injected clock, so tests can
|
|
598
|
-
* time-travel expiry against a real server). Returns the live
|
|
599
|
-
* [field, value, ...] pairs; entries at or past their fine-bound
|
|
600
|
-
* expiry are HDELed in the same atomic step, so a stale reader can
|
|
601
|
-
* never sweep a concurrently refreshed field.
|
|
602
|
-
*/
|
|
603
|
-
const PRESENCE_READ_LUA = `
|
|
604
|
-
local now = tonumber(ARGV[1])
|
|
605
|
-
local rows = redis.call('HGETALL', KEYS[1])
|
|
606
|
-
local live = {}
|
|
607
|
-
for i = 1, #rows, 2 do
|
|
608
|
-
if tonumber(cjson.decode(rows[i + 1])['e']) <= now then
|
|
609
|
-
redis.call('HDEL', KEYS[1], rows[i])
|
|
610
|
-
else
|
|
611
|
-
live[#live + 1] = rows[i]
|
|
612
|
-
live[#live + 1] = rows[i + 1]
|
|
613
|
-
end
|
|
614
|
-
end
|
|
615
|
-
return live
|
|
616
|
-
`;
|
|
617
|
-
/**
|
|
618
|
-
* KEYS: head, history, event counter. ARGV: snapshot frontier or ''.
|
|
619
|
-
* Returns one atomic multiversion-cache-plus-log-frontier read.
|
|
620
|
-
*/
|
|
621
|
-
const READ_STATE_LUA = `
|
|
622
|
-
local head = redis.call('GET', KEYS[1])
|
|
623
|
-
local head_index = head and cjson.decode(head)['index'] or nil
|
|
624
|
-
if head and (type(head_index) ~= 'number' or head_index < 0 or head_index > 9007199254740991 or head_index ~= math.floor(head_index)) then
|
|
625
|
-
return redis.error_reply('invalid snapshot head index')
|
|
626
|
-
end
|
|
627
|
-
local cap = ARGV[1]
|
|
628
|
-
local snapshot = nil
|
|
629
|
-
if head_index and (cap == '' or head_index <= tonumber(cap)) then
|
|
630
|
-
snapshot = head
|
|
631
|
-
else
|
|
632
|
-
local upper = cap == '' and '+inf' or cap
|
|
633
|
-
local historical = redis.call('ZREVRANGEBYSCORE', KEYS[2], upper, '-inf', 'LIMIT', 0, 1)
|
|
634
|
-
if #historical > 0 then snapshot = historical[1] end
|
|
635
|
-
end
|
|
636
|
-
local frontier = redis.call('GET', KEYS[3]) or '0'
|
|
637
|
-
return { head_index or '', snapshot or '', frontier }
|
|
638
|
-
`;
|
|
639
|
-
const fieldMap = (fields) => {
|
|
640
|
-
const map = {};
|
|
641
|
-
for (let i = 0; i + 1 < fields.length; i += 2) map[fields[i]] = fields[i + 1];
|
|
642
|
-
return map;
|
|
643
|
-
};
|
|
644
|
-
const STORED_EVENT_META_SUFFIXES = [
|
|
645
|
-
"p",
|
|
646
|
-
"pa",
|
|
647
|
-
"ri",
|
|
648
|
-
"ch",
|
|
649
|
-
"ce",
|
|
650
|
-
"f",
|
|
651
|
-
"fc",
|
|
652
|
-
"lc",
|
|
653
|
-
"lf",
|
|
654
|
-
"lfa",
|
|
655
|
-
"d",
|
|
656
|
-
"a",
|
|
657
|
-
"e"
|
|
658
|
-
];
|
|
659
|
-
const entryIndex = (entryId) => Number(entryId.slice(0, entryId.indexOf("-")));
|
|
660
|
-
const toEvent = (sessionId, entry) => {
|
|
661
|
-
const fields = fieldMap(entry[1]);
|
|
662
|
-
return {
|
|
663
|
-
id: fields["id"],
|
|
664
|
-
type: fields["type"],
|
|
665
|
-
payload: JSON.parse(fields["payload"]),
|
|
666
|
-
index: entryIndex(entry[0]),
|
|
667
|
-
sessionId,
|
|
668
|
-
createdAt: new Date(Number(fields["created_at"]))
|
|
669
|
-
};
|
|
670
|
-
};
|
|
671
|
-
const json = (value) => JSON.stringify(value) ?? "null";
|
|
672
|
-
const toCause = (raw) => {
|
|
673
|
-
if (raw === "" || raw === void 0) return null;
|
|
674
|
-
const value = JSON.parse(raw);
|
|
675
|
-
if (!Number.isInteger(value.index) || Number(value.index) < 1 || !Number.isInteger(value.attempt) || Number(value.attempt) < 1) throw new TypeError("stored event has an invalid cause");
|
|
676
|
-
if (value.batchSize !== void 0 && (!Number.isInteger(value.batchSize) || Number(value.batchSize) < 1)) throw new TypeError("stored event has an invalid cause");
|
|
677
|
-
return {
|
|
678
|
-
index: Number(value.index),
|
|
679
|
-
attempt: Number(value.attempt),
|
|
680
|
-
...value.batchSize === void 0 ? {} : { batchSize: Number(value.batchSize) }
|
|
681
|
-
};
|
|
682
|
-
};
|
|
683
|
-
const toClaimAvailableResult = (sessionId, reply) => {
|
|
684
|
-
const outcome = String(reply[0]);
|
|
685
|
-
if (outcome === "claimed") {
|
|
686
|
-
const count = Number(reply[1]);
|
|
687
|
-
const events = [];
|
|
688
|
-
for (let i = 0; i < count; i++) {
|
|
689
|
-
const offset = 2 + i * 10;
|
|
690
|
-
const entry = reply[offset + 9];
|
|
691
|
-
const event = toEvent(sessionId, entry);
|
|
692
|
-
const fields = fieldMap(entry[1]);
|
|
693
|
-
events.push(Object.assign(event, {
|
|
694
|
-
cause: toCause(fields["cause"]),
|
|
695
|
-
lane: fields["lane"] === "" ? null : fields["lane"],
|
|
696
|
-
processedAt: null,
|
|
697
|
-
processedByAttempt: null,
|
|
698
|
-
returnedEventIds: null,
|
|
699
|
-
firstClaimedAt: reply[offset + 3] === null ? null : new Date(Number(reply[offset + 3])),
|
|
700
|
-
lastClaimedAt: new Date(Number(reply[offset + 4])),
|
|
701
|
-
attemptCount: Number(reply[offset]),
|
|
702
|
-
claimHolder: String(reply[offset + 7]),
|
|
703
|
-
claimExpiresAt: new Date(Number(reply[offset + 8])),
|
|
704
|
-
failureCount: Number(reply[offset + 1]),
|
|
705
|
-
lastFailedAt: reply[offset + 5] === null ? null : new Date(Number(reply[offset + 5])),
|
|
706
|
-
lastFailedAttempt: reply[offset + 6] === null ? null : Number(reply[offset + 6]),
|
|
707
|
-
lastError: reply[offset + 2] === null ? null : String(reply[offset + 2]),
|
|
708
|
-
failedAt: null
|
|
709
|
-
}));
|
|
710
|
-
}
|
|
711
|
-
return {
|
|
712
|
-
outcome: "claimed",
|
|
713
|
-
events
|
|
714
|
-
};
|
|
715
|
-
}
|
|
716
|
-
if (outcome === "busy") return {
|
|
717
|
-
outcome,
|
|
718
|
-
retryAt: new Date(Number(reply[1]))
|
|
719
|
-
};
|
|
720
|
-
if (outcome === "settled") return { outcome };
|
|
721
|
-
throw new TypeError(`claim referenced a missing event in session '${sessionId}'`);
|
|
722
|
-
};
|
|
723
|
-
const wrap = async (fn) => {
|
|
724
|
-
try {
|
|
725
|
-
return await fn();
|
|
726
|
-
} catch (err) {
|
|
727
|
-
if (err instanceof A2Error || err instanceof TypeError) throw err;
|
|
728
|
-
throw new A2Error("STORE_UNAVAILABLE", "redis store operation failed", { cause: err });
|
|
729
|
-
}
|
|
730
|
-
};
|
|
731
|
-
function createRedisStoreCore(options) {
|
|
732
|
-
const { call, clock } = options;
|
|
733
|
-
const generateId = options.ids;
|
|
734
|
-
const prefix = options.keyPrefix;
|
|
735
|
-
const storeKey = (s) => `${prefix}:${s}:log`;
|
|
736
|
-
const countKey = (s) => `${prefix}:${s}:count`;
|
|
737
|
-
const metaKey = (s) => `${prefix}:${s}:meta`;
|
|
738
|
-
const pendingKey = (s) => `${prefix}:${s}:pending`;
|
|
739
|
-
const readyKey = (s) => `${prefix}:${s}:ready`;
|
|
740
|
-
const snapKey = (s, r) => `${prefix}:${s}:snap:${r}`;
|
|
741
|
-
const snapHistoryKey = (s, r) => `${prefix}:${s}:snap-history:${r}`;
|
|
742
|
-
const snapshotEventPinsKey = (s, index) => `${prefix}:${s}:snapshot-pins:${index}`;
|
|
743
|
-
const snapshotEventPinsPrefix = (s) => `${prefix}:${s}:snapshot-pins:`;
|
|
744
|
-
const snapshotPinCountsKey = (s) => `${prefix}:${s}:snapshot-pin-counts`;
|
|
745
|
-
const watchedKey = (s) => `${prefix}:${s}:watched`;
|
|
746
|
-
const presenceKey = (s) => `${prefix}:${s}:presence`;
|
|
747
|
-
const idsKey = `${prefix}:ids`;
|
|
748
|
-
const evalScript = (script, keys, args) => call("EVAL", script, keys.length, ...keys, ...args);
|
|
749
|
-
const readFrontier = async (sessionId) => {
|
|
750
|
-
const frontier = Number(await call("GET", countKey(sessionId)) ?? 0);
|
|
751
|
-
if (!Number.isSafeInteger(frontier) || frontier < 0) throw new A2Error("STORE_UNAVAILABLE", `redis event count for session '${sessionId}' is invalid`);
|
|
752
|
-
return frontier;
|
|
753
|
-
};
|
|
754
|
-
const readRangePage = async (sessionId, afterIndex, throughIndex, limit) => {
|
|
755
|
-
if (throughIndex <= afterIndex) return [];
|
|
756
|
-
const entries = await call("XRANGE", storeKey(sessionId), `${afterIndex + 1}-0`, `${throughIndex}-0`, "COUNT", limit);
|
|
757
|
-
if (entries.length === 0) return [];
|
|
758
|
-
const metaFields = entries.flatMap((entry) => {
|
|
759
|
-
const index = entryIndex(entry[0]);
|
|
760
|
-
return STORED_EVENT_META_SUFFIXES.map((suffix) => `${index}:${suffix}`);
|
|
761
|
-
});
|
|
762
|
-
const metaValues = await call("HMGET", metaKey(sessionId), ...metaFields) ?? [];
|
|
763
|
-
const meta = {};
|
|
764
|
-
for (let i = 0; i < metaFields.length; i += 1) {
|
|
765
|
-
const value = metaValues[i];
|
|
766
|
-
if (value !== null && value !== void 0) meta[metaFields[i]] = value;
|
|
767
|
-
}
|
|
768
|
-
return entries.map((entry) => {
|
|
769
|
-
const event = toEvent(sessionId, entry);
|
|
770
|
-
const idx = event.index;
|
|
771
|
-
const fields = fieldMap(entry[1]);
|
|
772
|
-
const processedAt = meta[`${idx}:p`];
|
|
773
|
-
const processedByAttempt = meta[`${idx}:pa`];
|
|
774
|
-
const returnedEventIds = meta[`${idx}:ri`];
|
|
775
|
-
const claimHolder = meta[`${idx}:ch`];
|
|
776
|
-
const claimExpiresAt = meta[`${idx}:ce`];
|
|
777
|
-
const failedAt = meta[`${idx}:f`];
|
|
778
|
-
const firstClaimedAt = meta[`${idx}:fc`];
|
|
779
|
-
const lastClaimedAt = meta[`${idx}:lc`];
|
|
780
|
-
const lastFailedAt = meta[`${idx}:lf`];
|
|
781
|
-
const lastFailedAttempt = meta[`${idx}:lfa`];
|
|
782
|
-
return Object.assign(event, {
|
|
783
|
-
cause: toCause(fields["cause"]),
|
|
784
|
-
lane: fields["lane"] === "" ? null : fields["lane"],
|
|
785
|
-
processedAt: processedAt === void 0 ? null : new Date(Number(processedAt)),
|
|
786
|
-
processedByAttempt: processedByAttempt === void 0 ? null : Number(processedByAttempt),
|
|
787
|
-
returnedEventIds: returnedEventIds === void 0 ? null : decodeReturnedEventIds(returnedEventIds),
|
|
788
|
-
firstClaimedAt: firstClaimedAt === void 0 ? null : new Date(Number(firstClaimedAt)),
|
|
789
|
-
lastClaimedAt: lastClaimedAt === void 0 ? null : new Date(Number(lastClaimedAt)),
|
|
790
|
-
attemptCount: Number(meta[`${idx}:d`] ?? 0),
|
|
791
|
-
claimHolder: claimHolder ?? null,
|
|
792
|
-
claimExpiresAt: claimExpiresAt === void 0 ? null : new Date(Number(claimExpiresAt)),
|
|
793
|
-
failureCount: Number(meta[`${idx}:a`] ?? 0),
|
|
794
|
-
lastFailedAt: lastFailedAt === void 0 ? null : new Date(Number(lastFailedAt)),
|
|
795
|
-
lastFailedAttempt: lastFailedAttempt === void 0 ? null : Number(lastFailedAttempt),
|
|
796
|
-
lastError: meta[`${idx}:e`] ?? null,
|
|
797
|
-
failedAt: failedAt === void 0 ? null : new Date(Number(failedAt))
|
|
798
|
-
});
|
|
799
|
-
});
|
|
800
|
-
};
|
|
801
|
-
const readEventPages = async (sessionId, afterIndex, throughIndex) => {
|
|
802
|
-
if (throughIndex <= afterIndex) return [];
|
|
803
|
-
const events = [];
|
|
804
|
-
let nextIndex = afterIndex + 1;
|
|
805
|
-
while (nextIndex <= throughIndex) {
|
|
806
|
-
const entries = await call("XRANGE", storeKey(sessionId), `${nextIndex}-0`, `${throughIndex}-0`, "COUNT", READ_PAGE_SIZE);
|
|
807
|
-
if (entries.length === 0) throw new A2Error("STORE_UNAVAILABLE", `redis event range for session '${sessionId}' ended before index ${throughIndex}`);
|
|
808
|
-
for (let position = 0; position < entries.length; position += 1) {
|
|
809
|
-
const index = entryIndex(entries[position][0]);
|
|
810
|
-
if (index !== nextIndex + position || index > throughIndex) throw new A2Error("STORE_UNAVAILABLE", `redis event range for session '${sessionId}' is not contiguous`);
|
|
811
|
-
}
|
|
812
|
-
events.push(...entries.map((entry) => toEvent(sessionId, entry)));
|
|
813
|
-
nextIndex = events.at(-1).index + 1;
|
|
814
|
-
}
|
|
815
|
-
return events;
|
|
816
|
-
};
|
|
817
|
-
const readEventRange = async (sessionId, afterIndex, throughIndex) => {
|
|
818
|
-
const events = [];
|
|
819
|
-
for (let start = afterIndex; start < throughIndex; start += READ_PAGE_SIZE * READ_CONCURRENCY) {
|
|
820
|
-
const ranges = Array.from({ length: Math.min(READ_CONCURRENCY, Math.ceil((throughIndex - start) / READ_PAGE_SIZE)) }, (_, position) => {
|
|
821
|
-
const after = start + position * READ_PAGE_SIZE;
|
|
822
|
-
return readEventPages(sessionId, after, Math.min(after + READ_PAGE_SIZE, throughIndex));
|
|
823
|
-
});
|
|
824
|
-
const results = await Promise.allSettled(ranges);
|
|
825
|
-
for (const result of results) {
|
|
826
|
-
if (result.status === "rejected") throw result.reason;
|
|
827
|
-
events.push(...result.value);
|
|
828
|
-
}
|
|
829
|
-
}
|
|
830
|
-
return events;
|
|
831
|
-
};
|
|
832
|
-
/** Bounded XRANGE pages restore the full-read contract on capped providers. */
|
|
833
|
-
const readRange = async (sessionId, afterIndex, throughIndex) => {
|
|
834
|
-
if (throughIndex !== void 0 && throughIndex <= afterIndex) return [];
|
|
835
|
-
const currentFrontier = await readFrontier(sessionId);
|
|
836
|
-
const frontier = Math.min(throughIndex ?? currentFrontier, currentFrontier);
|
|
837
|
-
if (frontier <= afterIndex) return [];
|
|
838
|
-
const rows = [];
|
|
839
|
-
let nextIndex = afterIndex + 1;
|
|
840
|
-
while (nextIndex <= frontier) {
|
|
841
|
-
const page = await readRangePage(sessionId, nextIndex - 1, frontier, READ_PAGE_SIZE);
|
|
842
|
-
if (page.length === 0) throw new A2Error("STORE_UNAVAILABLE", `redis event range for session '${sessionId}' ended before index ${frontier}`);
|
|
843
|
-
for (let position = 0; position < page.length; position += 1) if (page[position].index !== nextIndex + position) throw new A2Error("STORE_UNAVAILABLE", `redis event range for session '${sessionId}' is not contiguous`);
|
|
844
|
-
const lastIndex = page.at(-1).index;
|
|
845
|
-
if (lastIndex < nextIndex || lastIndex > frontier) throw new A2Error("STORE_UNAVAILABLE", `redis event range for session '${sessionId}' did not advance`);
|
|
846
|
-
rows.push(...page);
|
|
847
|
-
nextIndex = lastIndex + 1;
|
|
848
|
-
}
|
|
849
|
-
return rows;
|
|
850
|
-
};
|
|
851
|
-
return {
|
|
852
|
-
async append(sessionId, events) {
|
|
853
|
-
return wrap(async () => {
|
|
854
|
-
const suppliedIds = events.filter((e) => e.id !== void 0).map((e) => e.id);
|
|
855
|
-
if (new Set(suppliedIds).size !== suppliedIds.length) throw new A2Error("PARTIAL_DUPLICATE_BATCH", "batch contains the same event id more than once");
|
|
856
|
-
const now = clock.now().getTime();
|
|
857
|
-
const withIds = events.map((e) => ({
|
|
858
|
-
id: e.id ?? generateId(),
|
|
859
|
-
type: e.type,
|
|
860
|
-
payload: e.payload,
|
|
861
|
-
cause: e.cause ? { ...e.cause } : null,
|
|
862
|
-
lane: e.lane ?? null,
|
|
863
|
-
settled: e.settled === true
|
|
864
|
-
}));
|
|
865
|
-
const args = [
|
|
866
|
-
sessionId,
|
|
867
|
-
now,
|
|
868
|
-
withIds.length,
|
|
869
|
-
...withIds.flatMap((e) => [
|
|
870
|
-
e.id,
|
|
871
|
-
e.type,
|
|
872
|
-
json(e.payload),
|
|
873
|
-
e.cause ? json(e.cause) : "",
|
|
874
|
-
e.lane ?? "",
|
|
875
|
-
e.settled ? "1" : "0"
|
|
876
|
-
]),
|
|
877
|
-
`${prefix}:${sessionId}:notify`
|
|
878
|
-
];
|
|
879
|
-
const reply = await evalScript(APPEND_LUA, [
|
|
880
|
-
storeKey(sessionId),
|
|
881
|
-
countKey(sessionId),
|
|
882
|
-
idsKey,
|
|
883
|
-
metaKey(sessionId),
|
|
884
|
-
pendingKey(sessionId),
|
|
885
|
-
readyKey(sessionId),
|
|
886
|
-
watchedKey(sessionId)
|
|
887
|
-
], args);
|
|
888
|
-
const [verdict] = reply;
|
|
889
|
-
if (verdict === "empty") return {
|
|
890
|
-
events: [],
|
|
891
|
-
hasPending: reply[1] === "1"
|
|
892
|
-
};
|
|
893
|
-
if (verdict === "foreign") throw new A2Error("PARTIAL_DUPLICATE_BATCH", `event id '${reply[1]}' already exists in another session`);
|
|
894
|
-
if (verdict === "no_parent") throw new TypeError(`no event at index ${reply[1]} in session '${sessionId}'`);
|
|
895
|
-
if (verdict === "superseded") throw new A2Error("SUPERSEDED_ATTEMPT", `a newer attempt owns event ${reply[1]} in session '${sessionId}'`);
|
|
896
|
-
if (verdict === "partial") throw new A2Error("PARTIAL_DUPLICATE_BATCH", `batch mixes ${reply[1]} already-appended and ${events.length - Number(reply[1])} fresh events`);
|
|
897
|
-
if (verdict === "dup") {
|
|
898
|
-
const hasPending = reply[1] === "1";
|
|
899
|
-
const wanted = new Set(reply.slice(2).map(Number));
|
|
900
|
-
const min = Math.min(...wanted);
|
|
901
|
-
const existing = (await readRange(sessionId, min - 1)).filter((row) => wanted.has(row.index));
|
|
902
|
-
return {
|
|
903
|
-
events: idempotentReplay(events, existing),
|
|
904
|
-
hasPending
|
|
905
|
-
};
|
|
906
|
-
}
|
|
907
|
-
const base = Number(reply[1]) - 1;
|
|
908
|
-
return {
|
|
909
|
-
events: withIds.map((e, i) => ({
|
|
910
|
-
id: e.id,
|
|
911
|
-
type: e.type,
|
|
912
|
-
payload: structuredClone(e.payload),
|
|
913
|
-
index: base + 1 + i,
|
|
914
|
-
sessionId,
|
|
915
|
-
createdAt: new Date(now),
|
|
916
|
-
cause: e.cause,
|
|
917
|
-
lane: e.lane,
|
|
918
|
-
processedAt: e.settled ? new Date(now) : null,
|
|
919
|
-
processedByAttempt: null,
|
|
920
|
-
returnedEventIds: null,
|
|
921
|
-
firstClaimedAt: null,
|
|
922
|
-
lastClaimedAt: null,
|
|
923
|
-
attemptCount: 0,
|
|
924
|
-
claimHolder: null,
|
|
925
|
-
claimExpiresAt: null,
|
|
926
|
-
failureCount: 0,
|
|
927
|
-
lastFailedAt: null,
|
|
928
|
-
lastFailedAttempt: null,
|
|
929
|
-
lastError: null,
|
|
930
|
-
failedAt: null
|
|
931
|
-
})),
|
|
932
|
-
hasPending: reply[2] === "1"
|
|
933
|
-
};
|
|
934
|
-
});
|
|
935
|
-
},
|
|
936
|
-
async read(sessionId, opts) {
|
|
937
|
-
return wrap(async () => {
|
|
938
|
-
return await readRange(sessionId, opts?.afterIndex ?? 0, opts?.throughIndex);
|
|
939
|
-
});
|
|
940
|
-
},
|
|
941
|
-
async readHistory(sessionId, opts) {
|
|
942
|
-
return wrap(async () => {
|
|
943
|
-
const after = opts?.afterIndex ?? 0;
|
|
944
|
-
if (opts?.throughIndex !== void 0 && opts.throughIndex <= after) return [];
|
|
945
|
-
const frontier = await readFrontier(sessionId);
|
|
946
|
-
return readEventRange(sessionId, after, Math.min(opts?.throughIndex ?? frontier, frontier));
|
|
947
|
-
});
|
|
948
|
-
},
|
|
949
|
-
async claimAvailable({ sessionId, holder, ttlMs, expiresAtMs, excludeIndexes }) {
|
|
950
|
-
return wrap(async () => {
|
|
951
|
-
const now = clock.now().getTime();
|
|
952
|
-
const expiresAt = expiresAtMs ?? now + ttlMs;
|
|
953
|
-
const excluded = excludeIndexes ?? [];
|
|
954
|
-
const reply = await evalScript(CLAIM_AVAILABLE_LUA, [
|
|
955
|
-
storeKey(sessionId),
|
|
956
|
-
countKey(sessionId),
|
|
957
|
-
metaKey(sessionId),
|
|
958
|
-
pendingKey(sessionId),
|
|
959
|
-
readyKey(sessionId)
|
|
960
|
-
], [
|
|
961
|
-
holder,
|
|
962
|
-
now,
|
|
963
|
-
expiresAt,
|
|
964
|
-
excluded.length,
|
|
965
|
-
...excluded
|
|
966
|
-
]);
|
|
967
|
-
return toClaimAvailableResult(sessionId, reply);
|
|
968
|
-
});
|
|
969
|
-
},
|
|
970
|
-
async renewClaims({ sessionId, holder, claims, ttlMs, expiresAtMs }) {
|
|
971
|
-
return wrap(async () => {
|
|
972
|
-
const now = clock.now().getTime();
|
|
973
|
-
const expiresAt = expiresAtMs ?? now + ttlMs;
|
|
974
|
-
const reply = await evalScript(RENEW_CLAIMS_LUA, [countKey(sessionId), metaKey(sessionId)], [
|
|
975
|
-
holder,
|
|
976
|
-
now,
|
|
977
|
-
expiresAt,
|
|
978
|
-
claims.length,
|
|
979
|
-
...claims.flatMap((claim) => [claim.index, claim.attempt])
|
|
980
|
-
]);
|
|
981
|
-
const renewedCount = Number(reply[0]);
|
|
982
|
-
const values = reply.slice(1).map(Number);
|
|
983
|
-
return {
|
|
984
|
-
renewed: values.slice(0, renewedCount).toSorted((a, b) => a - b),
|
|
985
|
-
superseded: values.slice(renewedCount).toSorted((a, b) => a - b)
|
|
986
|
-
};
|
|
987
|
-
});
|
|
988
|
-
},
|
|
989
|
-
async completeAttempt({ sessionId, index, attempt, events }) {
|
|
990
|
-
return wrap(async () => {
|
|
991
|
-
const ids = events.map((event) => event.id);
|
|
992
|
-
if (new Set(ids).size !== ids.length) throw new A2Error("PARTIAL_DUPLICATE_BATCH", "batch contains the same event id more than once");
|
|
993
|
-
const now = clock.now().getTime();
|
|
994
|
-
const reply = await evalScript(COMPLETE_ATTEMPT_LUA, [
|
|
995
|
-
storeKey(sessionId),
|
|
996
|
-
countKey(sessionId),
|
|
997
|
-
idsKey,
|
|
998
|
-
metaKey(sessionId),
|
|
999
|
-
pendingKey(sessionId),
|
|
1000
|
-
readyKey(sessionId),
|
|
1001
|
-
watchedKey(sessionId),
|
|
1002
|
-
snapshotEventPinsKey(sessionId, index),
|
|
1003
|
-
snapshotPinCountsKey(sessionId)
|
|
1004
|
-
], [
|
|
1005
|
-
sessionId,
|
|
1006
|
-
now,
|
|
1007
|
-
index,
|
|
1008
|
-
attempt,
|
|
1009
|
-
events.length,
|
|
1010
|
-
...events.flatMap((event) => [
|
|
1011
|
-
event.id,
|
|
1012
|
-
event.type,
|
|
1013
|
-
json(event.payload),
|
|
1014
|
-
event.lane ?? "",
|
|
1015
|
-
event.settled === true ? "1" : "0"
|
|
1016
|
-
]),
|
|
1017
|
-
`${prefix}:${sessionId}:notify`
|
|
1018
|
-
]);
|
|
1019
|
-
const outcome = reply[0];
|
|
1020
|
-
if (outcome === "missing") throw new TypeError(`no event at index ${index} in session '${sessionId}'`);
|
|
1021
|
-
if (outcome === "partial") throw new A2Error("PARTIAL_DUPLICATE_BATCH", "returned event ids do not match the completed attempt");
|
|
1022
|
-
if (outcome === "superseded") return { outcome };
|
|
1023
|
-
const cause = {
|
|
1024
|
-
index,
|
|
1025
|
-
attempt
|
|
1026
|
-
};
|
|
1027
|
-
if (outcome === "ok") {
|
|
1028
|
-
const base = Number(reply[1]) - 1;
|
|
1029
|
-
return {
|
|
1030
|
-
outcome: "completed",
|
|
1031
|
-
events: events.map((event, i) => ({
|
|
1032
|
-
id: event.id,
|
|
1033
|
-
type: event.type,
|
|
1034
|
-
payload: structuredClone(event.payload),
|
|
1035
|
-
index: base + 1 + i,
|
|
1036
|
-
sessionId,
|
|
1037
|
-
createdAt: new Date(now),
|
|
1038
|
-
cause,
|
|
1039
|
-
lane: event.lane ?? null,
|
|
1040
|
-
processedAt: event.settled === true ? new Date(now) : null,
|
|
1041
|
-
processedByAttempt: null,
|
|
1042
|
-
returnedEventIds: null,
|
|
1043
|
-
firstClaimedAt: null,
|
|
1044
|
-
lastClaimedAt: null,
|
|
1045
|
-
attemptCount: 0,
|
|
1046
|
-
claimHolder: null,
|
|
1047
|
-
claimExpiresAt: null,
|
|
1048
|
-
failureCount: 0,
|
|
1049
|
-
lastFailedAt: null,
|
|
1050
|
-
lastFailedAttempt: null,
|
|
1051
|
-
lastError: null,
|
|
1052
|
-
failedAt: null
|
|
1053
|
-
}))
|
|
1054
|
-
};
|
|
1055
|
-
}
|
|
1056
|
-
if (outcome === "duplicate") {
|
|
1057
|
-
if (events.length === 0) return {
|
|
1058
|
-
outcome: "completed",
|
|
1059
|
-
events: []
|
|
1060
|
-
};
|
|
1061
|
-
const wanted = new Set(reply.slice(1).map(Number));
|
|
1062
|
-
const min = Math.min(...wanted);
|
|
1063
|
-
const rows = await readRange(sessionId, min - 1);
|
|
1064
|
-
const byIndex = new Map(rows.map((row) => [row.index, row]));
|
|
1065
|
-
const existing = reply.slice(1).map((value) => byIndex.get(Number(value)));
|
|
1066
|
-
return {
|
|
1067
|
-
outcome: "completed",
|
|
1068
|
-
events: idempotentReplay(events, existing)
|
|
1069
|
-
};
|
|
1070
|
-
}
|
|
1071
|
-
throw new TypeError(`unexpected completion outcome '${outcome}'`);
|
|
1072
|
-
});
|
|
1073
|
-
},
|
|
1074
|
-
async failAttempt({ sessionId, index, attempt, error, maxFailures }) {
|
|
1075
|
-
return wrap(async () => {
|
|
1076
|
-
const reply = await evalScript(FAIL_ATTEMPT_LUA, [
|
|
1077
|
-
countKey(sessionId),
|
|
1078
|
-
metaKey(sessionId),
|
|
1079
|
-
pendingKey(sessionId),
|
|
1080
|
-
readyKey(sessionId),
|
|
1081
|
-
snapshotEventPinsKey(sessionId, index),
|
|
1082
|
-
snapshotPinCountsKey(sessionId)
|
|
1083
|
-
], [
|
|
1084
|
-
index,
|
|
1085
|
-
attempt,
|
|
1086
|
-
error,
|
|
1087
|
-
maxFailures,
|
|
1088
|
-
clock.now().getTime()
|
|
1089
|
-
]);
|
|
1090
|
-
const outcome = reply[0];
|
|
1091
|
-
if (outcome === "missing") throw new TypeError(`no event at index ${index} in session '${sessionId}'`);
|
|
1092
|
-
return {
|
|
1093
|
-
outcome,
|
|
1094
|
-
failureCount: Number(reply[1])
|
|
1095
|
-
};
|
|
1096
|
-
});
|
|
1097
|
-
},
|
|
1098
|
-
async readState(sessionId, reducerName, stateOptions) {
|
|
1099
|
-
return wrap(async () => {
|
|
1100
|
-
const reply = await evalScript(READ_STATE_LUA, [
|
|
1101
|
-
snapKey(sessionId, reducerName),
|
|
1102
|
-
snapHistoryKey(sessionId, reducerName),
|
|
1103
|
-
countKey(sessionId)
|
|
1104
|
-
], [stateOptions?.snapshotThroughIndex ?? stateOptions?.throughIndex ?? ""]);
|
|
1105
|
-
const rawHead = reply[0];
|
|
1106
|
-
const raw = reply[1];
|
|
1107
|
-
const headIndex = rawHead === "" ? null : Number(rawHead);
|
|
1108
|
-
const parsed = raw === "" ? null : JSON.parse(raw);
|
|
1109
|
-
const currentFrontier = Number(reply[2]);
|
|
1110
|
-
if (!Number.isSafeInteger(currentFrontier) || currentFrontier < 0 || headIndex !== null && (!Number.isSafeInteger(headIndex) || headIndex < 0) || parsed !== null && (!Number.isSafeInteger(parsed.index) || parsed.index < 0)) throw new A2Error("STORE_UNAVAILABLE", `redis state frontier for session '${sessionId}' is invalid`);
|
|
1111
|
-
const frontier = Math.min(currentFrontier, stateOptions?.throughIndex ?? currentFrontier);
|
|
1112
|
-
const snapshot = parsed;
|
|
1113
|
-
const events = await readEventRange(sessionId, snapshot?.index ?? 0, frontier);
|
|
1114
|
-
return {
|
|
1115
|
-
headIndex,
|
|
1116
|
-
snapshot: snapshot ? {
|
|
1117
|
-
index: snapshot.index,
|
|
1118
|
-
state: snapshot.state
|
|
1119
|
-
} : null,
|
|
1120
|
-
events
|
|
1121
|
-
};
|
|
1122
|
-
});
|
|
1123
|
-
},
|
|
1124
|
-
async putSnapshots(sessionId, reducerName, writes) {
|
|
1125
|
-
const byIndex = /* @__PURE__ */ new Map();
|
|
1126
|
-
for (const write of writes) {
|
|
1127
|
-
const existing = byIndex.get(write.index);
|
|
1128
|
-
if (existing) {
|
|
1129
|
-
existing.state = write.state;
|
|
1130
|
-
for (const eventIndex of write.pinEventIndexes ?? []) existing.pinEventIndexes.add(eventIndex);
|
|
1131
|
-
} else byIndex.set(write.index, {
|
|
1132
|
-
index: write.index,
|
|
1133
|
-
state: write.state,
|
|
1134
|
-
pinEventIndexes: new Set(write.pinEventIndexes ?? [])
|
|
1135
|
-
});
|
|
1136
|
-
}
|
|
1137
|
-
const normalized = [...byIndex.values()].toSorted((a, b) => a.index - b.index);
|
|
1138
|
-
if (normalized.length === 0) return;
|
|
1139
|
-
await wrap(async () => {
|
|
1140
|
-
const args = [
|
|
1141
|
-
clock.now().getTime(),
|
|
1142
|
-
snapshotEventPinsPrefix(sessionId),
|
|
1143
|
-
normalized.length
|
|
1144
|
-
];
|
|
1145
|
-
for (const write of normalized) args.push(write.index, json({
|
|
1146
|
-
index: write.index,
|
|
1147
|
-
state: write.state,
|
|
1148
|
-
updatedAt: clock.now().getTime()
|
|
1149
|
-
}), write.pinEventIndexes.size, ...write.pinEventIndexes);
|
|
1150
|
-
await evalScript(PUT_SNAPSHOTS_LUA, [
|
|
1151
|
-
snapKey(sessionId, reducerName),
|
|
1152
|
-
snapHistoryKey(sessionId, reducerName),
|
|
1153
|
-
countKey(sessionId),
|
|
1154
|
-
metaKey(sessionId),
|
|
1155
|
-
snapshotPinCountsKey(sessionId)
|
|
1156
|
-
], args);
|
|
1157
|
-
});
|
|
1158
|
-
},
|
|
1159
|
-
presence: {
|
|
1160
|
-
async set(ns, participant, values, meta) {
|
|
1161
|
-
return wrap(async () => {
|
|
1162
|
-
const entries = Object.entries(values);
|
|
1163
|
-
if (entries.length === 0) return null;
|
|
1164
|
-
const atMs = meta.at.getTime();
|
|
1165
|
-
const expiresAtMs = clock.now().getTime() + meta.ttlMs;
|
|
1166
|
-
const applied = await evalScript(PRESENCE_SET_LUA, [presenceKey(ns)], [
|
|
1167
|
-
atMs,
|
|
1168
|
-
meta.ttlMs,
|
|
1169
|
-
entries.length,
|
|
1170
|
-
...entries.flatMap(([field, value]) => [
|
|
1171
|
-
JSON.stringify([participant, field]),
|
|
1172
|
-
value === null ? "" : json({
|
|
1173
|
-
v: value,
|
|
1174
|
-
s: meta.seen,
|
|
1175
|
-
a: atMs,
|
|
1176
|
-
e: expiresAtMs
|
|
1177
|
-
}),
|
|
1178
|
-
`${json(field)}:${json(value)}`
|
|
1179
|
-
]),
|
|
1180
|
-
presenceKey(ns),
|
|
1181
|
-
`${json({
|
|
1182
|
-
src: options.presenceSource,
|
|
1183
|
-
participant,
|
|
1184
|
-
seen: meta.seen,
|
|
1185
|
-
at: atMs
|
|
1186
|
-
}).slice(0, -1)},"values":{`
|
|
1187
|
-
]);
|
|
1188
|
-
if (applied.length === 0) return null;
|
|
1189
|
-
const appliedValues = nullProtoRecord();
|
|
1190
|
-
for (const position of applied) {
|
|
1191
|
-
const [field, value] = entries[Number(position) - 1];
|
|
1192
|
-
appliedValues[field] = structuredClone(value);
|
|
1193
|
-
}
|
|
1194
|
-
return {
|
|
1195
|
-
participant,
|
|
1196
|
-
values: appliedValues,
|
|
1197
|
-
seen: meta.seen,
|
|
1198
|
-
at: new Date(atMs)
|
|
1199
|
-
};
|
|
1200
|
-
});
|
|
1201
|
-
},
|
|
1202
|
-
async read(ns) {
|
|
1203
|
-
return wrap(async () => {
|
|
1204
|
-
const reply = await evalScript(PRESENCE_READ_LUA, [presenceKey(ns)], [clock.now().getTime()]);
|
|
1205
|
-
const rows = [];
|
|
1206
|
-
for (let i = 0; i + 1 < reply.length; i += 2) {
|
|
1207
|
-
const [participant, field] = JSON.parse(reply[i]);
|
|
1208
|
-
const entry = JSON.parse(reply[i + 1]);
|
|
1209
|
-
rows.push({
|
|
1210
|
-
participant,
|
|
1211
|
-
field,
|
|
1212
|
-
value: entry.v,
|
|
1213
|
-
seen: entry.s,
|
|
1214
|
-
at: new Date(entry.a),
|
|
1215
|
-
expiresAt: new Date(entry.e)
|
|
1216
|
-
});
|
|
1217
|
-
}
|
|
1218
|
-
return rows;
|
|
1219
|
-
});
|
|
1220
|
-
}
|
|
1221
|
-
},
|
|
1222
|
-
async readRange(sessionId, afterIndex, throughIndex) {
|
|
1223
|
-
return wrap(() => readRange(sessionId, afterIndex, throughIndex));
|
|
1224
|
-
},
|
|
1225
|
-
async readEvents(sessionId, afterIndex) {
|
|
1226
|
-
return wrap(async () => {
|
|
1227
|
-
return (await call("XRANGE", storeKey(sessionId), `${afterIndex + 1}-0`, "+", "COUNT", READ_PAGE_SIZE)).map((entry) => toEvent(sessionId, entry));
|
|
1228
|
-
});
|
|
1229
|
-
},
|
|
1230
|
-
async markWatched(sessionId, ttlMs) {
|
|
1231
|
-
await wrap(() => call("SET", watchedKey(sessionId), "1", "PX", ttlMs));
|
|
1232
|
-
}
|
|
1233
|
-
};
|
|
1234
|
-
}
|
|
1235
|
-
//#endregion
|
|
1236
|
-
//#region src/store-redis-notify.ts
|
|
1237
|
-
function createRedisNotifyStore(options) {
|
|
1238
|
-
const { client, clock, ids, keyPrefix: prefix } = options;
|
|
1239
|
-
const notifyChannel = (sessionId) => `${prefix}:${sessionId}:notify`;
|
|
1240
|
-
const presenceChannel = (ns) => `${prefix}:${ns}:presence`;
|
|
1241
|
-
const instanceId = crypto.randomUUID();
|
|
1242
|
-
const core = createRedisStoreCore({
|
|
1243
|
-
call: async (command, ...args) => {
|
|
1244
|
-
return (await client()).call(command, ...args);
|
|
1245
|
-
},
|
|
1246
|
-
clock,
|
|
1247
|
-
ids,
|
|
1248
|
-
keyPrefix: prefix,
|
|
1249
|
-
presenceSource: instanceId
|
|
1250
|
-
});
|
|
1251
|
-
let severed = false;
|
|
1252
|
-
let subscriber = null;
|
|
1253
|
-
/** channel → parked-feed wake-ups, armed before each catch-up read. */
|
|
1254
|
-
const wakers = /* @__PURE__ */ new Map();
|
|
1255
|
-
/** channel → refcounted subscription held for each live iterator. */
|
|
1256
|
-
const subscriptions = /* @__PURE__ */ new Map();
|
|
1257
|
-
/**
|
|
1258
|
-
* channel → last presence-marker refresh, throttling the SET.
|
|
1259
|
-
* Deliberately real time (`Date.now()`), not the injected clock:
|
|
1260
|
-
* the marker's PX expiry runs on server real time, and the throttle
|
|
1261
|
-
* must tick with the TTL it refreshes or time-traveling tests would
|
|
1262
|
-
* desync the two. Process-local and never stored, so the injected
|
|
1263
|
-
* clock's every-stored-timestamp pledge is untouched.
|
|
1264
|
-
*/
|
|
1265
|
-
const watched = /* @__PURE__ */ new Map();
|
|
1266
|
-
const presenceSubscriptions = /* @__PURE__ */ new Map();
|
|
1267
|
-
const pollingStreams = /* @__PURE__ */ new Set();
|
|
1268
|
-
options.onReconnect?.((channel) => {
|
|
1269
|
-
for (const wake of wakers.get(channel) ?? []) wake();
|
|
1270
|
-
presenceSubscriptions.get(channel)?.recovery?.reconnect().catch(() => {});
|
|
1271
|
-
});
|
|
1272
|
-
const deliverRemotePresence = ({ subscription, message }) => {
|
|
1273
|
-
let parsed;
|
|
1274
|
-
try {
|
|
1275
|
-
parsed = JSON.parse(message);
|
|
1276
|
-
} catch {
|
|
1277
|
-
return;
|
|
1278
|
-
}
|
|
1279
|
-
if (parsed.src === instanceId) return;
|
|
1280
|
-
const patch = {
|
|
1281
|
-
participant: parsed.participant,
|
|
1282
|
-
values: parsed.values,
|
|
1283
|
-
seen: parsed.seen,
|
|
1284
|
-
at: new Date(parsed.at)
|
|
1285
|
-
};
|
|
1286
|
-
subscription.recovery?.update(patch);
|
|
1287
|
-
for (const listener of subscription.listeners) listener(patch);
|
|
1288
|
-
};
|
|
1289
|
-
const getSubscriber = () => {
|
|
1290
|
-
subscriber ??= (async () => {
|
|
1291
|
-
const conn = (await client()).duplicate();
|
|
1292
|
-
conn.on("message", (channel, message) => {
|
|
1293
|
-
const set = wakers.get(channel);
|
|
1294
|
-
if (set) for (const wake of set) wake();
|
|
1295
|
-
const subscription = presenceSubscriptions.get(channel);
|
|
1296
|
-
if (subscription) deliverRemotePresence({
|
|
1297
|
-
subscription,
|
|
1298
|
-
message
|
|
1299
|
-
});
|
|
1300
|
-
});
|
|
1301
|
-
return conn;
|
|
1302
|
-
})();
|
|
1303
|
-
return subscriber;
|
|
1304
|
-
};
|
|
1305
|
-
/**
|
|
1306
|
-
* The subscriber, gated on connection readiness — for channel
|
|
1307
|
-
* commands only (`close` uses the ungated promise, so it can never
|
|
1308
|
-
* hang on an unreachable server). SUBSCRIBE carries redis's
|
|
1309
|
-
* ok-loading flag, so ioredis writes it mid-handshake, ahead of the
|
|
1310
|
-
* ready check's INFO — which then finds the connection already in
|
|
1311
|
-
* subscriber mode and fails the check (a spurious error event plus
|
|
1312
|
-
* a reconnect). ECHO has no such flag: it resolves only once the
|
|
1313
|
-
* connection is ready, so every channel command chained behind it
|
|
1314
|
-
* lands after the check.
|
|
1315
|
-
*/
|
|
1316
|
-
let subscriberGate = null;
|
|
1317
|
-
const gatedSubscriber = () => {
|
|
1318
|
-
subscriberGate ??= getSubscriber().then(async (conn) => {
|
|
1319
|
-
await conn.call("echo", "ready");
|
|
1320
|
-
return conn;
|
|
1321
|
-
});
|
|
1322
|
-
return subscriberGate;
|
|
1323
|
-
};
|
|
1324
|
-
const acquireChannel = async (channel) => {
|
|
1325
|
-
const existing = subscriptions.get(channel);
|
|
1326
|
-
if (existing) {
|
|
1327
|
-
existing.refs += 1;
|
|
1328
|
-
try {
|
|
1329
|
-
await existing.ready;
|
|
1330
|
-
} catch (err) {
|
|
1331
|
-
existing.refs -= 1;
|
|
1332
|
-
throw err;
|
|
1333
|
-
}
|
|
1334
|
-
return;
|
|
1335
|
-
}
|
|
1336
|
-
const lease = {
|
|
1337
|
-
refs: 1,
|
|
1338
|
-
ready: gatedSubscriber().then(async (conn) => {
|
|
1339
|
-
await conn.call("subscribe", channel);
|
|
1340
|
-
if (subscriptions.get(channel) !== lease || severed) return;
|
|
1341
|
-
const presence = presenceSubscriptions.get(channel);
|
|
1342
|
-
if (options.polling?.() && presence) {
|
|
1343
|
-
presence.recovery = void 0;
|
|
1344
|
-
const ns = channel.slice(prefix.length + 1, -9);
|
|
1345
|
-
presence.poll = pollPresence({
|
|
1346
|
-
read: () => core.presence.read(ns),
|
|
1347
|
-
deliver: (patch) => {
|
|
1348
|
-
for (const listener of presence.listeners) listener(patch);
|
|
1349
|
-
}
|
|
1350
|
-
});
|
|
1351
|
-
await presence.poll.ready;
|
|
1352
|
-
}
|
|
1353
|
-
})
|
|
1354
|
-
};
|
|
1355
|
-
subscriptions.set(channel, lease);
|
|
1356
|
-
try {
|
|
1357
|
-
await lease.ready;
|
|
1358
|
-
} catch (err) {
|
|
1359
|
-
if (subscriptions.get(channel) === lease) {
|
|
1360
|
-
subscriptions.delete(channel);
|
|
1361
|
-
const presence = presenceSubscriptions.get(channel);
|
|
1362
|
-
presence?.poll?.stop();
|
|
1363
|
-
if (presence) presence.poll = void 0;
|
|
1364
|
-
}
|
|
1365
|
-
throw err;
|
|
1366
|
-
}
|
|
1367
|
-
};
|
|
1368
|
-
const releaseChannel = (channel) => {
|
|
1369
|
-
const lease = subscriptions.get(channel);
|
|
1370
|
-
if (!lease) return;
|
|
1371
|
-
lease.refs -= 1;
|
|
1372
|
-
if (lease.refs > 0) return;
|
|
1373
|
-
subscriptions.delete(channel);
|
|
1374
|
-
watched.delete(channel);
|
|
1375
|
-
gatedSubscriber().then((conn) => conn.call("unsubscribe", channel)).catch(() => {});
|
|
1376
|
-
};
|
|
1377
|
-
const armWaker = (channel) => {
|
|
1378
|
-
let wake;
|
|
1379
|
-
const wakeup = new Promise((resolve) => {
|
|
1380
|
-
wake = resolve;
|
|
1381
|
-
});
|
|
1382
|
-
let set = wakers.get(channel);
|
|
1383
|
-
if (!set) {
|
|
1384
|
-
set = /* @__PURE__ */ new Set();
|
|
1385
|
-
wakers.set(channel, set);
|
|
1386
|
-
}
|
|
1387
|
-
set.add(wake);
|
|
1388
|
-
return {
|
|
1389
|
-
wakeup,
|
|
1390
|
-
wake,
|
|
1391
|
-
disarm: () => {
|
|
1392
|
-
set.delete(wake);
|
|
1393
|
-
if (set.size === 0) wakers.delete(channel);
|
|
1394
|
-
}
|
|
1395
|
-
};
|
|
1396
|
-
};
|
|
1397
|
-
return {
|
|
1398
|
-
append: core.append,
|
|
1399
|
-
read: core.read,
|
|
1400
|
-
readHistory: core.readHistory,
|
|
1401
|
-
claimAvailable: core.claimAvailable,
|
|
1402
|
-
renewClaims: core.renewClaims,
|
|
1403
|
-
completeAttempt: core.completeAttempt,
|
|
1404
|
-
failAttempt: core.failAttempt,
|
|
1405
|
-
readState: core.readState,
|
|
1406
|
-
putSnapshots: core.putSnapshots,
|
|
1407
|
-
presence: {
|
|
1408
|
-
async set(ns, participant, values, meta) {
|
|
1409
|
-
const patch = await core.presence.set(ns, participant, values, meta);
|
|
1410
|
-
if (!patch) return;
|
|
1411
|
-
const channel = presenceChannel(ns);
|
|
1412
|
-
const subscription = presenceSubscriptions.get(channel);
|
|
1413
|
-
if (subscription) {
|
|
1414
|
-
subscription.poll?.update(patch);
|
|
1415
|
-
subscription.recovery?.update(patch);
|
|
1416
|
-
for (const listener of subscription.listeners) listener(patch);
|
|
1417
|
-
}
|
|
1418
|
-
},
|
|
1419
|
-
async read(ns) {
|
|
1420
|
-
const channel = presenceChannel(ns);
|
|
1421
|
-
await subscriptions.get(channel)?.ready;
|
|
1422
|
-
return presenceSubscriptions.get(channel)?.recovery?.read() ?? core.presence.read(ns);
|
|
1423
|
-
},
|
|
1424
|
-
subscribe(ns, onPatch) {
|
|
1425
|
-
const channel = presenceChannel(ns);
|
|
1426
|
-
let subscription = presenceSubscriptions.get(channel);
|
|
1427
|
-
if (!subscription) {
|
|
1428
|
-
subscription = { listeners: /* @__PURE__ */ new Set() };
|
|
1429
|
-
presenceSubscriptions.set(channel, subscription);
|
|
1430
|
-
if (options.onReconnect && !options.polling?.()) {
|
|
1431
|
-
const current = subscription;
|
|
1432
|
-
subscription.recovery = presenceRecovery({
|
|
1433
|
-
read: () => core.presence.read(ns),
|
|
1434
|
-
deliver: (patch) => {
|
|
1435
|
-
for (const listener of current.listeners) listener(patch);
|
|
1436
|
-
},
|
|
1437
|
-
isActive: () => !severed && presenceSubscriptions.get(channel) === current
|
|
1438
|
-
});
|
|
1439
|
-
}
|
|
1440
|
-
}
|
|
1441
|
-
subscription.listeners.add(onPatch);
|
|
1442
|
-
acquireChannel(channel).catch(() => {});
|
|
1443
|
-
let stopped = false;
|
|
1444
|
-
return () => {
|
|
1445
|
-
if (stopped) return;
|
|
1446
|
-
stopped = true;
|
|
1447
|
-
subscription.listeners.delete(onPatch);
|
|
1448
|
-
if (subscription.listeners.size === 0) {
|
|
1449
|
-
subscription.poll?.stop();
|
|
1450
|
-
presenceSubscriptions.delete(channel);
|
|
1451
|
-
}
|
|
1452
|
-
releaseChannel(channel);
|
|
1453
|
-
};
|
|
1454
|
-
}
|
|
1455
|
-
},
|
|
1456
|
-
stream(sessionId, opts) {
|
|
1457
|
-
const startAfter = opts?.startAfter ?? 0;
|
|
1458
|
-
const channel = notifyChannel(sessionId);
|
|
1459
|
-
return { [Symbol.asyncIterator]() {
|
|
1460
|
-
let last = startAfter;
|
|
1461
|
-
let buffer = [];
|
|
1462
|
-
let closed = false;
|
|
1463
|
-
let subscribed = false;
|
|
1464
|
-
let pending = null;
|
|
1465
|
-
let interrupt = null;
|
|
1466
|
-
let poller;
|
|
1467
|
-
let stopActivity;
|
|
1468
|
-
const activity = createStreamActivity(() => {
|
|
1469
|
-
if (poller) streamActivities.get(poller)?.activate();
|
|
1470
|
-
});
|
|
1471
|
-
const release = () => {
|
|
1472
|
-
stopActivity?.();
|
|
1473
|
-
if (poller) {
|
|
1474
|
-
poller.return?.();
|
|
1475
|
-
pollingStreams.delete(poller);
|
|
1476
|
-
}
|
|
1477
|
-
if (!subscribed) return;
|
|
1478
|
-
subscribed = false;
|
|
1479
|
-
releaseChannel(channel);
|
|
1480
|
-
};
|
|
1481
|
-
const iterator = {
|
|
1482
|
-
async next() {
|
|
1483
|
-
for (;;) {
|
|
1484
|
-
if (closed) {
|
|
1485
|
-
release();
|
|
1486
|
-
return {
|
|
1487
|
-
value: void 0,
|
|
1488
|
-
done: true
|
|
1489
|
-
};
|
|
1490
|
-
}
|
|
1491
|
-
const row = buffer.shift();
|
|
1492
|
-
if (row) {
|
|
1493
|
-
last = row.index;
|
|
1494
|
-
return {
|
|
1495
|
-
value: row,
|
|
1496
|
-
done: false
|
|
1497
|
-
};
|
|
1498
|
-
}
|
|
1499
|
-
if (severed) {
|
|
1500
|
-
release();
|
|
1501
|
-
throw new A2Error("STORE_UNAVAILABLE", "the store was closed");
|
|
1502
|
-
}
|
|
1503
|
-
const waker = armWaker(channel);
|
|
1504
|
-
interrupt = waker.wake;
|
|
1505
|
-
try {
|
|
1506
|
-
if (!subscribed) {
|
|
1507
|
-
subscribed = true;
|
|
1508
|
-
await acquireChannel(channel);
|
|
1509
|
-
if (closed || severed) continue;
|
|
1510
|
-
}
|
|
1511
|
-
if (options.polling?.()) {
|
|
1512
|
-
if (!poller) {
|
|
1513
|
-
poller = pollingStream((after) => core.readEvents(sessionId, after), { startAfter: last })[Symbol.asyncIterator]();
|
|
1514
|
-
pollingStreams.add(poller);
|
|
1515
|
-
stopActivity = streamActivities.get(poller)?.subscribe(activity.setIdle);
|
|
1516
|
-
}
|
|
1517
|
-
const result = await poller.next();
|
|
1518
|
-
if (!result.done) presenceSubscriptions.get(presenceChannel(sessionId))?.poll?.wake();
|
|
1519
|
-
if (severed) throw new A2Error("STORE_UNAVAILABLE", "the store was closed");
|
|
1520
|
-
return result;
|
|
1521
|
-
}
|
|
1522
|
-
let marker = watched.get(channel);
|
|
1523
|
-
if (!marker || Date.now() - marker.at >= NOTIFY_TIMINGS.safetyReadMs) {
|
|
1524
|
-
marker = {
|
|
1525
|
-
at: Date.now(),
|
|
1526
|
-
ready: core.markWatched(sessionId, NOTIFY_TIMINGS.safetyReadMs * 3)
|
|
1527
|
-
};
|
|
1528
|
-
watched.set(channel, marker);
|
|
1529
|
-
}
|
|
1530
|
-
await marker.ready;
|
|
1531
|
-
if (closed || severed) continue;
|
|
1532
|
-
buffer = await core.readEvents(sessionId, last);
|
|
1533
|
-
if (buffer.length === 0 && !closed && !severed) {
|
|
1534
|
-
pending = defaultSleep(NOTIFY_TIMINGS.safetyReadMs);
|
|
1535
|
-
await Promise.race([waker.wakeup, pending.promise]);
|
|
1536
|
-
pending.cancel();
|
|
1537
|
-
pending = null;
|
|
1538
|
-
}
|
|
1539
|
-
} catch (err) {
|
|
1540
|
-
release();
|
|
1541
|
-
if (closed) return {
|
|
1542
|
-
value: void 0,
|
|
1543
|
-
done: true
|
|
1544
|
-
};
|
|
1545
|
-
if (err instanceof A2Error) throw err;
|
|
1546
|
-
throw new A2Error("STORE_UNAVAILABLE", "redis store operation failed", { cause: err });
|
|
1547
|
-
} finally {
|
|
1548
|
-
waker.disarm();
|
|
1549
|
-
interrupt = null;
|
|
1550
|
-
}
|
|
1551
|
-
}
|
|
1552
|
-
},
|
|
1553
|
-
async return() {
|
|
1554
|
-
closed = true;
|
|
1555
|
-
pending?.cancel();
|
|
1556
|
-
interrupt?.();
|
|
1557
|
-
release();
|
|
1558
|
-
return {
|
|
1559
|
-
value: void 0,
|
|
1560
|
-
done: true
|
|
1561
|
-
};
|
|
1562
|
-
}
|
|
1563
|
-
};
|
|
1564
|
-
streamActivities.set(iterator, activity);
|
|
1565
|
-
return iterator;
|
|
1566
|
-
} };
|
|
1567
|
-
},
|
|
1568
|
-
async close() {
|
|
1569
|
-
severed = true;
|
|
1570
|
-
for (const subscription of presenceSubscriptions.values()) subscription.poll?.stop();
|
|
1571
|
-
presenceSubscriptions.clear();
|
|
1572
|
-
for (const poller of pollingStreams) poller.return?.();
|
|
1573
|
-
pollingStreams.clear();
|
|
1574
|
-
for (const set of wakers.values()) for (const wake of set) wake();
|
|
1575
|
-
if (subscriber) (await subscriber.catch(() => null))?.disconnect();
|
|
1576
|
-
await options.closeClient();
|
|
1577
|
-
}
|
|
1578
|
-
};
|
|
1579
|
-
}
|
|
1580
|
-
//#endregion
|
|
1581
|
-
export { createRedisNotifyStore as t };
|
|
1582
|
-
|
|
1583
|
-
//# sourceMappingURL=store-redis-notify-BVLUfI3j.js.map
|