experimental-a2 0.16.1 → 0.18.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 +373 -0
- package/README.md +1 -1
- package/dist/{actor-ohPC-81x.d.ts → actor-Bjce9_5U.d.ts} +21 -11
- package/dist/actor-Bjce9_5U.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-BpOGm-zB.js} +621 -216
- package/dist/ai-BpOGm-zB.js.map +1 -0
- package/dist/{ai-CrEf6p_W.d.ts → ai-DrUaMyRt.d.ts} +114 -100
- package/dist/ai-DrUaMyRt.d.ts.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-fx.browser.d.ts +1 -0
- package/dist/ai-fx.browser.js +6 -0
- package/dist/ai-fx.browser.js.map +1 -0
- package/dist/ai-fx.d.ts +10 -0
- package/dist/ai-fx.d.ts.map +1 -0
- package/dist/ai-fx.js +634 -0
- package/dist/ai-fx.js.map +1 -0
- package/dist/ai-harness-BuLh_EYi.js +273 -0
- package/dist/ai-harness-BuLh_EYi.js.map +1 -0
- package/dist/ai-harness-DvsPeJeB.d.ts +26 -0
- package/dist/ai-harness-DvsPeJeB.d.ts.map +1 -0
- package/dist/ai-pi.browser.d.ts +1 -0
- package/dist/ai-pi.browser.js +6 -0
- package/dist/ai-pi.browser.js.map +1 -0
- package/dist/ai-pi.d.ts +17 -0
- package/dist/ai-pi.d.ts.map +1 -0
- package/dist/ai-pi.js +904 -0
- package/dist/ai-pi.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 +109 -43
- package/dist/ai-server.d.ts.map +1 -1
- package/dist/ai-server.js +526 -283
- package/dist/ai-server.js.map +1 -1
- package/dist/ai.d.ts +2 -2
- package/dist/ai.js +2 -2
- package/dist/bounded-cache-CPHWF-8h.js +41 -0
- package/dist/bounded-cache-CPHWF-8h.js.map +1 -0
- package/dist/{client-BYzHjkwU.js → client-BReKqwZC.js} +447 -80
- package/dist/client-BReKqwZC.js.map +1 -0
- package/dist/{client-CzyacQpJ.d.ts → client-h_DKFAjj.d.ts} +14 -13
- package/dist/client-h_DKFAjj.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-Bx-c0pDP.d.ts} +7 -3
- package/dist/initial-snapshot-Bx-c0pDP.d.ts.map +1 -0
- package/dist/{internal-Dq2qYxou.js → internal-B2T3U4Rq.js} +29 -2
- package/dist/internal-B2T3U4Rq.js.map +1 -0
- package/dist/otel.d.ts +1 -1
- package/dist/react-BBXgHWFc.d.ts +86 -0
- package/dist/react-BBXgHWFc.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-CqA59Q6U.d.ts} +46 -30
- package/dist/reducer-CqA59Q6U.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-BD5ckxZb.js → server-Dw_k_7kO.js} +904 -219
- package/dist/server-Dw_k_7kO.js.map +1 -0
- package/dist/{server-DbD7IJVK.d.ts → server-En7cbi3v.d.ts} +116 -26
- package/dist/server-En7cbi3v.d.ts.map +1 -0
- package/dist/server.d.ts +4 -4
- package/dist/server.js +2 -2
- package/dist/session-clear-Bo-XTFEy.d.ts +12 -0
- package/dist/session-clear-Bo-XTFEy.d.ts.map +1 -0
- package/dist/{errors-DCk6ch5n.js → session-clear-DGI4jTQ5.js} +17 -2
- package/dist/session-clear-DGI4jTQ5.js.map +1 -0
- package/dist/{store-DyZM6fS5.d.ts → store-Djdv0bnZ.d.ts} +73 -3
- package/dist/store-Djdv0bnZ.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 +205 -66
- package/dist/store-memory.js.map +1 -1
- package/dist/{store-polling-CmxUbV93.js → store-polling-B5FcwJfo.js} +21 -4
- package/dist/store-polling-B5FcwJfo.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 +450 -108
- package/dist/store-postgres.js.map +1 -1
- package/dist/{store-presence-polling-C7-XZyW9.js → store-presence-polling-BAwS8RWn.js} +3 -3
- package/dist/{store-presence-polling-C7-XZyW9.js.map → store-presence-polling-BAwS8RWn.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-BRITti_o.js +2493 -0
- package/dist/store-redis-notify-BRITti_o.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 +197 -33
- 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-Cyfqo6Qt.d.ts} +2 -2
- package/dist/telemetry-Cyfqo6Qt.d.ts.map +1 -0
- package/dist/{wire-BO5wWCb1.js → wire-rSUglYo8.js} +132 -10
- package/dist/wire-rSUglYo8.js.map +1 -0
- package/docs/01-quickstart.mdx +50 -84
- package/docs/{guides/07-examples.mdx → 02-examples.mdx} +10 -20
- package/docs/actors/01-introduction.mdx +48 -82
- package/docs/actors/02-concurrency.mdx +43 -70
- package/docs/actors/03-timers.mdx +28 -42
- package/docs/actors/04-routes.mdx +110 -137
- package/docs/actors/meta.ts +1 -1
- package/docs/agents/01-introduction.mdx +257 -0
- package/docs/agents/02-tools.mdx +247 -0
- package/docs/agents/03-conversations.mdx +538 -0
- package/docs/agents/04-harnesses.mdx +136 -0
- package/docs/agents/meta.ts +1 -0
- package/docs/client/01-react.mdx +294 -0
- package/docs/client/02-transports.mdx +86 -0
- package/docs/client/03-presence.mdx +224 -0
- package/docs/client/meta.ts +1 -0
- package/docs/contract/01-events.mdx +57 -0
- package/docs/contract/02-state.mdx +142 -0
- package/docs/contract/03-sessions.mdx +175 -0
- package/docs/index.mdx +112 -156
- package/docs/reference/01-core.mdx +139 -0
- package/docs/reference/02-server.mdx +855 -0
- package/docs/reference/03-client.mdx +301 -0
- package/docs/reference/04-ai.mdx +722 -0
- package/docs/reference/05-adapters.mdx +351 -0
- package/docs/reference/06-errors.mdx +138 -0
- package/docs/reference/meta.ts +1 -1
- package/docs/server/01-handlers.mdx +200 -0
- package/docs/server/02-timers.mdx +130 -0
- package/docs/server/03-cancellation.mdx +141 -0
- package/docs/server/04-routes.mdx +243 -0
- package/docs/server/05-production.mdx +322 -0
- package/docs/server/06-guarantees.mdx +123 -0
- package/examples/playground/DEPLOY.md +53 -7
- package/examples/playground/app/a0/[appId]/loading.tsx +16 -0
- package/examples/playground/app/a0/[appId]/page.tsx +53 -0
- package/examples/playground/app/a0/a0.css +818 -0
- package/examples/playground/app/a0/app-list.tsx +218 -0
- package/examples/playground/app/a0/app-view.tsx +63 -0
- package/examples/playground/app/a0/apps/[appId]/preview/route.ts +27 -0
- package/examples/playground/app/a0/apps/[appId]/route.ts +28 -0
- package/examples/playground/app/a0/apps.test.ts +128 -0
- package/examples/playground/app/a0/apps.ts +102 -0
- package/examples/playground/app/a0/chat.tsx +410 -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/fx/events/route.ts +9 -0
- package/examples/playground/app/a0/fx/scheduler/route.ts +7 -0
- package/examples/playground/app/a0/fx/short/events/route.ts +11 -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 +73 -0
- package/examples/playground/app/a0/model.ts +95 -0
- package/examples/playground/app/a0/no-photon.cjs +4 -0
- package/examples/playground/app/a0/no-quickjs.cjs +3 -0
- package/examples/playground/app/a0/page.tsx +14 -0
- package/examples/playground/app/a0/pi/events/route.ts +9 -0
- package/examples/playground/app/a0/pi/scheduler/route.ts +7 -0
- package/examples/playground/app/a0/pi/short/events/route.ts +11 -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 +75 -0
- package/examples/playground/app/a0/sandbox-session.ts +152 -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 +213 -0
- package/examples/playground/app/a0/session.ts +59 -0
- package/examples/playground/app/a0/shell.tsx +158 -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 +101 -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 +19 -15
- package/examples/playground/app/agent/session.ts +5 -6
- package/examples/playground/app/agent/telemetry.test.ts +189 -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 +12 -12
- 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 +112 -0
- package/examples/playground/app/chat/server.ts +113 -117
- 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/next.config.ts +17 -5
- package/examples/playground/package.json +13 -2
- package/examples/playground/scripts/a0-template.ts +77 -0
- package/examples/playground/vercel.json +36 -0
- package/package.json +29 -3
- 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 +2517 -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-fx.browser.ts +5 -0
- package/src/ai-fx.ts +833 -0
- package/src/ai-harness.ts +518 -0
- package/src/ai-message-base.ts +209 -0
- package/src/ai-message-projection.ts +262 -64
- package/src/ai-model-metadata.ts +11 -6
- package/src/ai-pi.browser.ts +5 -0
- package/src/ai-pi.ts +1263 -0
- 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-sdk-step.ts +2 -2
- package/src/ai-server.browser.ts +2 -2
- package/src/ai-server.ts +23 -1965
- 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 +52 -0
- package/src/libfx.d.ts +34 -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 +93 -11
- 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 +1133 -255
- 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 +252 -75
- package/src/store-polling.ts +18 -4
- package/src/store-postgres.ts +541 -114
- package/src/store-redis-core.ts +1450 -493
- package/src/store-redis-http.ts +18 -2
- package/src/store-redis-notify.ts +262 -85
- package/src/store-redis.ts +19 -9
- package/src/store-sqlite.ts +319 -45
- package/src/store.ts +79 -1
- package/src/stream-buffer.ts +43 -0
- package/src/telemetry.ts +14 -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/docs/concepts/01-contracts.mdx +0 -142
- package/docs/concepts/02-handlers.mdx +0 -284
- package/docs/concepts/03-durability.mdx +0 -314
- package/docs/concepts/04-state.mdx +0 -209
- package/docs/guides/01-timers.mdx +0 -185
- package/docs/guides/02-cancellation.mdx +0 -161
- package/docs/guides/03-react.mdx +0 -384
- package/docs/guides/04-local-first.mdx +0 -88
- package/docs/guides/05-production.mdx +0 -521
- package/docs/guides/06-ai-agents.mdx +0 -1318
- package/docs/guides/08-application-data.mdx +0 -113
- package/docs/guides/09-presence.mdx +0 -266
- package/docs/guides/10-transports.mdx +0 -221
- package/docs/reference/01-api.mdx +0 -1896
- package/docs/reference/02-errors.mdx +0 -149
- 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
- /package/docs/{concepts → contract}/meta.ts +0 -0
- /package/docs/{guides → server}/meta.ts +0 -0
package/src/server.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { decodeState, encodeState } from './reducer-runtime.ts'
|
|
1
2
|
// oxlint-disable no-await-in-loop -- scheduler advances after durable outcomes
|
|
2
3
|
/**
|
|
3
4
|
* experimental-a2/server — where a contract is implemented. `createServer({
|
|
@@ -24,28 +25,55 @@ import {
|
|
|
24
25
|
DRAIN_TIMINGS,
|
|
25
26
|
POLL_TIMINGS,
|
|
26
27
|
MAX_DATE_MS,
|
|
28
|
+
failedPreviousAttempt,
|
|
27
29
|
RESERVED_PARTICIPANT_IDS,
|
|
30
|
+
STATE_CACHE,
|
|
28
31
|
markSchedulerSendFailure,
|
|
32
|
+
stateTailReaders,
|
|
29
33
|
nullProtoRecord,
|
|
30
34
|
serverInternals,
|
|
31
35
|
type DrainOutcome,
|
|
32
36
|
type DrainResult,
|
|
33
37
|
} from './internal.ts'
|
|
38
|
+
import {
|
|
39
|
+
isSessionCleared,
|
|
40
|
+
sessionClearedError,
|
|
41
|
+
type SessionCleared,
|
|
42
|
+
} from './session-clear.ts'
|
|
43
|
+
import { boundedCache } from './bounded-cache.ts'
|
|
34
44
|
import { defaultSleep, type Sleeper } from './store-polling.ts'
|
|
35
45
|
import { createStreamActivity, streamActivities } from './stream-activity.ts'
|
|
46
|
+
import {
|
|
47
|
+
bufferedEvents,
|
|
48
|
+
isSerializedEvent,
|
|
49
|
+
takeBuffered,
|
|
50
|
+
} from './stream-buffer.ts'
|
|
36
51
|
import type {
|
|
37
52
|
A2Store,
|
|
38
53
|
AppendEvent,
|
|
39
54
|
Event,
|
|
40
55
|
EventCause,
|
|
41
56
|
StoreClaimAvailableResult,
|
|
57
|
+
StoreClearResult,
|
|
58
|
+
StoreReclaimResult,
|
|
59
|
+
StoreRedriveResult,
|
|
42
60
|
StoreSnapshotWrite,
|
|
43
61
|
StoreStateRead,
|
|
44
62
|
PresenceRow,
|
|
45
63
|
ReturnedEvent,
|
|
46
64
|
StoredEvent,
|
|
47
65
|
} from './store.ts'
|
|
48
|
-
import
|
|
66
|
+
import {
|
|
67
|
+
cloneInitial,
|
|
68
|
+
foldEvent,
|
|
69
|
+
type Reducer,
|
|
70
|
+
type RegisteredReducer,
|
|
71
|
+
} from './reducer.ts'
|
|
72
|
+
type PublicState<R> = undefined extends R
|
|
73
|
+
? never
|
|
74
|
+
: R extends { initialState: infer S }
|
|
75
|
+
? S
|
|
76
|
+
: never
|
|
49
77
|
import {
|
|
50
78
|
createServerFetch,
|
|
51
79
|
type A2Operation,
|
|
@@ -59,6 +87,7 @@ import {
|
|
|
59
87
|
deterministicEventId,
|
|
60
88
|
deterministicReturnedEventId,
|
|
61
89
|
deterministicScheduledEventId,
|
|
90
|
+
deterministicScheduleClearId,
|
|
62
91
|
deterministicScheduleId,
|
|
63
92
|
} from './deterministic-id.ts'
|
|
64
93
|
import { invocationDeadlineMs, platformWaitUntil } from './platform.ts'
|
|
@@ -66,6 +95,7 @@ import { retryableLazy } from './retryable-lazy.ts'
|
|
|
66
95
|
import type {
|
|
67
96
|
ScheduledEvent,
|
|
68
97
|
SchedulerAppendTask,
|
|
98
|
+
SchedulerClearTask,
|
|
69
99
|
SchedulerTask,
|
|
70
100
|
} from './scheduler-task.ts'
|
|
71
101
|
import {
|
|
@@ -79,13 +109,14 @@ export type HandlerContext<
|
|
|
79
109
|
D extends EventDefs,
|
|
80
110
|
K extends keyof D & string = keyof D & string,
|
|
81
111
|
P extends PresenceDefs = Record<never, never>,
|
|
112
|
+
S = never,
|
|
82
113
|
> = {
|
|
83
114
|
/** The triggering event. */
|
|
84
115
|
event: ContractEvent<D, K>
|
|
85
116
|
/** Durable, 1-based dispatch ordinal for this event. */
|
|
86
117
|
attempt: number
|
|
87
118
|
/** This session, with handler-scoped idempotent append. */
|
|
88
|
-
session: Session<D, HandlerAppend<D>, P>
|
|
119
|
+
session: Session<D, HandlerAppend<D>, P, S>
|
|
89
120
|
/** Fires on `abortOn` events (cancellation slice); dormant otherwise. */
|
|
90
121
|
signal: AbortSignal
|
|
91
122
|
}
|
|
@@ -94,8 +125,9 @@ export type Handler<
|
|
|
94
125
|
D extends EventDefs,
|
|
95
126
|
K extends keyof D & string = keyof D & string,
|
|
96
127
|
P extends PresenceDefs = Record<never, never>,
|
|
128
|
+
S = never,
|
|
97
129
|
> = (
|
|
98
|
-
ctx: HandlerContext<D, K, P>,
|
|
130
|
+
ctx: HandlerContext<D, K, P, S>,
|
|
99
131
|
) => Promise<void | AppendInput<D> | readonly AppendInput<D>[]>
|
|
100
132
|
|
|
101
133
|
export type LaneContext<
|
|
@@ -130,16 +162,70 @@ export type ScheduleDelay = `${number}${'ms' | 's' | 'm' | 'h' | 'd'}`
|
|
|
130
162
|
export type ScheduleTiming =
|
|
131
163
|
{ delay: ScheduleDelay; at?: never } | { at: Date; delay?: never }
|
|
132
164
|
|
|
133
|
-
export type SessionSchedule<D extends EventDefs> =
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
165
|
+
export type SessionSchedule<D extends EventDefs> = {
|
|
166
|
+
(
|
|
167
|
+
name: string,
|
|
168
|
+
timing: ScheduleTiming,
|
|
169
|
+
...events: AppendInput<D>[]
|
|
170
|
+
): Promise<void>
|
|
171
|
+
/**
|
|
172
|
+
* Publish a durable conditional clear. Delivery clears only while
|
|
173
|
+
* `ifLastIndex` is still the head, and finishes deleting before it
|
|
174
|
+
* acknowledges.
|
|
175
|
+
*/
|
|
176
|
+
clear(
|
|
177
|
+
name: string,
|
|
178
|
+
options: ScheduleTiming & { ifLastIndex: number },
|
|
179
|
+
): Promise<void>
|
|
180
|
+
}
|
|
138
181
|
|
|
139
182
|
export type StateOptions = {
|
|
140
183
|
through?: number | 'latest'
|
|
141
184
|
}
|
|
142
185
|
|
|
186
|
+
export type SessionClearOptions = {
|
|
187
|
+
/** Clear only while this is the head; a mismatch changes nothing. */
|
|
188
|
+
ifLastIndex?: number
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export type SessionClearResult = { cleared: boolean; index: number }
|
|
192
|
+
|
|
193
|
+
export type SessionRedriveOptions = {
|
|
194
|
+
/** `retry` runs the handler again with a fresh failure budget; `skip` settles the event without running it. */
|
|
195
|
+
action: 'retry' | 'skip'
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export type SessionRedriveResult = {
|
|
199
|
+
/** Whether the event was dead-lettered and is now resolved. */
|
|
200
|
+
redriven: boolean
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** A dead-lettered event, with what an operator needs to resolve it. */
|
|
204
|
+
export type DeadLetter<D extends EventDefs> = ContractEvent<D> & {
|
|
205
|
+
lane: string | null
|
|
206
|
+
/** Durable dispatch claims, including claims abandoned by hard kills. */
|
|
207
|
+
attemptCount: number
|
|
208
|
+
/** Caught handler failures since the event was appended or last retried. */
|
|
209
|
+
failureCount: number
|
|
210
|
+
/** The last handler failure, stringified. */
|
|
211
|
+
lastError: string | null
|
|
212
|
+
failedAt: Date
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** Dead-letter resolution; root sessions only. */
|
|
216
|
+
export type SessionDeadLetters<D extends EventDefs> = {
|
|
217
|
+
/** The session's dead-lettered events, oldest first. */
|
|
218
|
+
deadLetters(): Promise<DeadLetter<D>[]>
|
|
219
|
+
/**
|
|
220
|
+
* Atomically resolve one dead-lettered event, then wake the session like
|
|
221
|
+
* an append. Any other event is left alone and reports `redriven: false`.
|
|
222
|
+
*/
|
|
223
|
+
redrive(
|
|
224
|
+
index: number,
|
|
225
|
+
options: SessionRedriveOptions,
|
|
226
|
+
): Promise<SessionRedriveResult>
|
|
227
|
+
}
|
|
228
|
+
|
|
143
229
|
/**
|
|
144
230
|
* The presence members of a session — intersected in via
|
|
145
231
|
* `WithPresence`, so they exist exactly when the contract declares
|
|
@@ -156,7 +242,9 @@ export type SessionPresence<D extends EventDefs, P extends PresenceDefs> = {
|
|
|
156
242
|
stream(opts: {
|
|
157
243
|
startAfter?: number
|
|
158
244
|
presence: true
|
|
159
|
-
}): AsyncIterable<
|
|
245
|
+
}): AsyncIterable<
|
|
246
|
+
ContractEvent<D> | SessionCleared | PresencePatch<P> | PresenceSnapshot<P>
|
|
247
|
+
>
|
|
160
248
|
/**
|
|
161
249
|
* Validate against the contract's presence schemas, then broadcast.
|
|
162
250
|
* Never an append: no log row, no dispatch, no scheduler arm. `at`
|
|
@@ -178,20 +266,33 @@ export type Session<
|
|
|
178
266
|
D extends EventDefs,
|
|
179
267
|
Append = SessionAppend<D>,
|
|
180
268
|
P extends PresenceDefs = Record<never, never>,
|
|
269
|
+
S = never,
|
|
181
270
|
> = WithPresence<P, SessionPresence<D, P>> & {
|
|
182
271
|
readonly id: string
|
|
183
272
|
append: Append
|
|
184
273
|
schedule: SessionSchedule<D>
|
|
274
|
+
/**
|
|
275
|
+
* End the current lifetime: atomically append a clear marker, then
|
|
276
|
+
* delete the history below it. Indexes never restart.
|
|
277
|
+
*/
|
|
278
|
+
clear(options?: SessionClearOptions): Promise<SessionClearResult>
|
|
185
279
|
history(options?: { gte?: number; lte?: number }): Promise<ContractEvent<D>[]>
|
|
186
|
-
state
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
280
|
+
state: {
|
|
281
|
+
<T>(
|
|
282
|
+
reducer: Reducer<D, T, Record<never, never>>,
|
|
283
|
+
options?: StateOptions,
|
|
284
|
+
): Promise<{ state: T; index: number; clearIndex?: number }>
|
|
285
|
+
} & ([S] extends [never]
|
|
286
|
+
? unknown
|
|
287
|
+
: { (): Promise<{ state: S; index: number; clearIndex?: number }> })
|
|
190
288
|
/**
|
|
191
289
|
* A live feed of this session's events, starting after `startAfter`
|
|
192
290
|
* (exclusive). Server-side only. `server.fetch` exposes it over SSE.
|
|
291
|
+
* A clear arrives as a `SessionCleared` marker.
|
|
193
292
|
*/
|
|
194
|
-
stream(opts?: {
|
|
293
|
+
stream(opts?: {
|
|
294
|
+
startAfter?: number
|
|
295
|
+
}): AsyncIterable<ContractEvent<D> | SessionCleared>
|
|
195
296
|
}
|
|
196
297
|
|
|
197
298
|
/**
|
|
@@ -221,14 +322,18 @@ export type A2Scheduler = {
|
|
|
221
322
|
export type A2Server<
|
|
222
323
|
D extends EventDefs,
|
|
223
324
|
P extends PresenceDefs = Record<never, never>,
|
|
325
|
+
R extends RegisteredReducer<D, P> | undefined =
|
|
326
|
+
RegisteredReducer<D, P> | undefined,
|
|
224
327
|
> = {
|
|
225
328
|
/** The contract this server implements. */
|
|
226
|
-
readonly contract: Contract<D, P>
|
|
329
|
+
readonly contract: Contract<D, P, R>
|
|
227
330
|
readonly fetch: {
|
|
228
331
|
(request: Request): Promise<Response>
|
|
229
332
|
(request: Request, options: ServerFetchOptions<D, P>): Promise<Response>
|
|
230
333
|
}
|
|
231
|
-
session(
|
|
334
|
+
session(
|
|
335
|
+
id: string,
|
|
336
|
+
): Session<D, SessionAppend<D>, P, PublicState<R>> & SessionDeadLetters<D>
|
|
232
337
|
/**
|
|
233
338
|
* Process every currently eligible event. `settled` means nothing
|
|
234
339
|
* actionable remains, including work blocked behind a dead letter.
|
|
@@ -259,13 +364,58 @@ export async function deliverSchedulerAppend(
|
|
|
259
364
|
`a2 scheduler: append task for contract '${task.contract}' cannot be delivered to '${server.contract.name}'`,
|
|
260
365
|
)
|
|
261
366
|
}
|
|
367
|
+
const { originIndex } = task
|
|
368
|
+
// A malformed origin would read as cleared and drop the events silently.
|
|
369
|
+
if (
|
|
370
|
+
originIndex !== undefined &&
|
|
371
|
+
(!Number.isSafeInteger(originIndex) || originIndex < 1)
|
|
372
|
+
) {
|
|
373
|
+
throw new TypeError(
|
|
374
|
+
'a2 scheduler: append task originIndex must be a positive safe integer',
|
|
375
|
+
)
|
|
376
|
+
}
|
|
262
377
|
const internals = serverInternals.get(server)
|
|
263
378
|
if (!internals) {
|
|
264
379
|
throw new TypeError(
|
|
265
380
|
`a2 scheduler: server for contract '${task.contract}' cannot receive scheduled appends`,
|
|
266
381
|
)
|
|
267
382
|
}
|
|
268
|
-
await internals.schedulerAppend(task.sessionId, task.events)
|
|
383
|
+
await internals.schedulerAppend(task.sessionId, task.events, originIndex)
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/**
|
|
387
|
+
* Deliver one authenticated scheduler clear: a conditional
|
|
388
|
+
* `clear({ ifLastIndex })` that resolves after its deletion finishes, so a
|
|
389
|
+
* provider retry resumes an interrupted deletion. Custom scheduler adapters
|
|
390
|
+
* call this after validating their transport envelope; application code uses
|
|
391
|
+
* `session.schedule.clear`.
|
|
392
|
+
*/
|
|
393
|
+
export async function deliverSchedulerClear(
|
|
394
|
+
server: DrainableServer,
|
|
395
|
+
task: SchedulerClearTask,
|
|
396
|
+
): Promise<void> {
|
|
397
|
+
if (server.contract.name !== task.contract) {
|
|
398
|
+
throw new TypeError(
|
|
399
|
+
`a2 scheduler: clear task for contract '${task.contract}' cannot be delivered to '${server.contract.name}'`,
|
|
400
|
+
)
|
|
401
|
+
}
|
|
402
|
+
// Without its condition a clear task would erase the whole session.
|
|
403
|
+
if (
|
|
404
|
+
task.kind !== 'clear' ||
|
|
405
|
+
!Number.isSafeInteger(task.ifLastIndex) ||
|
|
406
|
+
task.ifLastIndex < 0
|
|
407
|
+
) {
|
|
408
|
+
throw new TypeError(
|
|
409
|
+
'a2 scheduler: a clear task needs kind "clear" and a non-negative safe integer ifLastIndex',
|
|
410
|
+
)
|
|
411
|
+
}
|
|
412
|
+
const internals = serverInternals.get(server)
|
|
413
|
+
if (!internals) {
|
|
414
|
+
throw new TypeError(
|
|
415
|
+
`a2 scheduler: server for contract '${task.contract}' cannot receive scheduled clears`,
|
|
416
|
+
)
|
|
417
|
+
}
|
|
418
|
+
await internals.schedulerClear(task.sessionId, task.ifLastIndex)
|
|
269
419
|
}
|
|
270
420
|
|
|
271
421
|
/**
|
|
@@ -292,13 +442,14 @@ export type HandlerEntry<
|
|
|
292
442
|
D extends EventDefs,
|
|
293
443
|
K extends keyof D & string = keyof D & string,
|
|
294
444
|
P extends PresenceDefs = Record<never, never>,
|
|
445
|
+
S = never,
|
|
295
446
|
> =
|
|
296
|
-
| Handler<D, K, P>
|
|
447
|
+
| Handler<D, K, P, S>
|
|
297
448
|
| {
|
|
298
449
|
abortOn?: AbortSpec<D, K>
|
|
299
450
|
/** Session-scoped FIFO key, resolved and persisted when the event lands. */
|
|
300
451
|
lane?: Lane<D, K>
|
|
301
|
-
handler: Handler<D, K, P>
|
|
452
|
+
handler: Handler<D, K, P, S>
|
|
302
453
|
}
|
|
303
454
|
|
|
304
455
|
type HandlerFunction = (ctx: never) => Promise<unknown>
|
|
@@ -315,9 +466,11 @@ export function handler(
|
|
|
315
466
|
export type ServerOptions<
|
|
316
467
|
D extends EventDefs,
|
|
317
468
|
P extends PresenceDefs = Record<never, never>,
|
|
469
|
+
R extends RegisteredReducer<D, P> | undefined =
|
|
470
|
+
RegisteredReducer<D, P> | undefined,
|
|
318
471
|
> = {
|
|
319
472
|
/** The contract this server implements (see `a2.contract`). */
|
|
320
|
-
contract: Contract<D, P>
|
|
473
|
+
contract: Contract<D, P, R>
|
|
321
474
|
/** Where events live. Defaults: sqlite in dev, memory in tests, required in prod. */
|
|
322
475
|
store?: A2Store
|
|
323
476
|
/**
|
|
@@ -341,11 +494,31 @@ export type ServerOptions<
|
|
|
341
494
|
* wasn't imported. Compose across files by spreading objects (note:
|
|
342
495
|
* a duplicate key under spread silently last-wins).
|
|
343
496
|
*/
|
|
344
|
-
handlers?: { [K in keyof D & string]?: HandlerEntry<D, K, P
|
|
497
|
+
handlers?: { [K in keyof D & string]?: HandlerEntry<D, K, P, PublicState<R>> }
|
|
345
498
|
}
|
|
346
499
|
|
|
347
500
|
const MAX_FAILURES = 10
|
|
348
501
|
|
|
502
|
+
/** Abort a lost attempt's handler and release its drain slot. */
|
|
503
|
+
function endSuperseded(
|
|
504
|
+
entry: {
|
|
505
|
+
supersede: AbortController
|
|
506
|
+
fence: (outcome: {
|
|
507
|
+
status: 'superseded'
|
|
508
|
+
opensLane: false
|
|
509
|
+
appendsEvents: false
|
|
510
|
+
}) => void
|
|
511
|
+
},
|
|
512
|
+
message: string,
|
|
513
|
+
): void {
|
|
514
|
+
entry.supersede.abort(new A2Error('SUPERSEDED_ATTEMPT', message))
|
|
515
|
+
entry.fence({ status: 'superseded', opensLane: false, appendsEvents: false })
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
/** Pause between clear-deletion batches that found only locked rows. */
|
|
519
|
+
const RECLAIM_STALL_MS = 20
|
|
520
|
+
const RECLAIM_STALL_LIMIT = 50
|
|
521
|
+
|
|
349
522
|
/** Default: how long a presence value survives without a refreshing set. */
|
|
350
523
|
const PRESENCE_TTL_MS = 60_000
|
|
351
524
|
|
|
@@ -470,6 +643,27 @@ const devDefaultStore = retryableLazy(() =>
|
|
|
470
643
|
import('./store-sqlite.ts').then((m) => m.sqlite()),
|
|
471
644
|
)
|
|
472
645
|
|
|
646
|
+
/** Waits for a scheduler arm for at most `schedulerArmTimeoutMs`. */
|
|
647
|
+
async function joinSchedulerArm(arm: Promise<void>): Promise<void> {
|
|
648
|
+
let timeout: ReturnType<typeof setTimeout> | null = null
|
|
649
|
+
try {
|
|
650
|
+
await Promise.race([
|
|
651
|
+
arm,
|
|
652
|
+
new Promise<never>((_, reject) => {
|
|
653
|
+
timeout = setTimeout(() => {
|
|
654
|
+
reject(
|
|
655
|
+
new Error(
|
|
656
|
+
`a2 scheduler arm timed out after ${DRAIN_TIMINGS.schedulerArmTimeoutMs}ms`,
|
|
657
|
+
),
|
|
658
|
+
)
|
|
659
|
+
}, DRAIN_TIMINGS.schedulerArmTimeoutMs)
|
|
660
|
+
}),
|
|
661
|
+
])
|
|
662
|
+
} finally {
|
|
663
|
+
if (timeout) clearTimeout(timeout)
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
|
|
473
667
|
function environment(): 'development' | 'test' | 'production' {
|
|
474
668
|
const env =
|
|
475
669
|
typeof process === 'undefined' ? undefined : process.env?.['NODE_ENV']
|
|
@@ -511,11 +705,32 @@ function foldPresenceRows(
|
|
|
511
705
|
return map
|
|
512
706
|
}
|
|
513
707
|
|
|
708
|
+
type SnapshotWrites = Map<
|
|
709
|
+
number,
|
|
710
|
+
{ state: unknown; pinEventIndexes: Set<number> }
|
|
711
|
+
>
|
|
712
|
+
|
|
713
|
+
const addSnapshotWrite = (
|
|
714
|
+
writes: SnapshotWrites,
|
|
715
|
+
index: number,
|
|
716
|
+
state: unknown,
|
|
717
|
+
pinEventIndex?: number,
|
|
718
|
+
): void => {
|
|
719
|
+
let write = writes.get(index)
|
|
720
|
+
if (!write) {
|
|
721
|
+
write = { state: cloneInitial(state), pinEventIndexes: new Set() }
|
|
722
|
+
writes.set(index, write)
|
|
723
|
+
}
|
|
724
|
+
if (pinEventIndex !== undefined) write.pinEventIndexes.add(pinEventIndex)
|
|
725
|
+
}
|
|
726
|
+
|
|
514
727
|
/** Implement a contract: bind its vocabulary to storage and reactions. */
|
|
515
728
|
export function createServer<
|
|
516
729
|
D extends EventDefs,
|
|
517
730
|
P extends PresenceDefs = Record<never, never>,
|
|
518
|
-
|
|
731
|
+
R extends RegisteredReducer<D, P> | undefined =
|
|
732
|
+
RegisteredReducer<D, P> | undefined,
|
|
733
|
+
>(options: ServerOptions<D, P, R>): A2Server<D, P, R> {
|
|
519
734
|
const serverContract = options?.contract
|
|
520
735
|
if (
|
|
521
736
|
serverContract === null ||
|
|
@@ -855,6 +1070,7 @@ export function createServer<
|
|
|
855
1070
|
cause?: EventCause,
|
|
856
1071
|
generatedIds?: readonly boolean[],
|
|
857
1072
|
requireDurableInitialArm = false,
|
|
1073
|
+
originIndex?: number,
|
|
858
1074
|
): Promise<ContractEvent<D>[]> =>
|
|
859
1075
|
telemetry.span(
|
|
860
1076
|
'a2.append',
|
|
@@ -885,9 +1101,20 @@ export function createServer<
|
|
|
885
1101
|
let rows: StoredEvent[]
|
|
886
1102
|
let shouldHealSession: boolean
|
|
887
1103
|
try {
|
|
888
|
-
const result = await store.append(
|
|
1104
|
+
const result = await store.append(
|
|
1105
|
+
nsId(sessionId),
|
|
1106
|
+
validated,
|
|
1107
|
+
originIndex === undefined ? undefined : { originIndex },
|
|
1108
|
+
)
|
|
889
1109
|
rows = result.events
|
|
890
|
-
|
|
1110
|
+
// A handler appending only settled events adds no work, and the
|
|
1111
|
+
// drain holding its trigger is already running.
|
|
1112
|
+
shouldHealSession =
|
|
1113
|
+
result.hasPending &&
|
|
1114
|
+
!(
|
|
1115
|
+
source === 'handler' &&
|
|
1116
|
+
validated.every((event) => event.settled === true)
|
|
1117
|
+
)
|
|
891
1118
|
} catch (err) {
|
|
892
1119
|
throw asStoreUnavailable(err)
|
|
893
1120
|
}
|
|
@@ -915,26 +1142,12 @@ export function createServer<
|
|
|
915
1142
|
await dispatchArm
|
|
916
1143
|
}
|
|
917
1144
|
if (initialArm) {
|
|
918
|
-
let timeout: ReturnType<typeof setTimeout> | null = null
|
|
919
1145
|
try {
|
|
920
|
-
await
|
|
921
|
-
initialArm,
|
|
922
|
-
new Promise<never>((_, reject) => {
|
|
923
|
-
timeout = setTimeout(() => {
|
|
924
|
-
reject(
|
|
925
|
-
new Error(
|
|
926
|
-
`a2 scheduler arm timed out after ${DRAIN_TIMINGS.schedulerArmTimeoutMs}ms`,
|
|
927
|
-
),
|
|
928
|
-
)
|
|
929
|
-
}, DRAIN_TIMINGS.schedulerArmTimeoutMs)
|
|
930
|
-
}),
|
|
931
|
-
])
|
|
1146
|
+
await joinSchedulerArm(initialArm)
|
|
932
1147
|
} catch (err) {
|
|
933
1148
|
span.setAttribute('a2.append.armed', false)
|
|
934
1149
|
span.recordError(err)
|
|
935
1150
|
if (requireDurableInitialArm) throw err
|
|
936
|
-
} finally {
|
|
937
|
-
if (timeout) clearTimeout(timeout)
|
|
938
1151
|
}
|
|
939
1152
|
}
|
|
940
1153
|
return appended
|
|
@@ -1005,12 +1218,18 @@ export function createServer<
|
|
|
1005
1218
|
}
|
|
1006
1219
|
}
|
|
1007
1220
|
|
|
1221
|
+
type StateResult = { state: unknown; index: number; clearIndex?: number }
|
|
1222
|
+
|
|
1008
1223
|
type PendingStateRead = {
|
|
1009
1224
|
sessionId: string
|
|
1010
1225
|
throughIndex?: number
|
|
1226
|
+
/** The id of the event at `throughIndex`, when the reader knows it. */
|
|
1227
|
+
throughEventId?: string
|
|
1011
1228
|
pinEventIndex?: number
|
|
1229
|
+
/** A handler read: rejected once this trigger index has been cleared. */
|
|
1230
|
+
triggerIndex?: number
|
|
1012
1231
|
span: A2SpanHandle
|
|
1013
|
-
resolve: (result:
|
|
1232
|
+
resolve: (result: StateResult) => void
|
|
1014
1233
|
reject: (reason: unknown) => void
|
|
1015
1234
|
}
|
|
1016
1235
|
|
|
@@ -1045,6 +1264,331 @@ export function createServer<
|
|
|
1045
1264
|
return plans
|
|
1046
1265
|
}
|
|
1047
1266
|
|
|
1267
|
+
const persistSnapshots = ({
|
|
1268
|
+
store,
|
|
1269
|
+
reducer,
|
|
1270
|
+
sessionId,
|
|
1271
|
+
snapshots,
|
|
1272
|
+
}: {
|
|
1273
|
+
store: A2Store
|
|
1274
|
+
reducer: Reducer<D, unknown>
|
|
1275
|
+
sessionId: string
|
|
1276
|
+
snapshots: StoreSnapshotWrite[]
|
|
1277
|
+
}): void => {
|
|
1278
|
+
const persistence = Promise.resolve()
|
|
1279
|
+
.then(() =>
|
|
1280
|
+
telemetry.span(
|
|
1281
|
+
'a2.snapshot',
|
|
1282
|
+
{
|
|
1283
|
+
'a2.contract': name,
|
|
1284
|
+
'a2.session_id': sessionId,
|
|
1285
|
+
'a2.snapshot.reducer': reducer.name,
|
|
1286
|
+
'a2.snapshot.index': snapshots.at(-1)!.index,
|
|
1287
|
+
'a2.snapshot.count': snapshots.length,
|
|
1288
|
+
},
|
|
1289
|
+
async (span) => {
|
|
1290
|
+
try {
|
|
1291
|
+
await store.putSnapshots(nsId(sessionId), reducer.name, snapshots)
|
|
1292
|
+
} catch (error) {
|
|
1293
|
+
try {
|
|
1294
|
+
span.recordError(error)
|
|
1295
|
+
} catch (telemetryError) {
|
|
1296
|
+
// oxlint-disable-next-line preserve-caught-error -- AggregateError preserves both failures.
|
|
1297
|
+
throw new AggregateError(
|
|
1298
|
+
[error, telemetryError],
|
|
1299
|
+
'Snapshot write and error reporting failed',
|
|
1300
|
+
{ cause: telemetryError },
|
|
1301
|
+
)
|
|
1302
|
+
}
|
|
1303
|
+
}
|
|
1304
|
+
},
|
|
1305
|
+
),
|
|
1306
|
+
)
|
|
1307
|
+
.catch((error: unknown) => {
|
|
1308
|
+
// oxlint-disable-next-line no-console -- telemetry failure must not hide failed background work.
|
|
1309
|
+
console.error(
|
|
1310
|
+
'[a2] error in a2.snapshot telemetry',
|
|
1311
|
+
{
|
|
1312
|
+
sessionId,
|
|
1313
|
+
reducer: reducer.name,
|
|
1314
|
+
index: snapshots.at(-1)!.index,
|
|
1315
|
+
},
|
|
1316
|
+
error,
|
|
1317
|
+
)
|
|
1318
|
+
})
|
|
1319
|
+
track(persistence)
|
|
1320
|
+
platformWaitUntil(persistence)
|
|
1321
|
+
}
|
|
1322
|
+
|
|
1323
|
+
// ── in-memory reducer state ──────────────────────────────────────
|
|
1324
|
+
/**
|
|
1325
|
+
* The latest folded state per (session, reducer) this process has seen,
|
|
1326
|
+
* with the index and id of the last event it reflects. An entry serves
|
|
1327
|
+
* only the reducer object that folded it, so a same-named reducer with
|
|
1328
|
+
* different logic reads the store. Cached states are never handed out;
|
|
1329
|
+
* readers get clones.
|
|
1330
|
+
*/
|
|
1331
|
+
type CachedState = {
|
|
1332
|
+
reducer: Reducer<D, unknown>
|
|
1333
|
+
index: number
|
|
1334
|
+
lastId: string
|
|
1335
|
+
state: unknown
|
|
1336
|
+
bytes: number
|
|
1337
|
+
/** Events folded since `bytes` was last measured. */
|
|
1338
|
+
unmeasured: number
|
|
1339
|
+
persistedIndex: number
|
|
1340
|
+
/** The latest clear marker at or below `index`, or zero. */
|
|
1341
|
+
clearIndex: number
|
|
1342
|
+
}
|
|
1343
|
+
const stateCache = boundedCache<string, CachedState>(STATE_CACHE)
|
|
1344
|
+
const cacheState = (key: string, next: CachedState): void => {
|
|
1345
|
+
const current = stateCache.get(key)
|
|
1346
|
+
if (
|
|
1347
|
+
current?.reducer === next.reducer &&
|
|
1348
|
+
(current.index > next.index ||
|
|
1349
|
+
(current.index === next.index &&
|
|
1350
|
+
current.persistedIndex >= next.persistedIndex))
|
|
1351
|
+
)
|
|
1352
|
+
return
|
|
1353
|
+
stateCache.set(key, next, next.bytes)
|
|
1354
|
+
}
|
|
1355
|
+
|
|
1356
|
+
const writeSnapshots = ({
|
|
1357
|
+
store,
|
|
1358
|
+
reducer,
|
|
1359
|
+
sessionId,
|
|
1360
|
+
writes,
|
|
1361
|
+
}: {
|
|
1362
|
+
store: A2Store
|
|
1363
|
+
reducer: Reducer<D, unknown>
|
|
1364
|
+
sessionId: string
|
|
1365
|
+
writes: SnapshotWrites
|
|
1366
|
+
}): StoreSnapshotWrite[] => {
|
|
1367
|
+
const snapshots = [...writes]
|
|
1368
|
+
.toSorted(([a], [b]) => a - b)
|
|
1369
|
+
.map(([index, write]) => {
|
|
1370
|
+
const snapshot: StoreSnapshotWrite = {
|
|
1371
|
+
index,
|
|
1372
|
+
state: encodeState({ reducer, state: write.state }),
|
|
1373
|
+
}
|
|
1374
|
+
if (write.pinEventIndexes.size > 0) {
|
|
1375
|
+
snapshot.pinEventIndexes = [...write.pinEventIndexes]
|
|
1376
|
+
}
|
|
1377
|
+
return snapshot
|
|
1378
|
+
})
|
|
1379
|
+
if (snapshots.length > 0) {
|
|
1380
|
+
persistSnapshots({ store, reducer, sessionId, snapshots })
|
|
1381
|
+
}
|
|
1382
|
+
return snapshots
|
|
1383
|
+
}
|
|
1384
|
+
|
|
1385
|
+
/**
|
|
1386
|
+
* Folds `rows` from `position` in bound order, resolving each entry with a
|
|
1387
|
+
* clone once the rows through its bound are folded. `visit` sees each
|
|
1388
|
+
* entry's state before later rows are folded into it.
|
|
1389
|
+
*/
|
|
1390
|
+
const foldEntries = ({
|
|
1391
|
+
reducer,
|
|
1392
|
+
entries,
|
|
1393
|
+
rows,
|
|
1394
|
+
position,
|
|
1395
|
+
state,
|
|
1396
|
+
index,
|
|
1397
|
+
clearIndex,
|
|
1398
|
+
snapshot,
|
|
1399
|
+
visit,
|
|
1400
|
+
}: {
|
|
1401
|
+
reducer: Reducer<D, unknown>
|
|
1402
|
+
entries: PendingStateRead[]
|
|
1403
|
+
rows: Event[]
|
|
1404
|
+
position: number
|
|
1405
|
+
state: unknown
|
|
1406
|
+
index: number
|
|
1407
|
+
/** The latest clear marker the rows reflect, or zero. */
|
|
1408
|
+
clearIndex: number
|
|
1409
|
+
snapshot: string
|
|
1410
|
+
visit: (
|
|
1411
|
+
entry: PendingStateRead,
|
|
1412
|
+
state: unknown,
|
|
1413
|
+
index: number,
|
|
1414
|
+
folded: number,
|
|
1415
|
+
) => void
|
|
1416
|
+
}): { state: unknown; index: number; position: number } => {
|
|
1417
|
+
const start = position
|
|
1418
|
+
const ordered = entries.toSorted(
|
|
1419
|
+
(a, b) =>
|
|
1420
|
+
(a.throughIndex ?? Number.POSITIVE_INFINITY) -
|
|
1421
|
+
(b.throughIndex ?? Number.POSITIVE_INFINITY),
|
|
1422
|
+
)
|
|
1423
|
+
for (const entry of ordered) {
|
|
1424
|
+
const target = entry.throughIndex ?? Number.POSITIVE_INFINITY
|
|
1425
|
+
while (position < rows.length && rows[position]!.index <= target) {
|
|
1426
|
+
const row = rows[position]!
|
|
1427
|
+
state = foldEvent(reducer, state, toPublic(row))
|
|
1428
|
+
index = row.index
|
|
1429
|
+
position += 1
|
|
1430
|
+
}
|
|
1431
|
+
if (
|
|
1432
|
+
(entry.triggerIndex !== undefined &&
|
|
1433
|
+
entry.triggerIndex <= clearIndex) ||
|
|
1434
|
+
(entry.throughIndex !== undefined &&
|
|
1435
|
+
entry.throughIndex > 0 &&
|
|
1436
|
+
entry.throughIndex < clearIndex)
|
|
1437
|
+
) {
|
|
1438
|
+
entry.reject(
|
|
1439
|
+
sessionClearedError(
|
|
1440
|
+
`session '${entry.sessionId}' was cleared at index ${clearIndex}`,
|
|
1441
|
+
),
|
|
1442
|
+
)
|
|
1443
|
+
continue
|
|
1444
|
+
}
|
|
1445
|
+
entry.span.setAttribute('a2.state.snapshot', snapshot)
|
|
1446
|
+
entry.span.setAttribute('a2.state.folded', position - start)
|
|
1447
|
+
entry.span.setAttribute('a2.state.index', index)
|
|
1448
|
+
visit(entry, state, index, position - start)
|
|
1449
|
+
// A result below the marker (the explicit seed) predates this lifetime.
|
|
1450
|
+
entry.resolve({
|
|
1451
|
+
state: cloneInitial(state),
|
|
1452
|
+
index,
|
|
1453
|
+
...(clearIndex > 0 && index >= clearIndex ? { clearIndex } : {}),
|
|
1454
|
+
})
|
|
1455
|
+
}
|
|
1456
|
+
return { state, index, position }
|
|
1457
|
+
}
|
|
1458
|
+
|
|
1459
|
+
/**
|
|
1460
|
+
* Events from `fromIndex` (inclusive) through `throughIndex` or the newest
|
|
1461
|
+
* event, or null when the tail is too long to be worth folding in memory.
|
|
1462
|
+
*/
|
|
1463
|
+
const readCachedTail = async (
|
|
1464
|
+
store: A2Store,
|
|
1465
|
+
sessionId: string,
|
|
1466
|
+
fromIndex: number,
|
|
1467
|
+
throughIndex?: number,
|
|
1468
|
+
): Promise<Event[] | null> => {
|
|
1469
|
+
const readTail = stateTailReaders.get(store)
|
|
1470
|
+
if (readTail) {
|
|
1471
|
+
try {
|
|
1472
|
+
return await readTail(nsId(sessionId), fromIndex, throughIndex)
|
|
1473
|
+
} catch (err) {
|
|
1474
|
+
throw asStoreUnavailable(err)
|
|
1475
|
+
}
|
|
1476
|
+
}
|
|
1477
|
+
const limit = fromIndex + STATE_CACHE.maxTail
|
|
1478
|
+
const capped = throughIndex === undefined || throughIndex > limit
|
|
1479
|
+
const rows = await readHistory(sessionId, {
|
|
1480
|
+
gte: fromIndex,
|
|
1481
|
+
lte: capped ? limit : throughIndex,
|
|
1482
|
+
})
|
|
1483
|
+
return capped && rows.at(-1)?.index === limit ? null : rows
|
|
1484
|
+
}
|
|
1485
|
+
|
|
1486
|
+
/**
|
|
1487
|
+
* Serves a plan from the cached state when every read is at or past the
|
|
1488
|
+
* cached index. A read bounded at the cached index by an event whose id
|
|
1489
|
+
* matches the cached one (a handler's own trigger) needs no store request;
|
|
1490
|
+
* any other read fetches the tail from the cached event onward and checks
|
|
1491
|
+
* that event's id. Returns false when the store must answer instead.
|
|
1492
|
+
*/
|
|
1493
|
+
const foldCachedPlan = async (
|
|
1494
|
+
store: A2Store,
|
|
1495
|
+
reducer: Reducer<D, unknown>,
|
|
1496
|
+
plan: StateReadPlan,
|
|
1497
|
+
): Promise<boolean> => {
|
|
1498
|
+
const key = stateCacheKey(plan.sessionId, reducer.name)
|
|
1499
|
+
const cached = stateCache.get(key)
|
|
1500
|
+
if (cached?.reducer !== reducer) return false
|
|
1501
|
+
if (
|
|
1502
|
+
plan.entries.some(
|
|
1503
|
+
(entry) =>
|
|
1504
|
+
entry.throughIndex !== undefined && entry.throughIndex < cached.index,
|
|
1505
|
+
)
|
|
1506
|
+
)
|
|
1507
|
+
return false
|
|
1508
|
+
let rows: Event[] = []
|
|
1509
|
+
if (
|
|
1510
|
+
plan.entries.some(
|
|
1511
|
+
(entry) =>
|
|
1512
|
+
entry.throughIndex !== cached.index ||
|
|
1513
|
+
entry.throughEventId !== cached.lastId,
|
|
1514
|
+
)
|
|
1515
|
+
) {
|
|
1516
|
+
const tail = await readCachedTail(
|
|
1517
|
+
store,
|
|
1518
|
+
plan.sessionId,
|
|
1519
|
+
cached.index,
|
|
1520
|
+
plan.throughIndex,
|
|
1521
|
+
)
|
|
1522
|
+
if (!tail) return false
|
|
1523
|
+
// A clear after the cached event, or a rewritten log, needs the store:
|
|
1524
|
+
// it applies the boundary and its fences.
|
|
1525
|
+
if (
|
|
1526
|
+
tail[0]?.index !== cached.index ||
|
|
1527
|
+
tail[0].id !== cached.lastId ||
|
|
1528
|
+
tail.some((row, position) => position > 0 && isSessionCleared(row))
|
|
1529
|
+
) {
|
|
1530
|
+
stateCache.delete(key)
|
|
1531
|
+
return false
|
|
1532
|
+
}
|
|
1533
|
+
rows = tail
|
|
1534
|
+
}
|
|
1535
|
+
const writes: SnapshotWrites = new Map()
|
|
1536
|
+
const folded = foldEntries({
|
|
1537
|
+
reducer,
|
|
1538
|
+
entries: plan.entries,
|
|
1539
|
+
rows,
|
|
1540
|
+
position: 1,
|
|
1541
|
+
state: rows.length > 1 ? cloneInitial(cached.state) : cached.state,
|
|
1542
|
+
index: cached.index,
|
|
1543
|
+
clearIndex: cached.clearIndex,
|
|
1544
|
+
snapshot: 'memory',
|
|
1545
|
+
visit: (entry, state, index) => {
|
|
1546
|
+
if (entry.pinEventIndex !== undefined) {
|
|
1547
|
+
addSnapshotWrite(writes, index, state, entry.pinEventIndex)
|
|
1548
|
+
}
|
|
1549
|
+
},
|
|
1550
|
+
})
|
|
1551
|
+
const added = Math.max(0, folded.position - 1)
|
|
1552
|
+
const rewritesHead =
|
|
1553
|
+
folded.index - cached.persistedIndex >= STATE_CACHE.snapshotEvery
|
|
1554
|
+
if (rewritesHead && !writes.has(folded.index)) {
|
|
1555
|
+
writes.set(folded.index, {
|
|
1556
|
+
state: folded.state,
|
|
1557
|
+
pinEventIndexes: new Set(),
|
|
1558
|
+
})
|
|
1559
|
+
}
|
|
1560
|
+
const snapshots = writeSnapshots({
|
|
1561
|
+
store,
|
|
1562
|
+
reducer,
|
|
1563
|
+
sessionId: plan.sessionId,
|
|
1564
|
+
writes,
|
|
1565
|
+
})
|
|
1566
|
+
const measure =
|
|
1567
|
+
rewritesHead || cached.unmeasured + added >= STATE_CACHE.measureEvery
|
|
1568
|
+
let grown = 0
|
|
1569
|
+
if (!measure) {
|
|
1570
|
+
for (let position = 1; position < folded.position; position += 1) {
|
|
1571
|
+
grown += JSON.stringify(rows[position]!.payload)?.length ?? 0
|
|
1572
|
+
}
|
|
1573
|
+
}
|
|
1574
|
+
cacheState(key, {
|
|
1575
|
+
reducer,
|
|
1576
|
+
index: folded.index,
|
|
1577
|
+
lastId: added > 0 ? rows[folded.position - 1]!.id : cached.lastId,
|
|
1578
|
+
state: folded.state,
|
|
1579
|
+
bytes: measure
|
|
1580
|
+
? measuredBytes(reducer, folded.state, folded.index, snapshots)
|
|
1581
|
+
: cached.bytes + grown,
|
|
1582
|
+
unmeasured: measure ? 0 : cached.unmeasured + added,
|
|
1583
|
+
persistedIndex: Math.max(
|
|
1584
|
+
cached.persistedIndex,
|
|
1585
|
+
snapshots.at(-1)?.index ?? 0,
|
|
1586
|
+
),
|
|
1587
|
+
clearIndex: cached.clearIndex,
|
|
1588
|
+
})
|
|
1589
|
+
return true
|
|
1590
|
+
}
|
|
1591
|
+
|
|
1048
1592
|
// The cache is untrusted: schema rejection refolds from the raw store.
|
|
1049
1593
|
const foldStatePlan = async (
|
|
1050
1594
|
store: A2Store,
|
|
@@ -1057,11 +1601,18 @@ export function createServer<
|
|
|
1057
1601
|
let snapshotOutcome = 'miss'
|
|
1058
1602
|
const snap = stateRead.snapshot
|
|
1059
1603
|
let rows = stateRead.events
|
|
1604
|
+
let clearIndex = stateRead.clearIndex ?? 0
|
|
1060
1605
|
if (snap) {
|
|
1061
|
-
|
|
1606
|
+
let decoded: unknown
|
|
1607
|
+
try {
|
|
1608
|
+
decoded = decodeState({ reducer, state: snap.state })
|
|
1609
|
+
} catch {
|
|
1610
|
+
snapshotOutcome = 'rejected'
|
|
1611
|
+
}
|
|
1612
|
+
if (snapshotOutcome !== 'rejected' && reducer.stateSchema) {
|
|
1062
1613
|
const result = validateSync(
|
|
1063
1614
|
reducer.stateSchema,
|
|
1064
|
-
|
|
1615
|
+
decoded,
|
|
1065
1616
|
'the stateSchema',
|
|
1066
1617
|
)
|
|
1067
1618
|
if (result.issues) {
|
|
@@ -1071,8 +1622,8 @@ export function createServer<
|
|
|
1071
1622
|
index = snap.index
|
|
1072
1623
|
snapshotOutcome = 'hit'
|
|
1073
1624
|
}
|
|
1074
|
-
} else {
|
|
1075
|
-
state =
|
|
1625
|
+
} else if (snapshotOutcome !== 'rejected') {
|
|
1626
|
+
state = decoded
|
|
1076
1627
|
index = snap.index
|
|
1077
1628
|
snapshotOutcome = 'hit'
|
|
1078
1629
|
}
|
|
@@ -1088,116 +1639,52 @@ export function createServer<
|
|
|
1088
1639
|
} catch (err) {
|
|
1089
1640
|
throw asStoreUnavailable(err)
|
|
1090
1641
|
}
|
|
1642
|
+
// Reads start at the latest marker, so a clear since readState leads.
|
|
1643
|
+
if (rows[0] && isSessionCleared(rows[0]))
|
|
1644
|
+
clearIndex = Math.max(clearIndex, rows[0].index)
|
|
1091
1645
|
}
|
|
1092
|
-
const
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
const row = rows[rowPosition]!
|
|
1106
|
-
state = reducer.fold(state, toPublic(row) as never)
|
|
1107
|
-
index = row.index
|
|
1108
|
-
rowPosition += 1
|
|
1109
|
-
}
|
|
1110
|
-
const resultState = cloneInitial(state)
|
|
1111
|
-
const folded = rowPosition
|
|
1112
|
-
entry.span.setAttribute('a2.state.snapshot', snapshotOutcome)
|
|
1113
|
-
entry.span.setAttribute('a2.state.folded', folded)
|
|
1114
|
-
entry.span.setAttribute('a2.state.index', index)
|
|
1115
|
-
if (index > 0 && (folded > 0 || entry.pinEventIndex !== undefined)) {
|
|
1646
|
+
const writes: SnapshotWrites = new Map()
|
|
1647
|
+
const folded = foldEntries({
|
|
1648
|
+
reducer,
|
|
1649
|
+
entries: plan.entries,
|
|
1650
|
+
rows,
|
|
1651
|
+
position: 0,
|
|
1652
|
+
state,
|
|
1653
|
+
index,
|
|
1654
|
+
clearIndex,
|
|
1655
|
+
snapshot: snapshotOutcome,
|
|
1656
|
+
visit: (entry, entryState, entryIndex, count) => {
|
|
1657
|
+
if (entryIndex === 0) return
|
|
1658
|
+
if (count === 0 && entry.pinEventIndex === undefined) return
|
|
1116
1659
|
const advancesHead =
|
|
1117
|
-
stateRead.headIndex === null ||
|
|
1660
|
+
stateRead.headIndex === null || entryIndex > stateRead.headIndex
|
|
1118
1661
|
const repairsHead =
|
|
1119
|
-
snapshotOutcome === 'rejected' &&
|
|
1120
|
-
if (
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
const snapshot: StoreSnapshotWrite = {
|
|
1146
|
-
index: snapshotIndex,
|
|
1147
|
-
state: write.state,
|
|
1148
|
-
}
|
|
1149
|
-
if (write.pinEventIndexes.size > 0) {
|
|
1150
|
-
snapshot.pinEventIndexes = [...write.pinEventIndexes]
|
|
1151
|
-
}
|
|
1152
|
-
snapshots.push(snapshot)
|
|
1153
|
-
}
|
|
1154
|
-
const persistence = Promise.resolve()
|
|
1155
|
-
.then(() =>
|
|
1156
|
-
telemetry.span(
|
|
1157
|
-
'a2.snapshot',
|
|
1158
|
-
{
|
|
1159
|
-
'a2.contract': name,
|
|
1160
|
-
'a2.session_id': plan.sessionId,
|
|
1161
|
-
'a2.snapshot.reducer': reducer.name,
|
|
1162
|
-
'a2.snapshot.index': snapshots.at(-1)!.index,
|
|
1163
|
-
'a2.snapshot.count': snapshots.length,
|
|
1164
|
-
},
|
|
1165
|
-
async (span) => {
|
|
1166
|
-
try {
|
|
1167
|
-
await store.putSnapshots(
|
|
1168
|
-
nsId(plan.sessionId),
|
|
1169
|
-
reducer.name,
|
|
1170
|
-
snapshots,
|
|
1171
|
-
)
|
|
1172
|
-
} catch (error) {
|
|
1173
|
-
try {
|
|
1174
|
-
span.recordError(error)
|
|
1175
|
-
} catch (telemetryError) {
|
|
1176
|
-
// oxlint-disable-next-line preserve-caught-error -- AggregateError preserves both failures.
|
|
1177
|
-
throw new AggregateError(
|
|
1178
|
-
[error, telemetryError],
|
|
1179
|
-
'Snapshot write and error reporting failed',
|
|
1180
|
-
{ cause: telemetryError },
|
|
1181
|
-
)
|
|
1182
|
-
}
|
|
1183
|
-
}
|
|
1184
|
-
},
|
|
1185
|
-
),
|
|
1186
|
-
)
|
|
1187
|
-
.catch((error: unknown) => {
|
|
1188
|
-
// oxlint-disable-next-line no-console -- telemetry failure must not hide failed background work.
|
|
1189
|
-
console.error(
|
|
1190
|
-
'[a2] error in a2.snapshot telemetry',
|
|
1191
|
-
{
|
|
1192
|
-
sessionId: plan.sessionId,
|
|
1193
|
-
reducer: reducer.name,
|
|
1194
|
-
index: snapshots.at(-1)!.index,
|
|
1195
|
-
},
|
|
1196
|
-
error,
|
|
1197
|
-
)
|
|
1198
|
-
})
|
|
1199
|
-
track(persistence)
|
|
1200
|
-
platformWaitUntil(persistence)
|
|
1662
|
+
snapshotOutcome === 'rejected' && entryIndex === stateRead.headIndex
|
|
1663
|
+
if (!advancesHead && !repairsHead && entry.pinEventIndex === undefined)
|
|
1664
|
+
return
|
|
1665
|
+
addSnapshotWrite(writes, entryIndex, entryState, entry.pinEventIndex)
|
|
1666
|
+
},
|
|
1667
|
+
})
|
|
1668
|
+
const snapshots = writeSnapshots({
|
|
1669
|
+
store,
|
|
1670
|
+
reducer,
|
|
1671
|
+
sessionId: plan.sessionId,
|
|
1672
|
+
writes,
|
|
1673
|
+
})
|
|
1674
|
+
if (folded.position > 0 && STATE_CACHE.maxEntries > 0) {
|
|
1675
|
+
cacheState(stateCacheKey(plan.sessionId, reducer.name), {
|
|
1676
|
+
reducer,
|
|
1677
|
+
index: folded.index,
|
|
1678
|
+
lastId: rows[folded.position - 1]!.id,
|
|
1679
|
+
state: folded.state,
|
|
1680
|
+
bytes: measuredBytes(reducer, folded.state, folded.index, snapshots),
|
|
1681
|
+
unmeasured: 0,
|
|
1682
|
+
persistedIndex: Math.min(
|
|
1683
|
+
Math.max(stateRead.headIndex ?? 0, ...writes.keys()),
|
|
1684
|
+
folded.index,
|
|
1685
|
+
),
|
|
1686
|
+
clearIndex,
|
|
1687
|
+
})
|
|
1201
1688
|
}
|
|
1202
1689
|
}
|
|
1203
1690
|
|
|
@@ -1212,7 +1699,17 @@ export function createServer<
|
|
|
1212
1699
|
reducer: Reducer<D, unknown>,
|
|
1213
1700
|
batch: PendingStateRead[],
|
|
1214
1701
|
): Promise<void> => {
|
|
1215
|
-
const plans =
|
|
1702
|
+
const plans: StateReadPlan[] = []
|
|
1703
|
+
await Promise.all(
|
|
1704
|
+
planStateReads(batch).map(async (plan) => {
|
|
1705
|
+
try {
|
|
1706
|
+
if (!(await foldCachedPlan(store, reducer, plan))) plans.push(plan)
|
|
1707
|
+
} catch (error) {
|
|
1708
|
+
for (const entry of plan.entries) entry.reject(error)
|
|
1709
|
+
}
|
|
1710
|
+
}),
|
|
1711
|
+
)
|
|
1712
|
+
if (plans.length === 0) return
|
|
1216
1713
|
let reads: (StoreStateRead | Promise<StoreStateRead>)[]
|
|
1217
1714
|
try {
|
|
1218
1715
|
reads = store.readStates
|
|
@@ -1243,7 +1740,8 @@ export function createServer<
|
|
|
1243
1740
|
)
|
|
1244
1741
|
} catch (error) {
|
|
1245
1742
|
const failure = asStoreUnavailable(error)
|
|
1246
|
-
for (const
|
|
1743
|
+
for (const plan of plans)
|
|
1744
|
+
for (const entry of plan.entries) entry.reject(failure)
|
|
1247
1745
|
return
|
|
1248
1746
|
}
|
|
1249
1747
|
await Promise.all(
|
|
@@ -1257,14 +1755,20 @@ export function createServer<
|
|
|
1257
1755
|
)
|
|
1258
1756
|
}
|
|
1259
1757
|
|
|
1758
|
+
type StateReadBounds = {
|
|
1759
|
+
throughIndex?: number
|
|
1760
|
+
throughEventId?: string
|
|
1761
|
+
pinEventIndex?: number
|
|
1762
|
+
triggerIndex?: number
|
|
1763
|
+
}
|
|
1764
|
+
|
|
1260
1765
|
const enqueueStateRead = (
|
|
1261
1766
|
store: A2Store,
|
|
1262
1767
|
sessionId: string,
|
|
1263
1768
|
reducer: Reducer<D, unknown>,
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
): Promise<{ state: unknown; index: number }> =>
|
|
1769
|
+
bounds: StateReadBounds,
|
|
1770
|
+
span: A2SpanHandle,
|
|
1771
|
+
): Promise<StateResult> =>
|
|
1268
1772
|
new Promise((resolve, reject) => {
|
|
1269
1773
|
const reducerName = reducer.name
|
|
1270
1774
|
let batch = pendingStateReads.get(reducerName)
|
|
@@ -1286,22 +1790,14 @@ export function createServer<
|
|
|
1286
1790
|
})
|
|
1287
1791
|
batch = opened
|
|
1288
1792
|
}
|
|
1289
|
-
batch.push({
|
|
1290
|
-
sessionId,
|
|
1291
|
-
...(throughIndex === undefined ? {} : { throughIndex }),
|
|
1292
|
-
...(pinEventIndex === undefined ? {} : { pinEventIndex }),
|
|
1293
|
-
span: span!,
|
|
1294
|
-
resolve,
|
|
1295
|
-
reject,
|
|
1296
|
-
})
|
|
1793
|
+
batch.push({ sessionId, ...bounds, span, resolve, reject })
|
|
1297
1794
|
})
|
|
1298
1795
|
|
|
1299
|
-
const readState = async <S
|
|
1796
|
+
const readState = async <S>(
|
|
1300
1797
|
sessionId: string,
|
|
1301
|
-
reducer: Reducer<D, S, Record<never, never
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
): Promise<{ state: S; index: number }> => {
|
|
1798
|
+
reducer: Reducer<D, S, Record<never, never>>,
|
|
1799
|
+
bounds: StateReadBounds,
|
|
1800
|
+
): Promise<{ state: S; index: number; clearIndex?: number }> => {
|
|
1305
1801
|
const store = await resolveStore()
|
|
1306
1802
|
return telemetry.span(
|
|
1307
1803
|
'a2.state',
|
|
@@ -1315,20 +1811,27 @@ export function createServer<
|
|
|
1315
1811
|
store,
|
|
1316
1812
|
sessionId,
|
|
1317
1813
|
reducer as Reducer<D, unknown>,
|
|
1318
|
-
|
|
1319
|
-
pinEventIndex,
|
|
1814
|
+
bounds,
|
|
1320
1815
|
span,
|
|
1321
|
-
) as Promise<{ state: S; index: number }>,
|
|
1816
|
+
) as Promise<{ state: S; index: number; clearIndex?: number }>,
|
|
1322
1817
|
)
|
|
1323
1818
|
}
|
|
1324
1819
|
|
|
1325
1820
|
const makeSchedule = (
|
|
1326
1821
|
sessionId: string,
|
|
1327
|
-
trigger: Pick<
|
|
1822
|
+
trigger: Pick<
|
|
1823
|
+
StoredEvent,
|
|
1824
|
+
'id' | 'createdAt' | 'index' | 'clearIndex'
|
|
1825
|
+
> | null,
|
|
1328
1826
|
): SessionSchedule<D> => {
|
|
1329
1827
|
const triggerEventId = trigger?.id ?? null
|
|
1330
1828
|
const triggerCreatedAt = trigger?.createdAt.getTime()
|
|
1331
|
-
|
|
1829
|
+
const clearIndex = trigger?.clearIndex ?? 0
|
|
1830
|
+
const schedule = async (
|
|
1831
|
+
scheduleName: string,
|
|
1832
|
+
timing: ScheduleTiming,
|
|
1833
|
+
...events: AppendInput<D>[]
|
|
1834
|
+
): Promise<void> => {
|
|
1332
1835
|
if (!scheduler) {
|
|
1333
1836
|
throw new TypeError(
|
|
1334
1837
|
'session.schedule requires a configured scheduler adapter',
|
|
@@ -1357,6 +1860,7 @@ export function createServer<
|
|
|
1357
1860
|
sessionId,
|
|
1358
1861
|
triggerEventId,
|
|
1359
1862
|
scheduleName,
|
|
1863
|
+
clearIndex,
|
|
1360
1864
|
)
|
|
1361
1865
|
const scheduledEvents: ScheduledEvent[] = await Promise.all(
|
|
1362
1866
|
validated.map(async (event, item) => ({
|
|
@@ -1384,11 +1888,60 @@ export function createServer<
|
|
|
1384
1888
|
sessionId,
|
|
1385
1889
|
dueAt,
|
|
1386
1890
|
events: scheduledEvents,
|
|
1891
|
+
...(trigger ? { originIndex: trigger.index } : {}),
|
|
1892
|
+
})
|
|
1893
|
+
} catch (error) {
|
|
1894
|
+
throw markSchedulerSendFailure(error)
|
|
1895
|
+
}
|
|
1896
|
+
}
|
|
1897
|
+
const clear = async (
|
|
1898
|
+
scheduleName: string,
|
|
1899
|
+
clearOptions: ScheduleTiming & { ifLastIndex: number },
|
|
1900
|
+
): Promise<void> => {
|
|
1901
|
+
if (!scheduler) {
|
|
1902
|
+
throw new TypeError(
|
|
1903
|
+
'session.schedule.clear requires a configured scheduler adapter',
|
|
1904
|
+
)
|
|
1905
|
+
}
|
|
1906
|
+
assertScheduleName(scheduleName)
|
|
1907
|
+
if (clearOptions === null || typeof clearOptions !== 'object') {
|
|
1908
|
+
throw new TypeError('schedule.clear requires an ifLastIndex')
|
|
1909
|
+
}
|
|
1910
|
+
const { ifLastIndex, ...timing } = clearOptions
|
|
1911
|
+
assertClearIndex(ifLastIndex)
|
|
1912
|
+
const dueAt = scheduleDueAt(
|
|
1913
|
+
timing as ScheduleTiming,
|
|
1914
|
+
triggerCreatedAt ?? Date.now(),
|
|
1915
|
+
)
|
|
1916
|
+
const store = await resolveStore()
|
|
1917
|
+
if (!store.clear || !store.reclaim) {
|
|
1918
|
+
throw new TypeError(
|
|
1919
|
+
'session.schedule.clear requires a store that supports clearing',
|
|
1920
|
+
)
|
|
1921
|
+
}
|
|
1922
|
+
const id = await deterministicScheduleClearId({
|
|
1923
|
+
contract: name,
|
|
1924
|
+
sessionId,
|
|
1925
|
+
triggerEventId,
|
|
1926
|
+
clearIndex,
|
|
1927
|
+
name: scheduleName,
|
|
1928
|
+
ifLastIndex,
|
|
1929
|
+
})
|
|
1930
|
+
try {
|
|
1931
|
+
await scheduler.schedule({
|
|
1932
|
+
version: 1,
|
|
1933
|
+
kind: 'clear',
|
|
1934
|
+
id,
|
|
1935
|
+
contract: name,
|
|
1936
|
+
sessionId,
|
|
1937
|
+
dueAt,
|
|
1938
|
+
ifLastIndex,
|
|
1387
1939
|
})
|
|
1388
1940
|
} catch (error) {
|
|
1389
1941
|
throw markSchedulerSendFailure(error)
|
|
1390
1942
|
}
|
|
1391
1943
|
}
|
|
1944
|
+
return Object.assign(schedule, { clear })
|
|
1392
1945
|
}
|
|
1393
1946
|
|
|
1394
1947
|
const toSessionEvent = (id: string, event: Event): ContractEvent<D> =>
|
|
@@ -1455,6 +2008,14 @@ export function createServer<
|
|
|
1455
2008
|
},
|
|
1456
2009
|
}
|
|
1457
2010
|
streamActivities.set(stream, activity)
|
|
2011
|
+
bufferedEvents.set(stream, () => {
|
|
2012
|
+
const event = closed || !inner ? undefined : takeBuffered(inner)
|
|
2013
|
+
if (!event) return undefined
|
|
2014
|
+
if (!isSerializedEvent(event)) return toSessionEvent(id, event)
|
|
2015
|
+
// Taken rows belong to this stream alone.
|
|
2016
|
+
event.sessionId = id
|
|
2017
|
+
return event
|
|
2018
|
+
})
|
|
1458
2019
|
return stream
|
|
1459
2020
|
},
|
|
1460
2021
|
}
|
|
@@ -1582,6 +2143,8 @@ export function createServer<
|
|
|
1582
2143
|
const ns = nsId(id)
|
|
1583
2144
|
let closed = false
|
|
1584
2145
|
let events: AsyncIterator<Event> | undefined
|
|
2146
|
+
// An event read the merge loop started and has not yet yielded.
|
|
2147
|
+
let pendingEvent: Promise<IteratorResult<Event>> | null = null
|
|
1585
2148
|
let unsubscribe: (() => void) | undefined
|
|
1586
2149
|
let sleeper: Sleeper | null = null
|
|
1587
2150
|
let wakeQueue: (() => void) | null = null
|
|
@@ -1763,7 +2326,6 @@ export function createServer<
|
|
|
1763
2326
|
}
|
|
1764
2327
|
return null
|
|
1765
2328
|
}
|
|
1766
|
-
let pendingEvent: Promise<IteratorResult<Event>> | null = null
|
|
1767
2329
|
try {
|
|
1768
2330
|
// Subscribe before the snapshot read: a patch landing between
|
|
1769
2331
|
// the two is painted twice, never lost — repaints are idempotent.
|
|
@@ -1873,36 +2435,245 @@ export function createServer<
|
|
|
1873
2435
|
},
|
|
1874
2436
|
}
|
|
1875
2437
|
streamActivities.set(stream, activity)
|
|
2438
|
+
bufferedEvents.set(stream, () => {
|
|
2439
|
+
// A held event may follow only events already yielded; while a
|
|
2440
|
+
// read is outstanding, its event must go first.
|
|
2441
|
+
if (closed || !events || pendingEvent) return undefined
|
|
2442
|
+
const event = takeBuffered(events)
|
|
2443
|
+
if (!event) return undefined
|
|
2444
|
+
if (!isSerializedEvent(event)) return toSessionEvent(id, event)
|
|
2445
|
+
// Taken rows belong to this stream alone.
|
|
2446
|
+
event.sessionId = id
|
|
2447
|
+
return event
|
|
2448
|
+
})
|
|
1876
2449
|
return stream
|
|
1877
2450
|
},
|
|
1878
2451
|
}
|
|
1879
2452
|
}
|
|
1880
2453
|
|
|
2454
|
+
// ── clearing (a2-implementation.md §17) ──────────────────────────
|
|
2455
|
+
|
|
2456
|
+
const reclaimAll = async (store: A2Store, ns: string): Promise<void> => {
|
|
2457
|
+
for (let stalls = 0; ;) {
|
|
2458
|
+
let batch: StoreReclaimResult
|
|
2459
|
+
try {
|
|
2460
|
+
// oxlint-disable-next-line no-await-in-loop -- bounded batches, oldest first
|
|
2461
|
+
batch = await store.reclaim!(ns)
|
|
2462
|
+
} catch (err) {
|
|
2463
|
+
throw asStoreUnavailable(err)
|
|
2464
|
+
}
|
|
2465
|
+
if (batch.done) return
|
|
2466
|
+
if (batch.deleted > 0) {
|
|
2467
|
+
stalls = 0
|
|
2468
|
+
continue
|
|
2469
|
+
}
|
|
2470
|
+
// Only rows other work holds locked remain; they free up shortly.
|
|
2471
|
+
stalls += 1
|
|
2472
|
+
if (stalls === RECLAIM_STALL_LIMIT) {
|
|
2473
|
+
throw new A2Error(
|
|
2474
|
+
'STORE_UNAVAILABLE',
|
|
2475
|
+
`deleting the cleared history of session '${stripNs(ns)}' made no progress`,
|
|
2476
|
+
)
|
|
2477
|
+
}
|
|
2478
|
+
// oxlint-disable-next-line no-await-in-loop -- paced retry
|
|
2479
|
+
await defaultSleep(RECLAIM_STALL_MS).promise
|
|
2480
|
+
}
|
|
2481
|
+
}
|
|
2482
|
+
|
|
2483
|
+
/** The handler attempt a `ctx.session.clear()` belongs to. */
|
|
2484
|
+
type ClearingAttempt = {
|
|
2485
|
+
cause: EventCause
|
|
2486
|
+
end: () => void
|
|
2487
|
+
signal: AbortSignal
|
|
2488
|
+
}
|
|
2489
|
+
|
|
2490
|
+
const clearSession = async (
|
|
2491
|
+
sessionId: string,
|
|
2492
|
+
clearOptions: SessionClearOptions | undefined,
|
|
2493
|
+
attempt: ClearingAttempt | undefined,
|
|
2494
|
+
): Promise<SessionClearResult> => {
|
|
2495
|
+
const ifLastIndex = clearOptions?.ifLastIndex
|
|
2496
|
+
if (ifLastIndex !== undefined) assertClearIndex(ifLastIndex)
|
|
2497
|
+
const store = await resolveStore()
|
|
2498
|
+
if (!store.clear || !store.reclaim) {
|
|
2499
|
+
throw new TypeError(
|
|
2500
|
+
'session.clear requires a store that supports clearing',
|
|
2501
|
+
)
|
|
2502
|
+
}
|
|
2503
|
+
const ns = nsId(sessionId)
|
|
2504
|
+
let result: StoreClearResult
|
|
2505
|
+
try {
|
|
2506
|
+
result = await store.clear({
|
|
2507
|
+
sessionId: ns,
|
|
2508
|
+
...(ifLastIndex === undefined ? {} : { ifLastIndex }),
|
|
2509
|
+
...(attempt ? { cause: attempt.cause } : {}),
|
|
2510
|
+
})
|
|
2511
|
+
} catch (err) {
|
|
2512
|
+
throw asStoreUnavailable(err)
|
|
2513
|
+
}
|
|
2514
|
+
if (result.cleared) {
|
|
2515
|
+
// This instance's own reads after the clear, including the clearing
|
|
2516
|
+
// handler's, go to the store and see the boundary.
|
|
2517
|
+
const cachePrefix = stateCacheKey(sessionId, '')
|
|
2518
|
+
stateCache.deleteWhere((key) => key.startsWith(cachePrefix))
|
|
2519
|
+
}
|
|
2520
|
+
const deletion = reclaimAll(store, ns)
|
|
2521
|
+
if (attempt) {
|
|
2522
|
+
if (attempt.cause.index <= result.clearIndex) attempt.end()
|
|
2523
|
+
// Ending the attempt releases its drain slot; the deletion must still
|
|
2524
|
+
// outlive the drain.
|
|
2525
|
+
const kept = deletion.catch(() => undefined)
|
|
2526
|
+
track(kept)
|
|
2527
|
+
platformWaitUntil(kept)
|
|
2528
|
+
}
|
|
2529
|
+
await deletion
|
|
2530
|
+
return { cleared: result.cleared, index: result.index }
|
|
2531
|
+
}
|
|
2532
|
+
|
|
2533
|
+
// ── dead letters ─────────────────────────────────────────────────
|
|
2534
|
+
|
|
2535
|
+
const redrivingStore = async (
|
|
2536
|
+
method: 'deadLetters' | 'redrive',
|
|
2537
|
+
): Promise<A2Store & Required<Pick<A2Store, 'deadLetters' | 'redrive'>>> => {
|
|
2538
|
+
const store = await resolveStore()
|
|
2539
|
+
if (!store.deadLetters || !store.redrive) {
|
|
2540
|
+
throw new TypeError(
|
|
2541
|
+
`session.${method} requires a store that supports redriving dead letters`,
|
|
2542
|
+
)
|
|
2543
|
+
}
|
|
2544
|
+
return store as A2Store & Required<Pick<A2Store, 'deadLetters' | 'redrive'>>
|
|
2545
|
+
}
|
|
2546
|
+
|
|
2547
|
+
const listDeadLetters = async (
|
|
2548
|
+
sessionId: string,
|
|
2549
|
+
): Promise<DeadLetter<D>[]> => {
|
|
2550
|
+
const store = await redrivingStore('deadLetters')
|
|
2551
|
+
let rows: StoredEvent[]
|
|
2552
|
+
try {
|
|
2553
|
+
rows = await store.deadLetters(nsId(sessionId))
|
|
2554
|
+
} catch (err) {
|
|
2555
|
+
throw asStoreUnavailable(err)
|
|
2556
|
+
}
|
|
2557
|
+
return rows.flatMap((row) =>
|
|
2558
|
+
row.failedAt === null
|
|
2559
|
+
? []
|
|
2560
|
+
: [
|
|
2561
|
+
{
|
|
2562
|
+
...toPublic(row),
|
|
2563
|
+
lane: row.lane,
|
|
2564
|
+
attemptCount: row.attemptCount,
|
|
2565
|
+
failureCount: row.failureCount,
|
|
2566
|
+
lastError: row.lastError,
|
|
2567
|
+
failedAt: row.failedAt,
|
|
2568
|
+
},
|
|
2569
|
+
],
|
|
2570
|
+
)
|
|
2571
|
+
}
|
|
2572
|
+
|
|
2573
|
+
const redriveEvent = async (
|
|
2574
|
+
sessionId: string,
|
|
2575
|
+
index: number,
|
|
2576
|
+
redriveOptions: SessionRedriveOptions,
|
|
2577
|
+
): Promise<SessionRedriveResult> => {
|
|
2578
|
+
assertRedriveIndex(index)
|
|
2579
|
+
const action = redriveOptions?.action
|
|
2580
|
+
if (action !== 'retry' && action !== 'skip') {
|
|
2581
|
+
throw new TypeError("redrive requires action 'retry' or 'skip'")
|
|
2582
|
+
}
|
|
2583
|
+
return telemetry.span(
|
|
2584
|
+
'a2.redrive',
|
|
2585
|
+
{
|
|
2586
|
+
'a2.contract': name,
|
|
2587
|
+
'a2.session_id': sessionId,
|
|
2588
|
+
'a2.redrive.index': index,
|
|
2589
|
+
'a2.redrive.action': action,
|
|
2590
|
+
},
|
|
2591
|
+
async (span) => {
|
|
2592
|
+
const store = await redrivingStore('redrive')
|
|
2593
|
+
let result: StoreRedriveResult
|
|
2594
|
+
try {
|
|
2595
|
+
result = await store.redrive({
|
|
2596
|
+
sessionId: nsId(sessionId),
|
|
2597
|
+
index,
|
|
2598
|
+
action,
|
|
2599
|
+
})
|
|
2600
|
+
} catch (err) {
|
|
2601
|
+
throw asStoreUnavailable(err)
|
|
2602
|
+
}
|
|
2603
|
+
span.setAttribute('a2.redrive.redriven', result.redriven)
|
|
2604
|
+
// Healing like a top-level append, even when nothing was redriven, so
|
|
2605
|
+
// repeating a redrive whose wakeup was lost resumes the session.
|
|
2606
|
+
if (result.hasPending) {
|
|
2607
|
+
const watchdogAt = scheduler
|
|
2608
|
+
? schedulerSlot(nextClaimWindow().watchdogAtMs)
|
|
2609
|
+
: undefined
|
|
2610
|
+
const arm =
|
|
2611
|
+
watchdogAt === undefined
|
|
2612
|
+
? null
|
|
2613
|
+
: startSchedulerArm(sessionId, watchdogAt)
|
|
2614
|
+
scheduleDrain(sessionId, watchdogAt)
|
|
2615
|
+
if (arm) {
|
|
2616
|
+
try {
|
|
2617
|
+
await joinSchedulerArm(arm)
|
|
2618
|
+
} catch (err) {
|
|
2619
|
+
span.setAttribute('a2.redrive.armed', false)
|
|
2620
|
+
span.recordError(err)
|
|
2621
|
+
}
|
|
2622
|
+
}
|
|
2623
|
+
}
|
|
2624
|
+
return { redriven: result.redriven }
|
|
2625
|
+
},
|
|
2626
|
+
)
|
|
2627
|
+
}
|
|
2628
|
+
|
|
1881
2629
|
const makeSession = <Append>(
|
|
1882
2630
|
id: string,
|
|
1883
2631
|
append: Append,
|
|
1884
|
-
trigger: Pick<
|
|
2632
|
+
trigger: Pick<
|
|
2633
|
+
StoredEvent,
|
|
2634
|
+
'id' | 'createdAt' | 'index' | 'clearIndex'
|
|
2635
|
+
> | null,
|
|
2636
|
+
attempt?: ClearingAttempt,
|
|
1885
2637
|
): Session<D, Append, P> =>
|
|
1886
2638
|
({
|
|
1887
2639
|
id,
|
|
1888
2640
|
append,
|
|
1889
2641
|
schedule: makeSchedule(id, trigger),
|
|
2642
|
+
clear: (clearOptions?: SessionClearOptions) =>
|
|
2643
|
+
clearSession(id, clearOptions, attempt),
|
|
1890
2644
|
history: async (bounds?: { gte?: number; lte?: number }) =>
|
|
1891
|
-
(await readHistory(id, bounds))
|
|
1892
|
-
|
|
1893
|
-
|
|
2645
|
+
(await readHistory(id, bounds))
|
|
2646
|
+
.filter((row) => !isSessionCleared(row))
|
|
2647
|
+
.map(toPublic),
|
|
2648
|
+
state: <S>(
|
|
2649
|
+
reducer: Reducer<
|
|
2650
|
+
D,
|
|
2651
|
+
S,
|
|
2652
|
+
Record<never, never>
|
|
2653
|
+
> = serverContract.reducer as unknown as Reducer<
|
|
2654
|
+
D,
|
|
2655
|
+
S,
|
|
2656
|
+
Record<never, never>
|
|
2657
|
+
>,
|
|
1894
2658
|
stateOptions?: StateOptions,
|
|
1895
2659
|
) => {
|
|
2660
|
+
if (!reducer)
|
|
2661
|
+
throw new A2Error('INVALID_PAYLOAD', 'contract has no public reducer')
|
|
1896
2662
|
const through = stateOptions?.through ?? trigger?.index
|
|
1897
2663
|
if (through !== undefined && through !== 'latest') {
|
|
1898
2664
|
assertStateIndex(through)
|
|
1899
2665
|
}
|
|
1900
|
-
|
|
1901
|
-
|
|
1902
|
-
|
|
1903
|
-
|
|
1904
|
-
|
|
1905
|
-
|
|
2666
|
+
const bounds: StateReadBounds = {}
|
|
2667
|
+
if (through !== undefined && through !== 'latest') {
|
|
2668
|
+
bounds.throughIndex = through
|
|
2669
|
+
if (trigger) bounds.pinEventIndex = trigger.index
|
|
2670
|
+
// Only a live attempt may be answered from memory with no store
|
|
2671
|
+
// request; a superseded one (including by a clear) reads the store.
|
|
2672
|
+
if (through === trigger?.index && !attempt?.signal.aborted)
|
|
2673
|
+
bounds.throughEventId = trigger.id
|
|
2674
|
+
}
|
|
2675
|
+
if (trigger) bounds.triggerIndex = trigger.index
|
|
2676
|
+
return readState(id, reducer, bounds)
|
|
1906
2677
|
},
|
|
1907
2678
|
stream: (opts?: { startAfter?: number; presence?: boolean }) => {
|
|
1908
2679
|
const startAfter = opts?.startAfter ?? 0
|
|
@@ -1932,6 +2703,7 @@ export function createServer<
|
|
|
1932
2703
|
trigger: StoredEvent,
|
|
1933
2704
|
attempt: number,
|
|
1934
2705
|
signal: AbortSignal,
|
|
2706
|
+
endAttempt: () => void,
|
|
1935
2707
|
): HandlerContext<D> => {
|
|
1936
2708
|
const publicSessionId = stripNs(trigger.sessionId)
|
|
1937
2709
|
const append: HandlerAppend<D> = async (appendName, ...events) => {
|
|
@@ -1955,15 +2727,22 @@ export function createServer<
|
|
|
1955
2727
|
generatedIds,
|
|
1956
2728
|
)
|
|
1957
2729
|
}
|
|
1958
|
-
|
|
2730
|
+
const ctx: HandlerContext<D> = {
|
|
1959
2731
|
event: toPublic(trigger),
|
|
1960
2732
|
attempt,
|
|
1961
2733
|
// The handler-scoped `setPresence` stamps `seen` with the
|
|
1962
2734
|
// triggering event's index by default — the frontier the handler
|
|
1963
2735
|
// provably reflects.
|
|
1964
|
-
session: makeSession(publicSessionId, append, trigger
|
|
2736
|
+
session: makeSession(publicSessionId, append, trigger, {
|
|
2737
|
+
cause: { index: trigger.index, attempt },
|
|
2738
|
+
end: endAttempt,
|
|
2739
|
+
signal,
|
|
2740
|
+
}),
|
|
1965
2741
|
signal,
|
|
1966
2742
|
}
|
|
2743
|
+
if (attempt > 1 && trigger.lastFailedAttempt === attempt - 1)
|
|
2744
|
+
failedPreviousAttempt.add(ctx)
|
|
2745
|
+
return ctx
|
|
1967
2746
|
}
|
|
1968
2747
|
|
|
1969
2748
|
const abortHubs = new Map<string, AbortHub>()
|
|
@@ -2267,17 +3046,10 @@ export function createServer<
|
|
|
2267
3046
|
if (!entry) continue
|
|
2268
3047
|
// Idempotent: the local lapse normally aborted this signal
|
|
2269
3048
|
// already — this is the proof, whose job is the release.
|
|
2270
|
-
|
|
2271
|
-
|
|
2272
|
-
|
|
2273
|
-
`a newer attempt owns event ${index} in session '${sessionId}'`,
|
|
2274
|
-
),
|
|
3049
|
+
endSuperseded(
|
|
3050
|
+
entry,
|
|
3051
|
+
`a newer attempt owns event ${index} in session '${sessionId}'`,
|
|
2275
3052
|
)
|
|
2276
|
-
entry.fence({
|
|
2277
|
-
status: 'superseded',
|
|
2278
|
-
opensLane: false,
|
|
2279
|
-
appendsEvents: false,
|
|
2280
|
-
})
|
|
2281
3053
|
}
|
|
2282
3054
|
if (window.deadlineCapped) stopRenewing = true
|
|
2283
3055
|
scheduleLapse()
|
|
@@ -2299,6 +3071,7 @@ export function createServer<
|
|
|
2299
3071
|
const runEvent = async (
|
|
2300
3072
|
event: StoredEvent,
|
|
2301
3073
|
supersedeSignal: AbortSignal,
|
|
3074
|
+
endAttempt: () => void,
|
|
2302
3075
|
): Promise<EventOutcome> => {
|
|
2303
3076
|
const registration = handlers.get(event.type)
|
|
2304
3077
|
return telemetry.span(
|
|
@@ -2338,7 +3111,7 @@ export function createServer<
|
|
|
2338
3111
|
])
|
|
2339
3112
|
}
|
|
2340
3113
|
const operation = registration?.handler(
|
|
2341
|
-
makeCtx(event, event.attemptCount, handlerSignal),
|
|
3114
|
+
makeCtx(event, event.attemptCount, handlerSignal, endAttempt),
|
|
2342
3115
|
)
|
|
2343
3116
|
const result = operation
|
|
2344
3117
|
? await (subscription
|
|
@@ -2369,17 +3142,31 @@ export function createServer<
|
|
|
2369
3142
|
// genuinely superseded attempt gets 'superseded' back with
|
|
2370
3143
|
// no mutation. One adjudicator, not two.
|
|
2371
3144
|
eventSpan.recordError(err)
|
|
2372
|
-
const failure = await
|
|
2373
|
-
|
|
2374
|
-
|
|
2375
|
-
|
|
2376
|
-
|
|
2377
|
-
|
|
2378
|
-
|
|
2379
|
-
|
|
2380
|
-
|
|
2381
|
-
|
|
2382
|
-
|
|
3145
|
+
const failure = await telemetry.span(
|
|
3146
|
+
'a2.commit',
|
|
3147
|
+
{
|
|
3148
|
+
'a2.contract': name,
|
|
3149
|
+
'a2.session_id': sessionId,
|
|
3150
|
+
'a2.commit.operation': 'fail',
|
|
3151
|
+
'a2.commit.index': event.index,
|
|
3152
|
+
'a2.commit.attempt': event.attemptCount,
|
|
3153
|
+
},
|
|
3154
|
+
async (commitSpan) => {
|
|
3155
|
+
const result = await store.failAttempt({
|
|
3156
|
+
sessionId: ns,
|
|
3157
|
+
index: event.index,
|
|
3158
|
+
attempt: event.attemptCount,
|
|
3159
|
+
error: describeError(err),
|
|
3160
|
+
// A NonRetriableError is terminal on its first throw:
|
|
3161
|
+
// deterministic failures should not burn the retry
|
|
3162
|
+
// budget before dead-lettering.
|
|
3163
|
+
maxFailures:
|
|
3164
|
+
err instanceof NonRetriableError ? 1 : MAX_FAILURES,
|
|
3165
|
+
})
|
|
3166
|
+
commitSpan.setAttribute('a2.commit.outcome', result.outcome)
|
|
3167
|
+
return result
|
|
3168
|
+
},
|
|
3169
|
+
)
|
|
2383
3170
|
eventSpan.setAttribute('a2.event.outcome', failure.outcome)
|
|
2384
3171
|
return {
|
|
2385
3172
|
status: failure.outcome,
|
|
@@ -2390,12 +3177,26 @@ export function createServer<
|
|
|
2390
3177
|
await subscription?.close()
|
|
2391
3178
|
}
|
|
2392
3179
|
await renewal
|
|
2393
|
-
const completion = await
|
|
2394
|
-
|
|
2395
|
-
|
|
2396
|
-
|
|
2397
|
-
|
|
2398
|
-
|
|
3180
|
+
const completion = await telemetry.span(
|
|
3181
|
+
'a2.commit',
|
|
3182
|
+
{
|
|
3183
|
+
'a2.contract': name,
|
|
3184
|
+
'a2.session_id': sessionId,
|
|
3185
|
+
'a2.commit.operation': 'complete',
|
|
3186
|
+
'a2.commit.index': event.index,
|
|
3187
|
+
'a2.commit.attempt': event.attemptCount,
|
|
3188
|
+
},
|
|
3189
|
+
async (commitSpan) => {
|
|
3190
|
+
const result = await store.completeAttempt({
|
|
3191
|
+
sessionId: ns,
|
|
3192
|
+
index: event.index,
|
|
3193
|
+
attempt: event.attemptCount,
|
|
3194
|
+
events: returned,
|
|
3195
|
+
})
|
|
3196
|
+
commitSpan.setAttribute('a2.commit.outcome', result.outcome)
|
|
3197
|
+
return result
|
|
3198
|
+
},
|
|
3199
|
+
)
|
|
2399
3200
|
eventSpan.setAttribute(
|
|
2400
3201
|
'a2.event.outcome',
|
|
2401
3202
|
completion.outcome === 'completed'
|
|
@@ -2430,29 +3231,28 @@ export function createServer<
|
|
|
2430
3231
|
// the new one.
|
|
2431
3232
|
const previous = active.get(event.index)
|
|
2432
3233
|
if (previous) {
|
|
2433
|
-
|
|
2434
|
-
|
|
2435
|
-
|
|
2436
|
-
`a newer attempt owns event ${event.index} in session '${sessionId}'`,
|
|
2437
|
-
),
|
|
3234
|
+
endSuperseded(
|
|
3235
|
+
previous,
|
|
3236
|
+
`a newer attempt owns event ${event.index} in session '${sessionId}'`,
|
|
2438
3237
|
)
|
|
2439
|
-
previous.fence({
|
|
2440
|
-
status: 'superseded',
|
|
2441
|
-
opensLane: false,
|
|
2442
|
-
appendsEvents: false,
|
|
2443
|
-
})
|
|
2444
3238
|
}
|
|
2445
3239
|
const supersede = new AbortController()
|
|
2446
3240
|
let fence!: (outcome: EventOutcome) => void
|
|
2447
3241
|
const fenced = new Promise<EventOutcome>((resolve) => {
|
|
2448
3242
|
fence = resolve
|
|
2449
3243
|
})
|
|
3244
|
+
// A handler's own committed clear supersedes it immediately.
|
|
3245
|
+
const endAttempt = (): void =>
|
|
3246
|
+
endSuperseded(
|
|
3247
|
+
{ supersede, fence },
|
|
3248
|
+
`event ${event.index} in session '${sessionId}' was cleared`,
|
|
3249
|
+
)
|
|
2450
3250
|
// The race lets a durable supersession release this slot while the
|
|
2451
3251
|
// zombie handler keeps running — its completion and failure are
|
|
2452
3252
|
// fenced by the store, so nothing it does from here is counted.
|
|
2453
3253
|
let entry!: ActiveEvent
|
|
2454
3254
|
const execution = Promise.race([
|
|
2455
|
-
runEvent(event, supersede.signal),
|
|
3255
|
+
runEvent(event, supersede.signal, endAttempt),
|
|
2456
3256
|
fenced,
|
|
2457
3257
|
]).then((eventOutcome) => {
|
|
2458
3258
|
// Identity-guarded: a fenced predecessor's continuation must
|
|
@@ -2493,17 +3293,29 @@ export function createServer<
|
|
|
2493
3293
|
let claim: StoreClaimAvailableResult | undefined
|
|
2494
3294
|
if (shouldClaim) {
|
|
2495
3295
|
const window = nextClaimWindow()
|
|
2496
|
-
claim = await
|
|
2497
|
-
|
|
2498
|
-
|
|
2499
|
-
|
|
2500
|
-
|
|
2501
|
-
|
|
2502
|
-
|
|
2503
|
-
|
|
2504
|
-
|
|
2505
|
-
|
|
2506
|
-
|
|
3296
|
+
claim = await telemetry.span(
|
|
3297
|
+
'a2.claim',
|
|
3298
|
+
{ 'a2.contract': name, 'a2.session_id': sessionId },
|
|
3299
|
+
async (claimSpan) => {
|
|
3300
|
+
const result = await store.claimAvailable({
|
|
3301
|
+
sessionId: ns,
|
|
3302
|
+
holder,
|
|
3303
|
+
ttlMs: window.ttlMs,
|
|
3304
|
+
...(window.deadlineCapped
|
|
3305
|
+
? { expiresAtMs: window.expiresAtMs }
|
|
3306
|
+
: {}),
|
|
3307
|
+
...(excluded.size === 0
|
|
3308
|
+
? {}
|
|
3309
|
+
: { excludeIndexes: [...excluded] }),
|
|
3310
|
+
})
|
|
3311
|
+
claimSpan.setAttribute('a2.claim.outcome', result.outcome)
|
|
3312
|
+
claimSpan.setAttribute(
|
|
3313
|
+
'a2.claim.count',
|
|
3314
|
+
result.outcome === 'claimed' ? result.events.length : 0,
|
|
3315
|
+
)
|
|
3316
|
+
return result
|
|
3317
|
+
},
|
|
3318
|
+
)
|
|
2507
3319
|
if (claim.outcome === 'claimed') {
|
|
2508
3320
|
requestWatchdog(window.watchdogAtMs)
|
|
2509
3321
|
if (window.deadlineCapped) stopRenewing = true
|
|
@@ -2595,6 +3407,7 @@ export function createServer<
|
|
|
2595
3407
|
mode: 'inline' | 'dispatch',
|
|
2596
3408
|
events: AppendInput<D>[],
|
|
2597
3409
|
requireDurableInitialArm = false,
|
|
3410
|
+
originIndex?: number,
|
|
2598
3411
|
): Promise<ContractEvent<D>[]> => {
|
|
2599
3412
|
return appendCore(
|
|
2600
3413
|
sessionId,
|
|
@@ -2605,10 +3418,13 @@ export function createServer<
|
|
|
2605
3418
|
undefined,
|
|
2606
3419
|
undefined,
|
|
2607
3420
|
requireDurableInitialArm,
|
|
3421
|
+
originIndex,
|
|
2608
3422
|
)
|
|
2609
3423
|
}
|
|
2610
3424
|
|
|
2611
|
-
const session = (
|
|
3425
|
+
const session = (
|
|
3426
|
+
id: string,
|
|
3427
|
+
): Session<D, SessionAppend<D>, P> & SessionDeadLetters<D> => {
|
|
2612
3428
|
assertSessionId(id)
|
|
2613
3429
|
const appendInline = async (...events: AppendInput<D>[]) =>
|
|
2614
3430
|
appendExternal(id, 'inline', events)
|
|
@@ -2617,14 +3433,36 @@ export function createServer<
|
|
|
2617
3433
|
const append = Object.assign(appendInline, {
|
|
2618
3434
|
dispatch: appendDispatch,
|
|
2619
3435
|
}) as SessionAppend<D>
|
|
2620
|
-
return makeSession(id, append, null)
|
|
3436
|
+
return Object.assign(makeSession(id, append, null), {
|
|
3437
|
+
deadLetters: () => listDeadLetters(id),
|
|
3438
|
+
redrive: (index: number, redriveOptions: SessionRedriveOptions) =>
|
|
3439
|
+
redriveEvent(id, index, redriveOptions),
|
|
3440
|
+
})
|
|
3441
|
+
}
|
|
3442
|
+
|
|
3443
|
+
const historyPage = async (
|
|
3444
|
+
sessionId: string,
|
|
3445
|
+
bounds: { gte: number; lte: number },
|
|
3446
|
+
): Promise<{ events: ContractEvent<D>[]; clearIndex?: number }> => {
|
|
3447
|
+
assertSessionId(sessionId)
|
|
3448
|
+
const rows = await readHistory(sessionId, bounds)
|
|
3449
|
+
// Reads start at the latest marker, so it can only lead the page.
|
|
3450
|
+
const marker = rows[0] && isSessionCleared(rows[0]) ? rows[0] : undefined
|
|
3451
|
+
return {
|
|
3452
|
+
events: (marker ? rows.slice(1) : rows).map(toPublic),
|
|
3453
|
+
...(marker ? { clearIndex: marker.index } : {}),
|
|
3454
|
+
}
|
|
2621
3455
|
}
|
|
2622
3456
|
|
|
2623
|
-
const fetch = createServerFetch({
|
|
2624
|
-
const self: A2Server<D, P> = {
|
|
3457
|
+
const fetch = createServerFetch({
|
|
2625
3458
|
contract: serverContract,
|
|
2626
|
-
fetch,
|
|
2627
3459
|
session,
|
|
3460
|
+
historyPage,
|
|
3461
|
+
})
|
|
3462
|
+
const self: A2Server<D, P, R> = {
|
|
3463
|
+
contract: serverContract,
|
|
3464
|
+
fetch,
|
|
3465
|
+
session: session as A2Server<D, P, R>['session'],
|
|
2628
3466
|
|
|
2629
3467
|
async drain(sessionId) {
|
|
2630
3468
|
assertSessionId(sessionId)
|
|
@@ -2643,14 +3481,38 @@ export function createServer<
|
|
|
2643
3481
|
assertSessionId(sessionId)
|
|
2644
3482
|
return drainSession(sessionId, opts?.watchdogDueAt)
|
|
2645
3483
|
},
|
|
2646
|
-
schedulerAppend: async (sessionId, events) => {
|
|
3484
|
+
schedulerAppend: async (sessionId, events, originIndex) => {
|
|
2647
3485
|
if (!scheduler) {
|
|
2648
3486
|
throw new TypeError(
|
|
2649
3487
|
'scheduled append delivery requires a configured scheduler adapter',
|
|
2650
3488
|
)
|
|
2651
3489
|
}
|
|
2652
3490
|
assertSessionId(sessionId)
|
|
2653
|
-
|
|
3491
|
+
try {
|
|
3492
|
+
await appendExternal(
|
|
3493
|
+
sessionId,
|
|
3494
|
+
'inline',
|
|
3495
|
+
[...events],
|
|
3496
|
+
true,
|
|
3497
|
+
originIndex,
|
|
3498
|
+
)
|
|
3499
|
+
} catch (error) {
|
|
3500
|
+
// The scheduling handler's lifetime was cleared: an acknowledged no-op.
|
|
3501
|
+
if (
|
|
3502
|
+
originIndex !== undefined &&
|
|
3503
|
+
error instanceof A2Error &&
|
|
3504
|
+
error.code === 'SESSION_CLEARED'
|
|
3505
|
+
)
|
|
3506
|
+
return
|
|
3507
|
+
throw error
|
|
3508
|
+
}
|
|
3509
|
+
},
|
|
3510
|
+
historyWithMarkers: async (sessionId, gte) =>
|
|
3511
|
+
(await readHistory(sessionId, { gte })).map(toPublic),
|
|
3512
|
+
schedulerClear: async (sessionId, ifLastIndex) => {
|
|
3513
|
+
assertSessionId(sessionId)
|
|
3514
|
+
assertClearIndex(ifLastIndex)
|
|
3515
|
+
await clearSession(sessionId, { ifLastIndex }, undefined)
|
|
2654
3516
|
},
|
|
2655
3517
|
})
|
|
2656
3518
|
return self
|
|
@@ -2683,6 +3545,18 @@ function assertStateIndex(value: number): void {
|
|
|
2683
3545
|
}
|
|
2684
3546
|
}
|
|
2685
3547
|
|
|
3548
|
+
function assertRedriveIndex(value: number): void {
|
|
3549
|
+
if (!Number.isSafeInteger(value) || value < 1) {
|
|
3550
|
+
throw new TypeError('redrive requires a positive safe-integer index')
|
|
3551
|
+
}
|
|
3552
|
+
}
|
|
3553
|
+
|
|
3554
|
+
function assertClearIndex(value: number): void {
|
|
3555
|
+
if (!Number.isSafeInteger(value) || value < 0) {
|
|
3556
|
+
throw new TypeError('ifLastIndex must be a non-negative safe integer')
|
|
3557
|
+
}
|
|
3558
|
+
}
|
|
3559
|
+
|
|
2686
3560
|
function assertAppendName(name: string): void {
|
|
2687
3561
|
if (typeof name !== 'string' || name.length === 0) {
|
|
2688
3562
|
throw new TypeError('handler append name must be a non-empty string')
|
|
@@ -2827,16 +3701,19 @@ function isJsonValue(value: unknown, ancestors: WeakSet<object>): boolean {
|
|
|
2827
3701
|
}
|
|
2828
3702
|
}
|
|
2829
3703
|
|
|
2830
|
-
|
|
2831
|
-
|
|
2832
|
-
|
|
2833
|
-
|
|
2834
|
-
|
|
2835
|
-
|
|
2836
|
-
|
|
2837
|
-
|
|
2838
|
-
|
|
2839
|
-
|
|
3704
|
+
const measuredBytes = (
|
|
3705
|
+
reducer: object,
|
|
3706
|
+
state: unknown,
|
|
3707
|
+
index: number,
|
|
3708
|
+
snapshots: StoreSnapshotWrite[],
|
|
3709
|
+
): number =>
|
|
3710
|
+
JSON.stringify(
|
|
3711
|
+
snapshots.find((snapshot) => snapshot.index === index)?.state ??
|
|
3712
|
+
encodeState({ reducer, state }),
|
|
3713
|
+
)?.length ?? 0
|
|
3714
|
+
|
|
3715
|
+
const stateCacheKey = (sessionId: string, reducerName: string): string =>
|
|
3716
|
+
`${sessionId}\u0000${reducerName}`
|
|
2840
3717
|
|
|
2841
3718
|
// Server-side types users need when passing custom stores or building
|
|
2842
3719
|
// transports — re-exported so experimental-a2/server is self-sufficient.
|
|
@@ -2857,6 +3734,7 @@ export type {
|
|
|
2857
3734
|
export type {
|
|
2858
3735
|
ScheduledEvent,
|
|
2859
3736
|
SchedulerAppendTask,
|
|
3737
|
+
SchedulerClearTask,
|
|
2860
3738
|
SchedulerDrainTask,
|
|
2861
3739
|
SchedulerTask,
|
|
2862
3740
|
} from './scheduler-task.ts'
|