experimental-a2 0.20.0 → 0.22.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 +55 -0
- package/README.md +1 -1
- package/dist/{actor-C7b-1arn.d.ts → actor-CqibLgjQ.d.ts} +15 -7
- package/dist/actor-CqibLgjQ.d.ts.map +1 -0
- package/dist/actor-client.d.ts +1 -1
- package/dist/actor-client.js +2 -2
- package/dist/actor-react.d.ts +3 -3
- package/dist/actor-react.js +5 -5
- package/dist/{actor-shared-t7w8xIKP.js → actor-shared-DFET5q16.js} +3 -3
- package/dist/actor-shared-DFET5q16.js.map +1 -0
- package/dist/actor.d.ts +1 -1
- package/dist/actor.js +54 -43
- package/dist/actor.js.map +1 -1
- package/dist/{ai-Cz3hqVhk.d.ts → ai-DAu77CDr.d.ts} +8 -8
- package/dist/{ai-Cz3hqVhk.d.ts.map → ai-DAu77CDr.d.ts.map} +1 -1
- package/dist/{ai-mMiWul1T.js → ai-EEfKyc-z.js} +9 -9
- package/dist/ai-EEfKyc-z.js.map +1 -0
- package/dist/ai-client.d.ts +2 -2
- package/dist/ai-client.js +2 -2
- package/dist/ai-fx.d.ts +4 -2
- package/dist/ai-fx.d.ts.map +1 -1
- package/dist/ai-fx.js +214 -67
- package/dist/ai-fx.js.map +1 -1
- package/dist/{ai-harness-CV6rXbB3.d.ts → ai-harness-DTjkAr2T.d.ts} +2 -2
- package/dist/{ai-harness-CV6rXbB3.d.ts.map → ai-harness-DTjkAr2T.d.ts.map} +1 -1
- package/dist/{ai-harness-Clbg5ei7.js → ai-harness-dQeJaR1t.js} +7 -3
- package/dist/ai-harness-dQeJaR1t.js.map +1 -0
- package/dist/ai-pi.d.ts +1 -1
- package/dist/ai-pi.js +3 -3
- package/dist/ai-pi.js.map +1 -1
- package/dist/ai-react.d.ts +5 -5
- package/dist/ai-react.js +2 -2
- package/dist/ai-server.d.ts +14 -17
- package/dist/ai-server.d.ts.map +1 -1
- package/dist/ai-server.js +86 -73
- package/dist/ai-server.js.map +1 -1
- package/dist/ai.d.ts +2 -2
- package/dist/ai.js +2 -2
- package/dist/{client-BhHLoNKF.js → client-BQS4i_V9.js} +29 -6
- package/dist/client-BQS4i_V9.js.map +1 -0
- package/dist/{client-h_DKFAjj.d.ts → client-CUMbS_38.d.ts} +3 -3
- package/dist/{client-h_DKFAjj.d.ts.map → client-CUMbS_38.d.ts.map} +1 -1
- package/dist/client.d.ts +3 -3
- package/dist/client.js +1 -1
- package/dist/{contract-BYKASlCV.js → contract-CWukt01d.js} +2 -2
- package/dist/contract-CWukt01d.js.map +1 -0
- package/dist/index.d.ts +5 -5
- package/dist/index.js +3 -3
- package/dist/{initial-snapshot-Bx-c0pDP.d.ts → initial-snapshot-D3Lve43o.d.ts} +4 -4
- package/dist/initial-snapshot-D3Lve43o.d.ts.map +1 -0
- package/dist/{internal-CscSY7K_.js → internal-CvsrprnU.js} +40 -16
- package/dist/internal-CvsrprnU.js.map +1 -0
- package/dist/otel.d.ts +1 -1
- package/dist/otel.js +1 -1
- package/dist/otel.js.map +1 -1
- package/dist/{react-BBXgHWFc.d.ts → react-BoHvC3v9.d.ts} +4 -4
- package/dist/{react-BBXgHWFc.d.ts.map → react-BoHvC3v9.d.ts.map} +1 -1
- package/dist/react.d.ts +1 -1
- package/dist/react.js +1 -1
- package/dist/{reducer-CqA59Q6U.d.ts → reducer-CYPHsSmd.d.ts} +2 -2
- package/dist/{reducer-CqA59Q6U.d.ts.map → reducer-CYPHsSmd.d.ts.map} +1 -1
- package/dist/{reducer-BAXc8JyF.js → reducer-CjmvZSAb.js} +2 -2
- package/dist/{reducer-BAXc8JyF.js.map → reducer-CjmvZSAb.js.map} +1 -1
- package/dist/runtime-consumer-CIeL33wj.js +12 -0
- package/dist/runtime-consumer-CIeL33wj.js.map +1 -0
- package/dist/scheduler-qstash.d.ts +2 -2
- package/dist/scheduler-qstash.js +3 -3
- package/dist/scheduler-task-X1SToDjP.js.map +1 -1
- package/dist/scheduler-vercel.d.ts +2 -2
- package/dist/scheduler-vercel.js +3 -3
- package/dist/scheduler-vercel.js.map +1 -1
- package/dist/{server-qyb5aJJG.js → server-DtFrWxi3.js} +412 -116
- package/dist/server-DtFrWxi3.js.map +1 -0
- package/dist/{server-DYAATFuT.d.ts → server-qgez0Xny.d.ts} +108 -41
- package/dist/server-qgez0Xny.d.ts.map +1 -0
- package/dist/server.browser.js +1 -1
- package/dist/server.browser.js.map +1 -1
- package/dist/server.d.ts +4 -4
- package/dist/server.js +2 -2
- package/dist/{session-clear-C-QwTXV1.d.ts → session-clear-D1PFe7xl.d.ts} +2 -2
- package/dist/session-clear-D1PFe7xl.d.ts.map +1 -0
- package/dist/{session-clear-DGI4jTQ5.js → session-clear-wHd5GCpZ.js} +5 -4
- package/dist/session-clear-wHd5GCpZ.js.map +1 -0
- package/dist/store-D0MhBvg6.d.ts +532 -0
- package/dist/store-D0MhBvg6.d.ts.map +1 -0
- package/dist/store-N8PXxDAS.js.map +1 -1
- package/dist/store-codec-DwrWlgQx.js +65 -0
- package/dist/store-codec-DwrWlgQx.js.map +1 -0
- package/dist/store-memory.d.ts +1 -1
- package/dist/store-memory.d.ts.map +1 -1
- package/dist/store-memory.js +406 -225
- package/dist/store-memory.js.map +1 -1
- package/dist/{store-polling-7WJV0YWP.js → store-polling-BZt2Rf3D.js} +2 -2
- package/dist/{store-polling-7WJV0YWP.js.map → store-polling-BZt2Rf3D.js.map} +1 -1
- package/dist/store-postgres.d.ts +1 -1
- package/dist/store-postgres.d.ts.map +1 -1
- package/dist/store-postgres.js +1598 -587
- package/dist/store-postgres.js.map +1 -1
- package/dist/store-presence-polling-JuAy2Ck2.js +111 -0
- package/dist/store-presence-polling-JuAy2Ck2.js.map +1 -0
- package/dist/store-redis-http.d.ts +1 -1
- package/dist/store-redis-http.js +4 -4
- package/dist/store-redis-notify-CsJw_hfD.js +3060 -0
- package/dist/store-redis-notify-CsJw_hfD.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 +4 -5
- 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 +527 -299
- package/dist/store-sqlite.js.map +1 -1
- package/dist/stream-buffer-DrTyiIuo.js +57 -0
- package/dist/stream-buffer-DrTyiIuo.js.map +1 -0
- package/dist/{telemetry-Cyfqo6Qt.d.ts → telemetry-CWwsh-Gx.d.ts} +3 -3
- package/dist/telemetry-CWwsh-Gx.d.ts.map +1 -0
- package/dist/{wire-BKxH1vbP.js → wire-CV-Z7wz-.js} +6 -5
- package/dist/wire-CV-Z7wz-.js.map +1 -0
- package/docs/01-quickstart.mdx +21 -15
- package/docs/02-examples.mdx +1 -1
- package/docs/actors/01-introduction.mdx +13 -2
- package/docs/actors/02-concurrency.mdx +1 -1
- package/docs/agents/01-introduction.mdx +1 -1
- package/docs/agents/02-tools.mdx +12 -11
- package/docs/agents/03-conversations.mdx +36 -25
- package/docs/agents/04-harnesses.mdx +14 -7
- package/docs/client/03-presence.mdx +18 -14
- package/docs/contract/01-events.mdx +2 -2
- package/docs/contract/02-state.mdx +18 -14
- package/docs/contract/03-sessions.mdx +32 -25
- package/docs/index.mdx +29 -24
- package/docs/reference/01-core.mdx +7 -6
- package/docs/reference/02-server.mdx +250 -191
- package/docs/reference/04-ai.mdx +33 -23
- package/docs/reference/05-telemetry.mdx +27 -13
- package/docs/reference/06-errors.mdx +25 -19
- package/docs/reference/meta.ts +1 -1
- package/docs/schedulers/01-overview.mdx +8 -8
- package/docs/schedulers/02-vercel-queues.mdx +2 -2
- package/docs/schedulers/03-qstash.mdx +4 -4
- package/docs/server/01-consumers.mdx +418 -0
- package/docs/server/02-timers.mdx +31 -21
- package/docs/server/03-cancellation.mdx +52 -44
- package/docs/server/04-routes.mdx +4 -1
- package/docs/server/05-sql-projections.mdx +391 -0
- package/docs/server/06-guarantees.mdx +61 -51
- package/docs/stores/01-overview.mdx +3 -3
- package/docs/stores/02-redis.mdx +24 -10
- package/docs/stores/03-redis-http.mdx +8 -6
- package/docs/stores/04-postgres.mdx +65 -14
- package/docs/stores/05-sqlite.mdx +5 -3
- package/docs/upgrades/01-overview.mdx +31 -0
- package/docs/upgrades/78-0-22.mdx +115 -0
- package/docs/upgrades/79-0-21.mdx +165 -0
- package/docs/upgrades/80-0-20.mdx +43 -0
- package/docs/upgrades/81-0-19.mdx +33 -0
- package/docs/upgrades/82-0-18.mdx +33 -0
- package/docs/upgrades/83-0-17.mdx +69 -0
- package/docs/upgrades/meta.ts +3 -0
- package/examples/playground/DEPLOY.md +39 -7
- package/examples/playground/app/a0/chat.test.tsx +2 -2
- package/examples/playground/app/a0/sandbox-log.ts +17 -15
- package/examples/playground/app/a0/sandbox.ts +58 -13
- package/examples/playground/app/a0/server.ts +4 -4
- package/examples/playground/app/a2slack/a2slack.css +1067 -0
- package/examples/playground/app/a2slack/c/[channel]/page.tsx +20 -0
- package/examples/playground/app/a2slack/consumers/agent.ts +63 -0
- package/examples/playground/app/a2slack/consumers/feed.ts +164 -0
- package/examples/playground/app/a2slack/consumers/notify.ts +116 -0
- package/examples/playground/app/a2slack/consumers/search.ts +37 -0
- package/examples/playground/app/a2slack/consumers/triage.ts +31 -0
- package/examples/playground/app/a2slack/consumers/welcome.ts +25 -0
- package/examples/playground/app/a2slack/db/a2.ts +209 -0
- package/examples/playground/app/a2slack/db/index.ts +73 -0
- package/examples/playground/app/a2slack/db/migrations/0000_extensions.sql +4 -0
- package/examples/playground/app/a2slack/db/migrations/0001_workspace.sql +74 -0
- package/examples/playground/app/a2slack/db/migrations/0002_a2.sql +215 -0
- package/examples/playground/app/a2slack/db/migrations/0003_a2_batch.sql +67 -0
- package/examples/playground/app/a2slack/db/migrations/0004_a2_session_event_ids.sql +2 -0
- package/examples/playground/app/a2slack/db/migrations/meta/0000_snapshot.json +18 -0
- package/examples/playground/app/a2slack/db/migrations/meta/0001_snapshot.json +500 -0
- package/examples/playground/app/a2slack/db/migrations/meta/0002_snapshot.json +1159 -0
- package/examples/playground/app/a2slack/db/migrations/meta/0003_snapshot.json +1159 -0
- package/examples/playground/app/a2slack/db/migrations/meta/0004_snapshot.json +1165 -0
- package/examples/playground/app/a2slack/db/migrations/meta/_journal.json +41 -0
- package/examples/playground/app/a2slack/db/queries.ts +263 -0
- package/examples/playground/app/a2slack/db/schema.ts +145 -0
- package/examples/playground/app/a2slack/drills/a2slack.drill.ts +708 -0
- package/examples/playground/app/a2slack/drills/harness.ts +244 -0
- package/examples/playground/app/a2slack/events/route.ts +36 -0
- package/examples/playground/app/a2slack/identity.ts +44 -0
- package/examples/playground/app/a2slack/inbox/route.ts +8 -0
- package/examples/playground/app/a2slack/lag/data/route.ts +8 -0
- package/examples/playground/app/a2slack/lag/page.tsx +101 -0
- package/examples/playground/app/a2slack/lag.ts +73 -0
- package/examples/playground/app/a2slack/layout.tsx +43 -0
- package/examples/playground/app/a2slack/messages/route.ts +17 -0
- package/examples/playground/app/a2slack/model.test.ts +208 -0
- package/examples/playground/app/a2slack/model.ts +369 -0
- package/examples/playground/app/a2slack/models.test.ts +72 -0
- package/examples/playground/app/a2slack/page.tsx +10 -0
- package/examples/playground/app/a2slack/scheduler/route.ts +5 -0
- package/examples/playground/app/a2slack/search/route.ts +40 -0
- package/examples/playground/app/a2slack/search-index.ts +108 -0
- package/examples/playground/app/a2slack/server.test.ts +315 -0
- package/examples/playground/app/a2slack/server.ts +55 -0
- package/examples/playground/app/a2slack/sessions.ts +50 -0
- package/examples/playground/app/a2slack/thread/route.ts +8 -0
- package/examples/playground/app/a2slack/ui/ChannelView.tsx +87 -0
- package/examples/playground/app/a2slack/ui/Composer.tsx +117 -0
- package/examples/playground/app/a2slack/ui/DotsShadow.tsx +42 -0
- package/examples/playground/app/a2slack/ui/Feed.tsx +157 -0
- package/examples/playground/app/a2slack/ui/InboxContext.tsx +63 -0
- package/examples/playground/app/a2slack/ui/InboxView.tsx +82 -0
- package/examples/playground/app/a2slack/ui/Menu.tsx +128 -0
- package/examples/playground/app/a2slack/ui/MessageRow.tsx +314 -0
- package/examples/playground/app/a2slack/ui/NameGate.tsx +35 -0
- package/examples/playground/app/a2slack/ui/SearchContext.tsx +146 -0
- package/examples/playground/app/a2slack/ui/SearchResults.tsx +97 -0
- package/examples/playground/app/a2slack/ui/Shell.tsx +34 -0
- package/examples/playground/app/a2slack/ui/Sidebar.tsx +199 -0
- package/examples/playground/app/a2slack/ui/ThreadPanel.tsx +134 -0
- package/examples/playground/app/a2slack/ui/useInbox.ts +71 -0
- package/examples/playground/app/agent/server.ts +3 -3
- package/examples/playground/app/chat/[chatId]/chat-client.tsx +1 -1
- package/examples/playground/app/chat/server.ts +45 -35
- package/examples/playground/app/components/demo-icons.tsx +7 -0
- package/examples/playground/app/components/demos.tsx +7 -0
- package/examples/playground/app/orders/[orderId]/order-client.tsx +2 -2
- package/examples/playground/app/orders/page.tsx +1 -1
- package/examples/playground/app/orders/server.ts +49 -30
- package/examples/playground/app/recovery/[recoveryId]/recovery-client.tsx +14 -15
- package/examples/playground/app/recovery/page.tsx +4 -4
- package/examples/playground/app/recovery/server.ts +23 -32
- package/examples/playground/drizzle.config.ts +14 -0
- package/examples/playground/lib/store.ts +1 -1
- package/examples/playground/next.config.ts +6 -1
- package/examples/playground/package.json +12 -1
- package/examples/playground/scripts/migrate.mjs +31 -0
- package/examples/playground/vercel.json +9 -0
- package/examples/playground/vitest.drills.config.ts +32 -0
- package/package.json +5 -2
- package/sql/postgres/drizzle-schema.ts +209 -0
- package/sql/postgres/schema.sql +227 -67
- package/src/actor-shared.ts +10 -2
- package/src/actor.ts +101 -71
- package/src/ai-agent.ts +162 -111
- package/src/ai-control-server.ts +8 -8
- package/src/ai-fx.ts +324 -82
- package/src/ai-harness.ts +17 -2
- package/src/ai-pi.ts +2 -2
- package/src/ai-server.ts +2 -2
- package/src/ai.ts +7 -7
- package/src/client.ts +19 -2
- package/src/contract.ts +1 -1
- package/src/deterministic-id.ts +27 -4
- package/src/errors.ts +8 -5
- package/src/idempotent-replay.ts +0 -1
- package/src/index.ts +1 -1
- package/src/internal.ts +57 -16
- package/src/otel.ts +1 -1
- package/src/postgres-schema.ts +98 -57
- package/src/presence-recovery.ts +18 -11
- package/src/presence-tombstones.ts +52 -0
- package/src/push-envelope.ts +20 -0
- package/src/runtime-consumer.ts +10 -0
- package/src/scheduler-task.ts +2 -2
- package/src/scheduler-vercel.ts +1 -1
- package/src/server.browser.ts +1 -1
- package/src/server.ts +819 -218
- package/src/session-socket.ts +1 -1
- package/src/store-codec.ts +66 -0
- package/src/store-memory.ts +604 -286
- package/src/store-postgres.ts +2006 -857
- package/src/store-presence-polling.ts +59 -34
- package/src/store-redis-core.ts +1961 -1333
- package/src/store-redis-notify.ts +76 -26
- package/src/store-redis.ts +2 -3
- package/src/store-sqlite.ts +915 -516
- package/src/store.ts +241 -102
- package/src/telemetry.ts +2 -1
- package/src/wire.ts +1 -0
- package/dist/actor-C7b-1arn.d.ts.map +0 -1
- package/dist/actor-shared-t7w8xIKP.js.map +0 -1
- package/dist/ai-harness-Clbg5ei7.js.map +0 -1
- package/dist/ai-mMiWul1T.js.map +0 -1
- package/dist/client-BhHLoNKF.js.map +0 -1
- package/dist/contract-BYKASlCV.js.map +0 -1
- package/dist/idempotent-replay-JdgMQRRm.js +0 -21
- package/dist/idempotent-replay-JdgMQRRm.js.map +0 -1
- package/dist/initial-snapshot-Bx-c0pDP.d.ts.map +0 -1
- package/dist/internal-CscSY7K_.js.map +0 -1
- package/dist/server-DYAATFuT.d.ts.map +0 -1
- package/dist/server-qyb5aJJG.js.map +0 -1
- package/dist/session-clear-C-QwTXV1.d.ts.map +0 -1
- package/dist/session-clear-DGI4jTQ5.js.map +0 -1
- package/dist/store-CyLUy86v.d.ts +0 -404
- package/dist/store-CyLUy86v.d.ts.map +0 -1
- package/dist/store-codec-DTG0Ftek.js +0 -10
- package/dist/store-codec-DTG0Ftek.js.map +0 -1
- package/dist/store-presence-polling-B1ixeqIS.js +0 -94
- package/dist/store-presence-polling-B1ixeqIS.js.map +0 -1
- package/dist/store-redis-notify-CM0AfLY0.js +0 -2544
- package/dist/store-redis-notify-CM0AfLY0.js.map +0 -1
- package/dist/stream-buffer-Cx7AAPGq.js +0 -23
- package/dist/stream-buffer-Cx7AAPGq.js.map +0 -1
- package/dist/telemetry-Cyfqo6Qt.d.ts.map +0 -1
- package/dist/wire-BKxH1vbP.js.map +0 -1
- package/docs/server/01-handlers.mdx +0 -227
package/src/server.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { decodeState, encodeState } from './reducer-runtime.ts'
|
|
|
2
2
|
// oxlint-disable no-await-in-loop -- scheduler advances after durable outcomes
|
|
3
3
|
/**
|
|
4
4
|
* experimental-a2/server — where a contract is implemented. `createServer({
|
|
5
|
-
* contract, store,
|
|
5
|
+
* contract, store, consumers, ... })` binds the vocabulary to storage and
|
|
6
6
|
* reactions; sessions, drains, claims, and scheduler all live here.
|
|
7
7
|
* Server-only by construction: this entry point is the only one that
|
|
8
8
|
* can reach a store backend, and its exports map fails loudly in browser
|
|
@@ -31,7 +31,10 @@ import {
|
|
|
31
31
|
markSchedulerSendFailure,
|
|
32
32
|
stateTailReaders,
|
|
33
33
|
nullProtoRecord,
|
|
34
|
+
runtimeConsumerFactory,
|
|
35
|
+
runtimeConsumers,
|
|
34
36
|
serverInternals,
|
|
37
|
+
drainSettledHooks,
|
|
35
38
|
type DrainOutcome,
|
|
36
39
|
type DrainResult,
|
|
37
40
|
} from './internal.ts'
|
|
@@ -41,6 +44,7 @@ import {
|
|
|
41
44
|
type SessionCleared,
|
|
42
45
|
} from './session-clear.ts'
|
|
43
46
|
import { boundedCache } from './bounded-cache.ts'
|
|
47
|
+
import { presenceTombstones } from './presence-tombstones.ts'
|
|
44
48
|
import { defaultSleep, type Sleeper } from './store-polling.ts'
|
|
45
49
|
import { createStreamActivity, streamActivities } from './stream-activity.ts'
|
|
46
50
|
import {
|
|
@@ -54,6 +58,10 @@ import type {
|
|
|
54
58
|
AppendEvent,
|
|
55
59
|
Event,
|
|
56
60
|
EventCause,
|
|
61
|
+
EventTarget,
|
|
62
|
+
HeadEvent,
|
|
63
|
+
HeadRef,
|
|
64
|
+
StoreConsumer,
|
|
57
65
|
StoreClaimAvailableResult,
|
|
58
66
|
StoreClearResult,
|
|
59
67
|
StoreReclaimResult,
|
|
@@ -105,8 +113,8 @@ import {
|
|
|
105
113
|
type A2Telemetry,
|
|
106
114
|
} from './telemetry.ts'
|
|
107
115
|
|
|
108
|
-
/** What every
|
|
109
|
-
export type
|
|
116
|
+
/** What every consumer's `run` receives. */
|
|
117
|
+
export type ConsumerContext<
|
|
110
118
|
D extends EventDefs,
|
|
111
119
|
K extends keyof D & string = keyof D & string,
|
|
112
120
|
P extends PresenceDefs = Record<never, never>,
|
|
@@ -116,21 +124,12 @@ export type HandlerContext<
|
|
|
116
124
|
event: ContractEvent<D, K>
|
|
117
125
|
/** Durable, 1-based dispatch ordinal for this event. */
|
|
118
126
|
attempt: number
|
|
119
|
-
/** This session, with
|
|
120
|
-
session: Session<D,
|
|
127
|
+
/** This session, with consumer-scoped idempotent append. */
|
|
128
|
+
session: Session<D, ConsumerAppend<D>, P, S>
|
|
121
129
|
/** Fires on `abortOn` events (cancellation slice); dormant otherwise. */
|
|
122
130
|
signal: AbortSignal
|
|
123
131
|
}
|
|
124
132
|
|
|
125
|
-
export type Handler<
|
|
126
|
-
D extends EventDefs,
|
|
127
|
-
K extends keyof D & string = keyof D & string,
|
|
128
|
-
P extends PresenceDefs = Record<never, never>,
|
|
129
|
-
S = never,
|
|
130
|
-
> = (
|
|
131
|
-
ctx: HandlerContext<D, K, P, S>,
|
|
132
|
-
) => Promise<void | AppendInput<D> | readonly AppendInput<D>[]>
|
|
133
|
-
|
|
134
133
|
export type LaneContext<
|
|
135
134
|
D extends EventDefs,
|
|
136
135
|
K extends keyof D & string = keyof D & string,
|
|
@@ -139,10 +138,15 @@ export type LaneContext<
|
|
|
139
138
|
event: Pick<ContractEvent<D, K>, 'type' | 'payload'> & { id?: string }
|
|
140
139
|
}
|
|
141
140
|
|
|
142
|
-
|
|
141
|
+
/**
|
|
142
|
+
* A consumer's lane: a string runs every event it takes in log order on one
|
|
143
|
+
* cursor; a function returns each event's lane, or null to run it
|
|
144
|
+
* concurrently.
|
|
145
|
+
*/
|
|
146
|
+
export type ConsumerLane<
|
|
143
147
|
D extends EventDefs,
|
|
144
148
|
K extends keyof D & string = keyof D & string,
|
|
145
|
-
> = string | ((context: LaneContext<D, K>) => string)
|
|
149
|
+
> = string | ((context: LaneContext<D, K>) => string | null)
|
|
146
150
|
|
|
147
151
|
export type SessionDispatch<D extends EventDefs> = (
|
|
148
152
|
...events: AppendInput<D>[]
|
|
@@ -153,7 +157,7 @@ export type SessionAppend<D extends EventDefs> = SessionDispatch<D> & {
|
|
|
153
157
|
dispatch: SessionDispatch<D>
|
|
154
158
|
}
|
|
155
159
|
|
|
156
|
-
export type
|
|
160
|
+
export type ConsumerAppend<D extends EventDefs> = (
|
|
157
161
|
name: string,
|
|
158
162
|
...events: AppendInput<D>[]
|
|
159
163
|
) => Promise<ContractEvent<D>[]>
|
|
@@ -192,7 +196,7 @@ export type SessionClearOptions = {
|
|
|
192
196
|
export type SessionClearResult = { cleared: boolean; index: number }
|
|
193
197
|
|
|
194
198
|
export type SessionRedriveOptions = {
|
|
195
|
-
/** `retry` runs the
|
|
199
|
+
/** `retry` runs the consumer again with a fresh failure budget; `skip` settles the event without running it. */
|
|
196
200
|
action: 'retry' | 'skip'
|
|
197
201
|
}
|
|
198
202
|
|
|
@@ -201,18 +205,36 @@ export type SessionRedriveResult = {
|
|
|
201
205
|
redriven: boolean
|
|
202
206
|
}
|
|
203
207
|
|
|
208
|
+
export type SessionConsumeOptions = {
|
|
209
|
+
/**
|
|
210
|
+
* Run until the consumer has handled every event it takes through this
|
|
211
|
+
* index. Default: the session's head when the call starts. An index past
|
|
212
|
+
* the head counts as the head.
|
|
213
|
+
*/
|
|
214
|
+
through?: number
|
|
215
|
+
/** Stops waiting: the call throws the signal's reason. */
|
|
216
|
+
signal?: AbortSignal
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
export type SessionConsumeResult = {
|
|
220
|
+
/** The consumer has handled every event it takes through this index. */
|
|
221
|
+
index: number
|
|
222
|
+
}
|
|
223
|
+
|
|
204
224
|
/** A dead-lettered event, with what an operator needs to resolve it. */
|
|
205
225
|
export type DeadLetter<D extends EventDefs> = ContractEvent<D> & {
|
|
226
|
+
/** The consumer this event is dead for; it may be done for others. */
|
|
227
|
+
consumer: string
|
|
206
228
|
lane: string | null
|
|
207
229
|
/** Durable dispatch claims, including claims abandoned by hard kills. */
|
|
208
230
|
attemptCount: number
|
|
209
231
|
/**
|
|
210
232
|
* Failed attempts since the event was appended or last retried: caught
|
|
211
|
-
*
|
|
233
|
+
* consumer failures, and attempts that ended before they finished.
|
|
212
234
|
*/
|
|
213
235
|
failureCount: number
|
|
214
236
|
/**
|
|
215
|
-
* The last error
|
|
237
|
+
* The last error the consumer threw, stringified; null when every failed
|
|
216
238
|
* attempt ended before it finished.
|
|
217
239
|
*/
|
|
218
240
|
lastError: string | null
|
|
@@ -228,7 +250,7 @@ export type SessionDeadLetters<D extends EventDefs> = {
|
|
|
228
250
|
* an append. Any other event is left alone and reports `redriven: false`.
|
|
229
251
|
*/
|
|
230
252
|
redrive(
|
|
231
|
-
index: number,
|
|
253
|
+
target: { consumer: string; index: number },
|
|
232
254
|
options: SessionRedriveOptions,
|
|
233
255
|
): Promise<SessionRedriveResult>
|
|
234
256
|
}
|
|
@@ -284,6 +306,17 @@ export type Session<
|
|
|
284
306
|
*/
|
|
285
307
|
clear(options?: SessionClearOptions): Promise<SessionClearResult>
|
|
286
308
|
history(options?: { gte?: number; lte?: number }): Promise<ContractEvent<D>[]>
|
|
309
|
+
/**
|
|
310
|
+
* Run a consumer with a string lane on this session, here, until it has
|
|
311
|
+
* handled every event it takes through `through`. The server's own
|
|
312
|
+
* consumer shares its cursor with background work, so this waits for
|
|
313
|
+
* whichever invocation holds it. A consumer the server doesn't run is
|
|
314
|
+
* pulled only by `consume`, and keeps its place between calls.
|
|
315
|
+
*/
|
|
316
|
+
consume(
|
|
317
|
+
consumer: Consumer<D>,
|
|
318
|
+
options?: SessionConsumeOptions,
|
|
319
|
+
): Promise<SessionConsumeResult>
|
|
287
320
|
state: {
|
|
288
321
|
<T>(
|
|
289
322
|
reducer: Reducer<D, T, Record<never, never>>,
|
|
@@ -303,7 +336,7 @@ export type Session<
|
|
|
303
336
|
}
|
|
304
337
|
|
|
305
338
|
/**
|
|
306
|
-
* The public server shape scheduler
|
|
339
|
+
* The public server shape scheduler consumers accept. It stays structural so
|
|
307
340
|
* servers of any contract mix in one route; A2's private server internals add
|
|
308
341
|
* delayed-append delivery without exposing an untyped append method here.
|
|
309
342
|
*/
|
|
@@ -426,11 +459,11 @@ export async function deliverSchedulerClear(
|
|
|
426
459
|
}
|
|
427
460
|
|
|
428
461
|
/**
|
|
429
|
-
* Which events fire `ctx.signal` while a
|
|
462
|
+
* Which events fire `ctx.signal` while a consumer runs — the preemption
|
|
430
463
|
* channel for user cancellation. The array form matches by type; the
|
|
431
464
|
* object form takes per-type predicates for targeted cancellation
|
|
432
|
-
* (`(event, trigger) => event.payload.of === trigger.id`).
|
|
433
|
-
* without `abortOn` pay nothing. An aborted
|
|
465
|
+
* (`(event, trigger) => event.payload.of === trigger.id`). Consumers
|
|
466
|
+
* without `abortOn` pay nothing. An aborted consumer should catch and
|
|
434
467
|
* return normally; throwing means "retry me".
|
|
435
468
|
*/
|
|
436
469
|
export type AbortSpec<D extends EventDefs, K extends keyof D & string> =
|
|
@@ -445,36 +478,209 @@ export type AbortSpec<D extends EventDefs, K extends keyof D & string> =
|
|
|
445
478
|
) => boolean)
|
|
446
479
|
}
|
|
447
480
|
|
|
448
|
-
|
|
481
|
+
/** What a consumer's `run` returns: nothing, one event, or a batch. */
|
|
482
|
+
export type ConsumerResult<D extends EventDefs> = Promise<
|
|
483
|
+
void | AppendInput<D> | readonly AppendInput<D>[]
|
|
484
|
+
>
|
|
485
|
+
|
|
486
|
+
/** How `createServer` runs a consumer. Built by `consumer().run()`. */
|
|
487
|
+
export type ConsumerSpec<D extends EventDefs> = {
|
|
488
|
+
readonly types: readonly (keyof D & string)[] | null
|
|
489
|
+
readonly lane: ConsumerLane<D> | null
|
|
490
|
+
readonly backfill: boolean
|
|
491
|
+
readonly abortOn: AbortSpec<D, keyof D & string> | null
|
|
492
|
+
readonly run: (ctx: ConsumerContext<D>) => ConsumerResult<D>
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
/** A named reader of a session's log with its own durable cursors. */
|
|
496
|
+
export type Consumer<D extends EventDefs> = {
|
|
497
|
+
readonly name: string
|
|
498
|
+
readonly contract: { readonly name: string }
|
|
499
|
+
readonly spec: ConsumerSpec<D>
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
export type ConsumerOptions<
|
|
449
503
|
D extends EventDefs,
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
> =
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
504
|
+
P extends PresenceDefs,
|
|
505
|
+
R extends RegisteredReducer<D, P> | undefined,
|
|
506
|
+
K extends keyof D & string,
|
|
507
|
+
> = {
|
|
508
|
+
/** The contract whose events it reads. */
|
|
509
|
+
contract: Contract<D, P, R>
|
|
510
|
+
/** Its identity, unique per server. A new name is a new consumer. */
|
|
511
|
+
name: string
|
|
512
|
+
/** The event types it runs on; default every type. */
|
|
513
|
+
types?: readonly K[]
|
|
514
|
+
/** Omitted: concurrent. A string: one ordered lane. A function: per event. */
|
|
515
|
+
lane?: ConsumerLane<D, K>
|
|
516
|
+
/** A new consumer starts at the beginning of the log. Needs a string lane. */
|
|
517
|
+
backfill?: boolean
|
|
518
|
+
abortOn?: AbortSpec<D, K>
|
|
519
|
+
}
|
|
461
520
|
|
|
462
|
-
|
|
521
|
+
/**
|
|
522
|
+
* A consumer written in `createServer({ consumers })`: `consumer()`'s
|
|
523
|
+
* options without the contract, which is the server's, and its `run`.
|
|
524
|
+
*/
|
|
525
|
+
export type InlineConsumer<
|
|
526
|
+
D extends EventDefs,
|
|
527
|
+
P extends PresenceDefs,
|
|
528
|
+
R extends RegisteredReducer<D, P> | undefined,
|
|
529
|
+
K extends keyof D & string,
|
|
530
|
+
> = Omit<ConsumerOptions<D, P, R, K>, 'contract'> & {
|
|
531
|
+
run: (ctx: ConsumerContext<D, K, P, PublicState<R>>) => ConsumerResult<D>
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
/** The types an inline consumer takes: the ones it lists, or every type. */
|
|
535
|
+
type InlineTypes<D extends EventDefs, X> = unknown extends X
|
|
536
|
+
? keyof D & string
|
|
537
|
+
: Extract<X, keyof D & string>
|
|
463
538
|
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
539
|
+
/**
|
|
540
|
+
* `createServer`'s consumers: each a `consumer()` or written inline. `C` is
|
|
541
|
+
* inferred per entry, so an inline `run` sees only the types it lists.
|
|
542
|
+
*/
|
|
543
|
+
export type ServerConsumers<
|
|
544
|
+
D extends EventDefs,
|
|
545
|
+
P extends PresenceDefs,
|
|
546
|
+
R extends RegisteredReducer<D, P> | undefined,
|
|
547
|
+
C extends readonly unknown[],
|
|
548
|
+
> = {
|
|
549
|
+
readonly [I in keyof C]:
|
|
550
|
+
InlineConsumer<D, P, R, InlineTypes<D, C[I]>> | Extract<C[I], Consumer<D>>
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
const CONSUMER_NAME = /^[a-z0-9][a-z0-9._:-]{0,127}$/i
|
|
554
|
+
|
|
555
|
+
/** Define a consumer: a named reader of each session's log. */
|
|
556
|
+
export function consumer<
|
|
557
|
+
D extends EventDefs,
|
|
558
|
+
P extends PresenceDefs,
|
|
559
|
+
R extends RegisteredReducer<D, P> | undefined,
|
|
560
|
+
const K extends keyof D & string = keyof D & string,
|
|
561
|
+
>(
|
|
562
|
+
options: ConsumerOptions<D, P, R, K>,
|
|
563
|
+
): {
|
|
564
|
+
run(
|
|
565
|
+
fn: (ctx: ConsumerContext<D, K, P, PublicState<R>>) => ConsumerResult<D>,
|
|
566
|
+
): Consumer<D>
|
|
567
|
+
} {
|
|
568
|
+
return defineConsumer(options, false)
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
/**
|
|
572
|
+
* `consumer()`, and for A2's own runtimes (`runtime`) a name under `a2.` and
|
|
573
|
+
* child ids without the name: their types are disjoint, so the event alone
|
|
574
|
+
* keys them, and regrouping A2's consumers never changes an id.
|
|
575
|
+
*/
|
|
576
|
+
function defineConsumer<
|
|
577
|
+
D extends EventDefs,
|
|
578
|
+
P extends PresenceDefs,
|
|
579
|
+
R extends RegisteredReducer<D, P> | undefined,
|
|
580
|
+
const K extends keyof D & string = keyof D & string,
|
|
581
|
+
>(
|
|
582
|
+
options: ConsumerOptions<D, P, R, K>,
|
|
583
|
+
runtime: boolean,
|
|
584
|
+
): {
|
|
585
|
+
run(
|
|
586
|
+
fn: (ctx: ConsumerContext<D, K, P, PublicState<R>>) => ConsumerResult<D>,
|
|
587
|
+
): Consumer<D>
|
|
588
|
+
} {
|
|
589
|
+
const contract = options?.contract
|
|
590
|
+
if (!contract || typeof contract.name !== 'string') {
|
|
591
|
+
throw new TypeError('consumer expects options with a contract')
|
|
592
|
+
}
|
|
593
|
+
const consumerName = options.name
|
|
594
|
+
if (typeof consumerName !== 'string' || !CONSUMER_NAME.test(consumerName)) {
|
|
595
|
+
throw new TypeError(
|
|
596
|
+
'consumer name must be 1-128 letters, digits, and . _ : - characters',
|
|
597
|
+
)
|
|
598
|
+
}
|
|
599
|
+
if (consumerName.startsWith('a2.') !== runtime) {
|
|
600
|
+
throw new TypeError(
|
|
601
|
+
runtime
|
|
602
|
+
? `A2's own consumers are named under 'a2.'`
|
|
603
|
+
: `consumer names starting with 'a2.' are reserved`,
|
|
604
|
+
)
|
|
605
|
+
}
|
|
606
|
+
const types = options.types === undefined ? null : [...options.types]
|
|
607
|
+
if (types?.length === 0) {
|
|
608
|
+
throw new TypeError(
|
|
609
|
+
`consumer '${consumerName}' lists no types; leave types out to take every type`,
|
|
610
|
+
)
|
|
611
|
+
}
|
|
612
|
+
for (const type of types ?? []) {
|
|
613
|
+
if (!Object.hasOwn(contract.events, type)) {
|
|
614
|
+
throw new TypeError(
|
|
615
|
+
`contract '${contract.name}' has no event type '${String(type)}'`,
|
|
616
|
+
)
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
const lane = options.lane ?? null
|
|
620
|
+
if (
|
|
621
|
+
lane !== null &&
|
|
622
|
+
typeof lane !== 'function' &&
|
|
623
|
+
(typeof lane !== 'string' || lane.length === 0)
|
|
624
|
+
) {
|
|
625
|
+
throw new TypeError(
|
|
626
|
+
`lane for consumer '${consumerName}' must be a non-empty string or a function`,
|
|
627
|
+
)
|
|
628
|
+
}
|
|
629
|
+
const backfill = options.backfill ?? false
|
|
630
|
+
if (backfill && typeof lane !== 'string') {
|
|
631
|
+
throw new TypeError(
|
|
632
|
+
`consumer '${consumerName}' backfills, so its lane must be a string`,
|
|
633
|
+
)
|
|
634
|
+
}
|
|
635
|
+
// Cancellation is for work in progress. Checking it while replaying
|
|
636
|
+
// history would read the rest of the log for every event replayed.
|
|
637
|
+
if (backfill && options.abortOn !== undefined) {
|
|
638
|
+
throw new TypeError(
|
|
639
|
+
`consumer '${consumerName}' backfills, so it can't use abortOn`,
|
|
640
|
+
)
|
|
641
|
+
}
|
|
642
|
+
return {
|
|
643
|
+
run(fn) {
|
|
644
|
+
if (typeof fn !== 'function') {
|
|
645
|
+
throw new TypeError(`consumer '${consumerName}' needs a run function`)
|
|
646
|
+
}
|
|
647
|
+
const defined: Consumer<D> = {
|
|
648
|
+
name: consumerName,
|
|
649
|
+
contract: { name: contract.name },
|
|
650
|
+
spec: {
|
|
651
|
+
types,
|
|
652
|
+
lane: lane as ConsumerLane<D> | null,
|
|
653
|
+
backfill,
|
|
654
|
+
abortOn: (options.abortOn ?? null) as AbortSpec<
|
|
655
|
+
D,
|
|
656
|
+
keyof D & string
|
|
657
|
+
> | null,
|
|
658
|
+
run: fn as unknown as (ctx: ConsumerContext<D>) => ConsumerResult<D>,
|
|
659
|
+
},
|
|
660
|
+
}
|
|
661
|
+
if (runtime) runtimeConsumers.add(defined)
|
|
662
|
+
return defined
|
|
663
|
+
},
|
|
664
|
+
}
|
|
471
665
|
}
|
|
666
|
+
runtimeConsumerFactory.define = (options) =>
|
|
667
|
+
defineConsumer(options as Parameters<typeof consumer>[0], true)
|
|
668
|
+
|
|
669
|
+
/** A registered consumer as the store needs to know it. */
|
|
670
|
+
const storeConsumerOf = (registered: {
|
|
671
|
+
name: string
|
|
672
|
+
log: StoreConsumer['log']
|
|
673
|
+
}): StoreConsumer =>
|
|
674
|
+
registered.log
|
|
675
|
+
? { name: registered.name, log: registered.log }
|
|
676
|
+
: { name: registered.name }
|
|
472
677
|
|
|
473
678
|
export type ServerOptions<
|
|
474
679
|
D extends EventDefs,
|
|
475
680
|
P extends PresenceDefs = Record<never, never>,
|
|
476
681
|
R extends RegisteredReducer<D, P> | undefined =
|
|
477
682
|
RegisteredReducer<D, P> | undefined,
|
|
683
|
+
C extends readonly unknown[] = readonly Consumer<D>[],
|
|
478
684
|
> = {
|
|
479
685
|
/** The contract this server implements (see `a2.contract`). */
|
|
480
686
|
contract: Contract<D, P, R>
|
|
@@ -496,16 +702,39 @@ export type ServerOptions<
|
|
|
496
702
|
*/
|
|
497
703
|
presence?: { ttlMs?: number }
|
|
498
704
|
/**
|
|
499
|
-
* The
|
|
500
|
-
*
|
|
501
|
-
*
|
|
502
|
-
* a duplicate key under spread silently last-wins).
|
|
705
|
+
* The consumers, each a named reader of every session's log with its own
|
|
706
|
+
* cursors: a `consumer()`, or its options and `run` written inline, on this
|
|
707
|
+
* contract. Names are unique; a duplicate throws.
|
|
503
708
|
*/
|
|
504
|
-
|
|
709
|
+
consumers?: ServerConsumers<D, P, R, C> | readonly Consumer<D>[]
|
|
505
710
|
}
|
|
506
711
|
|
|
507
712
|
const MAX_FAILURES = 10
|
|
508
713
|
|
|
714
|
+
/** How long `consume` waits before looking again at a head it can't run. */
|
|
715
|
+
const CONSUME_WAIT_MIN_MS = 25
|
|
716
|
+
const CONSUME_WAIT_MAX_MS = 1_000
|
|
717
|
+
|
|
718
|
+
/**
|
|
719
|
+
* `task`, or a rejection with `signal`'s reason once it aborts. The task runs
|
|
720
|
+
* on either way, and its own failure is caught, so nothing goes unhandled.
|
|
721
|
+
*/
|
|
722
|
+
const untilAborted = <T>(
|
|
723
|
+
task: Promise<T>,
|
|
724
|
+
signal: AbortSignal | undefined,
|
|
725
|
+
): Promise<T> => {
|
|
726
|
+
if (!signal) return task
|
|
727
|
+
task.catch(() => {})
|
|
728
|
+
if (signal.aborted) return Promise.reject(signal.reason)
|
|
729
|
+
return new Promise<T>((resolve, reject) => {
|
|
730
|
+
const abort = (): void => reject(signal.reason)
|
|
731
|
+
signal.addEventListener('abort', abort, { once: true })
|
|
732
|
+
task.then(resolve, reject).finally(() => {
|
|
733
|
+
signal.removeEventListener('abort', abort)
|
|
734
|
+
})
|
|
735
|
+
})
|
|
736
|
+
}
|
|
737
|
+
|
|
509
738
|
/** How long an event waits after its `failureCount`th failed attempt. */
|
|
510
739
|
const failureRetryDelayMs = (failureCount: number): number =>
|
|
511
740
|
Math.min(
|
|
@@ -513,7 +742,7 @@ const failureRetryDelayMs = (failureCount: number): number =>
|
|
|
513
742
|
DRAIN_TIMINGS.failureRetryMaxMs,
|
|
514
743
|
)
|
|
515
744
|
|
|
516
|
-
/** Abort a lost attempt's
|
|
745
|
+
/** Abort a lost attempt's consumer and release its drain slot. */
|
|
517
746
|
function endSuperseded(
|
|
518
747
|
entry: {
|
|
519
748
|
supersede: AbortController
|
|
@@ -725,30 +954,47 @@ function foldPresenceRows(
|
|
|
725
954
|
|
|
726
955
|
type SnapshotWrites = Map<
|
|
727
956
|
number,
|
|
728
|
-
{ state: unknown;
|
|
957
|
+
{ state: unknown; pins: Map<string, HeadRef> }
|
|
729
958
|
>
|
|
730
959
|
|
|
960
|
+
/** One cursor head, as a map key. */
|
|
961
|
+
const headKey = (ref: HeadRef): string =>
|
|
962
|
+
JSON.stringify([ref.consumer, ref.lane, ref.index])
|
|
963
|
+
|
|
964
|
+
const headRefOf = (head: HeadEvent): HeadRef => ({
|
|
965
|
+
consumer: head.consumer,
|
|
966
|
+
lane: head.lane,
|
|
967
|
+
index: head.index,
|
|
968
|
+
})
|
|
969
|
+
|
|
731
970
|
const addSnapshotWrite = (
|
|
732
971
|
writes: SnapshotWrites,
|
|
733
972
|
index: number,
|
|
734
973
|
state: unknown,
|
|
735
|
-
|
|
974
|
+
pin?: HeadRef,
|
|
736
975
|
): void => {
|
|
737
976
|
let write = writes.get(index)
|
|
738
977
|
if (!write) {
|
|
739
|
-
write = { state: cloneInitial(state),
|
|
978
|
+
write = { state: cloneInitial(state), pins: new Map() }
|
|
740
979
|
writes.set(index, write)
|
|
741
980
|
}
|
|
742
|
-
if (
|
|
981
|
+
if (pin !== undefined) write.pins.set(headKey(pin), pin)
|
|
743
982
|
}
|
|
744
983
|
|
|
984
|
+
/** Whether a registered consumer runs events of this type. */
|
|
985
|
+
const takes = (
|
|
986
|
+
registered: { readonly types: ReadonlySet<string> | null },
|
|
987
|
+
type: string,
|
|
988
|
+
): boolean => registered.types === null || registered.types.has(type)
|
|
989
|
+
|
|
745
990
|
/** Implement a contract: bind its vocabulary to storage and reactions. */
|
|
746
991
|
export function createServer<
|
|
747
992
|
D extends EventDefs,
|
|
748
993
|
P extends PresenceDefs = Record<never, never>,
|
|
749
994
|
R extends RegisteredReducer<D, P> | undefined =
|
|
750
995
|
RegisteredReducer<D, P> | undefined,
|
|
751
|
-
|
|
996
|
+
const C extends readonly unknown[] = readonly Consumer<D>[],
|
|
997
|
+
>(options: ServerOptions<D, P, R, C>): A2Server<D, P, R> {
|
|
752
998
|
const serverContract = options?.contract
|
|
753
999
|
if (
|
|
754
1000
|
serverContract === null ||
|
|
@@ -760,6 +1006,12 @@ export function createServer<
|
|
|
760
1006
|
'createServer expects options with a contract (see a2.contract)',
|
|
761
1007
|
)
|
|
762
1008
|
}
|
|
1009
|
+
// Ignored, a handler table would run nothing and say nothing.
|
|
1010
|
+
if (Object.hasOwn(options, 'handlers')) {
|
|
1011
|
+
throw new TypeError(
|
|
1012
|
+
'createServer no longer takes handlers: write each as a consumer in `consumers` (see https://www.a2.dev/upgrades/0-21)',
|
|
1013
|
+
)
|
|
1014
|
+
}
|
|
763
1015
|
const name = serverContract.name
|
|
764
1016
|
const defs = serverContract.events
|
|
765
1017
|
const presenceDefs: Readonly<PresenceDefs> = serverContract.presence
|
|
@@ -848,24 +1100,11 @@ export function createServer<
|
|
|
848
1100
|
context: { attempt: number },
|
|
849
1101
|
) => boolean)
|
|
850
1102
|
>
|
|
851
|
-
const
|
|
852
|
-
string,
|
|
853
|
-
|
|
854
|
-
>()
|
|
855
|
-
for (const [type, entry] of Object.entries(options.handlers ?? {})) {
|
|
856
|
-
if (entry === undefined) continue
|
|
857
|
-
if (!Object.hasOwn(defs, type)) {
|
|
858
|
-
throw new TypeError(
|
|
859
|
-
`contract '${name}' has no event type '${type}' — cannot register a handler for it`,
|
|
860
|
-
)
|
|
861
|
-
}
|
|
862
|
-
const callback = typeof entry === 'function' ? entry : entry?.handler
|
|
863
|
-
if (typeof callback !== 'function') {
|
|
864
|
-
throw new TypeError(`handler for '${type}' must be a function`)
|
|
865
|
-
}
|
|
1103
|
+
const abortMatcherOf = (
|
|
1104
|
+
spec: AbortSpec<D, keyof D & string> | null | undefined,
|
|
1105
|
+
): AbortMatcher | null => {
|
|
866
1106
|
let abortOn: AbortMatcher | null = null
|
|
867
|
-
|
|
868
|
-
if (spec !== undefined) {
|
|
1107
|
+
if (spec !== undefined && spec !== null) {
|
|
869
1108
|
abortOn = new Map()
|
|
870
1109
|
const pairs = Array.isArray(spec)
|
|
871
1110
|
? spec.map((abortType) => [abortType, true as const] as const)
|
|
@@ -893,22 +1132,117 @@ export function createServer<
|
|
|
893
1132
|
}
|
|
894
1133
|
if (abortOn.size === 0) abortOn = null
|
|
895
1134
|
}
|
|
896
|
-
|
|
1135
|
+
return abortOn
|
|
1136
|
+
}
|
|
1137
|
+
|
|
1138
|
+
/** A consumer as this server runs it. */
|
|
1139
|
+
type RegisteredConsumer = {
|
|
1140
|
+
name: string
|
|
1141
|
+
types: ReadonlySet<string> | null
|
|
1142
|
+
/** Lane-function and concurrent consumers; log consumers resolve none. */
|
|
1143
|
+
laneOf: ((sessionId: string, event: AppendEvent) => string | null) | null
|
|
1144
|
+
log: StoreConsumer['log']
|
|
1145
|
+
abortOn: (type: string) => AbortMatcher | null
|
|
1146
|
+
run: (ctx: ConsumerContext<D>) => Promise<unknown>
|
|
1147
|
+
/** A2's own consumers: child and timer ids leave out the name. */
|
|
1148
|
+
anonymousIds: boolean
|
|
1149
|
+
}
|
|
1150
|
+
const laneContext = (sessionId: string, event: AppendEvent): LaneContext<D> =>
|
|
1151
|
+
({
|
|
1152
|
+
sessionId,
|
|
1153
|
+
event: {
|
|
1154
|
+
type: event.type,
|
|
1155
|
+
payload: structuredClone(event.payload),
|
|
1156
|
+
...(event.id === undefined ? {} : { id: event.id }),
|
|
1157
|
+
},
|
|
1158
|
+
}) as LaneContext<D>
|
|
1159
|
+
const registeredConsumers = new Map<string, RegisteredConsumer>()
|
|
1160
|
+
/** A consumer definition as the server runs it. */
|
|
1161
|
+
const registrationOf = (defined: Consumer<D>): RegisteredConsumer => {
|
|
1162
|
+
const { types, lane, backfill, abortOn, run } = defined.spec
|
|
1163
|
+
const matcher = abortMatcherOf(abortOn)
|
|
1164
|
+
return {
|
|
1165
|
+
name: defined.name,
|
|
1166
|
+
types: types === null ? null : new Set(types),
|
|
1167
|
+
laneOf:
|
|
1168
|
+
typeof lane === 'string'
|
|
1169
|
+
? null
|
|
1170
|
+
: lane === null
|
|
1171
|
+
? () => null
|
|
1172
|
+
: (sessionId, event) => {
|
|
1173
|
+
const resolved = lane(laneContext(sessionId, event))
|
|
1174
|
+
if (
|
|
1175
|
+
resolved !== null &&
|
|
1176
|
+
(typeof resolved !== 'string' || resolved.length === 0)
|
|
1177
|
+
) {
|
|
1178
|
+
throw new TypeError(
|
|
1179
|
+
`lane for consumer '${defined.name}' must resolve to a non-empty string or null`,
|
|
1180
|
+
)
|
|
1181
|
+
}
|
|
1182
|
+
return resolved
|
|
1183
|
+
},
|
|
1184
|
+
log:
|
|
1185
|
+
typeof lane === 'string'
|
|
1186
|
+
? { lane, types: types === null ? null : [...types], backfill }
|
|
1187
|
+
: undefined,
|
|
1188
|
+
abortOn: () => matcher,
|
|
1189
|
+
run: run as (ctx: ConsumerContext<D>) => Promise<unknown>,
|
|
1190
|
+
anonymousIds: runtimeConsumers.has(defined),
|
|
1191
|
+
}
|
|
1192
|
+
}
|
|
1193
|
+
/** The definitions the server runs, so `consume` can tell its own from another. */
|
|
1194
|
+
const definitions = new Map<string, Consumer<D>>()
|
|
1195
|
+
/** A consumer written inline is `consumer()`'s options and `run`, on this contract. */
|
|
1196
|
+
const definitionOf = (entry: unknown): Consumer<D> => {
|
|
897
1197
|
if (
|
|
898
|
-
|
|
899
|
-
typeof
|
|
900
|
-
|
|
1198
|
+
entry === null ||
|
|
1199
|
+
typeof entry !== 'object' ||
|
|
1200
|
+
'spec' in entry ||
|
|
1201
|
+
!('run' in entry)
|
|
901
1202
|
) {
|
|
1203
|
+
return entry as Consumer<D>
|
|
1204
|
+
}
|
|
1205
|
+
const { run, ...rest } = entry as InlineConsumer<D, P, R, keyof D & string>
|
|
1206
|
+
return consumer({ ...rest, contract: serverContract }).run(run)
|
|
1207
|
+
}
|
|
1208
|
+
for (const entry of (options.consumers ?? []) as readonly unknown[]) {
|
|
1209
|
+
const defined = definitionOf(entry)
|
|
1210
|
+
if (defined?.contract?.name !== name || !defined.spec) {
|
|
902
1211
|
throw new TypeError(
|
|
903
|
-
`
|
|
1212
|
+
`createServer for '${name}' got a consumer of another contract`,
|
|
904
1213
|
)
|
|
905
1214
|
}
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
1215
|
+
if (registeredConsumers.has(defined.name)) {
|
|
1216
|
+
throw new TypeError(`two consumers are named '${defined.name}'`)
|
|
1217
|
+
}
|
|
1218
|
+
registeredConsumers.set(defined.name, registrationOf(defined))
|
|
1219
|
+
definitions.set(defined.name, defined)
|
|
1220
|
+
}
|
|
1221
|
+
// Runtime consumers leave their name out of child ids, so no event may
|
|
1222
|
+
// start two of them: their first returned events would share an id.
|
|
1223
|
+
const anonymousTypes = new Map<string, string>()
|
|
1224
|
+
for (const registered of registeredConsumers.values()) {
|
|
1225
|
+
if (!registered.anonymousIds) continue
|
|
1226
|
+
for (const type of registered.types ?? Object.keys(defs)) {
|
|
1227
|
+
const other = anonymousTypes.get(type)
|
|
1228
|
+
if (other !== undefined) {
|
|
1229
|
+
throw new TypeError(
|
|
1230
|
+
`A2's consumers '${other}' and '${registered.name}' both take '${type}'`,
|
|
1231
|
+
)
|
|
1232
|
+
}
|
|
1233
|
+
anonymousTypes.set(type, registered.name)
|
|
1234
|
+
}
|
|
911
1235
|
}
|
|
1236
|
+
/** The consumers this server serves, as the store needs to know them. */
|
|
1237
|
+
const storeConsumers: StoreConsumer[] = [...registeredConsumers.values()].map(
|
|
1238
|
+
storeConsumerOf,
|
|
1239
|
+
)
|
|
1240
|
+
/** Whether an event starts cursor work for any consumer. */
|
|
1241
|
+
const startsWork = (event: AppendEvent): boolean =>
|
|
1242
|
+
(event.targets?.length ?? 0) > 0 ||
|
|
1243
|
+
[...registeredConsumers.values()].some(
|
|
1244
|
+
(registered) => registered.log && takes(registered, event.type),
|
|
1245
|
+
)
|
|
912
1246
|
|
|
913
1247
|
const prefix = `${name}${NS}`
|
|
914
1248
|
const nsId = (sessionId: string): string => `${prefix}${sessionId}`
|
|
@@ -1048,28 +1382,15 @@ export function createServer<
|
|
|
1048
1382
|
const validated: AppendEvent = { type: e.type, payload: result.value }
|
|
1049
1383
|
if (e.id !== undefined) validated.id = e.id
|
|
1050
1384
|
if (!resolveDispatchMetadata) return validated
|
|
1051
|
-
const
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
sessionId,
|
|
1059
|
-
event: {
|
|
1060
|
-
type: e.type,
|
|
1061
|
-
payload: structuredClone(result.value),
|
|
1062
|
-
...(e.id === undefined ? {} : { id: e.id }),
|
|
1063
|
-
} as never,
|
|
1064
|
-
})
|
|
1065
|
-
: registration.lane
|
|
1066
|
-
if (typeof lane !== 'string' || lane.length === 0) {
|
|
1067
|
-
throw new TypeError(
|
|
1068
|
-
`lane for '${e.type}' must resolve to a non-empty string`,
|
|
1069
|
-
)
|
|
1070
|
-
}
|
|
1071
|
-
validated.lane = lane
|
|
1385
|
+
const targets: EventTarget[] = []
|
|
1386
|
+
for (const registered of registeredConsumers.values()) {
|
|
1387
|
+
if (!registered.laneOf || !takes(registered, e.type)) continue
|
|
1388
|
+
targets.push({
|
|
1389
|
+
consumer: registered.name,
|
|
1390
|
+
lane: registered.laneOf(sessionId, validated),
|
|
1391
|
+
})
|
|
1072
1392
|
}
|
|
1393
|
+
if (targets.length > 0) validated.targets = targets
|
|
1073
1394
|
return validated
|
|
1074
1395
|
})
|
|
1075
1396
|
}
|
|
@@ -1082,7 +1403,7 @@ export function createServer<
|
|
|
1082
1403
|
const appendCore = (
|
|
1083
1404
|
sessionId: string,
|
|
1084
1405
|
events: AppendInput<D>[],
|
|
1085
|
-
source: 'external' | '
|
|
1406
|
+
source: 'external' | 'consumer',
|
|
1086
1407
|
mode: 'inline' | 'dispatch',
|
|
1087
1408
|
onAppended: (rows: ContractEvent<D>[], watchdogDueAt?: number) => void,
|
|
1088
1409
|
cause?: EventCause,
|
|
@@ -1119,20 +1440,16 @@ export function createServer<
|
|
|
1119
1440
|
let rows: StoredEvent[]
|
|
1120
1441
|
let shouldHealSession: boolean
|
|
1121
1442
|
try {
|
|
1122
|
-
const result = await store.append(
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
)
|
|
1443
|
+
const result = await store.append(nsId(sessionId), validated, {
|
|
1444
|
+
consumers: storeConsumers,
|
|
1445
|
+
...(originIndex === undefined ? {} : { originIndex }),
|
|
1446
|
+
})
|
|
1127
1447
|
rows = result.events
|
|
1128
|
-
// A
|
|
1129
|
-
// drain holding its trigger is already running.
|
|
1448
|
+
// A consumer appending events no consumer takes adds no work, and
|
|
1449
|
+
// the drain holding its trigger is already running.
|
|
1130
1450
|
shouldHealSession =
|
|
1131
1451
|
result.hasPending &&
|
|
1132
|
-
!(
|
|
1133
|
-
source === 'handler' &&
|
|
1134
|
-
validated.every((event) => event.settled === true)
|
|
1135
|
-
)
|
|
1452
|
+
!(source === 'consumer' && !validated.some(startsWork))
|
|
1136
1453
|
} catch (err) {
|
|
1137
1454
|
throw asStoreUnavailable(err)
|
|
1138
1455
|
}
|
|
@@ -1243,8 +1560,8 @@ export function createServer<
|
|
|
1243
1560
|
throughIndex?: number
|
|
1244
1561
|
/** The id of the event at `throughIndex`, when the reader knows it. */
|
|
1245
1562
|
throughEventId?: string
|
|
1246
|
-
|
|
1247
|
-
/** A
|
|
1563
|
+
pin?: HeadRef
|
|
1564
|
+
/** A consumer read: rejected once this trigger index has been cleared. */
|
|
1248
1565
|
triggerIndex?: number
|
|
1249
1566
|
span: A2SpanHandle
|
|
1250
1567
|
resolve: (result: StateResult) => void
|
|
@@ -1389,9 +1706,7 @@ export function createServer<
|
|
|
1389
1706
|
index,
|
|
1390
1707
|
state: encodeState({ reducer, state: write.state }),
|
|
1391
1708
|
}
|
|
1392
|
-
if (write.
|
|
1393
|
-
snapshot.pinEventIndexes = [...write.pinEventIndexes]
|
|
1394
|
-
}
|
|
1709
|
+
if (write.pins.size > 0) snapshot.pins = [...write.pins.values()]
|
|
1395
1710
|
return snapshot
|
|
1396
1711
|
})
|
|
1397
1712
|
if (snapshots.length > 0) {
|
|
@@ -1504,7 +1819,7 @@ export function createServer<
|
|
|
1504
1819
|
/**
|
|
1505
1820
|
* Serves a plan from the cached state when every read is at or past the
|
|
1506
1821
|
* cached index. A read bounded at the cached index by an event whose id
|
|
1507
|
-
* matches the cached one (a
|
|
1822
|
+
* matches the cached one (a consumer's own trigger) needs no store request;
|
|
1508
1823
|
* any other read fetches the tail from the cached event onward and checks
|
|
1509
1824
|
* that event's id. Returns false when the store must answer instead.
|
|
1510
1825
|
*/
|
|
@@ -1561,8 +1876,8 @@ export function createServer<
|
|
|
1561
1876
|
clearIndex: cached.clearIndex,
|
|
1562
1877
|
snapshot: 'memory',
|
|
1563
1878
|
visit: (entry, state, index) => {
|
|
1564
|
-
if (entry.
|
|
1565
|
-
addSnapshotWrite(writes, index, state, entry.
|
|
1879
|
+
if (entry.pin !== undefined) {
|
|
1880
|
+
addSnapshotWrite(writes, index, state, entry.pin)
|
|
1566
1881
|
}
|
|
1567
1882
|
},
|
|
1568
1883
|
})
|
|
@@ -1572,7 +1887,7 @@ export function createServer<
|
|
|
1572
1887
|
if (rewritesHead && !writes.has(folded.index)) {
|
|
1573
1888
|
writes.set(folded.index, {
|
|
1574
1889
|
state: folded.state,
|
|
1575
|
-
|
|
1890
|
+
pins: new Map(),
|
|
1576
1891
|
})
|
|
1577
1892
|
}
|
|
1578
1893
|
const snapshots = writeSnapshots({
|
|
@@ -1673,14 +1988,13 @@ export function createServer<
|
|
|
1673
1988
|
snapshot: snapshotOutcome,
|
|
1674
1989
|
visit: (entry, entryState, entryIndex, count) => {
|
|
1675
1990
|
if (entryIndex === 0) return
|
|
1676
|
-
if (count === 0 && entry.
|
|
1991
|
+
if (count === 0 && entry.pin === undefined) return
|
|
1677
1992
|
const advancesHead =
|
|
1678
1993
|
stateRead.headIndex === null || entryIndex > stateRead.headIndex
|
|
1679
1994
|
const repairsHead =
|
|
1680
1995
|
snapshotOutcome === 'rejected' && entryIndex === stateRead.headIndex
|
|
1681
|
-
if (!advancesHead && !repairsHead && entry.
|
|
1682
|
-
|
|
1683
|
-
addSnapshotWrite(writes, entryIndex, entryState, entry.pinEventIndex)
|
|
1996
|
+
if (!advancesHead && !repairsHead && entry.pin === undefined) return
|
|
1997
|
+
addSnapshotWrite(writes, entryIndex, entryState, entry.pin)
|
|
1684
1998
|
},
|
|
1685
1999
|
})
|
|
1686
2000
|
const snapshots = writeSnapshots({
|
|
@@ -1776,7 +2090,7 @@ export function createServer<
|
|
|
1776
2090
|
type StateReadBounds = {
|
|
1777
2091
|
throughIndex?: number
|
|
1778
2092
|
throughEventId?: string
|
|
1779
|
-
|
|
2093
|
+
pin?: HeadRef
|
|
1780
2094
|
triggerIndex?: number
|
|
1781
2095
|
}
|
|
1782
2096
|
|
|
@@ -1795,7 +2109,7 @@ export function createServer<
|
|
|
1795
2109
|
pendingStateReads.set(reducerName, opened)
|
|
1796
2110
|
// One microtask is the whole batching window — deliberately NOT
|
|
1797
2111
|
// DataLoader's post-promise-job nextTick, which would defer
|
|
1798
|
-
//
|
|
2112
|
+
// consumer-internal reads past the entire microtask cascade and
|
|
1799
2113
|
// reorder drain interleaving (and has no edge-runtime primitive).
|
|
1800
2114
|
// It still coalesces reliably: siblings of one Promise.all
|
|
1801
2115
|
// traverse identical awaits (store resolution, span wrapper), so
|
|
@@ -1837,12 +2151,14 @@ export function createServer<
|
|
|
1837
2151
|
|
|
1838
2152
|
const makeSchedule = (
|
|
1839
2153
|
sessionId: string,
|
|
1840
|
-
trigger:
|
|
1841
|
-
StoredEvent,
|
|
1842
|
-
'id' | 'createdAt' | 'index' | 'clearIndex'
|
|
1843
|
-
> | null,
|
|
2154
|
+
trigger: HeadEvent | null,
|
|
1844
2155
|
): SessionSchedule<D> => {
|
|
1845
2156
|
const triggerEventId = trigger?.id ?? null
|
|
2157
|
+
/** Timers a consumer sets key on its name, except A2's own. */
|
|
2158
|
+
const triggerConsumer =
|
|
2159
|
+
trigger && !registeredConsumers.get(trigger.consumer)?.anonymousIds
|
|
2160
|
+
? trigger.consumer
|
|
2161
|
+
: undefined
|
|
1846
2162
|
const triggerCreatedAt = trigger?.createdAt.getTime()
|
|
1847
2163
|
const clearIndex = trigger?.clearIndex ?? 0
|
|
1848
2164
|
const schedule = async (
|
|
@@ -1879,6 +2195,7 @@ export function createServer<
|
|
|
1879
2195
|
triggerEventId,
|
|
1880
2196
|
scheduleName,
|
|
1881
2197
|
clearIndex,
|
|
2198
|
+
triggerConsumer,
|
|
1882
2199
|
)
|
|
1883
2200
|
const scheduledEvents: ScheduledEvent[] = await Promise.all(
|
|
1884
2201
|
validated.map(async (event, item) => ({
|
|
@@ -1944,6 +2261,7 @@ export function createServer<
|
|
|
1944
2261
|
clearIndex,
|
|
1945
2262
|
name: scheduleName,
|
|
1946
2263
|
ifLastIndex,
|
|
2264
|
+
...(triggerConsumer === undefined ? {} : { consumer: triggerConsumer }),
|
|
1947
2265
|
})
|
|
1948
2266
|
try {
|
|
1949
2267
|
await scheduler.schedule({
|
|
@@ -2123,7 +2441,7 @@ export function createServer<
|
|
|
2123
2441
|
{
|
|
2124
2442
|
seen: patch.seen ?? fallbackSeen,
|
|
2125
2443
|
// The sender's stamp is the LWW order; receipt time is the
|
|
2126
|
-
// fallback for stampless callers (
|
|
2444
|
+
// fallback for stampless callers (consumers, old wire
|
|
2127
2445
|
// clients), where receipt order is sender order — single
|
|
2128
2446
|
// writer. Storage anchors expiry on its own clock either way.
|
|
2129
2447
|
at: new Date(patch.at ?? Date.now()),
|
|
@@ -2213,11 +2531,13 @@ export function createServer<
|
|
|
2213
2531
|
}
|
|
2214
2532
|
|
|
2215
2533
|
// What this feed has already painted, per (participant, field) —
|
|
2216
|
-
// the comparison state for re-read diffs
|
|
2534
|
+
// the comparison state for re-read diffs and for queued patches,
|
|
2535
|
+
// with the stamps of painted nulls kept beside it.
|
|
2217
2536
|
const painted = new Map<
|
|
2218
2537
|
string,
|
|
2219
2538
|
Map<string, { seen: number; atMs: number; value: string }>
|
|
2220
2539
|
>()
|
|
2540
|
+
const paintedDeletes = presenceTombstones()
|
|
2221
2541
|
|
|
2222
2542
|
// One patch per (participant, field), never merged — a merged
|
|
2223
2543
|
// stamp would let a stale field inherit a fresher sibling's
|
|
@@ -2266,10 +2586,12 @@ export function createServer<
|
|
|
2266
2586
|
patches.push({
|
|
2267
2587
|
participant,
|
|
2268
2588
|
values: { [field]: null },
|
|
2269
|
-
// A cleared field has no surviving row
|
|
2270
|
-
//
|
|
2589
|
+
// A cleared field has no surviving row, so it keeps its
|
|
2590
|
+
// last painted stamps: only senders' clocks order the
|
|
2591
|
+
// plane, and an observer-stamped delete could outrank the
|
|
2592
|
+
// sender's next set.
|
|
2271
2593
|
seen: prior.seen,
|
|
2272
|
-
at: new Date(),
|
|
2594
|
+
at: new Date(prior.atMs),
|
|
2273
2595
|
})
|
|
2274
2596
|
}
|
|
2275
2597
|
if (fields.size === 0) painted.delete(participant)
|
|
@@ -2280,10 +2602,13 @@ export function createServer<
|
|
|
2280
2602
|
const paint = (patch: PresencePatch): void => {
|
|
2281
2603
|
let fields = painted.get(patch.participant)
|
|
2282
2604
|
for (const [field, value] of Object.entries(patch.values)) {
|
|
2605
|
+
const write = { participant: patch.participant, field, at: patch.at }
|
|
2283
2606
|
if (value === null || value === undefined) {
|
|
2284
2607
|
fields?.delete(field)
|
|
2608
|
+
if (value === null) paintedDeletes.keep(write)
|
|
2285
2609
|
continue
|
|
2286
2610
|
}
|
|
2611
|
+
paintedDeletes.drop(write)
|
|
2287
2612
|
if (!fields) {
|
|
2288
2613
|
fields = new Map()
|
|
2289
2614
|
painted.set(patch.participant, fields)
|
|
@@ -2328,11 +2653,23 @@ export function createServer<
|
|
|
2328
2653
|
}
|
|
2329
2654
|
wakeQueue?.()
|
|
2330
2655
|
}
|
|
2656
|
+
// A queued patch older than what this feed painted for its field —
|
|
2657
|
+
// the live value or its deletion — would repaint the past.
|
|
2658
|
+
const outdated = (
|
|
2659
|
+
write: Pick<PresenceRow, 'participant' | 'field' | 'at'>,
|
|
2660
|
+
): boolean => {
|
|
2661
|
+
const prior = painted.get(write.participant)?.get(write.field)
|
|
2662
|
+
return (
|
|
2663
|
+
(prior !== undefined && prior.atMs > write.at.getTime()) ||
|
|
2664
|
+
paintedDeletes.outranks(write)
|
|
2665
|
+
)
|
|
2666
|
+
}
|
|
2331
2667
|
const dequeue = (): PresencePatch | null => {
|
|
2332
2668
|
for (const [participant, fields] of queue) {
|
|
2333
2669
|
for (const [field, stamp] of fields) {
|
|
2334
2670
|
fields.delete(field)
|
|
2335
2671
|
if (fields.size === 0) queue.delete(participant)
|
|
2672
|
+
if (outdated({ participant, field, at: stamp.at })) continue
|
|
2336
2673
|
return {
|
|
2337
2674
|
participant,
|
|
2338
2675
|
values: { [field]: stamp.value },
|
|
@@ -2498,7 +2835,7 @@ export function createServer<
|
|
|
2498
2835
|
}
|
|
2499
2836
|
}
|
|
2500
2837
|
|
|
2501
|
-
/** The
|
|
2838
|
+
/** The consumer attempt a `ctx.session.clear()` belongs to. */
|
|
2502
2839
|
type ClearingAttempt = {
|
|
2503
2840
|
cause: EventCause
|
|
2504
2841
|
end: () => void
|
|
@@ -2531,7 +2868,7 @@ export function createServer<
|
|
|
2531
2868
|
}
|
|
2532
2869
|
if (result.cleared) {
|
|
2533
2870
|
// This instance's own reads after the clear, including the clearing
|
|
2534
|
-
//
|
|
2871
|
+
// consumer's, go to the store and see the boundary.
|
|
2535
2872
|
const cachePrefix = stateCacheKey(sessionId, '')
|
|
2536
2873
|
stateCache.deleteWhere((key) => key.startsWith(cachePrefix))
|
|
2537
2874
|
}
|
|
@@ -2566,7 +2903,7 @@ export function createServer<
|
|
|
2566
2903
|
sessionId: string,
|
|
2567
2904
|
): Promise<DeadLetter<D>[]> => {
|
|
2568
2905
|
const store = await redrivingStore('deadLetters')
|
|
2569
|
-
let rows:
|
|
2906
|
+
let rows: HeadEvent[]
|
|
2570
2907
|
try {
|
|
2571
2908
|
rows = await store.deadLetters(nsId(sessionId))
|
|
2572
2909
|
} catch (err) {
|
|
@@ -2578,6 +2915,7 @@ export function createServer<
|
|
|
2578
2915
|
: [
|
|
2579
2916
|
{
|
|
2580
2917
|
...toPublic(row),
|
|
2918
|
+
consumer: row.consumer,
|
|
2581
2919
|
lane: row.lane,
|
|
2582
2920
|
attemptCount: row.attemptCount,
|
|
2583
2921
|
failureCount: row.failureCount,
|
|
@@ -2590,10 +2928,15 @@ export function createServer<
|
|
|
2590
2928
|
|
|
2591
2929
|
const redriveEvent = async (
|
|
2592
2930
|
sessionId: string,
|
|
2593
|
-
index: number,
|
|
2931
|
+
target: { consumer: string; index: number },
|
|
2594
2932
|
redriveOptions: SessionRedriveOptions,
|
|
2595
2933
|
): Promise<SessionRedriveResult> => {
|
|
2934
|
+
const index = target?.index
|
|
2935
|
+
const redriven = target?.consumer
|
|
2596
2936
|
assertRedriveIndex(index)
|
|
2937
|
+
if (typeof redriven !== 'string' || redriven.length === 0) {
|
|
2938
|
+
throw new TypeError('redrive requires a consumer name')
|
|
2939
|
+
}
|
|
2597
2940
|
const action = redriveOptions?.action
|
|
2598
2941
|
if (action !== 'retry' && action !== 'skip') {
|
|
2599
2942
|
throw new TypeError("redrive requires action 'retry' or 'skip'")
|
|
@@ -2604,6 +2947,7 @@ export function createServer<
|
|
|
2604
2947
|
'a2.contract': name,
|
|
2605
2948
|
'a2.session_id': sessionId,
|
|
2606
2949
|
'a2.redrive.index': index,
|
|
2950
|
+
'a2.redrive.consumer': redriven,
|
|
2607
2951
|
'a2.redrive.action': action,
|
|
2608
2952
|
},
|
|
2609
2953
|
async (span) => {
|
|
@@ -2612,8 +2956,10 @@ export function createServer<
|
|
|
2612
2956
|
try {
|
|
2613
2957
|
result = await store.redrive({
|
|
2614
2958
|
sessionId: nsId(sessionId),
|
|
2959
|
+
consumer: redriven,
|
|
2615
2960
|
index,
|
|
2616
2961
|
action,
|
|
2962
|
+
consumers: storeConsumers,
|
|
2617
2963
|
})
|
|
2618
2964
|
} catch (err) {
|
|
2619
2965
|
throw asStoreUnavailable(err)
|
|
@@ -2644,18 +2990,169 @@ export function createServer<
|
|
|
2644
2990
|
)
|
|
2645
2991
|
}
|
|
2646
2992
|
|
|
2993
|
+
/**
|
|
2994
|
+
* `session.consume`: wakes the consumer's cursor, then drains only that
|
|
2995
|
+
* consumer until its head lies past `through`. A head another invocation
|
|
2996
|
+
* holds, or one waiting out a failure, is waited for; a dead-lettered head
|
|
2997
|
+
* throws.
|
|
2998
|
+
*/
|
|
2999
|
+
/** Per session, which consumer's running head is waiting in `consume` on which: a cycle never ends. */
|
|
3000
|
+
const consumeWaits = new Map<string, Map<string, string>>()
|
|
3001
|
+
|
|
3002
|
+
const consumeSession = (
|
|
3003
|
+
sessionId: string,
|
|
3004
|
+
defined: Consumer<D>,
|
|
3005
|
+
consumeOptions: SessionConsumeOptions = {},
|
|
3006
|
+
trigger: HeadEvent | null = null,
|
|
3007
|
+
): Promise<SessionConsumeResult> => {
|
|
3008
|
+
assertSessionId(sessionId)
|
|
3009
|
+
if (defined?.contract?.name !== name || !defined.spec) {
|
|
3010
|
+
throw new TypeError(
|
|
3011
|
+
`session.consume for '${name}' got a consumer of another contract`,
|
|
3012
|
+
)
|
|
3013
|
+
}
|
|
3014
|
+
const own = definitions.get(defined.name)
|
|
3015
|
+
if (own !== undefined && own !== defined) {
|
|
3016
|
+
throw new TypeError(
|
|
3017
|
+
`this server runs a different consumer named '${defined.name}'`,
|
|
3018
|
+
)
|
|
3019
|
+
}
|
|
3020
|
+
if (defined.name === trigger?.consumer) {
|
|
3021
|
+
throw new TypeError(
|
|
3022
|
+
`consumer '${defined.name}' can't consume itself: its own head is running`,
|
|
3023
|
+
)
|
|
3024
|
+
}
|
|
3025
|
+
const registered = own
|
|
3026
|
+
? registeredConsumers.get(defined.name)!
|
|
3027
|
+
: registrationOf(defined)
|
|
3028
|
+
const log = registered.log
|
|
3029
|
+
if (!log) {
|
|
3030
|
+
throw new TypeError(
|
|
3031
|
+
`consumer '${defined.name}' needs a string lane to be consumed: only then does it have one position`,
|
|
3032
|
+
)
|
|
3033
|
+
}
|
|
3034
|
+
const { through: requested, signal } = consumeOptions
|
|
3035
|
+
if (requested !== undefined) assertConsumeIndex(requested)
|
|
3036
|
+
// A run that waits on a consumer already waiting on it, here, would wait
|
|
3037
|
+
// forever. Checked and recorded before any await, so two can't both pass.
|
|
3038
|
+
// One that would close that cycle isn't recorded: it returns only if what
|
|
3039
|
+
// it asks for is done already, since the other's cursor can't move.
|
|
3040
|
+
const waiter = trigger?.consumer
|
|
3041
|
+
const waits =
|
|
3042
|
+
waiter === undefined
|
|
3043
|
+
? undefined
|
|
3044
|
+
: (consumeWaits.get(sessionId) ??
|
|
3045
|
+
consumeWaits.set(sessionId, new Map()).get(sessionId)!)
|
|
3046
|
+
let cycle = false
|
|
3047
|
+
if (waiter !== undefined && waits) {
|
|
3048
|
+
for (
|
|
3049
|
+
let next: string | undefined = defined.name;
|
|
3050
|
+
next !== undefined && !cycle;
|
|
3051
|
+
next = waits.get(next)
|
|
3052
|
+
) {
|
|
3053
|
+
cycle = next === waiter
|
|
3054
|
+
}
|
|
3055
|
+
if (!cycle) waits.set(waiter, defined.name)
|
|
3056
|
+
}
|
|
3057
|
+
const storeConsumer = { name: registered.name, log }
|
|
3058
|
+
const consumed = telemetry.span(
|
|
3059
|
+
'a2.consume',
|
|
3060
|
+
{
|
|
3061
|
+
'a2.contract': name,
|
|
3062
|
+
'a2.session_id': sessionId,
|
|
3063
|
+
'a2.consume.consumer': registered.name,
|
|
3064
|
+
},
|
|
3065
|
+
async (span) => {
|
|
3066
|
+
const store = await resolveStore()
|
|
3067
|
+
const ns = nsId(sessionId)
|
|
3068
|
+
let woken = await store.wakeLog({
|
|
3069
|
+
sessionId: ns,
|
|
3070
|
+
consumer: storeConsumer,
|
|
3071
|
+
})
|
|
3072
|
+
const through = Math.min(requested ?? woken.head, woken.head)
|
|
3073
|
+
span.setAttribute('a2.consume.through', through)
|
|
3074
|
+
const done = (): boolean =>
|
|
3075
|
+
woken.cursor.head === null || woken.cursor.head > through
|
|
3076
|
+
if (cycle && !done()) {
|
|
3077
|
+
throw new TypeError(
|
|
3078
|
+
`consumer '${waiter}' can't consume '${defined.name}' through ${through}: '${defined.name}' is waiting on it in session '${sessionId}'`,
|
|
3079
|
+
)
|
|
3080
|
+
}
|
|
3081
|
+
let drains = 0
|
|
3082
|
+
let wait = CONSUME_WAIT_MIN_MS
|
|
3083
|
+
while (!done()) {
|
|
3084
|
+
signal?.throwIfAborted()
|
|
3085
|
+
const { cursor } = woken
|
|
3086
|
+
if (cursor.failedAt !== null) {
|
|
3087
|
+
throw new A2Error(
|
|
3088
|
+
'CONSUMER_DEAD_LETTERED',
|
|
3089
|
+
`consumer '${registered.name}' is dead-lettered at event ${cursor.head} in session '${sessionId}': ${cursor.lastError ?? 'every attempt ended before it finished'}`,
|
|
3090
|
+
)
|
|
3091
|
+
}
|
|
3092
|
+
drains += 1
|
|
3093
|
+
const drained = drainSession(sessionId, { only: registered, through })
|
|
3094
|
+
try {
|
|
3095
|
+
await untilAborted(drained, signal)
|
|
3096
|
+
} catch (error) {
|
|
3097
|
+
// An abort stops the wait at once; a run in progress carries on,
|
|
3098
|
+
// and what it appends still reaches the server's consumers.
|
|
3099
|
+
if (signal?.aborted)
|
|
3100
|
+
void drained.then(
|
|
3101
|
+
() => scheduleDrain(sessionId),
|
|
3102
|
+
() => {},
|
|
3103
|
+
)
|
|
3104
|
+
throw error
|
|
3105
|
+
}
|
|
3106
|
+
const before = cursor
|
|
3107
|
+
woken = await store.wakeLog({
|
|
3108
|
+
sessionId: ns,
|
|
3109
|
+
consumer: storeConsumer,
|
|
3110
|
+
})
|
|
3111
|
+
const moved =
|
|
3112
|
+
woken.cursor.head !== before.head ||
|
|
3113
|
+
woken.cursor.attemptCount !== before.attemptCount
|
|
3114
|
+
if (done() || moved) {
|
|
3115
|
+
wait = CONSUME_WAIT_MIN_MS
|
|
3116
|
+
continue
|
|
3117
|
+
}
|
|
3118
|
+
// Another invocation holds the head, or it waits out a failure.
|
|
3119
|
+
const sleep = defaultSleep(wait)
|
|
3120
|
+
const abort = (): void => sleep.cancel()
|
|
3121
|
+
signal?.addEventListener('abort', abort, { once: true })
|
|
3122
|
+
await sleep.promise
|
|
3123
|
+
signal?.removeEventListener('abort', abort)
|
|
3124
|
+
wait = Math.min(wait * 2, CONSUME_WAIT_MAX_MS)
|
|
3125
|
+
}
|
|
3126
|
+
// Work past `through` carries on in the background, as after an append,
|
|
3127
|
+
// and so do the server's consumers of what this consumer's runs appended.
|
|
3128
|
+
if (drains > 0 || (own && woken.cursor.head !== null))
|
|
3129
|
+
scheduleDrain(sessionId)
|
|
3130
|
+
const index =
|
|
3131
|
+
woken.cursor.head === null ? woken.head : woken.cursor.head - 1
|
|
3132
|
+
span.setAttribute('a2.consume.index', index)
|
|
3133
|
+
span.setAttribute('a2.consume.drains', drains)
|
|
3134
|
+
return { index }
|
|
3135
|
+
},
|
|
3136
|
+
)
|
|
3137
|
+
return waiter === undefined || cycle
|
|
3138
|
+
? consumed
|
|
3139
|
+
: consumed.finally(() => {
|
|
3140
|
+
waits?.delete(waiter)
|
|
3141
|
+
if (waits?.size === 0) consumeWaits.delete(sessionId)
|
|
3142
|
+
})
|
|
3143
|
+
}
|
|
3144
|
+
|
|
2647
3145
|
const makeSession = <Append>(
|
|
2648
3146
|
id: string,
|
|
2649
3147
|
append: Append,
|
|
2650
|
-
trigger:
|
|
2651
|
-
StoredEvent,
|
|
2652
|
-
'id' | 'createdAt' | 'index' | 'clearIndex'
|
|
2653
|
-
> | null,
|
|
3148
|
+
trigger: HeadEvent | null,
|
|
2654
3149
|
attempt?: ClearingAttempt,
|
|
2655
3150
|
): Session<D, Append, P> =>
|
|
2656
3151
|
({
|
|
2657
3152
|
id,
|
|
2658
3153
|
append,
|
|
3154
|
+
consume: (defined: Consumer<D>, consumeOptions?: SessionConsumeOptions) =>
|
|
3155
|
+
consumeSession(id, defined, consumeOptions, trigger),
|
|
2659
3156
|
schedule: makeSchedule(id, trigger),
|
|
2660
3157
|
clear: (clearOptions?: SessionClearOptions) =>
|
|
2661
3158
|
clearSession(id, clearOptions, attempt),
|
|
@@ -2684,7 +3181,7 @@ export function createServer<
|
|
|
2684
3181
|
const bounds: StateReadBounds = {}
|
|
2685
3182
|
if (through !== undefined && through !== 'latest') {
|
|
2686
3183
|
bounds.throughIndex = through
|
|
2687
|
-
if (trigger) bounds.
|
|
3184
|
+
if (trigger) bounds.pin = headRefOf(trigger)
|
|
2688
3185
|
// Only a live attempt may be answered from memory with no store
|
|
2689
3186
|
// request; a superseded one (including by a clear) reads the store.
|
|
2690
3187
|
if (through === trigger?.index && !attempt?.signal.aborted)
|
|
@@ -2717,42 +3214,60 @@ export function createServer<
|
|
|
2717
3214
|
|
|
2718
3215
|
// ── drain ────────────────────────────────────────────────────────
|
|
2719
3216
|
|
|
3217
|
+
/** The consumer name a trigger's child ids key on, if any. */
|
|
3218
|
+
const idConsumerOf = (trigger: HeadEvent): string | undefined =>
|
|
3219
|
+
registeredConsumers.get(trigger.consumer)?.anonymousIds
|
|
3220
|
+
? undefined
|
|
3221
|
+
: trigger.consumer
|
|
3222
|
+
|
|
2720
3223
|
const makeCtx = (
|
|
2721
|
-
trigger:
|
|
3224
|
+
trigger: HeadEvent,
|
|
2722
3225
|
attempt: number,
|
|
2723
3226
|
signal: AbortSignal,
|
|
2724
3227
|
endAttempt: () => void,
|
|
2725
|
-
):
|
|
3228
|
+
): ConsumerContext<D> => {
|
|
2726
3229
|
const publicSessionId = stripNs(trigger.sessionId)
|
|
2727
|
-
const
|
|
3230
|
+
const idConsumer = idConsumerOf(trigger)
|
|
3231
|
+
const cause: EventCause = {
|
|
3232
|
+
consumer: trigger.consumer,
|
|
3233
|
+
lane: trigger.lane,
|
|
3234
|
+
index: trigger.index,
|
|
3235
|
+
attempt,
|
|
3236
|
+
}
|
|
3237
|
+
const append: ConsumerAppend<D> = async (appendName, ...events) => {
|
|
2728
3238
|
assertAppendName(appendName)
|
|
2729
3239
|
const generatedIds = events.map((event) => event.id === undefined)
|
|
2730
3240
|
const withIds = await Promise.all(
|
|
2731
3241
|
// oxlint-disable-next-line no-map-spread
|
|
2732
3242
|
events.map(async (event, item) => {
|
|
2733
3243
|
if (event.id !== undefined) return event
|
|
2734
|
-
const id = await deterministicEventId(
|
|
3244
|
+
const id = await deterministicEventId(
|
|
3245
|
+
trigger.id,
|
|
3246
|
+
appendName,
|
|
3247
|
+
item,
|
|
3248
|
+
idConsumer,
|
|
3249
|
+
)
|
|
2735
3250
|
return { ...event, id }
|
|
2736
3251
|
}),
|
|
2737
3252
|
)
|
|
2738
3253
|
return appendCore(
|
|
2739
3254
|
publicSessionId,
|
|
2740
3255
|
withIds,
|
|
2741
|
-
'
|
|
3256
|
+
'consumer',
|
|
2742
3257
|
'inline',
|
|
2743
3258
|
(_rows, watchdogDueAt) => scheduleDrain(publicSessionId, watchdogDueAt),
|
|
2744
|
-
|
|
3259
|
+
cause,
|
|
2745
3260
|
generatedIds,
|
|
2746
3261
|
)
|
|
2747
3262
|
}
|
|
2748
|
-
const ctx:
|
|
3263
|
+
const ctx: ConsumerContext<D> = {
|
|
2749
3264
|
event: toPublic(trigger),
|
|
2750
3265
|
attempt,
|
|
2751
|
-
// The
|
|
2752
|
-
// triggering event's index by default — the frontier the
|
|
3266
|
+
// The consumer-scoped `setPresence` stamps `seen` with the
|
|
3267
|
+
// triggering event's index by default — the frontier the consumer
|
|
2753
3268
|
// provably reflects.
|
|
2754
3269
|
session: makeSession(publicSessionId, append, trigger, {
|
|
2755
|
-
cause
|
|
3270
|
+
cause,
|
|
2756
3271
|
end: endAttempt,
|
|
2757
3272
|
signal,
|
|
2758
3273
|
}),
|
|
@@ -2858,7 +3373,7 @@ export function createServer<
|
|
|
2858
3373
|
const outcome = await Promise.race([
|
|
2859
3374
|
operation.then(
|
|
2860
3375
|
(value) => ({ type: 'completed' as const, value }),
|
|
2861
|
-
(error: unknown) => ({ type: '
|
|
3376
|
+
(error: unknown) => ({ type: 'run-failed' as const, error }),
|
|
2862
3377
|
),
|
|
2863
3378
|
hub.watch!.then(() =>
|
|
2864
3379
|
hub.error === null
|
|
@@ -2867,7 +3382,7 @@ export function createServer<
|
|
|
2867
3382
|
),
|
|
2868
3383
|
])
|
|
2869
3384
|
if (outcome.type === 'completed') return outcome.value
|
|
2870
|
-
if (outcome.type === '
|
|
3385
|
+
if (outcome.type === 'run-failed') throw outcome.error
|
|
2871
3386
|
if (outcome.type === 'monitor-failed') {
|
|
2872
3387
|
for (const active of hub.subscriptions) {
|
|
2873
3388
|
active.controller.abort(outcome.error)
|
|
@@ -2876,7 +3391,7 @@ export function createServer<
|
|
|
2876
3391
|
throw outcome.error
|
|
2877
3392
|
}
|
|
2878
3393
|
await operation.catch(() => {})
|
|
2879
|
-
throw new Error('abort monitor closed while
|
|
3394
|
+
throw new Error('abort monitor closed while consumers were active')
|
|
2880
3395
|
},
|
|
2881
3396
|
close: async () => {
|
|
2882
3397
|
hub.subscriptions.delete(subscription)
|
|
@@ -2895,18 +3410,22 @@ export function createServer<
|
|
|
2895
3410
|
|
|
2896
3411
|
const returnedEvents = async (
|
|
2897
3412
|
sessionId: string,
|
|
2898
|
-
trigger:
|
|
2899
|
-
result:
|
|
3413
|
+
trigger: HeadEvent,
|
|
3414
|
+
result: unknown,
|
|
2900
3415
|
): Promise<ReturnedEvent[]> => {
|
|
2901
3416
|
if (result === undefined) return []
|
|
2902
|
-
const events =
|
|
3417
|
+
const events = (
|
|
3418
|
+
Array.isArray(result) ? [...result] : [result]
|
|
3419
|
+
) as AppendInput<D>[]
|
|
2903
3420
|
if (events.length === 0) return []
|
|
3421
|
+
const idConsumer = idConsumerOf(trigger)
|
|
2904
3422
|
const withIds = await Promise.all(
|
|
2905
|
-
// oxlint-disable-next-line oxc/no-map-spread --
|
|
3423
|
+
// oxlint-disable-next-line oxc/no-map-spread -- consumer results remain caller-owned
|
|
2906
3424
|
events.map(async (event, item) => {
|
|
2907
3425
|
const withId = { ...event }
|
|
2908
3426
|
withId.id =
|
|
2909
|
-
event.id ??
|
|
3427
|
+
event.id ??
|
|
3428
|
+
(await deterministicReturnedEventId(trigger.id, item, idConsumer))
|
|
2910
3429
|
return withId
|
|
2911
3430
|
}),
|
|
2912
3431
|
)
|
|
@@ -2914,6 +3433,8 @@ export function createServer<
|
|
|
2914
3433
|
Object.assign(event, {
|
|
2915
3434
|
id: event.id!,
|
|
2916
3435
|
cause: {
|
|
3436
|
+
consumer: trigger.consumer,
|
|
3437
|
+
lane: trigger.lane,
|
|
2917
3438
|
index: trigger.index,
|
|
2918
3439
|
attempt: trigger.attemptCount,
|
|
2919
3440
|
},
|
|
@@ -2930,18 +3451,37 @@ export function createServer<
|
|
|
2930
3451
|
appendsEvents: boolean
|
|
2931
3452
|
}
|
|
2932
3453
|
|
|
2933
|
-
|
|
3454
|
+
type DrainOptions = {
|
|
3455
|
+
watchdogDueAt?: number | undefined
|
|
3456
|
+
signal?: DrainSignal | undefined
|
|
3457
|
+
/** Return a failure after arming as `failed` instead of throwing it. */
|
|
3458
|
+
handOffFailure?: boolean
|
|
3459
|
+
/** Claim and run only this consumer's heads, for `consume`. */
|
|
3460
|
+
only?: RegisteredConsumer | undefined
|
|
3461
|
+
/** With `only`: claim no head past it; what's past it is the background's. */
|
|
3462
|
+
through?: number | undefined
|
|
3463
|
+
}
|
|
3464
|
+
|
|
3465
|
+
const drainSession = async (
|
|
3466
|
+
sessionId: string,
|
|
3467
|
+
drainOptions: DrainOptions = {},
|
|
3468
|
+
): Promise<DrainResult> => {
|
|
3469
|
+
const result = await runDrain(sessionId, drainOptions)
|
|
3470
|
+
// A settled `only` drain finished one consumer's heads, not the session's.
|
|
3471
|
+
if (result.settled && drainOptions.only === undefined)
|
|
3472
|
+
drainSettledHooks.get(self)?.(sessionId)
|
|
3473
|
+
return result
|
|
3474
|
+
}
|
|
3475
|
+
|
|
3476
|
+
const runDrain = (
|
|
2934
3477
|
sessionId: string,
|
|
2935
3478
|
{
|
|
2936
3479
|
watchdogDueAt,
|
|
2937
3480
|
signal,
|
|
2938
3481
|
handOffFailure = false,
|
|
2939
|
-
|
|
2940
|
-
|
|
2941
|
-
|
|
2942
|
-
/** Return a failure after arming as `failed` instead of throwing it. */
|
|
2943
|
-
handOffFailure?: boolean
|
|
2944
|
-
} = {},
|
|
3482
|
+
only,
|
|
3483
|
+
through,
|
|
3484
|
+
}: DrainOptions = {},
|
|
2945
3485
|
): Promise<DrainResult> =>
|
|
2946
3486
|
telemetry.span(
|
|
2947
3487
|
'a2.drain',
|
|
@@ -2951,6 +3491,7 @@ export function createServer<
|
|
|
2951
3491
|
const ns = nsId(sessionId)
|
|
2952
3492
|
const holder = crypto.randomUUID()
|
|
2953
3493
|
type ActiveEvent = {
|
|
3494
|
+
ref: HeadRef
|
|
2954
3495
|
attempt: number
|
|
2955
3496
|
execution: Promise<EventOutcome>
|
|
2956
3497
|
supersede: AbortController
|
|
@@ -2959,9 +3500,15 @@ export function createServer<
|
|
|
2959
3500
|
/** Local lease estimate: request time + ttl — at or before the store's. */
|
|
2960
3501
|
expiresAtMs: number
|
|
2961
3502
|
lapsed: boolean
|
|
3503
|
+
/**
|
|
3504
|
+
* Its completion is committing. A renewal may then find the cursor
|
|
3505
|
+
* already past the head; completion is the fence, not that report.
|
|
3506
|
+
*/
|
|
3507
|
+
completing: boolean
|
|
2962
3508
|
}
|
|
2963
|
-
|
|
2964
|
-
const
|
|
3509
|
+
/** Running heads, keyed by `headKey`: one event can run for several consumers. */
|
|
3510
|
+
const active = new Map<string, ActiveEvent>()
|
|
3511
|
+
const excluded = new Map<string, HeadRef>()
|
|
2965
3512
|
let processed = 0
|
|
2966
3513
|
let sawFailure = false
|
|
2967
3514
|
let schedulerArm: Promise<void> | undefined
|
|
@@ -2978,13 +3525,13 @@ export function createServer<
|
|
|
2978
3525
|
const lapseDue = (): void => {
|
|
2979
3526
|
lapseTimer = null
|
|
2980
3527
|
const nowMs = Date.now()
|
|
2981
|
-
for (const
|
|
3528
|
+
for (const entry of active.values()) {
|
|
2982
3529
|
if (entry.lapsed || entry.expiresAtMs > nowMs) continue
|
|
2983
3530
|
entry.lapsed = true
|
|
2984
3531
|
entry.supersede.abort(
|
|
2985
3532
|
new A2Error(
|
|
2986
3533
|
'CLAIM_EXPIRED',
|
|
2987
|
-
`the claim on event ${index} in session '${sessionId}' lapsed without renewal`,
|
|
3534
|
+
`the claim on event ${entry.ref.index} for consumer '${entry.ref.consumer}' in session '${sessionId}' lapsed without renewal`,
|
|
2988
3535
|
),
|
|
2989
3536
|
)
|
|
2990
3537
|
}
|
|
@@ -3045,8 +3592,8 @@ export function createServer<
|
|
|
3045
3592
|
}
|
|
3046
3593
|
const window = nextClaimWindow()
|
|
3047
3594
|
requestWatchdog(window.watchdogAtMs)
|
|
3048
|
-
const claims = [...active.
|
|
3049
|
-
|
|
3595
|
+
const claims = [...active.values()].map((entry) => ({
|
|
3596
|
+
...entry.ref,
|
|
3050
3597
|
attempt: entry.attempt,
|
|
3051
3598
|
}))
|
|
3052
3599
|
const operation = store
|
|
@@ -3061,20 +3608,20 @@ export function createServer<
|
|
|
3061
3608
|
})
|
|
3062
3609
|
.then(
|
|
3063
3610
|
(result) => {
|
|
3064
|
-
for (const
|
|
3065
|
-
const entry = active.get(
|
|
3611
|
+
for (const ref of result.renewed) {
|
|
3612
|
+
const entry = active.get(headKey(ref))
|
|
3066
3613
|
if (entry && !entry.lapsed) {
|
|
3067
3614
|
entry.expiresAtMs = window.expiresAtMs
|
|
3068
3615
|
}
|
|
3069
3616
|
}
|
|
3070
|
-
for (const
|
|
3071
|
-
const entry = active.get(
|
|
3072
|
-
if (!entry) continue
|
|
3617
|
+
for (const ref of result.superseded) {
|
|
3618
|
+
const entry = active.get(headKey(ref))
|
|
3619
|
+
if (!entry || entry.completing) continue
|
|
3073
3620
|
// Idempotent: the local lapse normally aborted this signal
|
|
3074
3621
|
// already — this is the proof, whose job is the release.
|
|
3075
3622
|
endSuperseded(
|
|
3076
3623
|
entry,
|
|
3077
|
-
`a newer attempt owns event ${index} in session '${sessionId}'`,
|
|
3624
|
+
`a newer attempt owns event ${ref.index} for consumer '${ref.consumer}' in session '${sessionId}'`,
|
|
3078
3625
|
)
|
|
3079
3626
|
}
|
|
3080
3627
|
if (window.deadlineCapped) stopRenewing = true
|
|
@@ -3094,8 +3641,8 @@ export function createServer<
|
|
|
3094
3641
|
return tracked
|
|
3095
3642
|
}
|
|
3096
3643
|
|
|
3097
|
-
/** Records
|
|
3098
|
-
const reportExhausted = (event:
|
|
3644
|
+
/** Records a head the store dead-lettered on takeover; it never runs. */
|
|
3645
|
+
const reportExhausted = (event: HeadEvent): Promise<void> =>
|
|
3099
3646
|
telemetry.span(
|
|
3100
3647
|
'a2.event',
|
|
3101
3648
|
{
|
|
@@ -3104,8 +3651,9 @@ export function createServer<
|
|
|
3104
3651
|
'a2.event.type': event.type,
|
|
3105
3652
|
'a2.event.index': event.index,
|
|
3106
3653
|
'a2.event.id': event.id,
|
|
3654
|
+
'a2.event.consumer': event.consumer,
|
|
3107
3655
|
'a2.event.attempt': event.attemptCount,
|
|
3108
|
-
'a2.event.handled':
|
|
3656
|
+
'a2.event.handled': registeredConsumers.has(event.consumer),
|
|
3109
3657
|
...(event.lane === null ? {} : { 'a2.event.lane': event.lane }),
|
|
3110
3658
|
},
|
|
3111
3659
|
async (eventSpan) => {
|
|
@@ -3114,11 +3662,17 @@ export function createServer<
|
|
|
3114
3662
|
)
|
|
3115
3663
|
|
|
3116
3664
|
const runEvent = async (
|
|
3117
|
-
event:
|
|
3665
|
+
event: HeadEvent,
|
|
3118
3666
|
supersedeSignal: AbortSignal,
|
|
3119
3667
|
endAttempt: () => void,
|
|
3668
|
+
beginCompleting: () => void,
|
|
3669
|
+
sessionHead: number | undefined,
|
|
3120
3670
|
): Promise<EventOutcome> => {
|
|
3121
|
-
const registration =
|
|
3671
|
+
const registration =
|
|
3672
|
+
only?.name === event.consumer
|
|
3673
|
+
? only
|
|
3674
|
+
: registeredConsumers.get(event.consumer)
|
|
3675
|
+
const abortOn = registration?.abortOn(event.type) ?? null
|
|
3122
3676
|
return telemetry.span(
|
|
3123
3677
|
'a2.event',
|
|
3124
3678
|
{
|
|
@@ -3127,8 +3681,16 @@ export function createServer<
|
|
|
3127
3681
|
'a2.event.type': event.type,
|
|
3128
3682
|
'a2.event.index': event.index,
|
|
3129
3683
|
'a2.event.id': event.id,
|
|
3684
|
+
'a2.event.consumer': event.consumer,
|
|
3130
3685
|
'a2.event.attempt': event.attemptCount,
|
|
3131
3686
|
'a2.event.handled': registration !== undefined,
|
|
3687
|
+
'a2.event.age_ms': Math.max(
|
|
3688
|
+
0,
|
|
3689
|
+
Date.now() - event.createdAt.getTime(),
|
|
3690
|
+
),
|
|
3691
|
+
...(sessionHead === undefined
|
|
3692
|
+
? {}
|
|
3693
|
+
: { 'a2.event.lag': Math.max(0, sessionHead - event.index) }),
|
|
3132
3694
|
...(event.lane === null ? {} : { 'a2.event.lane': event.lane }),
|
|
3133
3695
|
},
|
|
3134
3696
|
async (eventSpan) => {
|
|
@@ -3139,25 +3701,25 @@ export function createServer<
|
|
|
3139
3701
|
close(): Promise<void>
|
|
3140
3702
|
}
|
|
3141
3703
|
| undefined
|
|
3142
|
-
let
|
|
3704
|
+
let runSignal = supersedeSignal
|
|
3143
3705
|
let completion: CompleteAttemptResult
|
|
3144
3706
|
let completing = false
|
|
3145
3707
|
try {
|
|
3146
|
-
if (
|
|
3708
|
+
if (abortOn) {
|
|
3147
3709
|
subscription = await subscribeAbort(
|
|
3148
3710
|
store,
|
|
3149
3711
|
ns,
|
|
3150
3712
|
event,
|
|
3151
|
-
|
|
3713
|
+
abortOn,
|
|
3152
3714
|
event.attemptCount,
|
|
3153
3715
|
)
|
|
3154
|
-
|
|
3716
|
+
runSignal = AbortSignal.any([
|
|
3155
3717
|
supersedeSignal,
|
|
3156
3718
|
subscription.signal,
|
|
3157
3719
|
])
|
|
3158
3720
|
}
|
|
3159
|
-
const operation = registration?.
|
|
3160
|
-
makeCtx(event, event.attemptCount,
|
|
3721
|
+
const operation = registration?.run(
|
|
3722
|
+
makeCtx(event, event.attemptCount, runSignal, endAttempt),
|
|
3161
3723
|
)
|
|
3162
3724
|
const result = operation
|
|
3163
3725
|
? await (subscription
|
|
@@ -3171,6 +3733,7 @@ export function createServer<
|
|
|
3171
3733
|
const returned = await returnedEvents(sessionId, event, result)
|
|
3172
3734
|
await renewal
|
|
3173
3735
|
completing = true
|
|
3736
|
+
beginCompleting()
|
|
3174
3737
|
completion = await telemetry.span(
|
|
3175
3738
|
'a2.commit',
|
|
3176
3739
|
{
|
|
@@ -3183,9 +3746,12 @@ export function createServer<
|
|
|
3183
3746
|
async (commitSpan) => {
|
|
3184
3747
|
const committed = await store.completeAttempt({
|
|
3185
3748
|
sessionId: ns,
|
|
3749
|
+
consumer: event.consumer,
|
|
3750
|
+
lane: event.lane,
|
|
3186
3751
|
index: event.index,
|
|
3187
3752
|
attempt: event.attemptCount,
|
|
3188
3753
|
events: returned,
|
|
3754
|
+
consumers: storeConsumers,
|
|
3189
3755
|
})
|
|
3190
3756
|
commitSpan.setAttribute(
|
|
3191
3757
|
'a2.commit.outcome',
|
|
@@ -3196,9 +3762,9 @@ export function createServer<
|
|
|
3196
3762
|
)
|
|
3197
3763
|
} catch (err) {
|
|
3198
3764
|
if (supersedeSignal.aborted && err === supersedeSignal.reason) {
|
|
3199
|
-
// The
|
|
3765
|
+
// The consumer surrendered to its own abort — identity, not
|
|
3200
3766
|
// code, so it cannot arrive from another context. A lapse
|
|
3201
|
-
// is not a
|
|
3767
|
+
// is not a consumer failure: no completion; the claim simply
|
|
3202
3768
|
// expires, and the claim that takes it over counts it.
|
|
3203
3769
|
eventSpan.setAttribute('a2.event.outcome', 'surrendered')
|
|
3204
3770
|
return {
|
|
@@ -3212,7 +3778,7 @@ export function createServer<
|
|
|
3212
3778
|
err instanceof A2Error &&
|
|
3213
3779
|
err.code === 'PARTIAL_DUPLICATE_BATCH'
|
|
3214
3780
|
// A store that cannot record the completion is not the
|
|
3215
|
-
//
|
|
3781
|
+
// consumer failing: the claim lapses, and the claim that takes
|
|
3216
3782
|
// it over counts it. A returned batch it refuses is.
|
|
3217
3783
|
if (completing && !refused) throw err
|
|
3218
3784
|
// Everything else — including a store fence rejection — is
|
|
@@ -3232,6 +3798,8 @@ export function createServer<
|
|
|
3232
3798
|
async (commitSpan) => {
|
|
3233
3799
|
const result = await store.failAttempt({
|
|
3234
3800
|
sessionId: ns,
|
|
3801
|
+
consumer: event.consumer,
|
|
3802
|
+
lane: event.lane,
|
|
3235
3803
|
index: event.index,
|
|
3236
3804
|
attempt: event.attemptCount,
|
|
3237
3805
|
error: describeError(err),
|
|
@@ -3271,7 +3839,7 @@ export function createServer<
|
|
|
3271
3839
|
appendsEvents: false,
|
|
3272
3840
|
}
|
|
3273
3841
|
}
|
|
3274
|
-
if (
|
|
3842
|
+
if (runSignal.aborted) {
|
|
3275
3843
|
eventSpan.setAttribute('a2.event.aborted', true)
|
|
3276
3844
|
}
|
|
3277
3845
|
return {
|
|
@@ -3285,16 +3853,22 @@ export function createServer<
|
|
|
3285
3853
|
|
|
3286
3854
|
let claimAgain = false
|
|
3287
3855
|
|
|
3288
|
-
const start = (
|
|
3289
|
-
|
|
3856
|
+
const start = (
|
|
3857
|
+
event: HeadEvent,
|
|
3858
|
+
expiresAtMs: number,
|
|
3859
|
+
sessionHead: number | undefined,
|
|
3860
|
+
): void => {
|
|
3861
|
+
const ref = headRefOf(event)
|
|
3862
|
+
const key = headKey(ref)
|
|
3863
|
+
// Re-claiming a head this drain still tracks is durable proof
|
|
3290
3864
|
// that the old attempt lost: our own new claim advanced the
|
|
3291
3865
|
// ordinal. Fence the old entry before it can be confused with
|
|
3292
3866
|
// the new one.
|
|
3293
|
-
const previous = active.get(
|
|
3867
|
+
const previous = active.get(key)
|
|
3294
3868
|
if (previous) {
|
|
3295
3869
|
endSuperseded(
|
|
3296
3870
|
previous,
|
|
3297
|
-
`a newer attempt owns event ${event.index} in session '${sessionId}'`,
|
|
3871
|
+
`a newer attempt owns event ${event.index} for consumer '${event.consumer}' in session '${sessionId}'`,
|
|
3298
3872
|
)
|
|
3299
3873
|
}
|
|
3300
3874
|
const supersede = new AbortController()
|
|
@@ -3302,40 +3876,50 @@ export function createServer<
|
|
|
3302
3876
|
const fenced = new Promise<EventOutcome>((resolve) => {
|
|
3303
3877
|
fence = resolve
|
|
3304
3878
|
})
|
|
3305
|
-
// A
|
|
3879
|
+
// A consumer's own committed clear supersedes it immediately.
|
|
3306
3880
|
const endAttempt = (): void =>
|
|
3307
3881
|
endSuperseded(
|
|
3308
3882
|
{ supersede, fence },
|
|
3309
|
-
`event ${event.index} in session '${sessionId}' was cleared`,
|
|
3883
|
+
`event ${event.index} for consumer '${event.consumer}' in session '${sessionId}' was cleared`,
|
|
3310
3884
|
)
|
|
3311
3885
|
// The race lets a durable supersession release this slot while the
|
|
3312
|
-
// zombie
|
|
3886
|
+
// zombie consumer keeps running — its completion and failure are
|
|
3313
3887
|
// fenced by the store, so nothing it does from here is counted.
|
|
3314
3888
|
let entry!: ActiveEvent
|
|
3315
3889
|
const execution = Promise.race([
|
|
3316
|
-
runEvent(
|
|
3890
|
+
runEvent(
|
|
3891
|
+
event,
|
|
3892
|
+
supersede.signal,
|
|
3893
|
+
endAttempt,
|
|
3894
|
+
() => {
|
|
3895
|
+
entry.completing = true
|
|
3896
|
+
},
|
|
3897
|
+
sessionHead,
|
|
3898
|
+
),
|
|
3317
3899
|
fenced,
|
|
3318
3900
|
]).then((eventOutcome) => {
|
|
3319
3901
|
// Identity-guarded: a fenced predecessor's continuation must
|
|
3320
3902
|
// not delete the entry of the attempt that replaced it.
|
|
3321
|
-
if (active.get(
|
|
3903
|
+
if (active.get(key) === entry) active.delete(key)
|
|
3322
3904
|
if (eventOutcome.status === 'processed') processed += 1
|
|
3323
3905
|
if (eventOutcome.status === 'failed') {
|
|
3324
|
-
excluded.
|
|
3906
|
+
excluded.set(key, ref)
|
|
3325
3907
|
sawFailure = true
|
|
3326
3908
|
}
|
|
3327
3909
|
claimAgain ||= eventOutcome.opensLane || eventOutcome.appendsEvents
|
|
3328
3910
|
return eventOutcome
|
|
3329
3911
|
})
|
|
3330
3912
|
entry = {
|
|
3913
|
+
ref,
|
|
3331
3914
|
attempt: event.attemptCount,
|
|
3332
3915
|
execution,
|
|
3333
3916
|
supersede,
|
|
3334
3917
|
fence,
|
|
3335
3918
|
expiresAtMs,
|
|
3336
3919
|
lapsed: false,
|
|
3920
|
+
completing: false,
|
|
3337
3921
|
}
|
|
3338
|
-
active.set(
|
|
3922
|
+
active.set(key, entry)
|
|
3339
3923
|
scheduleLapse()
|
|
3340
3924
|
if (!heartbeat && !stopRenewing) {
|
|
3341
3925
|
heartbeat = setInterval(
|
|
@@ -3370,9 +3954,11 @@ export function createServer<
|
|
|
3370
3954
|
...(window.deadlineCapped
|
|
3371
3955
|
? { expiresAtMs: window.expiresAtMs }
|
|
3372
3956
|
: {}),
|
|
3957
|
+
consumers: only ? [storeConsumerOf(only)] : storeConsumers,
|
|
3958
|
+
...(through === undefined ? {} : { through }),
|
|
3373
3959
|
...(excluded.size === 0
|
|
3374
3960
|
? {}
|
|
3375
|
-
: {
|
|
3961
|
+
: { exclude: [...excluded.values()] }),
|
|
3376
3962
|
})
|
|
3377
3963
|
claimSpan.setAttribute('a2.claim.outcome', result.outcome)
|
|
3378
3964
|
claimSpan.setAttribute(
|
|
@@ -3392,7 +3978,8 @@ export function createServer<
|
|
|
3392
3978
|
if (event.failedAt !== null) await reportExhausted(event)
|
|
3393
3979
|
if (runnable.length > 0) requestWatchdog(window.watchdogAtMs)
|
|
3394
3980
|
if (window.deadlineCapped) stopRenewing = true
|
|
3395
|
-
for (const event of runnable)
|
|
3981
|
+
for (const event of runnable)
|
|
3982
|
+
start(event, window.expiresAtMs, claim.head)
|
|
3396
3983
|
shouldClaim = false
|
|
3397
3984
|
}
|
|
3398
3985
|
}
|
|
@@ -3438,6 +4025,12 @@ export function createServer<
|
|
|
3438
4025
|
}
|
|
3439
4026
|
if (claim === undefined) continue
|
|
3440
4027
|
if (signal && signal.version !== signalVersion) continue
|
|
4028
|
+
// A run that finished while the claim was in flight may have
|
|
4029
|
+
// moved its cursor to a head the claim's snapshot missed.
|
|
4030
|
+
if (claimAgain) {
|
|
4031
|
+
claimAgain = false
|
|
4032
|
+
continue
|
|
4033
|
+
}
|
|
3441
4034
|
if (claim.outcome === 'busy') {
|
|
3442
4035
|
requestWatchdog(
|
|
3443
4036
|
claim.retryAt.getTime() + DRAIN_TIMINGS.watchdogGraceMs,
|
|
@@ -3512,8 +4105,10 @@ export function createServer<
|
|
|
3512
4105
|
}) as SessionAppend<D>
|
|
3513
4106
|
return Object.assign(makeSession(id, append, null), {
|
|
3514
4107
|
deadLetters: () => listDeadLetters(id),
|
|
3515
|
-
redrive: (
|
|
3516
|
-
|
|
4108
|
+
redrive: (
|
|
4109
|
+
target: { consumer: string; index: number },
|
|
4110
|
+
redriveOptions: SessionRedriveOptions,
|
|
4111
|
+
) => redriveEvent(id, target, redriveOptions),
|
|
3517
4112
|
})
|
|
3518
4113
|
}
|
|
3519
4114
|
|
|
@@ -3577,7 +4172,7 @@ export function createServer<
|
|
|
3577
4172
|
originIndex,
|
|
3578
4173
|
)
|
|
3579
4174
|
} catch (error) {
|
|
3580
|
-
// The scheduling
|
|
4175
|
+
// The scheduling consumer's lifetime was cleared: an acknowledged no-op.
|
|
3581
4176
|
if (
|
|
3582
4177
|
originIndex !== undefined &&
|
|
3583
4178
|
error instanceof A2Error &&
|
|
@@ -3625,6 +4220,12 @@ function assertStateIndex(value: number): void {
|
|
|
3625
4220
|
}
|
|
3626
4221
|
}
|
|
3627
4222
|
|
|
4223
|
+
function assertConsumeIndex(value: number): void {
|
|
4224
|
+
if (!Number.isSafeInteger(value) || value < 0) {
|
|
4225
|
+
throw new TypeError('consume through must be a non-negative safe integer')
|
|
4226
|
+
}
|
|
4227
|
+
}
|
|
4228
|
+
|
|
3628
4229
|
function assertRedriveIndex(value: number): void {
|
|
3629
4230
|
if (!Number.isSafeInteger(value) || value < 1) {
|
|
3630
4231
|
throw new TypeError('redrive requires a positive safe-integer index')
|
|
@@ -3639,7 +4240,7 @@ function assertClearIndex(value: number): void {
|
|
|
3639
4240
|
|
|
3640
4241
|
function assertAppendName(name: string): void {
|
|
3641
4242
|
if (typeof name !== 'string' || name.length === 0) {
|
|
3642
|
-
throw new TypeError('
|
|
4243
|
+
throw new TypeError('consumer append name must be a non-empty string')
|
|
3643
4244
|
}
|
|
3644
4245
|
}
|
|
3645
4246
|
|