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/ai.ts
CHANGED
|
@@ -94,7 +94,7 @@ import {
|
|
|
94
94
|
type ProgressBatches,
|
|
95
95
|
type StoredProgressPayload,
|
|
96
96
|
} from './ai-progress-batches.ts'
|
|
97
|
-
import type {
|
|
97
|
+
import type { ConsumerContext } from './server.ts'
|
|
98
98
|
import type { StandardSchemaV1 } from './standard-schema.ts'
|
|
99
99
|
import { assertSyncSchema, validateSync } from './validate.ts'
|
|
100
100
|
|
|
@@ -462,8 +462,8 @@ export type AgentContract<
|
|
|
462
462
|
export type AgentToolEventDefs = EventDefs &
|
|
463
463
|
Pick<AIEventDefs, 'ai.tool.execution.requested'>
|
|
464
464
|
|
|
465
|
-
/** The durable
|
|
466
|
-
export type AgentToolContext<D extends AgentToolEventDefs> =
|
|
465
|
+
/** The durable consumer context supplied when A2 executes a local AI tool. */
|
|
466
|
+
export type AgentToolContext<D extends AgentToolEventDefs> = ConsumerContext<
|
|
467
467
|
D,
|
|
468
468
|
'ai.tool.execution.requested'
|
|
469
469
|
>
|
|
@@ -2526,20 +2526,20 @@ export const inputs: AIInputs = {
|
|
|
2526
2526
|
}
|
|
2527
2527
|
|
|
2528
2528
|
/**
|
|
2529
|
-
* Read the durable
|
|
2529
|
+
* Read the durable consumer context ambient to the current AI tool
|
|
2530
2530
|
* execution. A2 runs each local tool's `execute` inside this scope, so
|
|
2531
2531
|
* plain AI SDK `tool()` definitions reach their `event`, `attempt`,
|
|
2532
2532
|
* `session`, and `signal` without an A2-specific wrapper. Pass the
|
|
2533
2533
|
* agent's contract to type application events; it is verified against
|
|
2534
2534
|
* the executing agent.
|
|
2535
2535
|
*/
|
|
2536
|
-
export function
|
|
2536
|
+
export function consumerContext<D extends AgentToolEventDefs = AIEventDefs>(
|
|
2537
2537
|
contract?: Contract<D>,
|
|
2538
2538
|
): AgentToolContext<D> {
|
|
2539
2539
|
const scope = ambientToolScopeStorage()?.getStore()
|
|
2540
2540
|
if (scope === undefined) {
|
|
2541
2541
|
throw new TypeError(
|
|
2542
|
-
"
|
|
2542
|
+
"consumerContext() requires an ambient A2 tool execution: call it inside a local tool's execute, or wrap direct tool tests with runWithConsumerContext",
|
|
2543
2543
|
)
|
|
2544
2544
|
}
|
|
2545
2545
|
if (
|
|
@@ -2548,7 +2548,7 @@ export function handlerContext<D extends AgentToolEventDefs = AIEventDefs>(
|
|
|
2548
2548
|
scope.contract.name !== contract.name
|
|
2549
2549
|
) {
|
|
2550
2550
|
throw new TypeError(
|
|
2551
|
-
`
|
|
2551
|
+
`consumerContext('${contract.name}') was called during a tool execution for agent '${scope.contract.name}'`,
|
|
2552
2552
|
)
|
|
2553
2553
|
}
|
|
2554
2554
|
return scope.context as AgentToolContext<D>
|
package/src/client.ts
CHANGED
|
@@ -38,6 +38,7 @@ import type {
|
|
|
38
38
|
PresencePatch,
|
|
39
39
|
WithPresence,
|
|
40
40
|
} from './contract.ts'
|
|
41
|
+
import { presenceTombstones } from './presence-tombstones.ts'
|
|
41
42
|
import { foldEvent, type Reducer, type StateSnapshot } from './reducer.ts'
|
|
42
43
|
import { isSessionCleared } from './session-clear.ts'
|
|
43
44
|
import { validateSync } from './validate.ts'
|
|
@@ -1560,6 +1561,8 @@ export function createClient<
|
|
|
1560
1561
|
// Participant and field keys arrive off the wire, so the map and
|
|
1561
1562
|
// every entry are built null-prototype — see `nullProtoRecord`.
|
|
1562
1563
|
let presenceState: PresenceMap = nullProtoRecord()
|
|
1564
|
+
/** Stamps of applied foreign nulls — kept out of `presenceState`. */
|
|
1565
|
+
const presenceDeletes = presenceTombstones()
|
|
1563
1566
|
let presenceLastSentAt = 0
|
|
1564
1567
|
/** The last LWW stamp issued — see `nextPresenceAt`. */
|
|
1565
1568
|
let presenceLastAt = 0
|
|
@@ -1763,14 +1766,19 @@ export function createClient<
|
|
|
1763
1766
|
for (const [field, value] of Object.entries(patch.values)) {
|
|
1764
1767
|
if (value === undefined) continue
|
|
1765
1768
|
const existing = entry[field]
|
|
1766
|
-
|
|
1769
|
+
const write = { participant: patch.participant, field, at: patch.at }
|
|
1770
|
+
// Field-wise LWW against the live value or its deletion: an older
|
|
1771
|
+
// stamp loses, a tie goes to the wire.
|
|
1767
1772
|
if (existing && existing.at.getTime() > patch.at.getTime()) continue
|
|
1773
|
+
if (presenceDeletes.outranks(write)) continue
|
|
1768
1774
|
if (value === null) {
|
|
1775
|
+
presenceDeletes.keep(write)
|
|
1769
1776
|
if (existing) {
|
|
1770
1777
|
delete entry[field]
|
|
1771
1778
|
changed = true
|
|
1772
1779
|
}
|
|
1773
1780
|
} else {
|
|
1781
|
+
presenceDeletes.drop(write)
|
|
1774
1782
|
entry[field] = { value, seen: patch.seen, at: patch.at }
|
|
1775
1783
|
changed = true
|
|
1776
1784
|
}
|
|
@@ -1787,7 +1795,16 @@ export function createClient<
|
|
|
1787
1795
|
const next: PresenceMap = nullProtoRecord()
|
|
1788
1796
|
for (const [id, fields] of Object.entries(incoming)) {
|
|
1789
1797
|
if (id === participant) continue
|
|
1790
|
-
|
|
1798
|
+
const entry: PresenceMap[string] = nullProtoRecord()
|
|
1799
|
+
for (const [field, stamp] of Object.entries(fields)) {
|
|
1800
|
+
if (stamp === undefined) continue
|
|
1801
|
+
const write = { participant: id, field, at: stamp.at }
|
|
1802
|
+
// A row older than the field's deletion must not resurrect it.
|
|
1803
|
+
if (presenceDeletes.outranks(write)) continue
|
|
1804
|
+
presenceDeletes.drop(write)
|
|
1805
|
+
entry[field] = stamp
|
|
1806
|
+
}
|
|
1807
|
+
if (Object.keys(entry).length > 0) next[id] = entry
|
|
1791
1808
|
}
|
|
1792
1809
|
// Foreign entries replaced wholesale; the own echo survives.
|
|
1793
1810
|
const own =
|
package/src/contract.ts
CHANGED
|
@@ -123,7 +123,7 @@ export type Contract<
|
|
|
123
123
|
readonly events: Readonly<D>
|
|
124
124
|
/** The presence vocabulary — empty unless declared. */
|
|
125
125
|
readonly presence: Readonly<P>
|
|
126
|
-
/** Preserve literal types for a
|
|
126
|
+
/** Preserve literal types for a consumer-returned event batch. */
|
|
127
127
|
readonly batch: EventBatchFactory<D>
|
|
128
128
|
readonly reducer: R
|
|
129
129
|
}
|
package/src/deterministic-id.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Deterministic default ids for
|
|
2
|
+
* Deterministic default ids for consumer `session.append` calls. A consumer
|
|
3
3
|
* re-run after a crash calls `session.append` again; giving
|
|
4
4
|
* each call a deterministic id — derived from the triggering event's id
|
|
5
5
|
* and the caller's stable name — makes the re-run a no-op via the store's
|
|
6
|
-
* unique index on event ids.
|
|
6
|
+
* per-session unique index on event ids.
|
|
7
7
|
*
|
|
8
8
|
* The id is a UUIDv5-shaped SHA-1 over
|
|
9
9
|
* `(namespace, triggerEventId, name, itemOrdinal)`, computed with
|
|
@@ -32,20 +32,38 @@ async function deterministicId(parts: Array<string | number>): Promise<string> {
|
|
|
32
32
|
return deterministicIdFromInput(parts.join('\u0000'))
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
+
/**
|
|
36
|
+
* `consumer` keys the id on the consumer that appends, so two consumers of
|
|
37
|
+
* one event never collide. A2's own consumers pass none: their types are
|
|
38
|
+
* disjoint, so the event alone keys them.
|
|
39
|
+
*/
|
|
35
40
|
export async function deterministicEventId(
|
|
36
41
|
triggerEventId: string,
|
|
37
42
|
name: string,
|
|
38
43
|
itemOrdinal: number,
|
|
44
|
+
consumer?: string,
|
|
39
45
|
): Promise<string> {
|
|
40
46
|
if (name.length === 0) throw new TypeError('append name must not be empty')
|
|
41
|
-
return deterministicId([
|
|
47
|
+
return deterministicId([
|
|
48
|
+
APPEND_NAMESPACE,
|
|
49
|
+
triggerEventId,
|
|
50
|
+
name,
|
|
51
|
+
itemOrdinal,
|
|
52
|
+
...(consumer === undefined ? [] : [consumer]),
|
|
53
|
+
])
|
|
42
54
|
}
|
|
43
55
|
|
|
44
56
|
export async function deterministicReturnedEventId(
|
|
45
57
|
triggerEventId: string,
|
|
46
58
|
itemOrdinal: number,
|
|
59
|
+
consumer?: string,
|
|
47
60
|
): Promise<string> {
|
|
48
|
-
return deterministicId([
|
|
61
|
+
return deterministicId([
|
|
62
|
+
RETURN_NAMESPACE,
|
|
63
|
+
triggerEventId,
|
|
64
|
+
itemOrdinal,
|
|
65
|
+
...(consumer === undefined ? [] : [consumer]),
|
|
66
|
+
])
|
|
49
67
|
}
|
|
50
68
|
|
|
51
69
|
/**
|
|
@@ -60,6 +78,7 @@ export async function deterministicScheduleId(
|
|
|
60
78
|
triggerEventId: string | null,
|
|
61
79
|
name: string,
|
|
62
80
|
clearIndex = 0,
|
|
81
|
+
consumer?: string,
|
|
63
82
|
): Promise<string> {
|
|
64
83
|
return deterministicIdFromInput(
|
|
65
84
|
JSON.stringify([
|
|
@@ -69,6 +88,7 @@ export async function deterministicScheduleId(
|
|
|
69
88
|
triggerEventId,
|
|
70
89
|
name,
|
|
71
90
|
...(clearIndex > 0 ? [clearIndex] : []),
|
|
91
|
+
...(consumer === undefined ? [] : [{ consumer }]),
|
|
72
92
|
]),
|
|
73
93
|
)
|
|
74
94
|
}
|
|
@@ -80,6 +100,7 @@ export async function deterministicScheduleClearId({
|
|
|
80
100
|
clearIndex,
|
|
81
101
|
name,
|
|
82
102
|
ifLastIndex,
|
|
103
|
+
consumer,
|
|
83
104
|
}: {
|
|
84
105
|
contract: string
|
|
85
106
|
sessionId: string
|
|
@@ -87,6 +108,7 @@ export async function deterministicScheduleClearId({
|
|
|
87
108
|
clearIndex: number
|
|
88
109
|
name: string
|
|
89
110
|
ifLastIndex: number
|
|
111
|
+
consumer?: string
|
|
90
112
|
}): Promise<string> {
|
|
91
113
|
return deterministicIdFromInput(
|
|
92
114
|
JSON.stringify([
|
|
@@ -97,6 +119,7 @@ export async function deterministicScheduleClearId({
|
|
|
97
119
|
clearIndex,
|
|
98
120
|
name,
|
|
99
121
|
ifLastIndex,
|
|
122
|
+
...(consumer === undefined ? [] : [{ consumer }]),
|
|
100
123
|
]),
|
|
101
124
|
)
|
|
102
125
|
}
|
package/src/errors.ts
CHANGED
|
@@ -3,13 +3,14 @@ export type A2ServerErrorCode =
|
|
|
3
3
|
| 'FORBIDDEN' // external ingress was denied by server.fetch authorization
|
|
4
4
|
| 'UNKNOWN_EVENT_TYPE' // event type not in the machine's `events` map
|
|
5
5
|
| 'PARTIAL_DUPLICATE_BATCH' // batch mixed already-appended and fresh events
|
|
6
|
-
| 'SUPERSEDED_ATTEMPT' //
|
|
6
|
+
| 'SUPERSEDED_ATTEMPT' // consumer append from an attempt a recovery claim or a clear replaced
|
|
7
7
|
| 'SESSION_CLEARED' // a read or call reached across the session's latest clear
|
|
8
8
|
| 'CLAIM_EXPIRED' // ctx.signal abort reason: the attempt's lease lapsed without renewal
|
|
9
9
|
| 'STORE_UNAVAILABLE' // store backend failure — retryable, nothing was written
|
|
10
10
|
| 'STORE_NOT_CONFIGURED' // no `store` in production, a missing peer dependency, or Postgres lacks A2's tables
|
|
11
11
|
| 'UNKNOWN_PRESENCE_FIELD' // presence field not in the contract's `presence` map
|
|
12
12
|
| 'PRESENCE_NOT_SUPPORTED' // contract declares presence but the store lacks the capability
|
|
13
|
+
| 'CONSUMER_DEAD_LETTERED' // session.consume reached a head that dead-lettered for that consumer
|
|
13
14
|
|
|
14
15
|
export type A2ErrorCode =
|
|
15
16
|
A2ServerErrorCode | 'TIMEOUT' | 'HTTP_ERROR' | 'CONNECTION_FAILED' | 'UNKNOWN'
|
|
@@ -34,14 +35,16 @@ export class A2Error extends Error {
|
|
|
34
35
|
}
|
|
35
36
|
}
|
|
36
37
|
|
|
37
|
-
/** A
|
|
38
|
+
/** A consumer write whose attempt no longer owns its triggering event. */
|
|
38
39
|
export function supersededAttemptError(
|
|
39
40
|
sessionId: string,
|
|
40
|
-
cause: { index: number; attempt: number },
|
|
41
|
+
cause: { consumer?: string; index: number; attempt: number },
|
|
41
42
|
): A2Error {
|
|
43
|
+
const of =
|
|
44
|
+
cause.consumer === undefined ? '' : ` for consumer '${cause.consumer}'`
|
|
42
45
|
return new A2Error(
|
|
43
46
|
'SUPERSEDED_ATTEMPT',
|
|
44
|
-
`attempt ${cause.attempt} no longer owns event ${cause.index} in session '${sessionId}'`,
|
|
47
|
+
`attempt ${cause.attempt} no longer owns event ${cause.index}${of} in session '${sessionId}'`,
|
|
45
48
|
)
|
|
46
49
|
}
|
|
47
50
|
|
|
@@ -58,7 +61,7 @@ export function asStoreUnavailable(err: unknown): A2Error {
|
|
|
58
61
|
}
|
|
59
62
|
|
|
60
63
|
/**
|
|
61
|
-
* Throw from a
|
|
64
|
+
* Throw from a consumer to reject retrying: the failure is adjudicated
|
|
62
65
|
* terminal immediately (dead-lettered on the first throw) instead of
|
|
63
66
|
* consuming the ten-failure retry budget. For deterministic failures —
|
|
64
67
|
* a guard that will refuse identically on every attempt — retrying is
|
package/src/idempotent-replay.ts
CHANGED
|
@@ -37,7 +37,6 @@ export const idempotentReplay = (
|
|
|
37
37
|
stored === undefined ||
|
|
38
38
|
stored.index !== firstIndex + index ||
|
|
39
39
|
stored.type !== event.type ||
|
|
40
|
-
stored.lane !== (event.lane ?? null) ||
|
|
41
40
|
stored.cause?.batchSize !== event.cause?.batchSize ||
|
|
42
41
|
canonicalJson(stored.payload) !== canonicalJson(event.payload)
|
|
43
42
|
)
|
package/src/index.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*
|
|
4
4
|
* This entry point is isomorphic by construction: the contract (the
|
|
5
5
|
* vocabulary everything shares), reducers derived from it, and the
|
|
6
|
-
* error type. Implementing a contract — storage,
|
|
6
|
+
* error type. Implementing a contract — storage, consumers, sessions —
|
|
7
7
|
* lives in experimental-a2/server, which never enters a browser bundle.
|
|
8
8
|
*/
|
|
9
9
|
|
package/src/internal.ts
CHANGED
|
@@ -75,7 +75,17 @@ export type ServerInternals = {
|
|
|
75
75
|
export const serverInternals: WeakMap<object, ServerInternals> = new WeakMap()
|
|
76
76
|
|
|
77
77
|
/**
|
|
78
|
-
*
|
|
78
|
+
* Consumers A2's own runtimes define: named under `a2.`, with child ids that
|
|
79
|
+
* leave the name out, since their types are disjoint. Defined through
|
|
80
|
+
* `runtimeConsumer` (runtime-consumer.ts), which the server module installs.
|
|
81
|
+
*/
|
|
82
|
+
export const runtimeConsumers: WeakSet<object> = new WeakSet()
|
|
83
|
+
|
|
84
|
+
export const runtimeConsumerFactory: { define?: (options: never) => unknown } =
|
|
85
|
+
{}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Consumer contexts whose previous attempt ended in a failure the consumer
|
|
79
89
|
* threw, as opposed to a claim that lapsed (a crash, a timeout, a frozen
|
|
80
90
|
* process). Only the attempt's own outcome is known, not how far it got.
|
|
81
91
|
*/
|
|
@@ -84,9 +94,9 @@ export const failedPreviousAttempt: WeakSet<object> = new WeakSet()
|
|
|
84
94
|
const objectLike = (value: unknown): value is object =>
|
|
85
95
|
(typeof value === 'object' && value !== null) || typeof value === 'function'
|
|
86
96
|
|
|
87
|
-
/** The ambient value delivered to `
|
|
97
|
+
/** The ambient value delivered to `consumerContext` during a tool execution. */
|
|
88
98
|
export type AmbientToolScope = {
|
|
89
|
-
/** Executing server identity; a test scope from `
|
|
99
|
+
/** Executing server identity; a test scope from `runWithConsumerContext` has none. */
|
|
90
100
|
contract?: { readonly name: string }
|
|
91
101
|
context: unknown
|
|
92
102
|
}
|
|
@@ -95,7 +105,7 @@ export type AmbientToolScope = {
|
|
|
95
105
|
* Structural AsyncLocalStorage surface for the ambient tool scope. The
|
|
96
106
|
* instance is created by the server-only AI entry point (the only place
|
|
97
107
|
* `node:async_hooks` may be imported) and shared through a global
|
|
98
|
-
* symbol slot, so `
|
|
108
|
+
* symbol slot, so `consumerContext` in the isomorphic AI entry point and
|
|
99
109
|
* independently bundled A2 copies all read the same scope.
|
|
100
110
|
*/
|
|
101
111
|
export type AmbientToolScopeStorage = {
|
|
@@ -267,7 +277,7 @@ export function assertSchedulerTargets(
|
|
|
267
277
|
export const DRAIN_TIMINGS: {
|
|
268
278
|
/** How long a claim lives without renewal. */
|
|
269
279
|
claimTtlMs: number
|
|
270
|
-
/** Heartbeat cadence while a
|
|
280
|
+
/** Heartbeat cadence while a consumer is running. */
|
|
271
281
|
claimHeartbeatMs: number
|
|
272
282
|
/** Base delay before retrying a failed renewal while the lease lives. */
|
|
273
283
|
renewRetryMs: number
|
|
@@ -289,6 +299,21 @@ export const DRAIN_TIMINGS: {
|
|
|
289
299
|
failureRetryMaxMs: 30_000,
|
|
290
300
|
}
|
|
291
301
|
|
|
302
|
+
/**
|
|
303
|
+
* How many kernels a harness keeps, per agent, for the next step of a session
|
|
304
|
+
* in its process. Mutable only as a white-box test seam; production always
|
|
305
|
+
* uses the default.
|
|
306
|
+
*/
|
|
307
|
+
export const HARNESS_PARKING: { maxKernels: number } = { maxKernels: 32 }
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* Per server: called when a drain of a session ends with nothing left to run
|
|
311
|
+
* for it in this process, so the layer above can let go of what it kept for
|
|
312
|
+
* that session's next step.
|
|
313
|
+
*/
|
|
314
|
+
export const drainSettledHooks: WeakMap<object, (sessionId: string) => void> =
|
|
315
|
+
new WeakMap()
|
|
316
|
+
|
|
292
317
|
/**
|
|
293
318
|
* Stream liveness knobs — the server-side SSE heartbeat and the
|
|
294
319
|
* client-side stall watchdog. Paired by design: the client declares a
|
|
@@ -313,16 +338,22 @@ export const POLL_TIMINGS: {
|
|
|
313
338
|
}
|
|
314
339
|
|
|
315
340
|
/**
|
|
316
|
-
*
|
|
317
|
-
*
|
|
318
|
-
*
|
|
319
|
-
*
|
|
320
|
-
*
|
|
341
|
+
* Timings for the notify-driven redis live stream. A parked feed re-reads
|
|
342
|
+
* the store every `safetyReadMs` even without a pub/sub wake-up, so a lost
|
|
343
|
+
* notification (subscriber reconnect gap, dropped PUBLISH) costs latency,
|
|
344
|
+
* never an event. Writers publish after their scripts, so wake-ups from
|
|
345
|
+
* concurrent writers can arrive out of order: a feed holds a gap for
|
|
346
|
+
* `reorderMs` before catching up with a read, and a write waits at most
|
|
347
|
+
* `publishMs` for its PUBLISH. Mutable only as a white-box test seam.
|
|
321
348
|
*/
|
|
322
349
|
export const NOTIFY_TIMINGS: {
|
|
323
350
|
safetyReadMs: number
|
|
351
|
+
reorderMs: number
|
|
352
|
+
publishMs: number
|
|
324
353
|
} = {
|
|
325
354
|
safetyReadMs: 10_000,
|
|
355
|
+
reorderMs: 50,
|
|
356
|
+
publishMs: 1_000,
|
|
326
357
|
}
|
|
327
358
|
|
|
328
359
|
/**
|
|
@@ -412,15 +443,25 @@ export const SOCKET_TIMINGS: {
|
|
|
412
443
|
}
|
|
413
444
|
|
|
414
445
|
/**
|
|
415
|
-
*
|
|
416
|
-
*
|
|
417
|
-
* (decision #36's rule): coupling the cadence to the POST ack tied
|
|
418
|
-
* remote cursor smoothness to RTT, and the ack serves no correctness
|
|
419
|
-
* purpose on a fire-and-forget LWW plane. Mutable only as a white-box
|
|
420
|
-
* test seam.
|
|
446
|
+
* Presence timings. Owned, not configurable (decision #36's rule).
|
|
447
|
+
* Mutable only as a white-box test seam.
|
|
421
448
|
*/
|
|
422
449
|
export const PRESENCE_TIMINGS: {
|
|
450
|
+
/**
|
|
451
|
+
* Client send cadence — a leading send immediately, then at most one
|
|
452
|
+
* merged send per interval (~15Hz). Coupling the cadence to the POST
|
|
453
|
+
* ack tied remote cursor smoothness to RTT, and the ack serves no
|
|
454
|
+
* correctness purpose on a fire-and-forget LWW plane.
|
|
455
|
+
*/
|
|
423
456
|
sendIntervalMs: number
|
|
457
|
+
/**
|
|
458
|
+
* How long, in local receipt time, a presence applier keeps a deleted
|
|
459
|
+
* field's stamp so an older set delivered after the delete still
|
|
460
|
+
* loses. Reorders span milliseconds; the bound keeps a long-lived
|
|
461
|
+
* client with churning participants from accumulating tombstones.
|
|
462
|
+
*/
|
|
463
|
+
tombstoneMs: number
|
|
424
464
|
} = {
|
|
425
465
|
sendIntervalMs: 66,
|
|
466
|
+
tombstoneMs: 60_000,
|
|
426
467
|
}
|
package/src/otel.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* Peer-depends on `@opentelemetry/api` (optional). Spans nest through
|
|
5
5
|
* the active context, so with a context manager registered (any real
|
|
6
6
|
* OTel setup, e.g. `@vercel/otel`) the happy path lands in one trace:
|
|
7
|
-
* request → a2.append → a2.drain → a2.event → the
|
|
7
|
+
* request → a2.append → a2.drain → a2.event → the consumer's own spans.
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import {
|
package/src/postgres-schema.ts
CHANGED
|
@@ -1,80 +1,121 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The Postgres schema contract. A2 never runs DDL in a Postgres database:
|
|
3
|
-
* `sql/postgres/schema.sql
|
|
4
|
-
*
|
|
5
|
-
*
|
|
3
|
+
* `sql/postgres/schema.sql`, or the tables in `drizzle-schema.ts` next to it,
|
|
4
|
+
* create
|
|
5
|
+
* the tables, and `notifications.sql` the triggers `listenConnectionString`
|
|
6
|
+
* needs. A store checks nothing up front. A query that finds a table or column
|
|
7
|
+
* missing fails anyway, so the store only turns that failure into one that
|
|
8
|
+
* says what to apply. Missing triggers fail no query, so a listening store
|
|
9
|
+
* looks for them when its listener starts.
|
|
6
10
|
*/
|
|
7
11
|
|
|
8
12
|
import { A2Error } from './errors.ts'
|
|
9
|
-
import type { PostgresClient } from './store-postgres.ts'
|
|
10
|
-
|
|
11
|
-
/** The `a2_schema` version this release needs; `schema.sql` records it. */
|
|
12
|
-
export const POSTGRES_SCHEMA_VERSION = 1
|
|
13
|
+
import type { PostgresClient, PostgresConnection } from './store-postgres.ts'
|
|
13
14
|
|
|
14
15
|
const PACKAGE = 'node_modules/experimental-a2'
|
|
15
16
|
|
|
16
|
-
const apply = (file: string): string =>
|
|
17
|
-
`apply ${PACKAGE}/sql/postgres/${file} to this database`
|
|
18
|
-
|
|
19
17
|
const SEE_DOCS = `See ${PACKAGE}/docs/stores/04-postgres.mdx.`
|
|
20
18
|
|
|
21
|
-
|
|
22
|
-
const
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
19
|
+
/** Postgres error codes a store that hasn't been set up raises. */
|
|
20
|
+
const UNDEFINED_TABLE = '42P01'
|
|
21
|
+
const UNDEFINED_COLUMN = '42703'
|
|
22
|
+
const UNDEFINED_FUNCTION = '42883'
|
|
23
|
+
/** `a2_batch` refuses to run outside read committed. */
|
|
24
|
+
const BATCH_ISOLATION = 'A2002'
|
|
25
|
+
const INSUFFICIENT_PRIVILEGE = '42501'
|
|
26
|
+
/** NUL, which Postgres can't store: in jsonb (`\u0000`), or in text. */
|
|
27
|
+
const UNTRANSLATABLE_CHARACTER = '22P05'
|
|
28
|
+
const CHARACTER_NOT_IN_REPERTOIRE = '22021'
|
|
26
29
|
|
|
27
|
-
/**
|
|
28
|
-
const
|
|
29
|
-
|
|
30
|
-
):
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
30
|
+
/** The Postgres error in `error`'s cause chain, for clients that wrap theirs. */
|
|
31
|
+
const postgresError = (
|
|
32
|
+
error: unknown,
|
|
33
|
+
): { code: string; message: string } | undefined => {
|
|
34
|
+
for (let current = error, depth = 0; depth < 5; depth += 1) {
|
|
35
|
+
if (typeof current !== 'object' || current === null) return undefined
|
|
36
|
+
const { code, message, cause } = current as {
|
|
37
|
+
code?: unknown
|
|
38
|
+
message?: unknown
|
|
39
|
+
cause?: unknown
|
|
40
|
+
}
|
|
41
|
+
if (typeof code === 'string' && /^[0-9A-Z]{5}$/.test(code))
|
|
42
|
+
return { code, message: typeof message === 'string' ? message : code }
|
|
43
|
+
current = cause
|
|
44
|
+
}
|
|
45
|
+
return undefined
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Explains a query that failed because A2's tables aren't set up, or on a NUL, or returns `error`. */
|
|
49
|
+
export const explainPostgresError = (error: unknown): unknown => {
|
|
50
|
+
const found = postgresError(error)
|
|
51
|
+
if (
|
|
52
|
+
found?.code === UNDEFINED_TABLE ||
|
|
53
|
+
found?.code === UNDEFINED_COLUMN ||
|
|
54
|
+
found?.code === UNDEFINED_FUNCTION
|
|
55
|
+
)
|
|
39
56
|
return new A2Error(
|
|
40
57
|
'STORE_NOT_CONFIGURED',
|
|
41
|
-
found
|
|
42
|
-
|
|
43
|
-
: `A2's tables aren't in this Postgres database, and A2 doesn't create tables at runtime: ${apply('schema.sql')}. ${SEE_DOCS}`,
|
|
58
|
+
`${found.message}: A2's tables or its function are missing, or from an earlier release, and A2 doesn't create or change them at runtime. Apply ${PACKAGE}/sql/postgres/schema.sql to this database, or migrate it to the tables in drizzle-schema.ts next to it, with schema.sql's a2_batch function. ${SEE_DOCS}`,
|
|
59
|
+
{ cause: error },
|
|
44
60
|
)
|
|
45
|
-
if (found
|
|
61
|
+
if (found?.code === INSUFFICIENT_PRIVILEGE)
|
|
46
62
|
return new A2Error(
|
|
47
63
|
'STORE_NOT_CONFIGURED',
|
|
48
|
-
|
|
64
|
+
`${found.message}: the role this store connects as needs select, insert, update, and delete on A2's tables, and execute on a2_batch. Create them as that role, or grant it those privileges. ${SEE_DOCS}`,
|
|
65
|
+
{ cause: error },
|
|
49
66
|
)
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
}:
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
try {
|
|
63
|
-
row = (await client.query(listen ? VERSION_AND_TRIGGERS : VERSION)).rows[0]
|
|
64
|
-
} catch (err) {
|
|
65
|
-
throw (await diagnose(client).catch(() => undefined)) ?? err
|
|
66
|
-
}
|
|
67
|
-
const version = Number(row?.['version'] ?? 0)
|
|
68
|
-
if (version < POSTGRES_SCHEMA_VERSION) {
|
|
69
|
-
throw new A2Error(
|
|
67
|
+
// Nothing was written: the payload is the problem, so retrying can't help.
|
|
68
|
+
if (
|
|
69
|
+
found?.code === UNTRANSLATABLE_CHARACTER ||
|
|
70
|
+
found?.code === CHARACTER_NOT_IN_REPERTOIRE
|
|
71
|
+
)
|
|
72
|
+
return new A2Error(
|
|
73
|
+
'INVALID_PAYLOAD',
|
|
74
|
+
`${found.message}: Postgres can't store a NUL character (\\u0000) in text or jsonb, so nothing was written. Remove it before appending.`,
|
|
75
|
+
{ cause: error },
|
|
76
|
+
)
|
|
77
|
+
if (found?.code === BATCH_ISOLATION)
|
|
78
|
+
return new A2Error(
|
|
70
79
|
'STORE_NOT_CONFIGURED',
|
|
71
|
-
|
|
80
|
+
`${found.message}: A2 reads what it locks in read committed, Postgres's default, and its connections here default to a stricter level. Set default_transaction_isolation to 'read committed' for the role or database this store connects to. ${SEE_DOCS}`,
|
|
81
|
+
{ cause: error },
|
|
72
82
|
)
|
|
73
|
-
|
|
74
|
-
|
|
83
|
+
return error
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const explained = <T>(promise: Promise<T>): Promise<T> =>
|
|
87
|
+
promise.catch((error: unknown) => {
|
|
88
|
+
throw explainPostgresError(error)
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
/** `client`, with every query's setup failures explained. */
|
|
92
|
+
export const explainingPostgresClient = (
|
|
93
|
+
client: PostgresClient,
|
|
94
|
+
): PostgresClient => ({
|
|
95
|
+
query: (text, values) => explained(client.query(text, values)),
|
|
96
|
+
...(client.connect && {
|
|
97
|
+
connect: async (): Promise<PostgresConnection> => {
|
|
98
|
+
const connection = await client.connect!()
|
|
99
|
+
return {
|
|
100
|
+
query: (text, values) => explained(connection.query(text, values)),
|
|
101
|
+
release: () => connection.release(),
|
|
102
|
+
}
|
|
103
|
+
},
|
|
104
|
+
}),
|
|
105
|
+
...(client.end && { end: () => client.end!() }),
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
/** Fails when the triggers `listenConnectionString` listens to are missing. */
|
|
109
|
+
export const checkPostgresTriggers = async (
|
|
110
|
+
client: PostgresClient,
|
|
111
|
+
): Promise<void> => {
|
|
112
|
+
const { rows } =
|
|
113
|
+
await client.query(`select count(*) as triggers from pg_trigger
|
|
114
|
+
where (tgname = 'a2_notify_events' and tgrelid = to_regclass('a2_events'))
|
|
115
|
+
or (tgname = 'a2_notify_presence' and tgrelid = to_regclass('a2_presence'))`)
|
|
116
|
+
if (Number(rows[0]?.['triggers']) < 2)
|
|
75
117
|
throw new A2Error(
|
|
76
118
|
'STORE_NOT_CONFIGURED',
|
|
77
|
-
`listenConnectionString needs A2's notification triggers, which aren't in this Postgres database. After schema.sql, ${
|
|
119
|
+
`listenConnectionString needs A2's notification triggers, which aren't in this Postgres database. After schema.sql, apply ${PACKAGE}/sql/postgres/notifications.sql to this database. ${SEE_DOCS}`,
|
|
78
120
|
)
|
|
79
|
-
}
|
|
80
121
|
}
|