experimental-a2 0.20.0 → 0.21.1

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 (286) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/README.md +1 -1
  3. package/dist/{actor-C7b-1arn.d.ts → actor-HnGmrOZZ.d.ts} +5 -5
  4. package/dist/{actor-C7b-1arn.d.ts.map → actor-HnGmrOZZ.d.ts.map} +1 -1
  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-t7w8xIKP.js.map → actor-shared-DFET5q16.js.map} +1 -1
  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-CvEnqVfo.d.ts} +8 -8
  15. package/dist/{ai-Cz3hqVhk.d.ts.map → ai-CvEnqVfo.d.ts.map} +1 -1
  16. package/dist/{ai-mMiWul1T.js → ai-CzE672Q3.js} +9 -9
  17. package/dist/ai-CzE672Q3.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 +1 -1
  21. package/dist/ai-fx.js +3 -6
  22. package/dist/ai-fx.js.map +1 -1
  23. package/dist/{ai-harness-CV6rXbB3.d.ts → ai-harness-Bfk-Lycn.d.ts} +2 -2
  24. package/dist/{ai-harness-CV6rXbB3.d.ts.map → ai-harness-Bfk-Lycn.d.ts.map} +1 -1
  25. package/dist/{ai-harness-Clbg5ei7.js → ai-harness-dQeJaR1t.js} +7 -3
  26. package/dist/ai-harness-dQeJaR1t.js.map +1 -0
  27. package/dist/ai-pi.d.ts +1 -1
  28. package/dist/ai-pi.js +3 -3
  29. package/dist/ai-pi.js.map +1 -1
  30. package/dist/ai-react.d.ts +5 -5
  31. package/dist/ai-react.js +2 -2
  32. package/dist/ai-server.d.ts +14 -17
  33. package/dist/ai-server.d.ts.map +1 -1
  34. package/dist/ai-server.js +80 -73
  35. package/dist/ai-server.js.map +1 -1
  36. package/dist/ai.d.ts +2 -2
  37. package/dist/ai.js +2 -2
  38. package/dist/{client-BhHLoNKF.js → client-9IeTsiAs.js} +5 -5
  39. package/dist/{client-BhHLoNKF.js.map → client-9IeTsiAs.js.map} +1 -1
  40. package/dist/{client-h_DKFAjj.d.ts → client-CUMbS_38.d.ts} +3 -3
  41. package/dist/{client-h_DKFAjj.d.ts.map → client-CUMbS_38.d.ts.map} +1 -1
  42. package/dist/client.d.ts +3 -3
  43. package/dist/client.js +1 -1
  44. package/dist/{contract-BYKASlCV.js → contract-CWukt01d.js} +2 -2
  45. package/dist/contract-CWukt01d.js.map +1 -0
  46. package/dist/index.d.ts +5 -5
  47. package/dist/index.js +3 -3
  48. package/dist/{initial-snapshot-Bx-c0pDP.d.ts → initial-snapshot-D3Lve43o.d.ts} +4 -4
  49. package/dist/initial-snapshot-D3Lve43o.d.ts.map +1 -0
  50. package/dist/{internal-CscSY7K_.js → internal-Bc4PvfHk.js} +10 -3
  51. package/dist/internal-Bc4PvfHk.js.map +1 -0
  52. package/dist/otel.d.ts +1 -1
  53. package/dist/otel.js +1 -1
  54. package/dist/otel.js.map +1 -1
  55. package/dist/{react-BBXgHWFc.d.ts → react-BoHvC3v9.d.ts} +4 -4
  56. package/dist/{react-BBXgHWFc.d.ts.map → react-BoHvC3v9.d.ts.map} +1 -1
  57. package/dist/react.d.ts +1 -1
  58. package/dist/react.js +1 -1
  59. package/dist/{reducer-CqA59Q6U.d.ts → reducer-CYPHsSmd.d.ts} +2 -2
  60. package/dist/{reducer-CqA59Q6U.d.ts.map → reducer-CYPHsSmd.d.ts.map} +1 -1
  61. package/dist/{reducer-BAXc8JyF.js → reducer-CjmvZSAb.js} +2 -2
  62. package/dist/{reducer-BAXc8JyF.js.map → reducer-CjmvZSAb.js.map} +1 -1
  63. package/dist/runtime-consumer-BWmht60z.js +12 -0
  64. package/dist/runtime-consumer-BWmht60z.js.map +1 -0
  65. package/dist/scheduler-qstash.d.ts +2 -2
  66. package/dist/scheduler-qstash.js +3 -3
  67. package/dist/scheduler-task-X1SToDjP.js.map +1 -1
  68. package/dist/scheduler-vercel.d.ts +2 -2
  69. package/dist/scheduler-vercel.js +3 -3
  70. package/dist/scheduler-vercel.js.map +1 -1
  71. package/dist/{server-DYAATFuT.d.ts → server-BshFZ0eL.d.ts} +108 -41
  72. package/dist/server-BshFZ0eL.d.ts.map +1 -0
  73. package/dist/{server-qyb5aJJG.js → server-DvIB8kj5.js} +387 -113
  74. package/dist/server-DvIB8kj5.js.map +1 -0
  75. package/dist/server.browser.js +1 -1
  76. package/dist/server.browser.js.map +1 -1
  77. package/dist/server.d.ts +4 -4
  78. package/dist/server.js +2 -2
  79. package/dist/{session-clear-C-QwTXV1.d.ts → session-clear-Bk_29eEw.d.ts} +2 -2
  80. package/dist/session-clear-Bk_29eEw.d.ts.map +1 -0
  81. package/dist/{session-clear-DGI4jTQ5.js → session-clear-wHd5GCpZ.js} +5 -4
  82. package/dist/session-clear-wHd5GCpZ.js.map +1 -0
  83. package/dist/store-N8PXxDAS.js.map +1 -1
  84. package/dist/store-Z2qjw50n.d.ts +523 -0
  85. package/dist/store-Z2qjw50n.d.ts.map +1 -0
  86. package/dist/store-codec-DwrWlgQx.js +65 -0
  87. package/dist/store-codec-DwrWlgQx.js.map +1 -0
  88. package/dist/store-memory.d.ts +1 -1
  89. package/dist/store-memory.d.ts.map +1 -1
  90. package/dist/store-memory.js +393 -210
  91. package/dist/store-memory.js.map +1 -1
  92. package/dist/{store-polling-7WJV0YWP.js → store-polling-DYNyxCev.js} +2 -2
  93. package/dist/{store-polling-7WJV0YWP.js.map → store-polling-DYNyxCev.js.map} +1 -1
  94. package/dist/store-postgres.d.ts +1 -1
  95. package/dist/store-postgres.d.ts.map +1 -1
  96. package/dist/store-postgres.js +1586 -566
  97. package/dist/store-postgres.js.map +1 -1
  98. package/dist/{store-presence-polling-B1ixeqIS.js → store-presence-polling-CzQ0wdtM.js} +3 -3
  99. package/dist/{store-presence-polling-B1ixeqIS.js.map → store-presence-polling-CzQ0wdtM.js.map} +1 -1
  100. package/dist/store-redis-http.d.ts +1 -1
  101. package/dist/store-redis-http.js +4 -4
  102. package/dist/store-redis-notify-IiVdlYHc.js +2919 -0
  103. package/dist/store-redis-notify-IiVdlYHc.js.map +1 -0
  104. package/dist/store-redis.d.ts +1 -1
  105. package/dist/store-redis.js +2 -2
  106. package/dist/store-sqlite.d.ts +1 -1
  107. package/dist/store-sqlite.d.ts.map +1 -1
  108. package/dist/store-sqlite.js +517 -287
  109. package/dist/store-sqlite.js.map +1 -1
  110. package/dist/{telemetry-Cyfqo6Qt.d.ts → telemetry-CWwsh-Gx.d.ts} +3 -3
  111. package/dist/telemetry-CWwsh-Gx.d.ts.map +1 -0
  112. package/dist/{wire-BKxH1vbP.js → wire-CZGqNZRx.js} +5 -4
  113. package/dist/wire-CZGqNZRx.js.map +1 -0
  114. package/docs/01-quickstart.mdx +21 -15
  115. package/docs/02-examples.mdx +1 -1
  116. package/docs/actors/01-introduction.mdx +8 -0
  117. package/docs/actors/02-concurrency.mdx +1 -1
  118. package/docs/agents/01-introduction.mdx +1 -1
  119. package/docs/agents/02-tools.mdx +12 -11
  120. package/docs/agents/03-conversations.mdx +30 -20
  121. package/docs/agents/04-harnesses.mdx +7 -3
  122. package/docs/client/03-presence.mdx +18 -14
  123. package/docs/contract/01-events.mdx +2 -2
  124. package/docs/contract/02-state.mdx +18 -14
  125. package/docs/contract/03-sessions.mdx +32 -25
  126. package/docs/index.mdx +29 -24
  127. package/docs/reference/01-core.mdx +4 -4
  128. package/docs/reference/02-server.mdx +245 -190
  129. package/docs/reference/04-ai.mdx +20 -19
  130. package/docs/reference/05-telemetry.mdx +27 -13
  131. package/docs/reference/06-errors.mdx +24 -18
  132. package/docs/reference/meta.ts +1 -1
  133. package/docs/schedulers/01-overview.mdx +8 -8
  134. package/docs/schedulers/02-vercel-queues.mdx +2 -2
  135. package/docs/schedulers/03-qstash.mdx +4 -4
  136. package/docs/server/01-consumers.mdx +415 -0
  137. package/docs/server/02-timers.mdx +31 -21
  138. package/docs/server/03-cancellation.mdx +52 -44
  139. package/docs/server/04-routes.mdx +4 -1
  140. package/docs/server/05-sql-projections.mdx +391 -0
  141. package/docs/server/06-guarantees.mdx +55 -48
  142. package/docs/stores/01-overview.mdx +3 -3
  143. package/docs/stores/02-redis.mdx +11 -3
  144. package/docs/stores/03-redis-http.mdx +2 -2
  145. package/docs/stores/04-postgres.mdx +65 -14
  146. package/docs/stores/05-sqlite.mdx +5 -3
  147. package/docs/upgrades/01-overview.mdx +30 -0
  148. package/docs/upgrades/79-0-21.mdx +165 -0
  149. package/docs/upgrades/80-0-20.mdx +43 -0
  150. package/docs/upgrades/81-0-19.mdx +33 -0
  151. package/docs/upgrades/82-0-18.mdx +33 -0
  152. package/docs/upgrades/83-0-17.mdx +69 -0
  153. package/docs/upgrades/meta.ts +3 -0
  154. package/examples/playground/DEPLOY.md +39 -7
  155. package/examples/playground/app/a0/chat.test.tsx +2 -2
  156. package/examples/playground/app/a0/sandbox-log.ts +17 -15
  157. package/examples/playground/app/a0/server.ts +4 -4
  158. package/examples/playground/app/a2slack/a2slack.css +1067 -0
  159. package/examples/playground/app/a2slack/c/[channel]/page.tsx +20 -0
  160. package/examples/playground/app/a2slack/consumers/agent.ts +63 -0
  161. package/examples/playground/app/a2slack/consumers/feed.ts +164 -0
  162. package/examples/playground/app/a2slack/consumers/notify.ts +116 -0
  163. package/examples/playground/app/a2slack/consumers/search.ts +37 -0
  164. package/examples/playground/app/a2slack/consumers/triage.ts +31 -0
  165. package/examples/playground/app/a2slack/consumers/welcome.ts +25 -0
  166. package/examples/playground/app/a2slack/db/a2.ts +206 -0
  167. package/examples/playground/app/a2slack/db/index.ts +73 -0
  168. package/examples/playground/app/a2slack/db/migrations/0000_extensions.sql +4 -0
  169. package/examples/playground/app/a2slack/db/migrations/0001_workspace.sql +74 -0
  170. package/examples/playground/app/a2slack/db/migrations/0002_a2.sql +215 -0
  171. package/examples/playground/app/a2slack/db/migrations/0003_a2_batch.sql +67 -0
  172. package/examples/playground/app/a2slack/db/migrations/meta/0000_snapshot.json +18 -0
  173. package/examples/playground/app/a2slack/db/migrations/meta/0001_snapshot.json +500 -0
  174. package/examples/playground/app/a2slack/db/migrations/meta/0002_snapshot.json +1159 -0
  175. package/examples/playground/app/a2slack/db/migrations/meta/0003_snapshot.json +1159 -0
  176. package/examples/playground/app/a2slack/db/migrations/meta/_journal.json +34 -0
  177. package/examples/playground/app/a2slack/db/queries.ts +263 -0
  178. package/examples/playground/app/a2slack/db/schema.ts +145 -0
  179. package/examples/playground/app/a2slack/drills/a2slack.drill.ts +708 -0
  180. package/examples/playground/app/a2slack/drills/harness.ts +244 -0
  181. package/examples/playground/app/a2slack/events/route.ts +36 -0
  182. package/examples/playground/app/a2slack/identity.ts +44 -0
  183. package/examples/playground/app/a2slack/inbox/route.ts +8 -0
  184. package/examples/playground/app/a2slack/lag/data/route.ts +8 -0
  185. package/examples/playground/app/a2slack/lag/page.tsx +101 -0
  186. package/examples/playground/app/a2slack/lag.ts +73 -0
  187. package/examples/playground/app/a2slack/layout.tsx +43 -0
  188. package/examples/playground/app/a2slack/messages/route.ts +17 -0
  189. package/examples/playground/app/a2slack/model.test.ts +208 -0
  190. package/examples/playground/app/a2slack/model.ts +369 -0
  191. package/examples/playground/app/a2slack/models.test.ts +72 -0
  192. package/examples/playground/app/a2slack/page.tsx +10 -0
  193. package/examples/playground/app/a2slack/scheduler/route.ts +5 -0
  194. package/examples/playground/app/a2slack/search/route.ts +40 -0
  195. package/examples/playground/app/a2slack/search-index.ts +108 -0
  196. package/examples/playground/app/a2slack/server.test.ts +315 -0
  197. package/examples/playground/app/a2slack/server.ts +55 -0
  198. package/examples/playground/app/a2slack/sessions.ts +50 -0
  199. package/examples/playground/app/a2slack/thread/route.ts +8 -0
  200. package/examples/playground/app/a2slack/ui/ChannelView.tsx +87 -0
  201. package/examples/playground/app/a2slack/ui/Composer.tsx +117 -0
  202. package/examples/playground/app/a2slack/ui/DotsShadow.tsx +42 -0
  203. package/examples/playground/app/a2slack/ui/Feed.tsx +157 -0
  204. package/examples/playground/app/a2slack/ui/InboxContext.tsx +63 -0
  205. package/examples/playground/app/a2slack/ui/InboxView.tsx +82 -0
  206. package/examples/playground/app/a2slack/ui/Menu.tsx +128 -0
  207. package/examples/playground/app/a2slack/ui/MessageRow.tsx +314 -0
  208. package/examples/playground/app/a2slack/ui/NameGate.tsx +35 -0
  209. package/examples/playground/app/a2slack/ui/SearchContext.tsx +146 -0
  210. package/examples/playground/app/a2slack/ui/SearchResults.tsx +97 -0
  211. package/examples/playground/app/a2slack/ui/Shell.tsx +34 -0
  212. package/examples/playground/app/a2slack/ui/Sidebar.tsx +199 -0
  213. package/examples/playground/app/a2slack/ui/ThreadPanel.tsx +134 -0
  214. package/examples/playground/app/a2slack/ui/useInbox.ts +71 -0
  215. package/examples/playground/app/agent/server.ts +3 -3
  216. package/examples/playground/app/chat/[chatId]/chat-client.tsx +1 -1
  217. package/examples/playground/app/chat/server.ts +45 -35
  218. package/examples/playground/app/components/demo-icons.tsx +7 -0
  219. package/examples/playground/app/components/demos.tsx +7 -0
  220. package/examples/playground/app/orders/[orderId]/order-client.tsx +2 -2
  221. package/examples/playground/app/orders/page.tsx +1 -1
  222. package/examples/playground/app/orders/server.ts +49 -30
  223. package/examples/playground/app/recovery/[recoveryId]/recovery-client.tsx +14 -15
  224. package/examples/playground/app/recovery/page.tsx +4 -4
  225. package/examples/playground/app/recovery/server.ts +23 -32
  226. package/examples/playground/drizzle.config.ts +14 -0
  227. package/examples/playground/next.config.ts +6 -1
  228. package/examples/playground/package.json +12 -1
  229. package/examples/playground/scripts/migrate.mjs +31 -0
  230. package/examples/playground/vercel.json +9 -0
  231. package/examples/playground/vitest.drills.config.ts +32 -0
  232. package/package.json +5 -2
  233. package/sql/postgres/drizzle-schema.ts +206 -0
  234. package/sql/postgres/schema.sql +206 -67
  235. package/src/actor.ts +101 -71
  236. package/src/ai-agent.ts +149 -111
  237. package/src/ai-control-server.ts +8 -8
  238. package/src/ai-fx.ts +4 -6
  239. package/src/ai-harness.ts +10 -2
  240. package/src/ai-pi.ts +2 -2
  241. package/src/ai-server.ts +2 -2
  242. package/src/ai.ts +7 -7
  243. package/src/contract.ts +1 -1
  244. package/src/deterministic-id.ts +26 -3
  245. package/src/errors.ts +8 -5
  246. package/src/idempotent-replay.ts +0 -1
  247. package/src/index.ts +1 -1
  248. package/src/internal.ts +15 -5
  249. package/src/otel.ts +1 -1
  250. package/src/postgres-schema.ts +98 -57
  251. package/src/push-envelope.ts +20 -0
  252. package/src/runtime-consumer.ts +10 -0
  253. package/src/scheduler-task.ts +2 -2
  254. package/src/scheduler-vercel.ts +1 -1
  255. package/src/server.browser.ts +1 -1
  256. package/src/server.ts +774 -207
  257. package/src/session-socket.ts +1 -1
  258. package/src/store-codec.ts +66 -0
  259. package/src/store-memory.ts +581 -255
  260. package/src/store-postgres.ts +1984 -825
  261. package/src/store-redis-core.ts +1624 -1152
  262. package/src/store-redis-notify.ts +2 -0
  263. package/src/store-sqlite.ts +891 -486
  264. package/src/store.ts +227 -97
  265. package/src/telemetry.ts +2 -1
  266. package/src/wire.ts +1 -0
  267. package/dist/ai-harness-Clbg5ei7.js.map +0 -1
  268. package/dist/ai-mMiWul1T.js.map +0 -1
  269. package/dist/contract-BYKASlCV.js.map +0 -1
  270. package/dist/idempotent-replay-JdgMQRRm.js +0 -21
  271. package/dist/idempotent-replay-JdgMQRRm.js.map +0 -1
  272. package/dist/initial-snapshot-Bx-c0pDP.d.ts.map +0 -1
  273. package/dist/internal-CscSY7K_.js.map +0 -1
  274. package/dist/server-DYAATFuT.d.ts.map +0 -1
  275. package/dist/server-qyb5aJJG.js.map +0 -1
  276. package/dist/session-clear-C-QwTXV1.d.ts.map +0 -1
  277. package/dist/session-clear-DGI4jTQ5.js.map +0 -1
  278. package/dist/store-CyLUy86v.d.ts +0 -404
  279. package/dist/store-CyLUy86v.d.ts.map +0 -1
  280. package/dist/store-codec-DTG0Ftek.js +0 -10
  281. package/dist/store-codec-DTG0Ftek.js.map +0 -1
  282. package/dist/store-redis-notify-CM0AfLY0.js +0 -2544
  283. package/dist/store-redis-notify-CM0AfLY0.js.map +0 -1
  284. package/dist/telemetry-Cyfqo6Qt.d.ts.map +0 -1
  285. package/dist/wire-BKxH1vbP.js.map +0 -1
  286. 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/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,5 +1,5 @@
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
@@ -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
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
  }
