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.
Files changed (309) hide show
  1. package/CHANGELOG.md +55 -0
  2. package/README.md +1 -1
  3. package/dist/{actor-C7b-1arn.d.ts → actor-CqibLgjQ.d.ts} +15 -7
  4. package/dist/actor-CqibLgjQ.d.ts.map +1 -0
  5. package/dist/actor-client.d.ts +1 -1
  6. package/dist/actor-client.js +2 -2
  7. package/dist/actor-react.d.ts +3 -3
  8. package/dist/actor-react.js +5 -5
  9. package/dist/{actor-shared-t7w8xIKP.js → actor-shared-DFET5q16.js} +3 -3
  10. package/dist/actor-shared-DFET5q16.js.map +1 -0
  11. package/dist/actor.d.ts +1 -1
  12. package/dist/actor.js +54 -43
  13. package/dist/actor.js.map +1 -1
  14. package/dist/{ai-Cz3hqVhk.d.ts → ai-DAu77CDr.d.ts} +8 -8
  15. package/dist/{ai-Cz3hqVhk.d.ts.map → ai-DAu77CDr.d.ts.map} +1 -1
  16. package/dist/{ai-mMiWul1T.js → ai-EEfKyc-z.js} +9 -9
  17. package/dist/ai-EEfKyc-z.js.map +1 -0
  18. package/dist/ai-client.d.ts +2 -2
  19. package/dist/ai-client.js +2 -2
  20. package/dist/ai-fx.d.ts +4 -2
  21. package/dist/ai-fx.d.ts.map +1 -1
  22. package/dist/ai-fx.js +214 -67
  23. package/dist/ai-fx.js.map +1 -1
  24. package/dist/{ai-harness-CV6rXbB3.d.ts → ai-harness-DTjkAr2T.d.ts} +2 -2
  25. package/dist/{ai-harness-CV6rXbB3.d.ts.map → ai-harness-DTjkAr2T.d.ts.map} +1 -1
  26. package/dist/{ai-harness-Clbg5ei7.js → ai-harness-dQeJaR1t.js} +7 -3
  27. package/dist/ai-harness-dQeJaR1t.js.map +1 -0
  28. package/dist/ai-pi.d.ts +1 -1
  29. package/dist/ai-pi.js +3 -3
  30. package/dist/ai-pi.js.map +1 -1
  31. package/dist/ai-react.d.ts +5 -5
  32. package/dist/ai-react.js +2 -2
  33. package/dist/ai-server.d.ts +14 -17
  34. package/dist/ai-server.d.ts.map +1 -1
  35. package/dist/ai-server.js +86 -73
  36. package/dist/ai-server.js.map +1 -1
  37. package/dist/ai.d.ts +2 -2
  38. package/dist/ai.js +2 -2
  39. package/dist/{client-BhHLoNKF.js → client-BQS4i_V9.js} +29 -6
  40. package/dist/client-BQS4i_V9.js.map +1 -0
  41. package/dist/{client-h_DKFAjj.d.ts → client-CUMbS_38.d.ts} +3 -3
  42. package/dist/{client-h_DKFAjj.d.ts.map → client-CUMbS_38.d.ts.map} +1 -1
  43. package/dist/client.d.ts +3 -3
  44. package/dist/client.js +1 -1
  45. package/dist/{contract-BYKASlCV.js → contract-CWukt01d.js} +2 -2
  46. package/dist/contract-CWukt01d.js.map +1 -0
  47. package/dist/index.d.ts +5 -5
  48. package/dist/index.js +3 -3
  49. package/dist/{initial-snapshot-Bx-c0pDP.d.ts → initial-snapshot-D3Lve43o.d.ts} +4 -4
  50. package/dist/initial-snapshot-D3Lve43o.d.ts.map +1 -0
  51. package/dist/{internal-CscSY7K_.js → internal-CvsrprnU.js} +40 -16
  52. package/dist/internal-CvsrprnU.js.map +1 -0
  53. package/dist/otel.d.ts +1 -1
  54. package/dist/otel.js +1 -1
  55. package/dist/otel.js.map +1 -1
  56. package/dist/{react-BBXgHWFc.d.ts → react-BoHvC3v9.d.ts} +4 -4
  57. package/dist/{react-BBXgHWFc.d.ts.map → react-BoHvC3v9.d.ts.map} +1 -1
  58. package/dist/react.d.ts +1 -1
  59. package/dist/react.js +1 -1
  60. package/dist/{reducer-CqA59Q6U.d.ts → reducer-CYPHsSmd.d.ts} +2 -2
  61. package/dist/{reducer-CqA59Q6U.d.ts.map → reducer-CYPHsSmd.d.ts.map} +1 -1
  62. package/dist/{reducer-BAXc8JyF.js → reducer-CjmvZSAb.js} +2 -2
  63. package/dist/{reducer-BAXc8JyF.js.map → reducer-CjmvZSAb.js.map} +1 -1
  64. package/dist/runtime-consumer-CIeL33wj.js +12 -0
  65. package/dist/runtime-consumer-CIeL33wj.js.map +1 -0
  66. package/dist/scheduler-qstash.d.ts +2 -2
  67. package/dist/scheduler-qstash.js +3 -3
  68. package/dist/scheduler-task-X1SToDjP.js.map +1 -1
  69. package/dist/scheduler-vercel.d.ts +2 -2
  70. package/dist/scheduler-vercel.js +3 -3
  71. package/dist/scheduler-vercel.js.map +1 -1
  72. package/dist/{server-qyb5aJJG.js → server-DtFrWxi3.js} +412 -116
  73. package/dist/server-DtFrWxi3.js.map +1 -0
  74. package/dist/{server-DYAATFuT.d.ts → server-qgez0Xny.d.ts} +108 -41
  75. package/dist/server-qgez0Xny.d.ts.map +1 -0
  76. package/dist/server.browser.js +1 -1
  77. package/dist/server.browser.js.map +1 -1
  78. package/dist/server.d.ts +4 -4
  79. package/dist/server.js +2 -2
  80. package/dist/{session-clear-C-QwTXV1.d.ts → session-clear-D1PFe7xl.d.ts} +2 -2
  81. package/dist/session-clear-D1PFe7xl.d.ts.map +1 -0
  82. package/dist/{session-clear-DGI4jTQ5.js → session-clear-wHd5GCpZ.js} +5 -4
  83. package/dist/session-clear-wHd5GCpZ.js.map +1 -0
  84. package/dist/store-D0MhBvg6.d.ts +532 -0
  85. package/dist/store-D0MhBvg6.d.ts.map +1 -0
  86. package/dist/store-N8PXxDAS.js.map +1 -1
  87. package/dist/store-codec-DwrWlgQx.js +65 -0
  88. package/dist/store-codec-DwrWlgQx.js.map +1 -0
  89. package/dist/store-memory.d.ts +1 -1
  90. package/dist/store-memory.d.ts.map +1 -1
  91. package/dist/store-memory.js +406 -225
  92. package/dist/store-memory.js.map +1 -1
  93. package/dist/{store-polling-7WJV0YWP.js → store-polling-BZt2Rf3D.js} +2 -2
  94. package/dist/{store-polling-7WJV0YWP.js.map → store-polling-BZt2Rf3D.js.map} +1 -1
  95. package/dist/store-postgres.d.ts +1 -1
  96. package/dist/store-postgres.d.ts.map +1 -1
  97. package/dist/store-postgres.js +1598 -587
  98. package/dist/store-postgres.js.map +1 -1
  99. package/dist/store-presence-polling-JuAy2Ck2.js +111 -0
  100. package/dist/store-presence-polling-JuAy2Ck2.js.map +1 -0
  101. package/dist/store-redis-http.d.ts +1 -1
  102. package/dist/store-redis-http.js +4 -4
  103. package/dist/store-redis-notify-CsJw_hfD.js +3060 -0
  104. package/dist/store-redis-notify-CsJw_hfD.js.map +1 -0
  105. package/dist/store-redis.d.ts +1 -1
  106. package/dist/store-redis.d.ts.map +1 -1
  107. package/dist/store-redis.js +4 -5
  108. package/dist/store-redis.js.map +1 -1
  109. package/dist/store-sqlite.d.ts +1 -1
  110. package/dist/store-sqlite.d.ts.map +1 -1
  111. package/dist/store-sqlite.js +527 -299
  112. package/dist/store-sqlite.js.map +1 -1
  113. package/dist/stream-buffer-DrTyiIuo.js +57 -0
  114. package/dist/stream-buffer-DrTyiIuo.js.map +1 -0
  115. package/dist/{telemetry-Cyfqo6Qt.d.ts → telemetry-CWwsh-Gx.d.ts} +3 -3
  116. package/dist/telemetry-CWwsh-Gx.d.ts.map +1 -0
  117. package/dist/{wire-BKxH1vbP.js → wire-CV-Z7wz-.js} +6 -5
  118. package/dist/wire-CV-Z7wz-.js.map +1 -0
  119. package/docs/01-quickstart.mdx +21 -15
  120. package/docs/02-examples.mdx +1 -1
  121. package/docs/actors/01-introduction.mdx +13 -2
  122. package/docs/actors/02-concurrency.mdx +1 -1
  123. package/docs/agents/01-introduction.mdx +1 -1
  124. package/docs/agents/02-tools.mdx +12 -11
  125. package/docs/agents/03-conversations.mdx +36 -25
  126. package/docs/agents/04-harnesses.mdx +14 -7
  127. package/docs/client/03-presence.mdx +18 -14
  128. package/docs/contract/01-events.mdx +2 -2
  129. package/docs/contract/02-state.mdx +18 -14
  130. package/docs/contract/03-sessions.mdx +32 -25
  131. package/docs/index.mdx +29 -24
  132. package/docs/reference/01-core.mdx +7 -6
  133. package/docs/reference/02-server.mdx +250 -191
  134. package/docs/reference/04-ai.mdx +33 -23
  135. package/docs/reference/05-telemetry.mdx +27 -13
  136. package/docs/reference/06-errors.mdx +25 -19
  137. package/docs/reference/meta.ts +1 -1
  138. package/docs/schedulers/01-overview.mdx +8 -8
  139. package/docs/schedulers/02-vercel-queues.mdx +2 -2
  140. package/docs/schedulers/03-qstash.mdx +4 -4
  141. package/docs/server/01-consumers.mdx +418 -0
  142. package/docs/server/02-timers.mdx +31 -21
  143. package/docs/server/03-cancellation.mdx +52 -44
  144. package/docs/server/04-routes.mdx +4 -1
  145. package/docs/server/05-sql-projections.mdx +391 -0
  146. package/docs/server/06-guarantees.mdx +61 -51
  147. package/docs/stores/01-overview.mdx +3 -3
  148. package/docs/stores/02-redis.mdx +24 -10
  149. package/docs/stores/03-redis-http.mdx +8 -6
  150. package/docs/stores/04-postgres.mdx +65 -14
  151. package/docs/stores/05-sqlite.mdx +5 -3
  152. package/docs/upgrades/01-overview.mdx +31 -0
  153. package/docs/upgrades/78-0-22.mdx +115 -0
  154. package/docs/upgrades/79-0-21.mdx +165 -0
  155. package/docs/upgrades/80-0-20.mdx +43 -0
  156. package/docs/upgrades/81-0-19.mdx +33 -0
  157. package/docs/upgrades/82-0-18.mdx +33 -0
  158. package/docs/upgrades/83-0-17.mdx +69 -0
  159. package/docs/upgrades/meta.ts +3 -0
  160. package/examples/playground/DEPLOY.md +39 -7
  161. package/examples/playground/app/a0/chat.test.tsx +2 -2
  162. package/examples/playground/app/a0/sandbox-log.ts +17 -15
  163. package/examples/playground/app/a0/sandbox.ts +58 -13
  164. package/examples/playground/app/a0/server.ts +4 -4
  165. package/examples/playground/app/a2slack/a2slack.css +1067 -0
  166. package/examples/playground/app/a2slack/c/[channel]/page.tsx +20 -0
  167. package/examples/playground/app/a2slack/consumers/agent.ts +63 -0
  168. package/examples/playground/app/a2slack/consumers/feed.ts +164 -0
  169. package/examples/playground/app/a2slack/consumers/notify.ts +116 -0
  170. package/examples/playground/app/a2slack/consumers/search.ts +37 -0
  171. package/examples/playground/app/a2slack/consumers/triage.ts +31 -0
  172. package/examples/playground/app/a2slack/consumers/welcome.ts +25 -0
  173. package/examples/playground/app/a2slack/db/a2.ts +209 -0
  174. package/examples/playground/app/a2slack/db/index.ts +73 -0
  175. package/examples/playground/app/a2slack/db/migrations/0000_extensions.sql +4 -0
  176. package/examples/playground/app/a2slack/db/migrations/0001_workspace.sql +74 -0
  177. package/examples/playground/app/a2slack/db/migrations/0002_a2.sql +215 -0
  178. package/examples/playground/app/a2slack/db/migrations/0003_a2_batch.sql +67 -0
  179. package/examples/playground/app/a2slack/db/migrations/0004_a2_session_event_ids.sql +2 -0
  180. package/examples/playground/app/a2slack/db/migrations/meta/0000_snapshot.json +18 -0
  181. package/examples/playground/app/a2slack/db/migrations/meta/0001_snapshot.json +500 -0
  182. package/examples/playground/app/a2slack/db/migrations/meta/0002_snapshot.json +1159 -0
  183. package/examples/playground/app/a2slack/db/migrations/meta/0003_snapshot.json +1159 -0
  184. package/examples/playground/app/a2slack/db/migrations/meta/0004_snapshot.json +1165 -0
  185. package/examples/playground/app/a2slack/db/migrations/meta/_journal.json +41 -0
  186. package/examples/playground/app/a2slack/db/queries.ts +263 -0
  187. package/examples/playground/app/a2slack/db/schema.ts +145 -0
  188. package/examples/playground/app/a2slack/drills/a2slack.drill.ts +708 -0
  189. package/examples/playground/app/a2slack/drills/harness.ts +244 -0
  190. package/examples/playground/app/a2slack/events/route.ts +36 -0
  191. package/examples/playground/app/a2slack/identity.ts +44 -0
  192. package/examples/playground/app/a2slack/inbox/route.ts +8 -0
  193. package/examples/playground/app/a2slack/lag/data/route.ts +8 -0
  194. package/examples/playground/app/a2slack/lag/page.tsx +101 -0
  195. package/examples/playground/app/a2slack/lag.ts +73 -0
  196. package/examples/playground/app/a2slack/layout.tsx +43 -0
  197. package/examples/playground/app/a2slack/messages/route.ts +17 -0
  198. package/examples/playground/app/a2slack/model.test.ts +208 -0
  199. package/examples/playground/app/a2slack/model.ts +369 -0
  200. package/examples/playground/app/a2slack/models.test.ts +72 -0
  201. package/examples/playground/app/a2slack/page.tsx +10 -0
  202. package/examples/playground/app/a2slack/scheduler/route.ts +5 -0
  203. package/examples/playground/app/a2slack/search/route.ts +40 -0
  204. package/examples/playground/app/a2slack/search-index.ts +108 -0
  205. package/examples/playground/app/a2slack/server.test.ts +315 -0
  206. package/examples/playground/app/a2slack/server.ts +55 -0
  207. package/examples/playground/app/a2slack/sessions.ts +50 -0
  208. package/examples/playground/app/a2slack/thread/route.ts +8 -0
  209. package/examples/playground/app/a2slack/ui/ChannelView.tsx +87 -0
  210. package/examples/playground/app/a2slack/ui/Composer.tsx +117 -0
  211. package/examples/playground/app/a2slack/ui/DotsShadow.tsx +42 -0
  212. package/examples/playground/app/a2slack/ui/Feed.tsx +157 -0
  213. package/examples/playground/app/a2slack/ui/InboxContext.tsx +63 -0
  214. package/examples/playground/app/a2slack/ui/InboxView.tsx +82 -0
  215. package/examples/playground/app/a2slack/ui/Menu.tsx +128 -0
  216. package/examples/playground/app/a2slack/ui/MessageRow.tsx +314 -0
  217. package/examples/playground/app/a2slack/ui/NameGate.tsx +35 -0
  218. package/examples/playground/app/a2slack/ui/SearchContext.tsx +146 -0
  219. package/examples/playground/app/a2slack/ui/SearchResults.tsx +97 -0
  220. package/examples/playground/app/a2slack/ui/Shell.tsx +34 -0
  221. package/examples/playground/app/a2slack/ui/Sidebar.tsx +199 -0
  222. package/examples/playground/app/a2slack/ui/ThreadPanel.tsx +134 -0
  223. package/examples/playground/app/a2slack/ui/useInbox.ts +71 -0
  224. package/examples/playground/app/agent/server.ts +3 -3
  225. package/examples/playground/app/chat/[chatId]/chat-client.tsx +1 -1
  226. package/examples/playground/app/chat/server.ts +45 -35
  227. package/examples/playground/app/components/demo-icons.tsx +7 -0
  228. package/examples/playground/app/components/demos.tsx +7 -0
  229. package/examples/playground/app/orders/[orderId]/order-client.tsx +2 -2
  230. package/examples/playground/app/orders/page.tsx +1 -1
  231. package/examples/playground/app/orders/server.ts +49 -30
  232. package/examples/playground/app/recovery/[recoveryId]/recovery-client.tsx +14 -15
  233. package/examples/playground/app/recovery/page.tsx +4 -4
  234. package/examples/playground/app/recovery/server.ts +23 -32
  235. package/examples/playground/drizzle.config.ts +14 -0
  236. package/examples/playground/lib/store.ts +1 -1
  237. package/examples/playground/next.config.ts +6 -1
  238. package/examples/playground/package.json +12 -1
  239. package/examples/playground/scripts/migrate.mjs +31 -0
  240. package/examples/playground/vercel.json +9 -0
  241. package/examples/playground/vitest.drills.config.ts +32 -0
  242. package/package.json +5 -2
  243. package/sql/postgres/drizzle-schema.ts +209 -0
  244. package/sql/postgres/schema.sql +227 -67
  245. package/src/actor-shared.ts +10 -2
  246. package/src/actor.ts +101 -71
  247. package/src/ai-agent.ts +162 -111
  248. package/src/ai-control-server.ts +8 -8
  249. package/src/ai-fx.ts +324 -82
  250. package/src/ai-harness.ts +17 -2
  251. package/src/ai-pi.ts +2 -2
  252. package/src/ai-server.ts +2 -2
  253. package/src/ai.ts +7 -7
  254. package/src/client.ts +19 -2
  255. package/src/contract.ts +1 -1
  256. package/src/deterministic-id.ts +27 -4
  257. package/src/errors.ts +8 -5
  258. package/src/idempotent-replay.ts +0 -1
  259. package/src/index.ts +1 -1
  260. package/src/internal.ts +57 -16
  261. package/src/otel.ts +1 -1
  262. package/src/postgres-schema.ts +98 -57
  263. package/src/presence-recovery.ts +18 -11
  264. package/src/presence-tombstones.ts +52 -0
  265. package/src/push-envelope.ts +20 -0
  266. package/src/runtime-consumer.ts +10 -0
  267. package/src/scheduler-task.ts +2 -2
  268. package/src/scheduler-vercel.ts +1 -1
  269. package/src/server.browser.ts +1 -1
  270. package/src/server.ts +819 -218
  271. package/src/session-socket.ts +1 -1
  272. package/src/store-codec.ts +66 -0
  273. package/src/store-memory.ts +604 -286
  274. package/src/store-postgres.ts +2006 -857
  275. package/src/store-presence-polling.ts +59 -34
  276. package/src/store-redis-core.ts +1961 -1333
  277. package/src/store-redis-notify.ts +76 -26
  278. package/src/store-redis.ts +2 -3
  279. package/src/store-sqlite.ts +915 -516
  280. package/src/store.ts +241 -102
  281. package/src/telemetry.ts +2 -1
  282. package/src/wire.ts +1 -0
  283. package/dist/actor-C7b-1arn.d.ts.map +0 -1
  284. package/dist/actor-shared-t7w8xIKP.js.map +0 -1
  285. package/dist/ai-harness-Clbg5ei7.js.map +0 -1
  286. package/dist/ai-mMiWul1T.js.map +0 -1
  287. package/dist/client-BhHLoNKF.js.map +0 -1
  288. package/dist/contract-BYKASlCV.js.map +0 -1
  289. package/dist/idempotent-replay-JdgMQRRm.js +0 -21
  290. package/dist/idempotent-replay-JdgMQRRm.js.map +0 -1
  291. package/dist/initial-snapshot-Bx-c0pDP.d.ts.map +0 -1
  292. package/dist/internal-CscSY7K_.js.map +0 -1
  293. package/dist/server-DYAATFuT.d.ts.map +0 -1
  294. package/dist/server-qyb5aJJG.js.map +0 -1
  295. package/dist/session-clear-C-QwTXV1.d.ts.map +0 -1
  296. package/dist/session-clear-DGI4jTQ5.js.map +0 -1
  297. package/dist/store-CyLUy86v.d.ts +0 -404
  298. package/dist/store-CyLUy86v.d.ts.map +0 -1
  299. package/dist/store-codec-DTG0Ftek.js +0 -10
  300. package/dist/store-codec-DTG0Ftek.js.map +0 -1
  301. package/dist/store-presence-polling-B1ixeqIS.js +0 -94
  302. package/dist/store-presence-polling-B1ixeqIS.js.map +0 -1
  303. package/dist/store-redis-notify-CM0AfLY0.js +0 -2544
  304. package/dist/store-redis-notify-CM0AfLY0.js.map +0 -1
  305. package/dist/stream-buffer-Cx7AAPGq.js +0 -23
  306. package/dist/stream-buffer-Cx7AAPGq.js.map +0 -1
  307. package/dist/telemetry-Cyfqo6Qt.d.ts.map +0 -1
  308. package/dist/wire-BKxH1vbP.js.map +0 -1
  309. 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 { HandlerContext } from './server.ts'
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 handler context supplied when A2 executes a local AI tool. */
466
- export type AgentToolContext<D extends AgentToolEventDefs> = HandlerContext<
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 handler context ambient to the current AI tool
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 handlerContext<D extends AgentToolEventDefs = AIEventDefs>(
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
- "handlerContext() requires an ambient A2 tool execution: call it inside a local tool's execute, or wrap direct tool tests with runWithHandlerContext",
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
- `handlerContext('${contract.name}') was called during a tool execution for agent '${scope.contract.name}'`,
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
- // Field-wise LWW: an older stamp loses, a tie goes to the wire.
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
- next[id] = fields
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 handler-returned event batch. */
126
+ /** Preserve literal types for a consumer-returned event batch. */
127
127
  readonly batch: EventBatchFactory<D>
128
128
  readonly reducer: R
129
129
  }
