experimental-a2 0.16.0 → 0.17.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +360 -0
- package/dist/{actor-ohPC-81x.d.ts → actor-DBbe5xeE.d.ts} +21 -11
- package/dist/actor-DBbe5xeE.d.ts.map +1 -0
- package/dist/actor-client.d.ts +2 -5
- package/dist/actor-client.d.ts.map +1 -1
- package/dist/actor-client.js +13 -4
- package/dist/actor-client.js.map +1 -1
- package/dist/actor-react.d.ts +5 -3
- package/dist/actor-react.d.ts.map +1 -1
- package/dist/actor-react.js +20 -10
- package/dist/actor-react.js.map +1 -1
- package/dist/{actor-shared-B5tJfzt-.js → actor-shared-t7w8xIKP.js} +12 -7
- package/dist/actor-shared-t7w8xIKP.js.map +1 -0
- package/dist/actor.d.ts +1 -1
- package/dist/actor.js +42 -23
- package/dist/actor.js.map +1 -1
- package/dist/{ai-CrLYwNEx.js → ai-UrWfKeuv.js} +966 -541
- package/dist/ai-UrWfKeuv.js.map +1 -0
- package/dist/ai-client.d.ts +27 -0
- package/dist/ai-client.d.ts.map +1 -0
- package/dist/ai-client.js +29 -0
- package/dist/ai-client.js.map +1 -0
- package/dist/ai-react.d.ts +96 -0
- package/dist/ai-react.d.ts.map +1 -0
- package/dist/ai-react.js +142 -0
- package/dist/ai-react.js.map +1 -0
- package/dist/ai-server.browser.js +2 -2
- package/dist/ai-server.browser.js.map +1 -1
- package/dist/ai-server.d.ts +80 -36
- package/dist/ai-server.d.ts.map +1 -1
- package/dist/ai-server.js +466 -265
- package/dist/ai-server.js.map +1 -1
- package/dist/{ai-HJ9fHfYI.d.ts → ai-vpGtcaCB.d.ts} +115 -101
- package/dist/ai-vpGtcaCB.d.ts.map +1 -0
- package/dist/ai.d.ts +2 -2
- package/dist/ai.js +2 -2
- package/dist/{client-BYzHjkwU.js → client-COaiaDub.js} +447 -80
- package/dist/client-COaiaDub.js.map +1 -0
- package/dist/{client-CzyacQpJ.d.ts → client-qRnRUzBS.d.ts} +14 -13
- package/dist/client-qRnRUzBS.d.ts.map +1 -0
- package/dist/client.d.ts +4 -2
- package/dist/client.js +1 -1
- package/dist/{contract-CKRg_E4q.js → contract-BYKASlCV.js} +12 -21
- package/dist/contract-BYKASlCV.js.map +1 -0
- package/dist/{idempotent-replay-DVOlyYbx.js → idempotent-replay-JdgMQRRm.js} +2 -2
- package/dist/{idempotent-replay-DVOlyYbx.js.map → idempotent-replay-JdgMQRRm.js.map} +1 -1
- package/dist/index.d.ts +6 -5
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -3
- package/dist/{errors-DvhSXnxk.d.ts → initial-snapshot-BrLso5x_.d.ts} +7 -3
- package/dist/initial-snapshot-BrLso5x_.d.ts.map +1 -0
- package/dist/{internal-Dq2qYxou.js → internal-CbVvEwad.js} +23 -2
- package/dist/internal-CbVvEwad.js.map +1 -0
- package/dist/otel.d.ts +1 -1
- package/dist/react-BbJ-g6Uu.d.ts +86 -0
- package/dist/react-BbJ-g6Uu.d.ts.map +1 -0
- package/dist/react.d.ts +2 -122
- package/dist/react.js +107 -100
- package/dist/react.js.map +1 -1
- package/dist/reducer-BAXc8JyF.js +87 -0
- package/dist/reducer-BAXc8JyF.js.map +1 -0
- package/dist/{reducer-otzHjuJj.d.ts → reducer-BYu6DT7h.d.ts} +45 -29
- package/dist/reducer-BYu6DT7h.d.ts.map +1 -0
- package/dist/reducer-runtime-DQwz_J59.js +28 -0
- package/dist/reducer-runtime-DQwz_J59.js.map +1 -0
- package/dist/scheduler-qstash.d.ts +3 -3
- package/dist/scheduler-qstash.d.ts.map +1 -1
- package/dist/scheduler-qstash.js +33 -10
- package/dist/scheduler-qstash.js.map +1 -1
- package/dist/{scheduler-task-BpzhPnRS.js → scheduler-task-X1SToDjP.js} +18 -3
- package/dist/scheduler-task-X1SToDjP.js.map +1 -0
- package/dist/scheduler-vercel.d.ts +4 -9
- package/dist/scheduler-vercel.d.ts.map +1 -1
- package/dist/scheduler-vercel.js +25 -44
- package/dist/scheduler-vercel.js.map +1 -1
- package/dist/{server-DbD7IJVK.d.ts → server-CGM8WzRG.d.ts} +87 -26
- package/dist/server-CGM8WzRG.d.ts.map +1 -0
- package/dist/{server-BD5ckxZb.js → server-Rj64fNvX.js} +854 -202
- package/dist/server-Rj64fNvX.js.map +1 -0
- package/dist/server.d.ts +4 -4
- package/dist/server.js +2 -2
- package/dist/{errors-DCk6ch5n.js → session-clear-DGI4jTQ5.js} +17 -2
- package/dist/session-clear-DGI4jTQ5.js.map +1 -0
- package/dist/session-clear-Dshpuhff.d.ts +12 -0
- package/dist/session-clear-Dshpuhff.d.ts.map +1 -0
- package/dist/{store-DyZM6fS5.d.ts → store-Gpkq7WBo.d.ts} +44 -3
- package/dist/store-Gpkq7WBo.d.ts.map +1 -0
- package/dist/store-N8PXxDAS.js.map +1 -1
- package/dist/store-memory.d.ts +1 -1
- package/dist/store-memory.d.ts.map +1 -1
- package/dist/store-memory.js +182 -69
- package/dist/store-memory.js.map +1 -1
- package/dist/{store-polling-CmxUbV93.js → store-polling-4_y8lETm.js} +21 -4
- package/dist/store-polling-4_y8lETm.js.map +1 -0
- package/dist/store-postgres.d.ts +1 -1
- package/dist/store-postgres.d.ts.map +1 -1
- package/dist/store-postgres.js +399 -108
- package/dist/store-postgres.js.map +1 -1
- package/dist/{store-presence-polling-C7-XZyW9.js → store-presence-polling-Q1C-k2dd.js} +3 -3
- package/dist/{store-presence-polling-C7-XZyW9.js.map → store-presence-polling-Q1C-k2dd.js.map} +1 -1
- package/dist/store-redis-http.d.ts +1 -1
- package/dist/store-redis-http.d.ts.map +1 -1
- package/dist/store-redis-http.js +293 -11
- package/dist/store-redis-http.js.map +1 -1
- package/dist/store-redis-notify-tqWNnMRu.js +2374 -0
- package/dist/store-redis-notify-tqWNnMRu.js.map +1 -0
- package/dist/store-redis.d.ts +1 -1
- package/dist/store-redis.d.ts.map +1 -1
- package/dist/store-redis.js +11 -11
- package/dist/store-redis.js.map +1 -1
- package/dist/store-sqlite.d.ts +1 -1
- package/dist/store-sqlite.d.ts.map +1 -1
- package/dist/store-sqlite.js +166 -32
- package/dist/store-sqlite.js.map +1 -1
- package/dist/stream-buffer-Cx7AAPGq.js +23 -0
- package/dist/stream-buffer-Cx7AAPGq.js.map +1 -0
- package/dist/{telemetry-B5jzpy6y.d.ts → telemetry-cZH-cibj.d.ts} +2 -2
- package/dist/telemetry-cZH-cibj.d.ts.map +1 -0
- package/dist/{wire-BO5wWCb1.js → wire-DjfHOvdW.js} +132 -10
- package/dist/wire-DjfHOvdW.js.map +1 -0
- package/docs/01-quickstart.mdx +2 -2
- package/docs/actors/01-introduction.mdx +4 -0
- package/docs/actors/04-routes.mdx +14 -1
- package/docs/concepts/01-contracts.mdx +95 -0
- package/docs/concepts/04-state.mdx +3 -3
- package/docs/guides/01-timers.mdx +22 -14
- package/docs/guides/02-cancellation.mdx +1 -1
- package/docs/guides/03-react.mdx +268 -328
- package/docs/guides/04-local-first.mdx +32 -73
- package/docs/guides/05-production.mdx +40 -27
- package/docs/guides/06-ai-agents.mdx +680 -580
- package/docs/guides/09-presence.mdx +3 -7
- package/docs/guides/10-transports.mdx +5 -5
- package/docs/index.mdx +10 -18
- package/docs/reference/01-api.mdx +662 -195
- package/docs/reference/02-errors.mdx +2 -1
- package/examples/playground/DEPLOY.md +52 -6
- package/examples/playground/app/a0/[appId]/loading.tsx +16 -0
- package/examples/playground/app/a0/[appId]/page.tsx +48 -0
- package/examples/playground/app/a0/a0.css +818 -0
- package/examples/playground/app/a0/app-list.tsx +211 -0
- package/examples/playground/app/a0/app-view.tsx +55 -0
- package/examples/playground/app/a0/apps/[appId]/preview/route.ts +27 -0
- package/examples/playground/app/a0/apps/[appId]/route.ts +23 -0
- package/examples/playground/app/a0/apps.test.ts +116 -0
- package/examples/playground/app/a0/apps.ts +87 -0
- package/examples/playground/app/a0/chat.tsx +364 -0
- package/examples/playground/app/a0/columns.tsx +109 -0
- package/examples/playground/app/a0/context-menu.tsx +116 -0
- package/examples/playground/app/a0/cookies.ts +46 -0
- package/examples/playground/app/a0/crash-on-request.ts +72 -0
- package/examples/playground/app/a0/crashes/route.ts +3 -0
- package/examples/playground/app/a0/crashes.test.ts +33 -0
- package/examples/playground/app/a0/crashes.ts +28 -0
- package/examples/playground/app/a0/devtools.tsx +164 -0
- package/examples/playground/app/a0/events/route.ts +6 -0
- package/examples/playground/app/a0/flags.ts +30 -0
- package/examples/playground/app/a0/layout.tsx +38 -0
- package/examples/playground/app/a0/list/route.ts +10 -0
- package/examples/playground/app/a0/model.test.ts +56 -0
- package/examples/playground/app/a0/model.ts +58 -0
- package/examples/playground/app/a0/page.tsx +14 -0
- package/examples/playground/app/a0/preview-pane.tsx +145 -0
- package/examples/playground/app/a0/preview.test.ts +53 -0
- package/examples/playground/app/a0/preview.ts +26 -0
- package/examples/playground/app/a0/sandbox-log.ts +69 -0
- package/examples/playground/app/a0/sandbox.ts +191 -0
- package/examples/playground/app/a0/sandboxes/scheduler/route.ts +6 -0
- package/examples/playground/app/a0/scheduler/route.ts +7 -0
- package/examples/playground/app/a0/server.ts +158 -0
- package/examples/playground/app/a0/session.ts +47 -0
- package/examples/playground/app/a0/shell.tsx +150 -0
- package/examples/playground/app/a0/short/events/route.ts +9 -0
- package/examples/playground/app/a0/splitter.tsx +120 -0
- package/examples/playground/app/agent/[agentId]/agent-client.tsx +144 -174
- package/examples/playground/app/agent/[agentId]/command-receipt.test.tsx +65 -0
- package/examples/playground/app/agent/[agentId]/command-receipt.tsx +27 -0
- package/examples/playground/app/agent/[agentId]/compaction-event.tsx +25 -24
- package/examples/playground/app/agent/[agentId]/compaction-panel.test.tsx +119 -0
- package/examples/playground/app/agent/[agentId]/compaction-panel.tsx +208 -165
- package/examples/playground/app/agent/[agentId]/page.tsx +2 -16
- package/examples/playground/app/agent/events/route.ts +5 -2
- package/examples/playground/app/agent/ingress.test.ts +98 -0
- package/examples/playground/app/agent/model-input.test.tsx +58 -0
- package/examples/playground/app/agent/model-input.tsx +115 -0
- package/examples/playground/app/agent/model.test.ts +73 -0
- package/examples/playground/app/agent/model.ts +58 -26
- package/examples/playground/app/agent/new-agent-session.tsx +37 -19
- package/examples/playground/app/agent/page.tsx +4 -4
- package/examples/playground/app/agent/server.ts +15 -14
- package/examples/playground/app/agent/session.ts +5 -6
- package/examples/playground/app/agent/telemetry.test.ts +168 -0
- package/examples/playground/app/agent/telemetry.ts +30 -0
- package/examples/playground/app/agent/trace.test.ts +23 -0
- package/examples/playground/app/agent/trace.ts +18 -0
- package/examples/playground/app/canvas/[canvasId]/canvas-client.tsx +11 -11
- package/examples/playground/app/canvas/[canvasId]/page.tsx +1 -2
- package/examples/playground/app/canvas/[canvasId]/session.ts +2 -2
- package/examples/playground/app/canvas/model.ts +53 -53
- package/examples/playground/app/chat/[chatId]/agent-state.test.ts +176 -0
- package/examples/playground/app/chat/[chatId]/agent-stream-drawer.tsx +16 -1
- package/examples/playground/app/chat/[chatId]/chat-client.tsx +55 -77
- package/examples/playground/app/chat/[chatId]/chat-view.ts +37 -3
- package/examples/playground/app/chat/[chatId]/page.tsx +7 -15
- package/examples/playground/app/chat/[chatId]/session.ts +2 -19
- package/examples/playground/app/chat/agent/route.ts +9 -0
- package/examples/playground/app/chat/model.test.ts +46 -119
- package/examples/playground/app/chat/model.ts +82 -128
- package/examples/playground/app/chat/scheduler/route.ts +2 -2
- package/examples/playground/app/chat/server.test.ts +108 -0
- package/examples/playground/app/chat/server.ts +69 -73
- package/examples/playground/app/components/activity-feed.tsx +30 -1
- package/examples/playground/app/components/activity-history.test.tsx +35 -0
- package/examples/playground/app/components/demo-icons.tsx +89 -0
- package/examples/playground/app/components/demos.tsx +33 -0
- package/examples/playground/app/components/fast-link.test.tsx +58 -0
- package/examples/playground/app/components/fast-link.tsx +55 -0
- package/examples/playground/app/components/site-header.tsx +96 -0
- package/examples/playground/app/counter/counter-client.tsx +3 -3
- package/examples/playground/app/counter/model.test.ts +3 -3
- package/examples/playground/app/counter/model.ts +11 -21
- package/examples/playground/app/counter/page.tsx +2 -15
- package/examples/playground/app/counter/session.ts +2 -2
- package/examples/playground/app/documents/[documentId]/code-editor.tsx +12 -1
- package/examples/playground/app/documents/[documentId]/document-client.tsx +10 -11
- package/examples/playground/app/documents/[documentId]/page.tsx +1 -2
- package/examples/playground/app/documents/[documentId]/session.ts +2 -2
- package/examples/playground/app/documents/model.ts +27 -27
- package/examples/playground/app/globals.css +420 -207
- package/examples/playground/app/initial-state.test.tsx +54 -0
- package/examples/playground/app/layout.tsx +7 -23
- package/examples/playground/app/orders/[orderId]/page.tsx +1 -2
- package/examples/playground/app/orders/[orderId]/session.ts +2 -2
- package/examples/playground/app/orders/model.ts +49 -49
- package/examples/playground/app/page.tsx +13 -102
- package/examples/playground/app/recovery/[recoveryId]/page.tsx +1 -2
- package/examples/playground/app/recovery/[recoveryId]/session.ts +2 -2
- package/examples/playground/app/recovery/model.ts +30 -30
- package/examples/playground/instrumentation.ts +5 -0
- package/examples/playground/package.json +10 -2
- package/examples/playground/scripts/a0-template.ts +77 -0
- package/examples/playground/vercel.json +18 -0
- package/package.json +3 -1
- package/src/actor-client.ts +16 -4
- package/src/actor-react.ts +25 -11
- package/src/actor-shared.ts +21 -6
- package/src/actor.ts +76 -31
- package/src/ai-agent-actions.ts +219 -0
- package/src/ai-agent.ts +2343 -0
- package/src/ai-app-state.ts +91 -0
- package/src/ai-client-state.ts +39 -2
- 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 +94 -55
- package/src/ai-control.ts +2 -0
- package/src/ai-coordinator.ts +23 -22
- package/src/ai-message-base.ts +209 -0
- package/src/ai-message-projection.ts +382 -58
- package/src/ai-progress-batches.ts +26 -0
- package/src/ai-projector.ts +95 -14
- package/src/ai-react.ts +146 -0
- package/src/ai-server.browser.ts +2 -2
- package/src/ai-server.ts +20 -1951
- package/src/ai-stored-state.ts +48 -163
- package/src/ai.ts +343 -276
- package/src/bootstrap-stream.ts +72 -0
- package/src/bounded-cache.ts +55 -0
- package/src/client-render-session.ts +49 -0
- package/src/client.ts +688 -151
- package/src/contract.ts +92 -42
- package/src/deterministic-id.ts +37 -0
- package/src/equal-json.ts +32 -0
- package/src/errors.ts +13 -1
- package/src/index.ts +5 -1
- package/src/initial-snapshot.ts +39 -0
- package/src/internal.ts +45 -0
- package/src/postgres-notification-scope.ts +14 -3
- package/src/react.ts +171 -206
- package/src/redis-http-reply.ts +256 -0
- package/src/redis-http-stream.ts +73 -0
- package/src/redis-http-subscriptions.ts +13 -5
- package/src/reducer-runtime.ts +52 -0
- package/src/reducer.ts +92 -10
- package/src/scheduler-qstash.ts +49 -20
- package/src/scheduler-task.ts +34 -2
- package/src/scheduler-vercel.ts +32 -84
- package/src/server-fetch.ts +139 -35
- package/src/server.ts +957 -237
- package/src/session-clear.ts +23 -0
- package/src/session-socket.ts +154 -49
- package/src/sse.ts +71 -4
- package/src/store-memory.ts +220 -78
- package/src/store-polling.ts +18 -4
- package/src/store-postgres.ts +461 -114
- package/src/store-redis-core.ts +1260 -434
- package/src/store-redis-http.ts +18 -2
- package/src/store-redis-notify.ts +260 -85
- package/src/store-redis.ts +19 -9
- package/src/store-sqlite.ts +259 -45
- package/src/store.ts +46 -1
- package/src/stream-buffer.ts +43 -0
- package/src/telemetry.ts +13 -1
- package/src/wire.ts +241 -13
- package/dist/actor-ohPC-81x.d.ts.map +0 -1
- package/dist/actor-shared-B5tJfzt-.js.map +0 -1
- package/dist/ai-CrLYwNEx.js.map +0 -1
- package/dist/ai-HJ9fHfYI.d.ts.map +0 -1
- package/dist/client-BYzHjkwU.js.map +0 -1
- package/dist/client-CzyacQpJ.d.ts.map +0 -1
- package/dist/contract-CKRg_E4q.js.map +0 -1
- package/dist/errors-DCk6ch5n.js.map +0 -1
- package/dist/errors-DvhSXnxk.d.ts.map +0 -1
- package/dist/internal-Dq2qYxou.js.map +0 -1
- package/dist/react.d.ts.map +0 -1
- package/dist/reducer-DEMjEY_O.js +0 -29
- package/dist/reducer-DEMjEY_O.js.map +0 -1
- package/dist/reducer-otzHjuJj.d.ts.map +0 -1
- package/dist/scheduler-task-BpzhPnRS.js.map +0 -1
- package/dist/server-BD5ckxZb.js.map +0 -1
- package/dist/server-DbD7IJVK.d.ts.map +0 -1
- package/dist/store-DyZM6fS5.d.ts.map +0 -1
- package/dist/store-polling-CmxUbV93.js.map +0 -1
- package/dist/store-redis-notify-BVLUfI3j.js +0 -1583
- package/dist/store-redis-notify-BVLUfI3j.js.map +0 -1
- package/dist/telemetry-B5jzpy6y.d.ts.map +0 -1
- package/dist/validate-XKT4FSNn.js +0 -30
- package/dist/validate-XKT4FSNn.js.map +0 -1
- package/dist/wire-BO5wWCb1.js.map +0 -1
- package/examples/playground/app/agent/[agentId]/compaction/route.ts +0 -14
- package/examples/playground/app/agent/compaction-settings.test.ts +0 -144
- package/examples/playground/app/agent/compaction-settings.ts +0 -49
- package/examples/playground/app/agent/compaction-timeline.test.ts +0 -377
- package/examples/playground/app/agent/compaction-timeline.ts +0 -208
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({
|
|
@@ -25,27 +26,52 @@ import {
|
|
|
25
26
|
POLL_TIMINGS,
|
|
26
27
|
MAX_DATE_MS,
|
|
27
28
|
RESERVED_PARTICIPANT_IDS,
|
|
29
|
+
STATE_CACHE,
|
|
28
30
|
markSchedulerSendFailure,
|
|
31
|
+
stateTailReaders,
|
|
29
32
|
nullProtoRecord,
|
|
30
33
|
serverInternals,
|
|
31
34
|
type DrainOutcome,
|
|
32
35
|
type DrainResult,
|
|
33
36
|
} from './internal.ts'
|
|
37
|
+
import {
|
|
38
|
+
isSessionCleared,
|
|
39
|
+
sessionClearedError,
|
|
40
|
+
type SessionCleared,
|
|
41
|
+
} from './session-clear.ts'
|
|
42
|
+
import { boundedCache } from './bounded-cache.ts'
|
|
34
43
|
import { defaultSleep, type Sleeper } from './store-polling.ts'
|
|
35
44
|
import { createStreamActivity, streamActivities } from './stream-activity.ts'
|
|
45
|
+
import {
|
|
46
|
+
bufferedEvents,
|
|
47
|
+
isSerializedEvent,
|
|
48
|
+
takeBuffered,
|
|
49
|
+
} from './stream-buffer.ts'
|
|
36
50
|
import type {
|
|
37
51
|
A2Store,
|
|
38
52
|
AppendEvent,
|
|
39
53
|
Event,
|
|
40
54
|
EventCause,
|
|
41
55
|
StoreClaimAvailableResult,
|
|
56
|
+
StoreClearResult,
|
|
57
|
+
StoreReclaimResult,
|
|
42
58
|
StoreSnapshotWrite,
|
|
43
59
|
StoreStateRead,
|
|
44
60
|
PresenceRow,
|
|
45
61
|
ReturnedEvent,
|
|
46
62
|
StoredEvent,
|
|
47
63
|
} from './store.ts'
|
|
48
|
-
import
|
|
64
|
+
import {
|
|
65
|
+
cloneInitial,
|
|
66
|
+
foldEvent,
|
|
67
|
+
type Reducer,
|
|
68
|
+
type RegisteredReducer,
|
|
69
|
+
} from './reducer.ts'
|
|
70
|
+
type PublicState<R> = undefined extends R
|
|
71
|
+
? never
|
|
72
|
+
: R extends { initialState: infer S }
|
|
73
|
+
? S
|
|
74
|
+
: never
|
|
49
75
|
import {
|
|
50
76
|
createServerFetch,
|
|
51
77
|
type A2Operation,
|
|
@@ -59,6 +85,7 @@ import {
|
|
|
59
85
|
deterministicEventId,
|
|
60
86
|
deterministicReturnedEventId,
|
|
61
87
|
deterministicScheduledEventId,
|
|
88
|
+
deterministicScheduleClearId,
|
|
62
89
|
deterministicScheduleId,
|
|
63
90
|
} from './deterministic-id.ts'
|
|
64
91
|
import { invocationDeadlineMs, platformWaitUntil } from './platform.ts'
|
|
@@ -66,6 +93,7 @@ import { retryableLazy } from './retryable-lazy.ts'
|
|
|
66
93
|
import type {
|
|
67
94
|
ScheduledEvent,
|
|
68
95
|
SchedulerAppendTask,
|
|
96
|
+
SchedulerClearTask,
|
|
69
97
|
SchedulerTask,
|
|
70
98
|
} from './scheduler-task.ts'
|
|
71
99
|
import {
|
|
@@ -79,13 +107,14 @@ export type HandlerContext<
|
|
|
79
107
|
D extends EventDefs,
|
|
80
108
|
K extends keyof D & string = keyof D & string,
|
|
81
109
|
P extends PresenceDefs = Record<never, never>,
|
|
110
|
+
S = never,
|
|
82
111
|
> = {
|
|
83
112
|
/** The triggering event. */
|
|
84
113
|
event: ContractEvent<D, K>
|
|
85
114
|
/** Durable, 1-based dispatch ordinal for this event. */
|
|
86
115
|
attempt: number
|
|
87
116
|
/** This session, with handler-scoped idempotent append. */
|
|
88
|
-
session: Session<D, HandlerAppend<D>, P>
|
|
117
|
+
session: Session<D, HandlerAppend<D>, P, S>
|
|
89
118
|
/** Fires on `abortOn` events (cancellation slice); dormant otherwise. */
|
|
90
119
|
signal: AbortSignal
|
|
91
120
|
}
|
|
@@ -94,8 +123,9 @@ export type Handler<
|
|
|
94
123
|
D extends EventDefs,
|
|
95
124
|
K extends keyof D & string = keyof D & string,
|
|
96
125
|
P extends PresenceDefs = Record<never, never>,
|
|
126
|
+
S = never,
|
|
97
127
|
> = (
|
|
98
|
-
ctx: HandlerContext<D, K, P>,
|
|
128
|
+
ctx: HandlerContext<D, K, P, S>,
|
|
99
129
|
) => Promise<void | AppendInput<D> | readonly AppendInput<D>[]>
|
|
100
130
|
|
|
101
131
|
export type LaneContext<
|
|
@@ -130,16 +160,34 @@ export type ScheduleDelay = `${number}${'ms' | 's' | 'm' | 'h' | 'd'}`
|
|
|
130
160
|
export type ScheduleTiming =
|
|
131
161
|
{ delay: ScheduleDelay; at?: never } | { at: Date; delay?: never }
|
|
132
162
|
|
|
133
|
-
export type SessionSchedule<D extends EventDefs> =
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
163
|
+
export type SessionSchedule<D extends EventDefs> = {
|
|
164
|
+
(
|
|
165
|
+
name: string,
|
|
166
|
+
timing: ScheduleTiming,
|
|
167
|
+
...events: AppendInput<D>[]
|
|
168
|
+
): Promise<void>
|
|
169
|
+
/**
|
|
170
|
+
* Publish a durable conditional clear. Delivery clears only while
|
|
171
|
+
* `ifLastIndex` is still the head, and finishes deleting before it
|
|
172
|
+
* acknowledges.
|
|
173
|
+
*/
|
|
174
|
+
clear(
|
|
175
|
+
name: string,
|
|
176
|
+
options: ScheduleTiming & { ifLastIndex: number },
|
|
177
|
+
): Promise<void>
|
|
178
|
+
}
|
|
138
179
|
|
|
139
180
|
export type StateOptions = {
|
|
140
181
|
through?: number | 'latest'
|
|
141
182
|
}
|
|
142
183
|
|
|
184
|
+
export type SessionClearOptions = {
|
|
185
|
+
/** Clear only while this is the head; a mismatch changes nothing. */
|
|
186
|
+
ifLastIndex?: number
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export type SessionClearResult = { cleared: boolean; index: number }
|
|
190
|
+
|
|
143
191
|
/**
|
|
144
192
|
* The presence members of a session — intersected in via
|
|
145
193
|
* `WithPresence`, so they exist exactly when the contract declares
|
|
@@ -156,7 +204,9 @@ export type SessionPresence<D extends EventDefs, P extends PresenceDefs> = {
|
|
|
156
204
|
stream(opts: {
|
|
157
205
|
startAfter?: number
|
|
158
206
|
presence: true
|
|
159
|
-
}): AsyncIterable<
|
|
207
|
+
}): AsyncIterable<
|
|
208
|
+
ContractEvent<D> | SessionCleared | PresencePatch<P> | PresenceSnapshot<P>
|
|
209
|
+
>
|
|
160
210
|
/**
|
|
161
211
|
* Validate against the contract's presence schemas, then broadcast.
|
|
162
212
|
* Never an append: no log row, no dispatch, no scheduler arm. `at`
|
|
@@ -178,20 +228,33 @@ export type Session<
|
|
|
178
228
|
D extends EventDefs,
|
|
179
229
|
Append = SessionAppend<D>,
|
|
180
230
|
P extends PresenceDefs = Record<never, never>,
|
|
231
|
+
S = never,
|
|
181
232
|
> = WithPresence<P, SessionPresence<D, P>> & {
|
|
182
233
|
readonly id: string
|
|
183
234
|
append: Append
|
|
184
235
|
schedule: SessionSchedule<D>
|
|
236
|
+
/**
|
|
237
|
+
* End the current lifetime: atomically append a clear marker, then
|
|
238
|
+
* delete the history below it. Indexes never restart.
|
|
239
|
+
*/
|
|
240
|
+
clear(options?: SessionClearOptions): Promise<SessionClearResult>
|
|
185
241
|
history(options?: { gte?: number; lte?: number }): Promise<ContractEvent<D>[]>
|
|
186
|
-
state
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
242
|
+
state: {
|
|
243
|
+
<T>(
|
|
244
|
+
reducer: Reducer<D, T, Record<never, never>>,
|
|
245
|
+
options?: StateOptions,
|
|
246
|
+
): Promise<{ state: T; index: number; clearIndex?: number }>
|
|
247
|
+
} & ([S] extends [never]
|
|
248
|
+
? unknown
|
|
249
|
+
: { (): Promise<{ state: S; index: number; clearIndex?: number }> })
|
|
190
250
|
/**
|
|
191
251
|
* A live feed of this session's events, starting after `startAfter`
|
|
192
252
|
* (exclusive). Server-side only. `server.fetch` exposes it over SSE.
|
|
253
|
+
* A clear arrives as a `SessionCleared` marker.
|
|
193
254
|
*/
|
|
194
|
-
stream(opts?: {
|
|
255
|
+
stream(opts?: {
|
|
256
|
+
startAfter?: number
|
|
257
|
+
}): AsyncIterable<ContractEvent<D> | SessionCleared>
|
|
195
258
|
}
|
|
196
259
|
|
|
197
260
|
/**
|
|
@@ -221,14 +284,16 @@ export type A2Scheduler = {
|
|
|
221
284
|
export type A2Server<
|
|
222
285
|
D extends EventDefs,
|
|
223
286
|
P extends PresenceDefs = Record<never, never>,
|
|
287
|
+
R extends RegisteredReducer<D, P> | undefined =
|
|
288
|
+
RegisteredReducer<D, P> | undefined,
|
|
224
289
|
> = {
|
|
225
290
|
/** The contract this server implements. */
|
|
226
|
-
readonly contract: Contract<D, P>
|
|
291
|
+
readonly contract: Contract<D, P, R>
|
|
227
292
|
readonly fetch: {
|
|
228
293
|
(request: Request): Promise<Response>
|
|
229
294
|
(request: Request, options: ServerFetchOptions<D, P>): Promise<Response>
|
|
230
295
|
}
|
|
231
|
-
session(id: string): Session<D, SessionAppend<D>, P
|
|
296
|
+
session(id: string): Session<D, SessionAppend<D>, P, PublicState<R>>
|
|
232
297
|
/**
|
|
233
298
|
* Process every currently eligible event. `settled` means nothing
|
|
234
299
|
* actionable remains, including work blocked behind a dead letter.
|
|
@@ -259,13 +324,58 @@ export async function deliverSchedulerAppend(
|
|
|
259
324
|
`a2 scheduler: append task for contract '${task.contract}' cannot be delivered to '${server.contract.name}'`,
|
|
260
325
|
)
|
|
261
326
|
}
|
|
327
|
+
const { originIndex } = task
|
|
328
|
+
// A malformed origin would read as cleared and drop the events silently.
|
|
329
|
+
if (
|
|
330
|
+
originIndex !== undefined &&
|
|
331
|
+
(!Number.isSafeInteger(originIndex) || originIndex < 1)
|
|
332
|
+
) {
|
|
333
|
+
throw new TypeError(
|
|
334
|
+
'a2 scheduler: append task originIndex must be a positive safe integer',
|
|
335
|
+
)
|
|
336
|
+
}
|
|
262
337
|
const internals = serverInternals.get(server)
|
|
263
338
|
if (!internals) {
|
|
264
339
|
throw new TypeError(
|
|
265
340
|
`a2 scheduler: server for contract '${task.contract}' cannot receive scheduled appends`,
|
|
266
341
|
)
|
|
267
342
|
}
|
|
268
|
-
await internals.schedulerAppend(task.sessionId, task.events)
|
|
343
|
+
await internals.schedulerAppend(task.sessionId, task.events, originIndex)
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* Deliver one authenticated scheduler clear: a conditional
|
|
348
|
+
* `clear({ ifLastIndex })` that resolves after its deletion finishes, so a
|
|
349
|
+
* provider retry resumes an interrupted deletion. Custom scheduler adapters
|
|
350
|
+
* call this after validating their transport envelope; application code uses
|
|
351
|
+
* `session.schedule.clear`.
|
|
352
|
+
*/
|
|
353
|
+
export async function deliverSchedulerClear(
|
|
354
|
+
server: DrainableServer,
|
|
355
|
+
task: SchedulerClearTask,
|
|
356
|
+
): Promise<void> {
|
|
357
|
+
if (server.contract.name !== task.contract) {
|
|
358
|
+
throw new TypeError(
|
|
359
|
+
`a2 scheduler: clear task for contract '${task.contract}' cannot be delivered to '${server.contract.name}'`,
|
|
360
|
+
)
|
|
361
|
+
}
|
|
362
|
+
// Without its condition a clear task would erase the whole session.
|
|
363
|
+
if (
|
|
364
|
+
task.kind !== 'clear' ||
|
|
365
|
+
!Number.isSafeInteger(task.ifLastIndex) ||
|
|
366
|
+
task.ifLastIndex < 0
|
|
367
|
+
) {
|
|
368
|
+
throw new TypeError(
|
|
369
|
+
'a2 scheduler: a clear task needs kind "clear" and a non-negative safe integer ifLastIndex',
|
|
370
|
+
)
|
|
371
|
+
}
|
|
372
|
+
const internals = serverInternals.get(server)
|
|
373
|
+
if (!internals) {
|
|
374
|
+
throw new TypeError(
|
|
375
|
+
`a2 scheduler: server for contract '${task.contract}' cannot receive scheduled clears`,
|
|
376
|
+
)
|
|
377
|
+
}
|
|
378
|
+
await internals.schedulerClear(task.sessionId, task.ifLastIndex)
|
|
269
379
|
}
|
|
270
380
|
|
|
271
381
|
/**
|
|
@@ -292,13 +402,14 @@ export type HandlerEntry<
|
|
|
292
402
|
D extends EventDefs,
|
|
293
403
|
K extends keyof D & string = keyof D & string,
|
|
294
404
|
P extends PresenceDefs = Record<never, never>,
|
|
405
|
+
S = never,
|
|
295
406
|
> =
|
|
296
|
-
| Handler<D, K, P>
|
|
407
|
+
| Handler<D, K, P, S>
|
|
297
408
|
| {
|
|
298
409
|
abortOn?: AbortSpec<D, K>
|
|
299
410
|
/** Session-scoped FIFO key, resolved and persisted when the event lands. */
|
|
300
411
|
lane?: Lane<D, K>
|
|
301
|
-
handler: Handler<D, K, P>
|
|
412
|
+
handler: Handler<D, K, P, S>
|
|
302
413
|
}
|
|
303
414
|
|
|
304
415
|
type HandlerFunction = (ctx: never) => Promise<unknown>
|
|
@@ -315,9 +426,11 @@ export function handler(
|
|
|
315
426
|
export type ServerOptions<
|
|
316
427
|
D extends EventDefs,
|
|
317
428
|
P extends PresenceDefs = Record<never, never>,
|
|
429
|
+
R extends RegisteredReducer<D, P> | undefined =
|
|
430
|
+
RegisteredReducer<D, P> | undefined,
|
|
318
431
|
> = {
|
|
319
432
|
/** The contract this server implements (see `a2.contract`). */
|
|
320
|
-
contract: Contract<D, P>
|
|
433
|
+
contract: Contract<D, P, R>
|
|
321
434
|
/** Where events live. Defaults: sqlite in dev, memory in tests, required in prod. */
|
|
322
435
|
store?: A2Store
|
|
323
436
|
/**
|
|
@@ -341,11 +454,31 @@ export type ServerOptions<
|
|
|
341
454
|
* wasn't imported. Compose across files by spreading objects (note:
|
|
342
455
|
* a duplicate key under spread silently last-wins).
|
|
343
456
|
*/
|
|
344
|
-
handlers?: { [K in keyof D & string]?: HandlerEntry<D, K, P
|
|
457
|
+
handlers?: { [K in keyof D & string]?: HandlerEntry<D, K, P, PublicState<R>> }
|
|
345
458
|
}
|
|
346
459
|
|
|
347
460
|
const MAX_FAILURES = 10
|
|
348
461
|
|
|
462
|
+
/** Abort a lost attempt's handler and release its drain slot. */
|
|
463
|
+
function endSuperseded(
|
|
464
|
+
entry: {
|
|
465
|
+
supersede: AbortController
|
|
466
|
+
fence: (outcome: {
|
|
467
|
+
status: 'superseded'
|
|
468
|
+
opensLane: false
|
|
469
|
+
appendsEvents: false
|
|
470
|
+
}) => void
|
|
471
|
+
},
|
|
472
|
+
message: string,
|
|
473
|
+
): void {
|
|
474
|
+
entry.supersede.abort(new A2Error('SUPERSEDED_ATTEMPT', message))
|
|
475
|
+
entry.fence({ status: 'superseded', opensLane: false, appendsEvents: false })
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
/** Pause between clear-deletion batches that found only locked rows. */
|
|
479
|
+
const RECLAIM_STALL_MS = 20
|
|
480
|
+
const RECLAIM_STALL_LIMIT = 50
|
|
481
|
+
|
|
349
482
|
/** Default: how long a presence value survives without a refreshing set. */
|
|
350
483
|
const PRESENCE_TTL_MS = 60_000
|
|
351
484
|
|
|
@@ -511,11 +644,32 @@ function foldPresenceRows(
|
|
|
511
644
|
return map
|
|
512
645
|
}
|
|
513
646
|
|
|
647
|
+
type SnapshotWrites = Map<
|
|
648
|
+
number,
|
|
649
|
+
{ state: unknown; pinEventIndexes: Set<number> }
|
|
650
|
+
>
|
|
651
|
+
|
|
652
|
+
const addSnapshotWrite = (
|
|
653
|
+
writes: SnapshotWrites,
|
|
654
|
+
index: number,
|
|
655
|
+
state: unknown,
|
|
656
|
+
pinEventIndex?: number,
|
|
657
|
+
): void => {
|
|
658
|
+
let write = writes.get(index)
|
|
659
|
+
if (!write) {
|
|
660
|
+
write = { state: cloneInitial(state), pinEventIndexes: new Set() }
|
|
661
|
+
writes.set(index, write)
|
|
662
|
+
}
|
|
663
|
+
if (pinEventIndex !== undefined) write.pinEventIndexes.add(pinEventIndex)
|
|
664
|
+
}
|
|
665
|
+
|
|
514
666
|
/** Implement a contract: bind its vocabulary to storage and reactions. */
|
|
515
667
|
export function createServer<
|
|
516
668
|
D extends EventDefs,
|
|
517
669
|
P extends PresenceDefs = Record<never, never>,
|
|
518
|
-
|
|
670
|
+
R extends RegisteredReducer<D, P> | undefined =
|
|
671
|
+
RegisteredReducer<D, P> | undefined,
|
|
672
|
+
>(options: ServerOptions<D, P, R>): A2Server<D, P, R> {
|
|
519
673
|
const serverContract = options?.contract
|
|
520
674
|
if (
|
|
521
675
|
serverContract === null ||
|
|
@@ -855,6 +1009,7 @@ export function createServer<
|
|
|
855
1009
|
cause?: EventCause,
|
|
856
1010
|
generatedIds?: readonly boolean[],
|
|
857
1011
|
requireDurableInitialArm = false,
|
|
1012
|
+
originIndex?: number,
|
|
858
1013
|
): Promise<ContractEvent<D>[]> =>
|
|
859
1014
|
telemetry.span(
|
|
860
1015
|
'a2.append',
|
|
@@ -885,9 +1040,20 @@ export function createServer<
|
|
|
885
1040
|
let rows: StoredEvent[]
|
|
886
1041
|
let shouldHealSession: boolean
|
|
887
1042
|
try {
|
|
888
|
-
const result = await store.append(
|
|
1043
|
+
const result = await store.append(
|
|
1044
|
+
nsId(sessionId),
|
|
1045
|
+
validated,
|
|
1046
|
+
originIndex === undefined ? undefined : { originIndex },
|
|
1047
|
+
)
|
|
889
1048
|
rows = result.events
|
|
890
|
-
|
|
1049
|
+
// A handler appending only settled events adds no work, and the
|
|
1050
|
+
// drain holding its trigger is already running.
|
|
1051
|
+
shouldHealSession =
|
|
1052
|
+
result.hasPending &&
|
|
1053
|
+
!(
|
|
1054
|
+
source === 'handler' &&
|
|
1055
|
+
validated.every((event) => event.settled === true)
|
|
1056
|
+
)
|
|
891
1057
|
} catch (err) {
|
|
892
1058
|
throw asStoreUnavailable(err)
|
|
893
1059
|
}
|
|
@@ -1005,12 +1171,18 @@ export function createServer<
|
|
|
1005
1171
|
}
|
|
1006
1172
|
}
|
|
1007
1173
|
|
|
1174
|
+
type StateResult = { state: unknown; index: number; clearIndex?: number }
|
|
1175
|
+
|
|
1008
1176
|
type PendingStateRead = {
|
|
1009
1177
|
sessionId: string
|
|
1010
1178
|
throughIndex?: number
|
|
1179
|
+
/** The id of the event at `throughIndex`, when the reader knows it. */
|
|
1180
|
+
throughEventId?: string
|
|
1011
1181
|
pinEventIndex?: number
|
|
1182
|
+
/** A handler read: rejected once this trigger index has been cleared. */
|
|
1183
|
+
triggerIndex?: number
|
|
1012
1184
|
span: A2SpanHandle
|
|
1013
|
-
resolve: (result:
|
|
1185
|
+
resolve: (result: StateResult) => void
|
|
1014
1186
|
reject: (reason: unknown) => void
|
|
1015
1187
|
}
|
|
1016
1188
|
|
|
@@ -1045,6 +1217,331 @@ export function createServer<
|
|
|
1045
1217
|
return plans
|
|
1046
1218
|
}
|
|
1047
1219
|
|
|
1220
|
+
const persistSnapshots = ({
|
|
1221
|
+
store,
|
|
1222
|
+
reducer,
|
|
1223
|
+
sessionId,
|
|
1224
|
+
snapshots,
|
|
1225
|
+
}: {
|
|
1226
|
+
store: A2Store
|
|
1227
|
+
reducer: Reducer<D, unknown>
|
|
1228
|
+
sessionId: string
|
|
1229
|
+
snapshots: StoreSnapshotWrite[]
|
|
1230
|
+
}): void => {
|
|
1231
|
+
const persistence = Promise.resolve()
|
|
1232
|
+
.then(() =>
|
|
1233
|
+
telemetry.span(
|
|
1234
|
+
'a2.snapshot',
|
|
1235
|
+
{
|
|
1236
|
+
'a2.contract': name,
|
|
1237
|
+
'a2.session_id': sessionId,
|
|
1238
|
+
'a2.snapshot.reducer': reducer.name,
|
|
1239
|
+
'a2.snapshot.index': snapshots.at(-1)!.index,
|
|
1240
|
+
'a2.snapshot.count': snapshots.length,
|
|
1241
|
+
},
|
|
1242
|
+
async (span) => {
|
|
1243
|
+
try {
|
|
1244
|
+
await store.putSnapshots(nsId(sessionId), reducer.name, snapshots)
|
|
1245
|
+
} catch (error) {
|
|
1246
|
+
try {
|
|
1247
|
+
span.recordError(error)
|
|
1248
|
+
} catch (telemetryError) {
|
|
1249
|
+
// oxlint-disable-next-line preserve-caught-error -- AggregateError preserves both failures.
|
|
1250
|
+
throw new AggregateError(
|
|
1251
|
+
[error, telemetryError],
|
|
1252
|
+
'Snapshot write and error reporting failed',
|
|
1253
|
+
{ cause: telemetryError },
|
|
1254
|
+
)
|
|
1255
|
+
}
|
|
1256
|
+
}
|
|
1257
|
+
},
|
|
1258
|
+
),
|
|
1259
|
+
)
|
|
1260
|
+
.catch((error: unknown) => {
|
|
1261
|
+
// oxlint-disable-next-line no-console -- telemetry failure must not hide failed background work.
|
|
1262
|
+
console.error(
|
|
1263
|
+
'[a2] error in a2.snapshot telemetry',
|
|
1264
|
+
{
|
|
1265
|
+
sessionId,
|
|
1266
|
+
reducer: reducer.name,
|
|
1267
|
+
index: snapshots.at(-1)!.index,
|
|
1268
|
+
},
|
|
1269
|
+
error,
|
|
1270
|
+
)
|
|
1271
|
+
})
|
|
1272
|
+
track(persistence)
|
|
1273
|
+
platformWaitUntil(persistence)
|
|
1274
|
+
}
|
|
1275
|
+
|
|
1276
|
+
// ── in-memory reducer state ──────────────────────────────────────
|
|
1277
|
+
/**
|
|
1278
|
+
* The latest folded state per (session, reducer) this process has seen,
|
|
1279
|
+
* with the index and id of the last event it reflects. An entry serves
|
|
1280
|
+
* only the reducer object that folded it, so a same-named reducer with
|
|
1281
|
+
* different logic reads the store. Cached states are never handed out;
|
|
1282
|
+
* readers get clones.
|
|
1283
|
+
*/
|
|
1284
|
+
type CachedState = {
|
|
1285
|
+
reducer: Reducer<D, unknown>
|
|
1286
|
+
index: number
|
|
1287
|
+
lastId: string
|
|
1288
|
+
state: unknown
|
|
1289
|
+
bytes: number
|
|
1290
|
+
/** Events folded since `bytes` was last measured. */
|
|
1291
|
+
unmeasured: number
|
|
1292
|
+
persistedIndex: number
|
|
1293
|
+
/** The latest clear marker at or below `index`, or zero. */
|
|
1294
|
+
clearIndex: number
|
|
1295
|
+
}
|
|
1296
|
+
const stateCache = boundedCache<string, CachedState>(STATE_CACHE)
|
|
1297
|
+
const cacheState = (key: string, next: CachedState): void => {
|
|
1298
|
+
const current = stateCache.get(key)
|
|
1299
|
+
if (
|
|
1300
|
+
current?.reducer === next.reducer &&
|
|
1301
|
+
(current.index > next.index ||
|
|
1302
|
+
(current.index === next.index &&
|
|
1303
|
+
current.persistedIndex >= next.persistedIndex))
|
|
1304
|
+
)
|
|
1305
|
+
return
|
|
1306
|
+
stateCache.set(key, next, next.bytes)
|
|
1307
|
+
}
|
|
1308
|
+
|
|
1309
|
+
const writeSnapshots = ({
|
|
1310
|
+
store,
|
|
1311
|
+
reducer,
|
|
1312
|
+
sessionId,
|
|
1313
|
+
writes,
|
|
1314
|
+
}: {
|
|
1315
|
+
store: A2Store
|
|
1316
|
+
reducer: Reducer<D, unknown>
|
|
1317
|
+
sessionId: string
|
|
1318
|
+
writes: SnapshotWrites
|
|
1319
|
+
}): StoreSnapshotWrite[] => {
|
|
1320
|
+
const snapshots = [...writes]
|
|
1321
|
+
.toSorted(([a], [b]) => a - b)
|
|
1322
|
+
.map(([index, write]) => {
|
|
1323
|
+
const snapshot: StoreSnapshotWrite = {
|
|
1324
|
+
index,
|
|
1325
|
+
state: encodeState({ reducer, state: write.state }),
|
|
1326
|
+
}
|
|
1327
|
+
if (write.pinEventIndexes.size > 0) {
|
|
1328
|
+
snapshot.pinEventIndexes = [...write.pinEventIndexes]
|
|
1329
|
+
}
|
|
1330
|
+
return snapshot
|
|
1331
|
+
})
|
|
1332
|
+
if (snapshots.length > 0) {
|
|
1333
|
+
persistSnapshots({ store, reducer, sessionId, snapshots })
|
|
1334
|
+
}
|
|
1335
|
+
return snapshots
|
|
1336
|
+
}
|
|
1337
|
+
|
|
1338
|
+
/**
|
|
1339
|
+
* Folds `rows` from `position` in bound order, resolving each entry with a
|
|
1340
|
+
* clone once the rows through its bound are folded. `visit` sees each
|
|
1341
|
+
* entry's state before later rows are folded into it.
|
|
1342
|
+
*/
|
|
1343
|
+
const foldEntries = ({
|
|
1344
|
+
reducer,
|
|
1345
|
+
entries,
|
|
1346
|
+
rows,
|
|
1347
|
+
position,
|
|
1348
|
+
state,
|
|
1349
|
+
index,
|
|
1350
|
+
clearIndex,
|
|
1351
|
+
snapshot,
|
|
1352
|
+
visit,
|
|
1353
|
+
}: {
|
|
1354
|
+
reducer: Reducer<D, unknown>
|
|
1355
|
+
entries: PendingStateRead[]
|
|
1356
|
+
rows: Event[]
|
|
1357
|
+
position: number
|
|
1358
|
+
state: unknown
|
|
1359
|
+
index: number
|
|
1360
|
+
/** The latest clear marker the rows reflect, or zero. */
|
|
1361
|
+
clearIndex: number
|
|
1362
|
+
snapshot: string
|
|
1363
|
+
visit: (
|
|
1364
|
+
entry: PendingStateRead,
|
|
1365
|
+
state: unknown,
|
|
1366
|
+
index: number,
|
|
1367
|
+
folded: number,
|
|
1368
|
+
) => void
|
|
1369
|
+
}): { state: unknown; index: number; position: number } => {
|
|
1370
|
+
const start = position
|
|
1371
|
+
const ordered = entries.toSorted(
|
|
1372
|
+
(a, b) =>
|
|
1373
|
+
(a.throughIndex ?? Number.POSITIVE_INFINITY) -
|
|
1374
|
+
(b.throughIndex ?? Number.POSITIVE_INFINITY),
|
|
1375
|
+
)
|
|
1376
|
+
for (const entry of ordered) {
|
|
1377
|
+
const target = entry.throughIndex ?? Number.POSITIVE_INFINITY
|
|
1378
|
+
while (position < rows.length && rows[position]!.index <= target) {
|
|
1379
|
+
const row = rows[position]!
|
|
1380
|
+
state = foldEvent(reducer, state, toPublic(row))
|
|
1381
|
+
index = row.index
|
|
1382
|
+
position += 1
|
|
1383
|
+
}
|
|
1384
|
+
if (
|
|
1385
|
+
(entry.triggerIndex !== undefined &&
|
|
1386
|
+
entry.triggerIndex <= clearIndex) ||
|
|
1387
|
+
(entry.throughIndex !== undefined &&
|
|
1388
|
+
entry.throughIndex > 0 &&
|
|
1389
|
+
entry.throughIndex < clearIndex)
|
|
1390
|
+
) {
|
|
1391
|
+
entry.reject(
|
|
1392
|
+
sessionClearedError(
|
|
1393
|
+
`session '${entry.sessionId}' was cleared at index ${clearIndex}`,
|
|
1394
|
+
),
|
|
1395
|
+
)
|
|
1396
|
+
continue
|
|
1397
|
+
}
|
|
1398
|
+
entry.span.setAttribute('a2.state.snapshot', snapshot)
|
|
1399
|
+
entry.span.setAttribute('a2.state.folded', position - start)
|
|
1400
|
+
entry.span.setAttribute('a2.state.index', index)
|
|
1401
|
+
visit(entry, state, index, position - start)
|
|
1402
|
+
// A result below the marker (the explicit seed) predates this lifetime.
|
|
1403
|
+
entry.resolve({
|
|
1404
|
+
state: cloneInitial(state),
|
|
1405
|
+
index,
|
|
1406
|
+
...(clearIndex > 0 && index >= clearIndex ? { clearIndex } : {}),
|
|
1407
|
+
})
|
|
1408
|
+
}
|
|
1409
|
+
return { state, index, position }
|
|
1410
|
+
}
|
|
1411
|
+
|
|
1412
|
+
/**
|
|
1413
|
+
* Events from `fromIndex` (inclusive) through `throughIndex` or the newest
|
|
1414
|
+
* event, or null when the tail is too long to be worth folding in memory.
|
|
1415
|
+
*/
|
|
1416
|
+
const readCachedTail = async (
|
|
1417
|
+
store: A2Store,
|
|
1418
|
+
sessionId: string,
|
|
1419
|
+
fromIndex: number,
|
|
1420
|
+
throughIndex?: number,
|
|
1421
|
+
): Promise<Event[] | null> => {
|
|
1422
|
+
const readTail = stateTailReaders.get(store)
|
|
1423
|
+
if (readTail) {
|
|
1424
|
+
try {
|
|
1425
|
+
return await readTail(nsId(sessionId), fromIndex, throughIndex)
|
|
1426
|
+
} catch (err) {
|
|
1427
|
+
throw asStoreUnavailable(err)
|
|
1428
|
+
}
|
|
1429
|
+
}
|
|
1430
|
+
const limit = fromIndex + STATE_CACHE.maxTail
|
|
1431
|
+
const capped = throughIndex === undefined || throughIndex > limit
|
|
1432
|
+
const rows = await readHistory(sessionId, {
|
|
1433
|
+
gte: fromIndex,
|
|
1434
|
+
lte: capped ? limit : throughIndex,
|
|
1435
|
+
})
|
|
1436
|
+
return capped && rows.at(-1)?.index === limit ? null : rows
|
|
1437
|
+
}
|
|
1438
|
+
|
|
1439
|
+
/**
|
|
1440
|
+
* Serves a plan from the cached state when every read is at or past the
|
|
1441
|
+
* cached index. A read bounded at the cached index by an event whose id
|
|
1442
|
+
* matches the cached one (a handler's own trigger) needs no store request;
|
|
1443
|
+
* any other read fetches the tail from the cached event onward and checks
|
|
1444
|
+
* that event's id. Returns false when the store must answer instead.
|
|
1445
|
+
*/
|
|
1446
|
+
const foldCachedPlan = async (
|
|
1447
|
+
store: A2Store,
|
|
1448
|
+
reducer: Reducer<D, unknown>,
|
|
1449
|
+
plan: StateReadPlan,
|
|
1450
|
+
): Promise<boolean> => {
|
|
1451
|
+
const key = stateCacheKey(plan.sessionId, reducer.name)
|
|
1452
|
+
const cached = stateCache.get(key)
|
|
1453
|
+
if (cached?.reducer !== reducer) return false
|
|
1454
|
+
if (
|
|
1455
|
+
plan.entries.some(
|
|
1456
|
+
(entry) =>
|
|
1457
|
+
entry.throughIndex !== undefined && entry.throughIndex < cached.index,
|
|
1458
|
+
)
|
|
1459
|
+
)
|
|
1460
|
+
return false
|
|
1461
|
+
let rows: Event[] = []
|
|
1462
|
+
if (
|
|
1463
|
+
plan.entries.some(
|
|
1464
|
+
(entry) =>
|
|
1465
|
+
entry.throughIndex !== cached.index ||
|
|
1466
|
+
entry.throughEventId !== cached.lastId,
|
|
1467
|
+
)
|
|
1468
|
+
) {
|
|
1469
|
+
const tail = await readCachedTail(
|
|
1470
|
+
store,
|
|
1471
|
+
plan.sessionId,
|
|
1472
|
+
cached.index,
|
|
1473
|
+
plan.throughIndex,
|
|
1474
|
+
)
|
|
1475
|
+
if (!tail) return false
|
|
1476
|
+
// A clear after the cached event, or a rewritten log, needs the store:
|
|
1477
|
+
// it applies the boundary and its fences.
|
|
1478
|
+
if (
|
|
1479
|
+
tail[0]?.index !== cached.index ||
|
|
1480
|
+
tail[0].id !== cached.lastId ||
|
|
1481
|
+
tail.some((row, position) => position > 0 && isSessionCleared(row))
|
|
1482
|
+
) {
|
|
1483
|
+
stateCache.delete(key)
|
|
1484
|
+
return false
|
|
1485
|
+
}
|
|
1486
|
+
rows = tail
|
|
1487
|
+
}
|
|
1488
|
+
const writes: SnapshotWrites = new Map()
|
|
1489
|
+
const folded = foldEntries({
|
|
1490
|
+
reducer,
|
|
1491
|
+
entries: plan.entries,
|
|
1492
|
+
rows,
|
|
1493
|
+
position: 1,
|
|
1494
|
+
state: rows.length > 1 ? cloneInitial(cached.state) : cached.state,
|
|
1495
|
+
index: cached.index,
|
|
1496
|
+
clearIndex: cached.clearIndex,
|
|
1497
|
+
snapshot: 'memory',
|
|
1498
|
+
visit: (entry, state, index) => {
|
|
1499
|
+
if (entry.pinEventIndex !== undefined) {
|
|
1500
|
+
addSnapshotWrite(writes, index, state, entry.pinEventIndex)
|
|
1501
|
+
}
|
|
1502
|
+
},
|
|
1503
|
+
})
|
|
1504
|
+
const added = Math.max(0, folded.position - 1)
|
|
1505
|
+
const rewritesHead =
|
|
1506
|
+
folded.index - cached.persistedIndex >= STATE_CACHE.snapshotEvery
|
|
1507
|
+
if (rewritesHead && !writes.has(folded.index)) {
|
|
1508
|
+
writes.set(folded.index, {
|
|
1509
|
+
state: folded.state,
|
|
1510
|
+
pinEventIndexes: new Set(),
|
|
1511
|
+
})
|
|
1512
|
+
}
|
|
1513
|
+
const snapshots = writeSnapshots({
|
|
1514
|
+
store,
|
|
1515
|
+
reducer,
|
|
1516
|
+
sessionId: plan.sessionId,
|
|
1517
|
+
writes,
|
|
1518
|
+
})
|
|
1519
|
+
const measure =
|
|
1520
|
+
rewritesHead || cached.unmeasured + added >= STATE_CACHE.measureEvery
|
|
1521
|
+
let grown = 0
|
|
1522
|
+
if (!measure) {
|
|
1523
|
+
for (let position = 1; position < folded.position; position += 1) {
|
|
1524
|
+
grown += JSON.stringify(rows[position]!.payload)?.length ?? 0
|
|
1525
|
+
}
|
|
1526
|
+
}
|
|
1527
|
+
cacheState(key, {
|
|
1528
|
+
reducer,
|
|
1529
|
+
index: folded.index,
|
|
1530
|
+
lastId: added > 0 ? rows[folded.position - 1]!.id : cached.lastId,
|
|
1531
|
+
state: folded.state,
|
|
1532
|
+
bytes: measure
|
|
1533
|
+
? measuredBytes(reducer, folded.state, folded.index, snapshots)
|
|
1534
|
+
: cached.bytes + grown,
|
|
1535
|
+
unmeasured: measure ? 0 : cached.unmeasured + added,
|
|
1536
|
+
persistedIndex: Math.max(
|
|
1537
|
+
cached.persistedIndex,
|
|
1538
|
+
snapshots.at(-1)?.index ?? 0,
|
|
1539
|
+
),
|
|
1540
|
+
clearIndex: cached.clearIndex,
|
|
1541
|
+
})
|
|
1542
|
+
return true
|
|
1543
|
+
}
|
|
1544
|
+
|
|
1048
1545
|
// The cache is untrusted: schema rejection refolds from the raw store.
|
|
1049
1546
|
const foldStatePlan = async (
|
|
1050
1547
|
store: A2Store,
|
|
@@ -1057,11 +1554,18 @@ export function createServer<
|
|
|
1057
1554
|
let snapshotOutcome = 'miss'
|
|
1058
1555
|
const snap = stateRead.snapshot
|
|
1059
1556
|
let rows = stateRead.events
|
|
1557
|
+
let clearIndex = stateRead.clearIndex ?? 0
|
|
1060
1558
|
if (snap) {
|
|
1061
|
-
|
|
1559
|
+
let decoded: unknown
|
|
1560
|
+
try {
|
|
1561
|
+
decoded = decodeState({ reducer, state: snap.state })
|
|
1562
|
+
} catch {
|
|
1563
|
+
snapshotOutcome = 'rejected'
|
|
1564
|
+
}
|
|
1565
|
+
if (snapshotOutcome !== 'rejected' && reducer.stateSchema) {
|
|
1062
1566
|
const result = validateSync(
|
|
1063
1567
|
reducer.stateSchema,
|
|
1064
|
-
|
|
1568
|
+
decoded,
|
|
1065
1569
|
'the stateSchema',
|
|
1066
1570
|
)
|
|
1067
1571
|
if (result.issues) {
|
|
@@ -1071,8 +1575,8 @@ export function createServer<
|
|
|
1071
1575
|
index = snap.index
|
|
1072
1576
|
snapshotOutcome = 'hit'
|
|
1073
1577
|
}
|
|
1074
|
-
} else {
|
|
1075
|
-
state =
|
|
1578
|
+
} else if (snapshotOutcome !== 'rejected') {
|
|
1579
|
+
state = decoded
|
|
1076
1580
|
index = snap.index
|
|
1077
1581
|
snapshotOutcome = 'hit'
|
|
1078
1582
|
}
|
|
@@ -1088,116 +1592,52 @@ export function createServer<
|
|
|
1088
1592
|
} catch (err) {
|
|
1089
1593
|
throw asStoreUnavailable(err)
|
|
1090
1594
|
}
|
|
1595
|
+
// Reads start at the latest marker, so a clear since readState leads.
|
|
1596
|
+
if (rows[0] && isSessionCleared(rows[0]))
|
|
1597
|
+
clearIndex = Math.max(clearIndex, rows[0].index)
|
|
1091
1598
|
}
|
|
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)) {
|
|
1599
|
+
const writes: SnapshotWrites = new Map()
|
|
1600
|
+
const folded = foldEntries({
|
|
1601
|
+
reducer,
|
|
1602
|
+
entries: plan.entries,
|
|
1603
|
+
rows,
|
|
1604
|
+
position: 0,
|
|
1605
|
+
state,
|
|
1606
|
+
index,
|
|
1607
|
+
clearIndex,
|
|
1608
|
+
snapshot: snapshotOutcome,
|
|
1609
|
+
visit: (entry, entryState, entryIndex, count) => {
|
|
1610
|
+
if (entryIndex === 0) return
|
|
1611
|
+
if (count === 0 && entry.pinEventIndex === undefined) return
|
|
1116
1612
|
const advancesHead =
|
|
1117
|
-
stateRead.headIndex === null ||
|
|
1613
|
+
stateRead.headIndex === null || entryIndex > stateRead.headIndex
|
|
1118
1614
|
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)
|
|
1615
|
+
snapshotOutcome === 'rejected' && entryIndex === stateRead.headIndex
|
|
1616
|
+
if (!advancesHead && !repairsHead && entry.pinEventIndex === undefined)
|
|
1617
|
+
return
|
|
1618
|
+
addSnapshotWrite(writes, entryIndex, entryState, entry.pinEventIndex)
|
|
1619
|
+
},
|
|
1620
|
+
})
|
|
1621
|
+
const snapshots = writeSnapshots({
|
|
1622
|
+
store,
|
|
1623
|
+
reducer,
|
|
1624
|
+
sessionId: plan.sessionId,
|
|
1625
|
+
writes,
|
|
1626
|
+
})
|
|
1627
|
+
if (folded.position > 0 && STATE_CACHE.maxEntries > 0) {
|
|
1628
|
+
cacheState(stateCacheKey(plan.sessionId, reducer.name), {
|
|
1629
|
+
reducer,
|
|
1630
|
+
index: folded.index,
|
|
1631
|
+
lastId: rows[folded.position - 1]!.id,
|
|
1632
|
+
state: folded.state,
|
|
1633
|
+
bytes: measuredBytes(reducer, folded.state, folded.index, snapshots),
|
|
1634
|
+
unmeasured: 0,
|
|
1635
|
+
persistedIndex: Math.min(
|
|
1636
|
+
Math.max(stateRead.headIndex ?? 0, ...writes.keys()),
|
|
1637
|
+
folded.index,
|
|
1638
|
+
),
|
|
1639
|
+
clearIndex,
|
|
1640
|
+
})
|
|
1201
1641
|
}
|
|
1202
1642
|
}
|
|
1203
1643
|
|
|
@@ -1212,7 +1652,17 @@ export function createServer<
|
|
|
1212
1652
|
reducer: Reducer<D, unknown>,
|
|
1213
1653
|
batch: PendingStateRead[],
|
|
1214
1654
|
): Promise<void> => {
|
|
1215
|
-
const plans =
|
|
1655
|
+
const plans: StateReadPlan[] = []
|
|
1656
|
+
await Promise.all(
|
|
1657
|
+
planStateReads(batch).map(async (plan) => {
|
|
1658
|
+
try {
|
|
1659
|
+
if (!(await foldCachedPlan(store, reducer, plan))) plans.push(plan)
|
|
1660
|
+
} catch (error) {
|
|
1661
|
+
for (const entry of plan.entries) entry.reject(error)
|
|
1662
|
+
}
|
|
1663
|
+
}),
|
|
1664
|
+
)
|
|
1665
|
+
if (plans.length === 0) return
|
|
1216
1666
|
let reads: (StoreStateRead | Promise<StoreStateRead>)[]
|
|
1217
1667
|
try {
|
|
1218
1668
|
reads = store.readStates
|
|
@@ -1243,7 +1693,8 @@ export function createServer<
|
|
|
1243
1693
|
)
|
|
1244
1694
|
} catch (error) {
|
|
1245
1695
|
const failure = asStoreUnavailable(error)
|
|
1246
|
-
for (const
|
|
1696
|
+
for (const plan of plans)
|
|
1697
|
+
for (const entry of plan.entries) entry.reject(failure)
|
|
1247
1698
|
return
|
|
1248
1699
|
}
|
|
1249
1700
|
await Promise.all(
|
|
@@ -1257,14 +1708,20 @@ export function createServer<
|
|
|
1257
1708
|
)
|
|
1258
1709
|
}
|
|
1259
1710
|
|
|
1711
|
+
type StateReadBounds = {
|
|
1712
|
+
throughIndex?: number
|
|
1713
|
+
throughEventId?: string
|
|
1714
|
+
pinEventIndex?: number
|
|
1715
|
+
triggerIndex?: number
|
|
1716
|
+
}
|
|
1717
|
+
|
|
1260
1718
|
const enqueueStateRead = (
|
|
1261
1719
|
store: A2Store,
|
|
1262
1720
|
sessionId: string,
|
|
1263
1721
|
reducer: Reducer<D, unknown>,
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
): Promise<{ state: unknown; index: number }> =>
|
|
1722
|
+
bounds: StateReadBounds,
|
|
1723
|
+
span: A2SpanHandle,
|
|
1724
|
+
): Promise<StateResult> =>
|
|
1268
1725
|
new Promise((resolve, reject) => {
|
|
1269
1726
|
const reducerName = reducer.name
|
|
1270
1727
|
let batch = pendingStateReads.get(reducerName)
|
|
@@ -1286,22 +1743,14 @@ export function createServer<
|
|
|
1286
1743
|
})
|
|
1287
1744
|
batch = opened
|
|
1288
1745
|
}
|
|
1289
|
-
batch.push({
|
|
1290
|
-
sessionId,
|
|
1291
|
-
...(throughIndex === undefined ? {} : { throughIndex }),
|
|
1292
|
-
...(pinEventIndex === undefined ? {} : { pinEventIndex }),
|
|
1293
|
-
span: span!,
|
|
1294
|
-
resolve,
|
|
1295
|
-
reject,
|
|
1296
|
-
})
|
|
1746
|
+
batch.push({ sessionId, ...bounds, span, resolve, reject })
|
|
1297
1747
|
})
|
|
1298
1748
|
|
|
1299
|
-
const readState = async <S
|
|
1749
|
+
const readState = async <S>(
|
|
1300
1750
|
sessionId: string,
|
|
1301
|
-
reducer: Reducer<D, S, Record<never, never
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
): Promise<{ state: S; index: number }> => {
|
|
1751
|
+
reducer: Reducer<D, S, Record<never, never>>,
|
|
1752
|
+
bounds: StateReadBounds,
|
|
1753
|
+
): Promise<{ state: S; index: number; clearIndex?: number }> => {
|
|
1305
1754
|
const store = await resolveStore()
|
|
1306
1755
|
return telemetry.span(
|
|
1307
1756
|
'a2.state',
|
|
@@ -1315,20 +1764,27 @@ export function createServer<
|
|
|
1315
1764
|
store,
|
|
1316
1765
|
sessionId,
|
|
1317
1766
|
reducer as Reducer<D, unknown>,
|
|
1318
|
-
|
|
1319
|
-
pinEventIndex,
|
|
1767
|
+
bounds,
|
|
1320
1768
|
span,
|
|
1321
|
-
) as Promise<{ state: S; index: number }>,
|
|
1769
|
+
) as Promise<{ state: S; index: number; clearIndex?: number }>,
|
|
1322
1770
|
)
|
|
1323
1771
|
}
|
|
1324
1772
|
|
|
1325
1773
|
const makeSchedule = (
|
|
1326
1774
|
sessionId: string,
|
|
1327
|
-
trigger: Pick<
|
|
1775
|
+
trigger: Pick<
|
|
1776
|
+
StoredEvent,
|
|
1777
|
+
'id' | 'createdAt' | 'index' | 'clearIndex'
|
|
1778
|
+
> | null,
|
|
1328
1779
|
): SessionSchedule<D> => {
|
|
1329
1780
|
const triggerEventId = trigger?.id ?? null
|
|
1330
1781
|
const triggerCreatedAt = trigger?.createdAt.getTime()
|
|
1331
|
-
|
|
1782
|
+
const clearIndex = trigger?.clearIndex ?? 0
|
|
1783
|
+
const schedule = async (
|
|
1784
|
+
scheduleName: string,
|
|
1785
|
+
timing: ScheduleTiming,
|
|
1786
|
+
...events: AppendInput<D>[]
|
|
1787
|
+
): Promise<void> => {
|
|
1332
1788
|
if (!scheduler) {
|
|
1333
1789
|
throw new TypeError(
|
|
1334
1790
|
'session.schedule requires a configured scheduler adapter',
|
|
@@ -1357,6 +1813,7 @@ export function createServer<
|
|
|
1357
1813
|
sessionId,
|
|
1358
1814
|
triggerEventId,
|
|
1359
1815
|
scheduleName,
|
|
1816
|
+
clearIndex,
|
|
1360
1817
|
)
|
|
1361
1818
|
const scheduledEvents: ScheduledEvent[] = await Promise.all(
|
|
1362
1819
|
validated.map(async (event, item) => ({
|
|
@@ -1384,11 +1841,60 @@ export function createServer<
|
|
|
1384
1841
|
sessionId,
|
|
1385
1842
|
dueAt,
|
|
1386
1843
|
events: scheduledEvents,
|
|
1844
|
+
...(trigger ? { originIndex: trigger.index } : {}),
|
|
1387
1845
|
})
|
|
1388
1846
|
} catch (error) {
|
|
1389
1847
|
throw markSchedulerSendFailure(error)
|
|
1390
1848
|
}
|
|
1391
1849
|
}
|
|
1850
|
+
const clear = async (
|
|
1851
|
+
scheduleName: string,
|
|
1852
|
+
clearOptions: ScheduleTiming & { ifLastIndex: number },
|
|
1853
|
+
): Promise<void> => {
|
|
1854
|
+
if (!scheduler) {
|
|
1855
|
+
throw new TypeError(
|
|
1856
|
+
'session.schedule.clear requires a configured scheduler adapter',
|
|
1857
|
+
)
|
|
1858
|
+
}
|
|
1859
|
+
assertScheduleName(scheduleName)
|
|
1860
|
+
if (clearOptions === null || typeof clearOptions !== 'object') {
|
|
1861
|
+
throw new TypeError('schedule.clear requires an ifLastIndex')
|
|
1862
|
+
}
|
|
1863
|
+
const { ifLastIndex, ...timing } = clearOptions
|
|
1864
|
+
assertClearIndex(ifLastIndex)
|
|
1865
|
+
const dueAt = scheduleDueAt(
|
|
1866
|
+
timing as ScheduleTiming,
|
|
1867
|
+
triggerCreatedAt ?? Date.now(),
|
|
1868
|
+
)
|
|
1869
|
+
const store = await resolveStore()
|
|
1870
|
+
if (!store.clear || !store.reclaim) {
|
|
1871
|
+
throw new TypeError(
|
|
1872
|
+
'session.schedule.clear requires a store that supports clearing',
|
|
1873
|
+
)
|
|
1874
|
+
}
|
|
1875
|
+
const id = await deterministicScheduleClearId({
|
|
1876
|
+
contract: name,
|
|
1877
|
+
sessionId,
|
|
1878
|
+
triggerEventId,
|
|
1879
|
+
clearIndex,
|
|
1880
|
+
name: scheduleName,
|
|
1881
|
+
ifLastIndex,
|
|
1882
|
+
})
|
|
1883
|
+
try {
|
|
1884
|
+
await scheduler.schedule({
|
|
1885
|
+
version: 1,
|
|
1886
|
+
kind: 'clear',
|
|
1887
|
+
id,
|
|
1888
|
+
contract: name,
|
|
1889
|
+
sessionId,
|
|
1890
|
+
dueAt,
|
|
1891
|
+
ifLastIndex,
|
|
1892
|
+
})
|
|
1893
|
+
} catch (error) {
|
|
1894
|
+
throw markSchedulerSendFailure(error)
|
|
1895
|
+
}
|
|
1896
|
+
}
|
|
1897
|
+
return Object.assign(schedule, { clear })
|
|
1392
1898
|
}
|
|
1393
1899
|
|
|
1394
1900
|
const toSessionEvent = (id: string, event: Event): ContractEvent<D> =>
|
|
@@ -1455,6 +1961,14 @@ export function createServer<
|
|
|
1455
1961
|
},
|
|
1456
1962
|
}
|
|
1457
1963
|
streamActivities.set(stream, activity)
|
|
1964
|
+
bufferedEvents.set(stream, () => {
|
|
1965
|
+
const event = closed || !inner ? undefined : takeBuffered(inner)
|
|
1966
|
+
if (!event) return undefined
|
|
1967
|
+
if (!isSerializedEvent(event)) return toSessionEvent(id, event)
|
|
1968
|
+
// Taken rows belong to this stream alone.
|
|
1969
|
+
event.sessionId = id
|
|
1970
|
+
return event
|
|
1971
|
+
})
|
|
1458
1972
|
return stream
|
|
1459
1973
|
},
|
|
1460
1974
|
}
|
|
@@ -1582,6 +2096,8 @@ export function createServer<
|
|
|
1582
2096
|
const ns = nsId(id)
|
|
1583
2097
|
let closed = false
|
|
1584
2098
|
let events: AsyncIterator<Event> | undefined
|
|
2099
|
+
// An event read the merge loop started and has not yet yielded.
|
|
2100
|
+
let pendingEvent: Promise<IteratorResult<Event>> | null = null
|
|
1585
2101
|
let unsubscribe: (() => void) | undefined
|
|
1586
2102
|
let sleeper: Sleeper | null = null
|
|
1587
2103
|
let wakeQueue: (() => void) | null = null
|
|
@@ -1763,7 +2279,6 @@ export function createServer<
|
|
|
1763
2279
|
}
|
|
1764
2280
|
return null
|
|
1765
2281
|
}
|
|
1766
|
-
let pendingEvent: Promise<IteratorResult<Event>> | null = null
|
|
1767
2282
|
try {
|
|
1768
2283
|
// Subscribe before the snapshot read: a patch landing between
|
|
1769
2284
|
// the two is painted twice, never lost — repaints are idempotent.
|
|
@@ -1873,36 +2388,149 @@ export function createServer<
|
|
|
1873
2388
|
},
|
|
1874
2389
|
}
|
|
1875
2390
|
streamActivities.set(stream, activity)
|
|
2391
|
+
bufferedEvents.set(stream, () => {
|
|
2392
|
+
// A held event may follow only events already yielded; while a
|
|
2393
|
+
// read is outstanding, its event must go first.
|
|
2394
|
+
if (closed || !events || pendingEvent) return undefined
|
|
2395
|
+
const event = takeBuffered(events)
|
|
2396
|
+
if (!event) return undefined
|
|
2397
|
+
if (!isSerializedEvent(event)) return toSessionEvent(id, event)
|
|
2398
|
+
// Taken rows belong to this stream alone.
|
|
2399
|
+
event.sessionId = id
|
|
2400
|
+
return event
|
|
2401
|
+
})
|
|
1876
2402
|
return stream
|
|
1877
2403
|
},
|
|
1878
2404
|
}
|
|
1879
2405
|
}
|
|
1880
2406
|
|
|
2407
|
+
// ── clearing (a2-implementation.md §17) ──────────────────────────
|
|
2408
|
+
|
|
2409
|
+
const reclaimAll = async (store: A2Store, ns: string): Promise<void> => {
|
|
2410
|
+
for (let stalls = 0; ;) {
|
|
2411
|
+
let batch: StoreReclaimResult
|
|
2412
|
+
try {
|
|
2413
|
+
// oxlint-disable-next-line no-await-in-loop -- bounded batches, oldest first
|
|
2414
|
+
batch = await store.reclaim!(ns)
|
|
2415
|
+
} catch (err) {
|
|
2416
|
+
throw asStoreUnavailable(err)
|
|
2417
|
+
}
|
|
2418
|
+
if (batch.done) return
|
|
2419
|
+
if (batch.deleted > 0) {
|
|
2420
|
+
stalls = 0
|
|
2421
|
+
continue
|
|
2422
|
+
}
|
|
2423
|
+
// Only rows other work holds locked remain; they free up shortly.
|
|
2424
|
+
stalls += 1
|
|
2425
|
+
if (stalls === RECLAIM_STALL_LIMIT) {
|
|
2426
|
+
throw new A2Error(
|
|
2427
|
+
'STORE_UNAVAILABLE',
|
|
2428
|
+
`deleting the cleared history of session '${stripNs(ns)}' made no progress`,
|
|
2429
|
+
)
|
|
2430
|
+
}
|
|
2431
|
+
// oxlint-disable-next-line no-await-in-loop -- paced retry
|
|
2432
|
+
await defaultSleep(RECLAIM_STALL_MS).promise
|
|
2433
|
+
}
|
|
2434
|
+
}
|
|
2435
|
+
|
|
2436
|
+
/** The handler attempt a `ctx.session.clear()` belongs to. */
|
|
2437
|
+
type ClearingAttempt = {
|
|
2438
|
+
cause: EventCause
|
|
2439
|
+
end: () => void
|
|
2440
|
+
signal: AbortSignal
|
|
2441
|
+
}
|
|
2442
|
+
|
|
2443
|
+
const clearSession = async (
|
|
2444
|
+
sessionId: string,
|
|
2445
|
+
clearOptions: SessionClearOptions | undefined,
|
|
2446
|
+
attempt: ClearingAttempt | undefined,
|
|
2447
|
+
): Promise<SessionClearResult> => {
|
|
2448
|
+
const ifLastIndex = clearOptions?.ifLastIndex
|
|
2449
|
+
if (ifLastIndex !== undefined) assertClearIndex(ifLastIndex)
|
|
2450
|
+
const store = await resolveStore()
|
|
2451
|
+
if (!store.clear || !store.reclaim) {
|
|
2452
|
+
throw new TypeError(
|
|
2453
|
+
'session.clear requires a store that supports clearing',
|
|
2454
|
+
)
|
|
2455
|
+
}
|
|
2456
|
+
const ns = nsId(sessionId)
|
|
2457
|
+
let result: StoreClearResult
|
|
2458
|
+
try {
|
|
2459
|
+
result = await store.clear({
|
|
2460
|
+
sessionId: ns,
|
|
2461
|
+
...(ifLastIndex === undefined ? {} : { ifLastIndex }),
|
|
2462
|
+
...(attempt ? { cause: attempt.cause } : {}),
|
|
2463
|
+
})
|
|
2464
|
+
} catch (err) {
|
|
2465
|
+
throw asStoreUnavailable(err)
|
|
2466
|
+
}
|
|
2467
|
+
if (result.cleared) {
|
|
2468
|
+
// This instance's own reads after the clear, including the clearing
|
|
2469
|
+
// handler's, go to the store and see the boundary.
|
|
2470
|
+
const cachePrefix = stateCacheKey(sessionId, '')
|
|
2471
|
+
stateCache.deleteWhere((key) => key.startsWith(cachePrefix))
|
|
2472
|
+
}
|
|
2473
|
+
const deletion = reclaimAll(store, ns)
|
|
2474
|
+
if (attempt) {
|
|
2475
|
+
if (attempt.cause.index <= result.clearIndex) attempt.end()
|
|
2476
|
+
// Ending the attempt releases its drain slot; the deletion must still
|
|
2477
|
+
// outlive the drain.
|
|
2478
|
+
const kept = deletion.catch(() => undefined)
|
|
2479
|
+
track(kept)
|
|
2480
|
+
platformWaitUntil(kept)
|
|
2481
|
+
}
|
|
2482
|
+
await deletion
|
|
2483
|
+
return { cleared: result.cleared, index: result.index }
|
|
2484
|
+
}
|
|
2485
|
+
|
|
1881
2486
|
const makeSession = <Append>(
|
|
1882
2487
|
id: string,
|
|
1883
2488
|
append: Append,
|
|
1884
|
-
trigger: Pick<
|
|
2489
|
+
trigger: Pick<
|
|
2490
|
+
StoredEvent,
|
|
2491
|
+
'id' | 'createdAt' | 'index' | 'clearIndex'
|
|
2492
|
+
> | null,
|
|
2493
|
+
attempt?: ClearingAttempt,
|
|
1885
2494
|
): Session<D, Append, P> =>
|
|
1886
2495
|
({
|
|
1887
2496
|
id,
|
|
1888
2497
|
append,
|
|
1889
2498
|
schedule: makeSchedule(id, trigger),
|
|
2499
|
+
clear: (clearOptions?: SessionClearOptions) =>
|
|
2500
|
+
clearSession(id, clearOptions, attempt),
|
|
1890
2501
|
history: async (bounds?: { gte?: number; lte?: number }) =>
|
|
1891
|
-
(await readHistory(id, bounds))
|
|
1892
|
-
|
|
1893
|
-
|
|
2502
|
+
(await readHistory(id, bounds))
|
|
2503
|
+
.filter((row) => !isSessionCleared(row))
|
|
2504
|
+
.map(toPublic),
|
|
2505
|
+
state: <S>(
|
|
2506
|
+
reducer: Reducer<
|
|
2507
|
+
D,
|
|
2508
|
+
S,
|
|
2509
|
+
Record<never, never>
|
|
2510
|
+
> = serverContract.reducer as unknown as Reducer<
|
|
2511
|
+
D,
|
|
2512
|
+
S,
|
|
2513
|
+
Record<never, never>
|
|
2514
|
+
>,
|
|
1894
2515
|
stateOptions?: StateOptions,
|
|
1895
2516
|
) => {
|
|
2517
|
+
if (!reducer)
|
|
2518
|
+
throw new A2Error('INVALID_PAYLOAD', 'contract has no public reducer')
|
|
1896
2519
|
const through = stateOptions?.through ?? trigger?.index
|
|
1897
2520
|
if (through !== undefined && through !== 'latest') {
|
|
1898
2521
|
assertStateIndex(through)
|
|
1899
2522
|
}
|
|
1900
|
-
|
|
1901
|
-
|
|
1902
|
-
|
|
1903
|
-
|
|
1904
|
-
|
|
1905
|
-
|
|
2523
|
+
const bounds: StateReadBounds = {}
|
|
2524
|
+
if (through !== undefined && through !== 'latest') {
|
|
2525
|
+
bounds.throughIndex = through
|
|
2526
|
+
if (trigger) bounds.pinEventIndex = trigger.index
|
|
2527
|
+
// Only a live attempt may be answered from memory with no store
|
|
2528
|
+
// request; a superseded one (including by a clear) reads the store.
|
|
2529
|
+
if (through === trigger?.index && !attempt?.signal.aborted)
|
|
2530
|
+
bounds.throughEventId = trigger.id
|
|
2531
|
+
}
|
|
2532
|
+
if (trigger) bounds.triggerIndex = trigger.index
|
|
2533
|
+
return readState(id, reducer, bounds)
|
|
1906
2534
|
},
|
|
1907
2535
|
stream: (opts?: { startAfter?: number; presence?: boolean }) => {
|
|
1908
2536
|
const startAfter = opts?.startAfter ?? 0
|
|
@@ -1932,6 +2560,7 @@ export function createServer<
|
|
|
1932
2560
|
trigger: StoredEvent,
|
|
1933
2561
|
attempt: number,
|
|
1934
2562
|
signal: AbortSignal,
|
|
2563
|
+
endAttempt: () => void,
|
|
1935
2564
|
): HandlerContext<D> => {
|
|
1936
2565
|
const publicSessionId = stripNs(trigger.sessionId)
|
|
1937
2566
|
const append: HandlerAppend<D> = async (appendName, ...events) => {
|
|
@@ -1961,7 +2590,11 @@ export function createServer<
|
|
|
1961
2590
|
// The handler-scoped `setPresence` stamps `seen` with the
|
|
1962
2591
|
// triggering event's index by default — the frontier the handler
|
|
1963
2592
|
// provably reflects.
|
|
1964
|
-
session: makeSession(publicSessionId, append, trigger
|
|
2593
|
+
session: makeSession(publicSessionId, append, trigger, {
|
|
2594
|
+
cause: { index: trigger.index, attempt },
|
|
2595
|
+
end: endAttempt,
|
|
2596
|
+
signal,
|
|
2597
|
+
}),
|
|
1965
2598
|
signal,
|
|
1966
2599
|
}
|
|
1967
2600
|
}
|
|
@@ -2267,17 +2900,10 @@ export function createServer<
|
|
|
2267
2900
|
if (!entry) continue
|
|
2268
2901
|
// Idempotent: the local lapse normally aborted this signal
|
|
2269
2902
|
// already — this is the proof, whose job is the release.
|
|
2270
|
-
|
|
2271
|
-
|
|
2272
|
-
|
|
2273
|
-
`a newer attempt owns event ${index} in session '${sessionId}'`,
|
|
2274
|
-
),
|
|
2903
|
+
endSuperseded(
|
|
2904
|
+
entry,
|
|
2905
|
+
`a newer attempt owns event ${index} in session '${sessionId}'`,
|
|
2275
2906
|
)
|
|
2276
|
-
entry.fence({
|
|
2277
|
-
status: 'superseded',
|
|
2278
|
-
opensLane: false,
|
|
2279
|
-
appendsEvents: false,
|
|
2280
|
-
})
|
|
2281
2907
|
}
|
|
2282
2908
|
if (window.deadlineCapped) stopRenewing = true
|
|
2283
2909
|
scheduleLapse()
|
|
@@ -2299,6 +2925,7 @@ export function createServer<
|
|
|
2299
2925
|
const runEvent = async (
|
|
2300
2926
|
event: StoredEvent,
|
|
2301
2927
|
supersedeSignal: AbortSignal,
|
|
2928
|
+
endAttempt: () => void,
|
|
2302
2929
|
): Promise<EventOutcome> => {
|
|
2303
2930
|
const registration = handlers.get(event.type)
|
|
2304
2931
|
return telemetry.span(
|
|
@@ -2338,7 +2965,7 @@ export function createServer<
|
|
|
2338
2965
|
])
|
|
2339
2966
|
}
|
|
2340
2967
|
const operation = registration?.handler(
|
|
2341
|
-
makeCtx(event, event.attemptCount, handlerSignal),
|
|
2968
|
+
makeCtx(event, event.attemptCount, handlerSignal, endAttempt),
|
|
2342
2969
|
)
|
|
2343
2970
|
const result = operation
|
|
2344
2971
|
? await (subscription
|
|
@@ -2369,17 +2996,31 @@ export function createServer<
|
|
|
2369
2996
|
// genuinely superseded attempt gets 'superseded' back with
|
|
2370
2997
|
// no mutation. One adjudicator, not two.
|
|
2371
2998
|
eventSpan.recordError(err)
|
|
2372
|
-
const failure = await
|
|
2373
|
-
|
|
2374
|
-
|
|
2375
|
-
|
|
2376
|
-
|
|
2377
|
-
|
|
2378
|
-
|
|
2379
|
-
|
|
2380
|
-
|
|
2381
|
-
|
|
2382
|
-
|
|
2999
|
+
const failure = await telemetry.span(
|
|
3000
|
+
'a2.commit',
|
|
3001
|
+
{
|
|
3002
|
+
'a2.contract': name,
|
|
3003
|
+
'a2.session_id': sessionId,
|
|
3004
|
+
'a2.commit.operation': 'fail',
|
|
3005
|
+
'a2.commit.index': event.index,
|
|
3006
|
+
'a2.commit.attempt': event.attemptCount,
|
|
3007
|
+
},
|
|
3008
|
+
async (commitSpan) => {
|
|
3009
|
+
const result = await store.failAttempt({
|
|
3010
|
+
sessionId: ns,
|
|
3011
|
+
index: event.index,
|
|
3012
|
+
attempt: event.attemptCount,
|
|
3013
|
+
error: describeError(err),
|
|
3014
|
+
// A NonRetriableError is terminal on its first throw:
|
|
3015
|
+
// deterministic failures should not burn the retry
|
|
3016
|
+
// budget before dead-lettering.
|
|
3017
|
+
maxFailures:
|
|
3018
|
+
err instanceof NonRetriableError ? 1 : MAX_FAILURES,
|
|
3019
|
+
})
|
|
3020
|
+
commitSpan.setAttribute('a2.commit.outcome', result.outcome)
|
|
3021
|
+
return result
|
|
3022
|
+
},
|
|
3023
|
+
)
|
|
2383
3024
|
eventSpan.setAttribute('a2.event.outcome', failure.outcome)
|
|
2384
3025
|
return {
|
|
2385
3026
|
status: failure.outcome,
|
|
@@ -2390,12 +3031,26 @@ export function createServer<
|
|
|
2390
3031
|
await subscription?.close()
|
|
2391
3032
|
}
|
|
2392
3033
|
await renewal
|
|
2393
|
-
const completion = await
|
|
2394
|
-
|
|
2395
|
-
|
|
2396
|
-
|
|
2397
|
-
|
|
2398
|
-
|
|
3034
|
+
const completion = await telemetry.span(
|
|
3035
|
+
'a2.commit',
|
|
3036
|
+
{
|
|
3037
|
+
'a2.contract': name,
|
|
3038
|
+
'a2.session_id': sessionId,
|
|
3039
|
+
'a2.commit.operation': 'complete',
|
|
3040
|
+
'a2.commit.index': event.index,
|
|
3041
|
+
'a2.commit.attempt': event.attemptCount,
|
|
3042
|
+
},
|
|
3043
|
+
async (commitSpan) => {
|
|
3044
|
+
const result = await store.completeAttempt({
|
|
3045
|
+
sessionId: ns,
|
|
3046
|
+
index: event.index,
|
|
3047
|
+
attempt: event.attemptCount,
|
|
3048
|
+
events: returned,
|
|
3049
|
+
})
|
|
3050
|
+
commitSpan.setAttribute('a2.commit.outcome', result.outcome)
|
|
3051
|
+
return result
|
|
3052
|
+
},
|
|
3053
|
+
)
|
|
2399
3054
|
eventSpan.setAttribute(
|
|
2400
3055
|
'a2.event.outcome',
|
|
2401
3056
|
completion.outcome === 'completed'
|
|
@@ -2430,29 +3085,28 @@ export function createServer<
|
|
|
2430
3085
|
// the new one.
|
|
2431
3086
|
const previous = active.get(event.index)
|
|
2432
3087
|
if (previous) {
|
|
2433
|
-
|
|
2434
|
-
|
|
2435
|
-
|
|
2436
|
-
`a newer attempt owns event ${event.index} in session '${sessionId}'`,
|
|
2437
|
-
),
|
|
3088
|
+
endSuperseded(
|
|
3089
|
+
previous,
|
|
3090
|
+
`a newer attempt owns event ${event.index} in session '${sessionId}'`,
|
|
2438
3091
|
)
|
|
2439
|
-
previous.fence({
|
|
2440
|
-
status: 'superseded',
|
|
2441
|
-
opensLane: false,
|
|
2442
|
-
appendsEvents: false,
|
|
2443
|
-
})
|
|
2444
3092
|
}
|
|
2445
3093
|
const supersede = new AbortController()
|
|
2446
3094
|
let fence!: (outcome: EventOutcome) => void
|
|
2447
3095
|
const fenced = new Promise<EventOutcome>((resolve) => {
|
|
2448
3096
|
fence = resolve
|
|
2449
3097
|
})
|
|
3098
|
+
// A handler's own committed clear supersedes it immediately.
|
|
3099
|
+
const endAttempt = (): void =>
|
|
3100
|
+
endSuperseded(
|
|
3101
|
+
{ supersede, fence },
|
|
3102
|
+
`event ${event.index} in session '${sessionId}' was cleared`,
|
|
3103
|
+
)
|
|
2450
3104
|
// The race lets a durable supersession release this slot while the
|
|
2451
3105
|
// zombie handler keeps running — its completion and failure are
|
|
2452
3106
|
// fenced by the store, so nothing it does from here is counted.
|
|
2453
3107
|
let entry!: ActiveEvent
|
|
2454
3108
|
const execution = Promise.race([
|
|
2455
|
-
runEvent(event, supersede.signal),
|
|
3109
|
+
runEvent(event, supersede.signal, endAttempt),
|
|
2456
3110
|
fenced,
|
|
2457
3111
|
]).then((eventOutcome) => {
|
|
2458
3112
|
// Identity-guarded: a fenced predecessor's continuation must
|
|
@@ -2493,17 +3147,29 @@ export function createServer<
|
|
|
2493
3147
|
let claim: StoreClaimAvailableResult | undefined
|
|
2494
3148
|
if (shouldClaim) {
|
|
2495
3149
|
const window = nextClaimWindow()
|
|
2496
|
-
claim = await
|
|
2497
|
-
|
|
2498
|
-
|
|
2499
|
-
|
|
2500
|
-
|
|
2501
|
-
|
|
2502
|
-
|
|
2503
|
-
|
|
2504
|
-
|
|
2505
|
-
|
|
2506
|
-
|
|
3150
|
+
claim = await telemetry.span(
|
|
3151
|
+
'a2.claim',
|
|
3152
|
+
{ 'a2.contract': name, 'a2.session_id': sessionId },
|
|
3153
|
+
async (claimSpan) => {
|
|
3154
|
+
const result = await store.claimAvailable({
|
|
3155
|
+
sessionId: ns,
|
|
3156
|
+
holder,
|
|
3157
|
+
ttlMs: window.ttlMs,
|
|
3158
|
+
...(window.deadlineCapped
|
|
3159
|
+
? { expiresAtMs: window.expiresAtMs }
|
|
3160
|
+
: {}),
|
|
3161
|
+
...(excluded.size === 0
|
|
3162
|
+
? {}
|
|
3163
|
+
: { excludeIndexes: [...excluded] }),
|
|
3164
|
+
})
|
|
3165
|
+
claimSpan.setAttribute('a2.claim.outcome', result.outcome)
|
|
3166
|
+
claimSpan.setAttribute(
|
|
3167
|
+
'a2.claim.count',
|
|
3168
|
+
result.outcome === 'claimed' ? result.events.length : 0,
|
|
3169
|
+
)
|
|
3170
|
+
return result
|
|
3171
|
+
},
|
|
3172
|
+
)
|
|
2507
3173
|
if (claim.outcome === 'claimed') {
|
|
2508
3174
|
requestWatchdog(window.watchdogAtMs)
|
|
2509
3175
|
if (window.deadlineCapped) stopRenewing = true
|
|
@@ -2595,6 +3261,7 @@ export function createServer<
|
|
|
2595
3261
|
mode: 'inline' | 'dispatch',
|
|
2596
3262
|
events: AppendInput<D>[],
|
|
2597
3263
|
requireDurableInitialArm = false,
|
|
3264
|
+
originIndex?: number,
|
|
2598
3265
|
): Promise<ContractEvent<D>[]> => {
|
|
2599
3266
|
return appendCore(
|
|
2600
3267
|
sessionId,
|
|
@@ -2605,6 +3272,7 @@ export function createServer<
|
|
|
2605
3272
|
undefined,
|
|
2606
3273
|
undefined,
|
|
2607
3274
|
requireDurableInitialArm,
|
|
3275
|
+
originIndex,
|
|
2608
3276
|
)
|
|
2609
3277
|
}
|
|
2610
3278
|
|
|
@@ -2620,11 +3288,29 @@ export function createServer<
|
|
|
2620
3288
|
return makeSession(id, append, null)
|
|
2621
3289
|
}
|
|
2622
3290
|
|
|
2623
|
-
const
|
|
2624
|
-
|
|
3291
|
+
const historyPage = async (
|
|
3292
|
+
sessionId: string,
|
|
3293
|
+
bounds: { gte: number; lte: number },
|
|
3294
|
+
): Promise<{ events: ContractEvent<D>[]; clearIndex?: number }> => {
|
|
3295
|
+
assertSessionId(sessionId)
|
|
3296
|
+
const rows = await readHistory(sessionId, bounds)
|
|
3297
|
+
// Reads start at the latest marker, so it can only lead the page.
|
|
3298
|
+
const marker = rows[0] && isSessionCleared(rows[0]) ? rows[0] : undefined
|
|
3299
|
+
return {
|
|
3300
|
+
events: (marker ? rows.slice(1) : rows).map(toPublic),
|
|
3301
|
+
...(marker ? { clearIndex: marker.index } : {}),
|
|
3302
|
+
}
|
|
3303
|
+
}
|
|
3304
|
+
|
|
3305
|
+
const fetch = createServerFetch({
|
|
2625
3306
|
contract: serverContract,
|
|
2626
|
-
fetch,
|
|
2627
3307
|
session,
|
|
3308
|
+
historyPage,
|
|
3309
|
+
})
|
|
3310
|
+
const self: A2Server<D, P, R> = {
|
|
3311
|
+
contract: serverContract,
|
|
3312
|
+
fetch,
|
|
3313
|
+
session: session as A2Server<D, P, R>['session'],
|
|
2628
3314
|
|
|
2629
3315
|
async drain(sessionId) {
|
|
2630
3316
|
assertSessionId(sessionId)
|
|
@@ -2643,14 +3329,38 @@ export function createServer<
|
|
|
2643
3329
|
assertSessionId(sessionId)
|
|
2644
3330
|
return drainSession(sessionId, opts?.watchdogDueAt)
|
|
2645
3331
|
},
|
|
2646
|
-
schedulerAppend: async (sessionId, events) => {
|
|
3332
|
+
schedulerAppend: async (sessionId, events, originIndex) => {
|
|
2647
3333
|
if (!scheduler) {
|
|
2648
3334
|
throw new TypeError(
|
|
2649
3335
|
'scheduled append delivery requires a configured scheduler adapter',
|
|
2650
3336
|
)
|
|
2651
3337
|
}
|
|
2652
3338
|
assertSessionId(sessionId)
|
|
2653
|
-
|
|
3339
|
+
try {
|
|
3340
|
+
await appendExternal(
|
|
3341
|
+
sessionId,
|
|
3342
|
+
'inline',
|
|
3343
|
+
[...events],
|
|
3344
|
+
true,
|
|
3345
|
+
originIndex,
|
|
3346
|
+
)
|
|
3347
|
+
} catch (error) {
|
|
3348
|
+
// The scheduling handler's lifetime was cleared: an acknowledged no-op.
|
|
3349
|
+
if (
|
|
3350
|
+
originIndex !== undefined &&
|
|
3351
|
+
error instanceof A2Error &&
|
|
3352
|
+
error.code === 'SESSION_CLEARED'
|
|
3353
|
+
)
|
|
3354
|
+
return
|
|
3355
|
+
throw error
|
|
3356
|
+
}
|
|
3357
|
+
},
|
|
3358
|
+
historyWithMarkers: async (sessionId, gte) =>
|
|
3359
|
+
(await readHistory(sessionId, { gte })).map(toPublic),
|
|
3360
|
+
schedulerClear: async (sessionId, ifLastIndex) => {
|
|
3361
|
+
assertSessionId(sessionId)
|
|
3362
|
+
assertClearIndex(ifLastIndex)
|
|
3363
|
+
await clearSession(sessionId, { ifLastIndex }, undefined)
|
|
2654
3364
|
},
|
|
2655
3365
|
})
|
|
2656
3366
|
return self
|
|
@@ -2683,6 +3393,12 @@ function assertStateIndex(value: number): void {
|
|
|
2683
3393
|
}
|
|
2684
3394
|
}
|
|
2685
3395
|
|
|
3396
|
+
function assertClearIndex(value: number): void {
|
|
3397
|
+
if (!Number.isSafeInteger(value) || value < 0) {
|
|
3398
|
+
throw new TypeError('ifLastIndex must be a non-negative safe integer')
|
|
3399
|
+
}
|
|
3400
|
+
}
|
|
3401
|
+
|
|
2686
3402
|
function assertAppendName(name: string): void {
|
|
2687
3403
|
if (typeof name !== 'string' || name.length === 0) {
|
|
2688
3404
|
throw new TypeError('handler append name must be a non-empty string')
|
|
@@ -2827,16 +3543,19 @@ function isJsonValue(value: unknown, ancestors: WeakSet<object>): boolean {
|
|
|
2827
3543
|
}
|
|
2828
3544
|
}
|
|
2829
3545
|
|
|
2830
|
-
|
|
2831
|
-
|
|
2832
|
-
|
|
2833
|
-
|
|
2834
|
-
|
|
2835
|
-
|
|
2836
|
-
|
|
2837
|
-
|
|
2838
|
-
|
|
2839
|
-
|
|
3546
|
+
const measuredBytes = (
|
|
3547
|
+
reducer: object,
|
|
3548
|
+
state: unknown,
|
|
3549
|
+
index: number,
|
|
3550
|
+
snapshots: StoreSnapshotWrite[],
|
|
3551
|
+
): number =>
|
|
3552
|
+
JSON.stringify(
|
|
3553
|
+
snapshots.find((snapshot) => snapshot.index === index)?.state ??
|
|
3554
|
+
encodeState({ reducer, state }),
|
|
3555
|
+
)?.length ?? 0
|
|
3556
|
+
|
|
3557
|
+
const stateCacheKey = (sessionId: string, reducerName: string): string =>
|
|
3558
|
+
`${sessionId}\u0000${reducerName}`
|
|
2840
3559
|
|
|
2841
3560
|
// Server-side types users need when passing custom stores or building
|
|
2842
3561
|
// transports — re-exported so experimental-a2/server is self-sufficient.
|
|
@@ -2857,6 +3576,7 @@ export type {
|
|
|
2857
3576
|
export type {
|
|
2858
3577
|
ScheduledEvent,
|
|
2859
3578
|
SchedulerAppendTask,
|
|
3579
|
+
SchedulerClearTask,
|
|
2860
3580
|
SchedulerDrainTask,
|
|
2861
3581
|
SchedulerTask,
|
|
2862
3582
|
} from './scheduler-task.ts'
|