@@ -77,6 +77,13 @@ export function parsePresenceSibling(
77
77
  return out
78
78
  }
79
79
 
80
+ /** The longest event id a push may carry: ids are indexed, and Postgres's index rows have a limit. */
81
+ const MAX_PUSHED_ID = 256
82
+
83
+ /** A UUIDv5, the shape of the ids A2 derives (deterministic-id.ts). */
84
+ const DERIVED_ID =
85
+ /^[0-9a-f]{8}-[0-9a-f]{4}-5[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
86
+
80
87
  /** The events half of the envelope, shared by the POST body and socket
81
88
  * `push` frames so INVALID_PAYLOAD shapes have one implementation. */
82
89
  export function parsePushEvents(events: unknown): ParsedPushEvent[] {
@@ -94,6 +101,19 @@ export function parsePushEvents(events: unknown): ParsedPushEvent[] {
94
101
  if (id !== undefined && typeof id !== 'string') {
95
102
  throw invalid(`events[${i}].id must be a string when present`)
96
103
  }
104
+ if (id !== undefined && (id.length === 0 || id.length > MAX_PUSHED_ID)) {
105
+ throw invalid(
106
+ `events[${i}].id must be 1-${MAX_PUSHED_ID} characters when present`,
107
+ )
108
+ }
109
+ // A2 derives the ids of what consumers append, return, and schedule as
110
+ // UUIDv5s, from inputs a caller can know: one pushed first would take its
111
+ // place. Callers' own ids are v4 (crypto.randomUUID) or v8 (idempotentId).
112
+ if (id !== undefined && DERIVED_ID.test(id)) {
113
+ throw invalid(
114
+ `events[${i}].id is shaped like the ids A2 derives for consumers (a UUIDv5); use crypto.randomUUID() or idempotentId()`,
115
+ )
116
+ }
97
117
  const out: { type: string; payload: unknown; id?: string } = {
98
118
  type,
99
119
  payload,
@@ -0,0 +1,10 @@
1
+ /**
2
+ * `consumer()` for A2's own runtimes (actors, the AI runtime): their names
3
+ * sit under the reserved `a2.` prefix, and their child ids leave the name
4
+ * out, as the handler tables they replaced had. Not a public entry point.
5
+ */
6
+ import { runtimeConsumerFactory } from './internal.ts'
7
+ import type { consumer } from './server.ts'
8
+
9
+ export const runtimeConsumer = ((options: never) =>
10
+ runtimeConsumerFactory.define!(options)) as typeof consumer
@@ -5,7 +5,7 @@ export type ScheduledEvent = {
5
5
  payload: unknown
6
6
  }
7
7
 
8
- /** A durable wake-up for pending handler work. */
8
+ /** A durable wake-up for pending consumer work. */
9
9
  export type SchedulerDrainTask = {
10
10
  version: 1
11
11
  kind: 'drain'
@@ -26,7 +26,7 @@ export type SchedulerAppendTask = {
26
26
  /** Earliest useful delivery as epoch milliseconds. */
27
27
  dueAt: number
28
28
  events: readonly ScheduledEvent[]
29
- /** The scheduling handler's event; delivery skips once it is cleared. */
29
+ /** The scheduling consumer's event; delivery skips once it is cleared. */
30
30
  originIndex?: number
31
31
  }
32
32
 
@@ -14,7 +14,7 @@
14
14
  * the configured deployment scope for sending and receiving.
15
15
  * - `handler(...servers)` is the delivery route: look up the server for
16
16
  * the contract named in the message and drain it. Settled drains ack;
17
- * contention, handler failure, and a drain that throws after arming its
17
+ * contention, consumer failure, and a drain that throws after arming its
18
18
  * successor all ack once a later watchdog is durable.
19
19
  *
20
20
  * `@vercel/queue` is an optional peer dependency, imported lazily. The
@@ -2,7 +2,7 @@
2
2
  * The browser build of experimental-a2/server. There isn't one — on purpose.
3
3
  * Contracts and reducers (from `experimental-a2`) and the session client (from
4
4
  * `experimental-a2/client` / `experimental-a2/react`) are the isomorphic surface; the server is
5
- * where handlers and storage live. This module existing in a client
5
+ * where consumers and storage live. This module existing in a client
6
6
  * bundle means a `'use client'` file (or something it imports)
7
7
  * value-imported your server module.
8
8
  */