@@ -1,9 +1,9 @@
1
1
  /**
2
- * Deterministic default ids for handler `session.append` calls. A handler
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([APPEND_NAMESPACE, triggerEventId, name, itemOrdinal])
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([RETURN_NAMESPACE, triggerEventId, itemOrdinal])
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' // handler append from an attempt a recovery claim or a clear replaced
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 handler write whose attempt no longer owns its triggering event. */
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 handler to reject retrying: the failure is adjudicated
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
@@ -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, handlers, sessions —
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
- * Handler contexts whose previous attempt ended in a failure the handler
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 `handlerContext` during a tool execution. */
97
+ /** The ambient value delivered to `consumerContext` during a tool execution. */
88
98
  export type AmbientToolScope = {
89
- /** Executing server identity; a test scope from `runWithHandlerContext` has none. */
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 `handlerContext` in the isomorphic AI entry point and
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 handler is running. */
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
- * Safety-read cadence for the notify-driven redis live stream. A
317
- * parked feed re-reads the store at this interval even without a
318
- * pub/sub wake-up, so a lost notification (subscriber reconnect gap,
319
- * dropped PUBLISH) costs latency, never an event. Mutable only as a
320
- * white-box test seam.
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
- * Client presence send cadence — a leading send immediately, then at
416
- * most one merged send per interval (~15Hz). Owned, not configurable
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 handler's own spans.
7
+ * request → a2.append → a2.drain → a2.event → the consumer's own spans.
8
8
  */
9
9
 
10
10
  import {
@@ -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` (and `notifications.sql` for
4
- * `listenConnectionString`) create the tables, and a store checks the
5
- * recorded version before its first query.
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
- const VERSION = 'select version from a2_schema'
22
- const VERSION_AND_TRIGGERS = `select version, (select count(*) from pg_trigger
23
- where (tgname = 'a2_notify_events' and tgrelid = to_regclass('a2_events'))
24
- or (tgname = 'a2_notify_presence' and tgrelid = to_regclass('a2_presence')))
25
- as notify_triggers from a2_schema`
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
- /** Explains a failed version read, or returns undefined when the schema is not the cause. */
28
- const diagnose = async (
29
- client: PostgresClient,
30
- ): Promise<A2Error | undefined> => {
31
- const [found] = (
32
- await client.query(`select
33
- to_regclass('a2_schema') is not null as marked,
34
- to_regclass('a2_events') is not null as installed,
35
- has_table_privilege(to_regclass('a2_schema'), 'select') as readable`)
36
- ).rows
37
- if (!found) return undefined
38
- if (!found['marked'])
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['installed']
42
- ? `This Postgres database has A2's tables but no a2_schema version. That is expected once after upgrading from a release that created its tables at runtime, and your events are untouched. A2 doesn't create or change tables at runtime: ${apply('schema.sql')}. ${SEE_DOCS}`
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['readable'] === false)
61
+ if (found?.code === INSUFFICIENT_PRIVILEGE)
46
62
  return new A2Error(
47
63
  'STORE_NOT_CONFIGURED',
48
- `The role this store connects as can't read a2_schema. Apply schema.sql as that role, or grant it select on a2_schema and select, insert, update, and delete on A2's other tables. ${SEE_DOCS}`,
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
- return undefined
51
- }
52
-
53
- /** Reads the schema version (and, with a listener, the triggers) in one query that takes no lock a writer holds. */
54
- export const checkPostgresSchema = async ({
55
- client,
56
- listen,
57
- }: {
58
- client: PostgresClient
59
- listen: boolean
60
- }): Promise<void> => {
61
- let row: Record<string, unknown> | undefined
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
- `This Postgres database has A2 schema version ${version}, and this release of experimental-a2 needs version ${POSTGRES_SCHEMA_VERSION}: ${apply('schema.sql')}. ${SEE_DOCS}`,
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
- if (listen && Number(row?.['notify_triggers']) < 2) {
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, ${apply('notifications.sql')}. ${SEE_DOCS}`,
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
  }