experimental-a2 0.19.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 (320) hide show
  1. package/CHANGELOG.md +50 -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.d.ts.map +1 -1
  22. package/dist/ai-fx.js +36 -9
  23. package/dist/ai-fx.js.map +1 -1
  24. package/dist/{ai-harness-CV6rXbB3.d.ts → ai-harness-Bfk-Lycn.d.ts} +2 -2
  25. package/dist/ai-harness-Bfk-Lycn.d.ts.map +1 -0
  26. package/dist/{ai-harness-BuuC-gJ3.js → ai-harness-dQeJaR1t.js} +17 -4
  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 +4 -4
  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 +84 -75
  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-9IeTsiAs.js} +5 -5
  40. package/dist/{client-BhHLoNKF.js.map → client-9IeTsiAs.js.map} +1 -1
  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-Bc4PvfHk.js} +10 -3
  52. package/dist/internal-Bc4PvfHk.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-BWmht60z.js +12 -0
  65. package/dist/runtime-consumer-BWmht60z.js.map +1 -0
  66. package/dist/scheduler-qstash.d.ts +2 -2
  67. package/dist/scheduler-qstash.d.ts.map +1 -1
  68. package/dist/scheduler-qstash.js +7 -7
  69. package/dist/scheduler-qstash.js.map +1 -1
  70. package/dist/scheduler-task-X1SToDjP.js.map +1 -1
  71. package/dist/scheduler-vercel.d.ts +2 -2
  72. package/dist/scheduler-vercel.d.ts.map +1 -1
  73. package/dist/scheduler-vercel.js +5 -4
  74. package/dist/scheduler-vercel.js.map +1 -1
  75. package/dist/{server-DYAATFuT.d.ts → server-BshFZ0eL.d.ts} +108 -41
  76. package/dist/server-BshFZ0eL.d.ts.map +1 -0
  77. package/dist/{server-D-Y4pqwu.js → server-DvIB8kj5.js} +423 -135
  78. package/dist/server-DvIB8kj5.js.map +1 -0
  79. package/dist/server.browser.js +1 -1
  80. package/dist/server.browser.js.map +1 -1
  81. package/dist/server.d.ts +4 -4
  82. package/dist/server.js +2 -2
  83. package/dist/{session-clear-C-QwTXV1.d.ts → session-clear-Bk_29eEw.d.ts} +2 -2
  84. package/dist/session-clear-Bk_29eEw.d.ts.map +1 -0
  85. package/dist/{session-clear-DGI4jTQ5.js → session-clear-wHd5GCpZ.js} +5 -4
  86. package/dist/session-clear-wHd5GCpZ.js.map +1 -0
  87. package/dist/store-N8PXxDAS.js.map +1 -1
  88. package/dist/store-Z2qjw50n.d.ts +523 -0
  89. package/dist/store-Z2qjw50n.d.ts.map +1 -0
  90. package/dist/store-codec-DwrWlgQx.js +65 -0
  91. package/dist/store-codec-DwrWlgQx.js.map +1 -0
  92. package/dist/store-memory.d.ts +1 -1
  93. package/dist/store-memory.d.ts.map +1 -1
  94. package/dist/store-memory.js +393 -210
  95. package/dist/store-memory.js.map +1 -1
  96. package/dist/{store-polling-7WJV0YWP.js → store-polling-DYNyxCev.js} +2 -2
  97. package/dist/{store-polling-7WJV0YWP.js.map → store-polling-DYNyxCev.js.map} +1 -1
  98. package/dist/store-postgres.d.ts +1 -1
  99. package/dist/store-postgres.d.ts.map +1 -1
  100. package/dist/store-postgres.js +1595 -673
  101. package/dist/store-postgres.js.map +1 -1
  102. package/dist/{store-presence-polling-B1ixeqIS.js → store-presence-polling-CzQ0wdtM.js} +3 -3
  103. package/dist/{store-presence-polling-B1ixeqIS.js.map → store-presence-polling-CzQ0wdtM.js.map} +1 -1
  104. package/dist/store-redis-http.d.ts +1 -1
  105. package/dist/store-redis-http.js +4 -4
  106. package/dist/store-redis-notify-IiVdlYHc.js +2919 -0
  107. package/dist/store-redis-notify-IiVdlYHc.js.map +1 -0
  108. package/dist/store-redis.d.ts +1 -1
  109. package/dist/store-redis.js +2 -2
  110. package/dist/store-sqlite.d.ts +1 -1
  111. package/dist/store-sqlite.d.ts.map +1 -1
  112. package/dist/store-sqlite.js +517 -287
  113. package/dist/store-sqlite.js.map +1 -1
  114. package/dist/{telemetry-Cyfqo6Qt.d.ts → telemetry-CWwsh-Gx.d.ts} +3 -3
  115. package/dist/telemetry-CWwsh-Gx.d.ts.map +1 -0
  116. package/dist/{wire-BKxH1vbP.js → wire-CZGqNZRx.js} +5 -4
  117. package/dist/wire-CZGqNZRx.js.map +1 -0
  118. package/docs/01-quickstart.mdx +28 -19
  119. package/docs/02-examples.mdx +1 -1
  120. package/docs/actors/01-introduction.mdx +9 -1
  121. package/docs/actors/02-concurrency.mdx +1 -1
  122. package/docs/actors/meta.ts +1 -1
  123. package/docs/agents/01-introduction.mdx +1 -1
  124. package/docs/agents/02-tools.mdx +13 -12
  125. package/docs/agents/03-conversations.mdx +31 -21
  126. package/docs/agents/04-harnesses.mdx +7 -3
  127. package/docs/agents/meta.ts +1 -1
  128. package/docs/client/03-presence.mdx +18 -14
  129. package/docs/client/meta.ts +1 -1
  130. package/docs/contract/01-events.mdx +2 -2
  131. package/docs/contract/02-state.mdx +18 -14
  132. package/docs/contract/03-sessions.mdx +32 -25
  133. package/docs/index.mdx +31 -26
  134. package/docs/reference/01-core.mdx +12 -12
  135. package/docs/reference/02-server.mdx +247 -192
  136. package/docs/reference/04-ai.mdx +29 -20
  137. package/docs/reference/05-telemetry.mdx +146 -0
  138. package/docs/reference/06-errors.mdx +24 -18
  139. package/docs/reference/meta.ts +1 -1
  140. package/docs/schedulers/01-overview.mdx +112 -0
  141. package/docs/schedulers/02-vercel-queues.mdx +87 -0
  142. package/docs/schedulers/03-qstash.mdx +123 -0
  143. package/docs/schedulers/meta.ts +1 -0
  144. package/docs/server/01-consumers.mdx +415 -0
  145. package/docs/server/02-timers.mdx +33 -23
  146. package/docs/server/03-cancellation.mdx +52 -44
  147. package/docs/server/04-routes.mdx +4 -1
  148. package/docs/server/05-sql-projections.mdx +391 -0
  149. package/docs/server/06-guarantees.mdx +60 -50
  150. package/docs/stores/01-overview.mdx +37 -0
  151. package/docs/stores/02-redis.mdx +57 -0
  152. package/docs/stores/03-redis-http.mdx +56 -0
  153. package/docs/stores/04-postgres.mdx +172 -0
  154. package/docs/stores/05-sqlite.mdx +36 -0
  155. package/docs/stores/06-memory.mdx +22 -0
  156. package/docs/stores/meta.ts +1 -0
  157. package/docs/upgrades/01-overview.mdx +30 -0
  158. package/docs/upgrades/79-0-21.mdx +165 -0
  159. package/docs/upgrades/80-0-20.mdx +43 -0
  160. package/docs/upgrades/81-0-19.mdx +33 -0
  161. package/docs/upgrades/82-0-18.mdx +33 -0
  162. package/docs/upgrades/83-0-17.mdx +69 -0
  163. package/docs/upgrades/meta.ts +3 -0
  164. package/examples/playground/DEPLOY.md +48 -11
  165. package/examples/playground/app/a0/apps/[appId]/route.ts +6 -10
  166. package/examples/playground/app/a0/chat.test.tsx +421 -330
  167. package/examples/playground/app/a0/sandbox-log.ts +17 -15
  168. package/examples/playground/app/a0/server.ts +4 -4
  169. package/examples/playground/app/a0/shell.tsx +1 -1
  170. package/examples/playground/app/a0/uploads/route.test.ts +59 -0
  171. package/examples/playground/app/a2slack/a2slack.css +1067 -0
  172. package/examples/playground/app/a2slack/c/[channel]/page.tsx +20 -0
  173. package/examples/playground/app/a2slack/consumers/agent.ts +63 -0
  174. package/examples/playground/app/a2slack/consumers/feed.ts +164 -0
  175. package/examples/playground/app/a2slack/consumers/notify.ts +116 -0
  176. package/examples/playground/app/a2slack/consumers/search.ts +37 -0
  177. package/examples/playground/app/a2slack/consumers/triage.ts +31 -0
  178. package/examples/playground/app/a2slack/consumers/welcome.ts +25 -0
  179. package/examples/playground/app/a2slack/db/a2.ts +206 -0
  180. package/examples/playground/app/a2slack/db/index.ts +73 -0
  181. package/examples/playground/app/a2slack/db/migrations/0000_extensions.sql +4 -0
  182. package/examples/playground/app/a2slack/db/migrations/0001_workspace.sql +74 -0
  183. package/examples/playground/app/a2slack/db/migrations/0002_a2.sql +215 -0
  184. package/examples/playground/app/a2slack/db/migrations/0003_a2_batch.sql +67 -0
  185. package/examples/playground/app/a2slack/db/migrations/meta/0000_snapshot.json +18 -0
  186. package/examples/playground/app/a2slack/db/migrations/meta/0001_snapshot.json +500 -0
  187. package/examples/playground/app/a2slack/db/migrations/meta/0002_snapshot.json +1159 -0
  188. package/examples/playground/app/a2slack/db/migrations/meta/0003_snapshot.json +1159 -0
  189. package/examples/playground/app/a2slack/db/migrations/meta/_journal.json +34 -0
  190. package/examples/playground/app/a2slack/db/queries.ts +263 -0
  191. package/examples/playground/app/a2slack/db/schema.ts +145 -0
  192. package/examples/playground/app/a2slack/drills/a2slack.drill.ts +708 -0
  193. package/examples/playground/app/a2slack/drills/harness.ts +244 -0
  194. package/examples/playground/app/a2slack/events/route.ts +36 -0
  195. package/examples/playground/app/a2slack/identity.ts +44 -0
  196. package/examples/playground/app/a2slack/inbox/route.ts +8 -0
  197. package/examples/playground/app/a2slack/lag/data/route.ts +8 -0
  198. package/examples/playground/app/a2slack/lag/page.tsx +101 -0
  199. package/examples/playground/app/a2slack/lag.ts +73 -0
  200. package/examples/playground/app/a2slack/layout.tsx +43 -0
  201. package/examples/playground/app/a2slack/messages/route.ts +17 -0
  202. package/examples/playground/app/a2slack/model.test.ts +208 -0
  203. package/examples/playground/app/a2slack/model.ts +369 -0
  204. package/examples/playground/app/a2slack/models.test.ts +72 -0
  205. package/examples/playground/app/a2slack/page.tsx +10 -0
  206. package/examples/playground/app/a2slack/scheduler/route.ts +5 -0
  207. package/examples/playground/app/a2slack/search/route.ts +40 -0
  208. package/examples/playground/app/a2slack/search-index.ts +108 -0
  209. package/examples/playground/app/a2slack/server.test.ts +315 -0
  210. package/examples/playground/app/a2slack/server.ts +55 -0
  211. package/examples/playground/app/a2slack/sessions.ts +50 -0
  212. package/examples/playground/app/a2slack/thread/route.ts +8 -0
  213. package/examples/playground/app/a2slack/ui/ChannelView.tsx +87 -0
  214. package/examples/playground/app/a2slack/ui/Composer.tsx +117 -0
  215. package/examples/playground/app/a2slack/ui/DotsShadow.tsx +42 -0
  216. package/examples/playground/app/a2slack/ui/Feed.tsx +157 -0
  217. package/examples/playground/app/a2slack/ui/InboxContext.tsx +63 -0
  218. package/examples/playground/app/a2slack/ui/InboxView.tsx +82 -0
  219. package/examples/playground/app/a2slack/ui/Menu.tsx +128 -0
  220. package/examples/playground/app/a2slack/ui/MessageRow.tsx +314 -0
  221. package/examples/playground/app/a2slack/ui/NameGate.tsx +35 -0
  222. package/examples/playground/app/a2slack/ui/SearchContext.tsx +146 -0
  223. package/examples/playground/app/a2slack/ui/SearchResults.tsx +97 -0
  224. package/examples/playground/app/a2slack/ui/Shell.tsx +34 -0
  225. package/examples/playground/app/a2slack/ui/Sidebar.tsx +199 -0
  226. package/examples/playground/app/a2slack/ui/ThreadPanel.tsx +134 -0
  227. package/examples/playground/app/a2slack/ui/useInbox.ts +71 -0
  228. package/examples/playground/app/agent/[agentId]/agent-queue.test.tsx +116 -90
  229. package/examples/playground/app/agent/[agentId]/compaction-panel.test.tsx +107 -76
  230. package/examples/playground/app/agent/ingress.test.ts +41 -17
  231. package/examples/playground/app/agent/model-input.test.tsx +92 -43
  232. package/examples/playground/app/agent/server.ts +3 -3
  233. package/examples/playground/app/chat/[chatId]/agent-stream-drawer.test.tsx +2 -2
  234. package/examples/playground/app/chat/[chatId]/chat-client.tsx +1 -1
  235. package/examples/playground/app/chat/server.test.ts +38 -65
  236. package/examples/playground/app/chat/server.ts +45 -35
  237. package/examples/playground/app/components/activity-history.test.tsx +60 -18
  238. package/examples/playground/app/components/demo-icons.tsx +7 -0
  239. package/examples/playground/app/components/demos.tsx +7 -0
  240. package/examples/playground/app/components/fast-link.test.tsx +3 -5
  241. package/examples/playground/app/initial-state.test.tsx +86 -45
  242. package/examples/playground/app/orders/[orderId]/order-client.tsx +2 -2
  243. package/examples/playground/app/orders/page.tsx +1 -1
  244. package/examples/playground/app/orders/server.ts +49 -30
  245. package/examples/playground/app/recovery/[recoveryId]/recovery-client.tsx +14 -15
  246. package/examples/playground/app/recovery/page.tsx +4 -4
  247. package/examples/playground/app/recovery/server.ts +23 -32
  248. package/examples/playground/drizzle.config.ts +14 -0
  249. package/examples/playground/next.config.ts +6 -1
  250. package/examples/playground/package.json +13 -1
  251. package/examples/playground/scripts/migrate.mjs +31 -0
  252. package/examples/playground/test/agent.ts +35 -0
  253. package/examples/playground/test/model.ts +149 -0
  254. package/examples/playground/test/serve.ts +184 -0
  255. package/examples/playground/test/server-only.ts +1 -0
  256. package/examples/playground/vercel.json +9 -0
  257. package/examples/playground/vitest.config.ts +27 -0
  258. package/examples/playground/vitest.drills.config.ts +32 -0
  259. package/package.json +7 -2
  260. package/sql/postgres/drizzle-schema.ts +206 -0
  261. package/sql/postgres/notifications.sql +24 -0
  262. package/sql/postgres/schema.sql +276 -0
  263. package/src/actor.ts +101 -71
  264. package/src/ai-agent.ts +154 -115
  265. package/src/ai-control-server.ts +8 -8
  266. package/src/ai-fx.ts +41 -9
  267. package/src/ai-harness.ts +28 -2
  268. package/src/ai-pi.ts +2 -2
  269. package/src/ai-server.ts +2 -2
  270. package/src/ai.ts +7 -7
  271. package/src/contract.ts +1 -1
  272. package/src/deterministic-id.ts +26 -3
  273. package/src/errors.ts +9 -6
  274. package/src/idempotent-replay.ts +0 -1
  275. package/src/index.ts +1 -1
  276. package/src/internal.ts +17 -6
  277. package/src/otel.ts +1 -1
  278. package/src/postgres-notifications.ts +0 -22
  279. package/src/postgres-resources.ts +6 -9
  280. package/src/postgres-schema.ts +121 -0
  281. package/src/push-envelope.ts +20 -0
  282. package/src/runtime-consumer.ts +10 -0
  283. package/src/scheduler-qstash.ts +12 -5
  284. package/src/scheduler-task.ts +2 -2
  285. package/src/scheduler-vercel.ts +10 -4
  286. package/src/server.browser.ts +1 -1
  287. package/src/server.ts +839 -238
  288. package/src/session-socket.ts +1 -1
  289. package/src/store-codec.ts +66 -0
  290. package/src/store-memory.ts +581 -255
  291. package/src/store-postgres.ts +1984 -943
  292. package/src/store-redis-core.ts +1624 -1152
  293. package/src/store-redis-notify.ts +2 -0
  294. package/src/store-sqlite.ts +891 -486
  295. package/src/store.ts +227 -97
  296. package/src/telemetry.ts +2 -1
  297. package/src/wire.ts +1 -0
  298. package/dist/ai-harness-BuuC-gJ3.js.map +0 -1
  299. package/dist/ai-harness-CV6rXbB3.d.ts.map +0 -1
  300. package/dist/ai-mMiWul1T.js.map +0 -1
  301. package/dist/contract-BYKASlCV.js.map +0 -1
  302. package/dist/idempotent-replay-JdgMQRRm.js +0 -21
  303. package/dist/idempotent-replay-JdgMQRRm.js.map +0 -1
  304. package/dist/initial-snapshot-Bx-c0pDP.d.ts.map +0 -1
  305. package/dist/internal-CscSY7K_.js.map +0 -1
  306. package/dist/server-D-Y4pqwu.js.map +0 -1
  307. package/dist/server-DYAATFuT.d.ts.map +0 -1
  308. package/dist/session-clear-C-QwTXV1.d.ts.map +0 -1
  309. package/dist/session-clear-DGI4jTQ5.js.map +0 -1
  310. package/dist/store-CyLUy86v.d.ts +0 -404
  311. package/dist/store-CyLUy86v.d.ts.map +0 -1
  312. package/dist/store-codec-DTG0Ftek.js +0 -10
  313. package/dist/store-codec-DTG0Ftek.js.map +0 -1
  314. package/dist/store-redis-notify-CM0AfLY0.js +0 -2544
  315. package/dist/store-redis-notify-CM0AfLY0.js.map +0 -1
  316. package/dist/telemetry-Cyfqo6Qt.d.ts.map +0 -1
  317. package/dist/wire-BKxH1vbP.js.map +0 -1
  318. package/docs/reference/05-adapters.mdx +0 -351
  319. package/docs/server/01-handlers.mdx +0 -200
  320. package/docs/server/05-production.mdx +0 -323
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  title: Cancellation
3
- description: There is no abort() API. Cancelling is appending, and the running handler finds out through a signal.
3
+ description: There is no abort() API. Cancelling is appending, and the running consumer finds out through a signal.
4
4
  ---
5
5
 
6
6
  ## Cancelling is appending
@@ -14,44 +14,51 @@ A user presses stop on a streaming AI response. That's an event: same route,
14
14
  same auth, same log as every other event. Two things then happen, at two
15
15
  speeds.
16
16
 
17
- ## The running handler stops
17
+ ## The running consumer stops
18
18
 
19
19
  ```ts server/chat.ts
20
- import { createServer } from 'experimental-a2/server'
20
+ import { consumer, createServer } from 'experimental-a2/server'
21
21
  import { chat } from '@/contracts'
22
22
 
23
+ const generate = consumer({
24
+ contract: chat,
25
+ name: 'generate',
26
+ types: ['generate'],
27
+ abortOn: ['cancelled'],
28
+ }).run(async (ctx) => {
29
+ // your abortable work; hand it ctx.signal:
30
+ // await streamText({ signal: ctx.signal, ... })
31
+ })
32
+
23
33
  export const chatServer = createServer({
24
34
  contract: chat,
25
- handlers: {
26
- generate: {
27
- abortOn: ['cancelled'],
28
- handler: async (ctx) => {
29
- // your abortable work; hand it ctx.signal:
30
- // await streamText({ signal: ctx.signal, ... })
31
- },
32
- },
33
- },
35
+ consumers: [generate],
34
36
  })
35
37
  ```
36
38
 
37
- `abortOn` names the events that interrupt the handler. When a `cancelled`
38
- event lands, or had already landed before the handler started, `ctx.signal`
39
- fires. Handlers without `abortOn` hold no subscription.
39
+ `abortOn` names the events that interrupt the run. When a `cancelled` event
40
+ lands, or had already landed before the run started, `ctx.signal` fires.
41
+ Consumers without `abortOn` hold no subscription.
40
42
 
41
43
  When one session runs several generations over its lifetime, match the
42
44
  instance with a predicate:
43
45
 
44
46
  ```ts
45
- // server/chat.ts, the generate entry, targeted by instance:
46
- generate: {
47
+ // server/chat.ts, the generate consumer, targeted by instance:
48
+ import { consumer } from 'experimental-a2/server'
49
+ import { chat } from '@/contracts'
50
+
51
+ const generate = consumer({
52
+ contract: chat,
53
+ name: 'generate',
54
+ types: ['generate'],
47
55
  abortOn: {
48
56
  // only the cancel that names this run fires the signal
49
57
  cancelled: (event, trigger) => event.payload.of === trigger.id,
50
58
  },
51
- handler: async (ctx) => {
52
- // ...
53
- },
54
- }
59
+ }).run(async (ctx) => {
60
+ // ...
61
+ })
55
62
  ```
56
63
 
57
64
  The pushing side already knows the id it's cancelling: it's in the hook's
@@ -59,7 +66,7 @@ The pushing side already knows the id it's cancelling: it's in the hook's
59
66
 
60
67
  ## When a run loses its claim
61
68
 
62
- Every handler's signal, with or without `abortOn`, also fires when the run
69
+ Every consumer's signal, with or without `abortOn`, also fires when the run
63
70
  loses its claim: `CLAIM_EXPIRED` when the lease lapses without renewal
64
71
  (decided locally, so it works while the store is unreachable), or
65
72
  `SUPERSEDED_ATTEMPT` when a recovery run has claimed the event. Either way, a
@@ -79,35 +86,36 @@ case:
79
86
  run that finishes before any successor claims the event still completes.
80
87
  - **Any other throw** is a real failure and spends the retry budget.
81
88
 
82
- ## Clean up after the handler
89
+ ## Clean up after the run
83
90
 
84
- A `cancelled` handler can run while the interrupted one is still unwinding:
85
- the signal is the preemption channel, the event is the durable record. When
86
- cleanup must wait, put both event types in one lane. The event still fires the
87
- signal on append, but its own handler waits for earlier work in the lane:
91
+ A consumer of `cancelled` can run while the interrupted run is still
92
+ unwinding: the signal is the preemption channel, the event is the durable
93
+ record. When cleanup must wait, take both event types in one consumer with a
94
+ string lane. The `cancelled` event still fires the signal on append, but runs
95
+ only after earlier work in the lane:
88
96
 
89
97
  ```ts server/chat-with-cleanup.ts
90
- import { createServer } from 'experimental-a2/server'
98
+ import { consumer, createServer } from 'experimental-a2/server'
91
99
  import { chat } from '@/contracts'
92
100
 
101
+ const generation = consumer({
102
+ contract: chat,
103
+ name: 'generation',
104
+ types: ['generate', 'cancelled'],
105
+ lane: 'generation',
106
+ abortOn: ['cancelled'],
107
+ }).run(async (ctx) => {
108
+ if (ctx.event.type === 'generate') {
109
+ // your abortable work; hand it ctx.signal:
110
+ // await streamText({ signal: ctx.signal, ... })
111
+ return
112
+ }
113
+ // your cleanup, after generate has returned
114
+ })
115
+
93
116
  export const chatServer = createServer({
94
117
  contract: chat,
95
- handlers: {
96
- generate: {
97
- lane: 'generation',
98
- abortOn: ['cancelled'],
99
- handler: async (ctx) => {
100
- // your abortable work; hand it ctx.signal:
101
- // await streamText({ signal: ctx.signal, ... })
102
- },
103
- },
104
- cancelled: {
105
- lane: 'generation',
106
- handler: async () => {
107
- // your cleanup, after generate has returned
108
- },
109
- },
110
- },
118
+ consumers: [generation],
111
119
  })
112
120
  ```
113
121
 
@@ -15,7 +15,10 @@ export const POST = ordersServer.fetch
15
15
  `server.fetch` is bound, so an unprotected route is two exports. A plain GET
16
16
  streams one session over SSE, a GET with `gte` and `lte` returns a history
17
17
  slice, and POST appends the pushed events. If the contract declares presence,
18
- the same route streams and accepts it.
18
+ the same route streams and accepts it. A pushed event's `id` is optional: up
19
+ to 256 characters, and never shaped like a UUIDv5, the ids A2 derives for what
20
+ consumers append, return, and schedule, so a caller can't take one first. Use
21
+ `crypto.randomUUID()` or [`idempotentId()`](/reference/core#idempotentidparts).
19
22
 
20
23
  Wrap the call when the route needs authentication or authorization:
21
24
 
@@ -0,0 +1,391 @@
1
+ ---
2
+ title: SQL projections
3
+ description: Keep tables in your own database from A2's logs with a consumer, so lists, filters, and joins are SQL queries.
4
+ ---
5
+
6
+ ## The tables
7
+
8
+ ```ts db/schema.ts
9
+ import { bigint, pgTable, primaryKey, text } from 'drizzle-orm/pg-core'
10
+
11
+ /** The order list: one row per order. */
12
+ export const orderList = pgTable('order_list', {
13
+ orderId: text('order_id').primaryKey(),
14
+ shopId: text('shop_id').notNull(),
15
+ status: text('status').notNull(),
16
+ })
17
+
18
+ /** The last event each consumer applied from each session's log. */
19
+ export const positions = pgTable(
20
+ 'positions',
21
+ {
22
+ consumer: text('consumer').notNull(),
23
+ sessionId: text('session_id').notNull(),
24
+ logIndex: bigint('log_index', { mode: 'number' }).notNull(),
25
+ },
26
+ (table) => [primaryKey({ columns: [table.consumer, table.sessionId] })],
27
+ )
28
+ ```
29
+
30
+ ```ts db/client.ts
31
+ import { drizzle } from 'drizzle-orm/node-postgres'
32
+ import * as schema from '@/db/schema'
33
+
34
+ export const db = drizzle({
35
+ connection: { connectionString: process.env.DATABASE_URL },
36
+ schema,
37
+ })
38
+ ```
39
+
40
+ A2 keeps each session's log. When people list, filter, and join across
41
+ sessions, read tables in your database instead, kept from the logs by a
42
+ [consumer](/server/consumers): a projection. This page keeps an order list
43
+ with Drizzle on Postgres, as an example. The pattern is the same with any SQL
44
+ database and client.
45
+
46
+ The order list has a row per order. `positions` has a row per consumer and
47
+ session: the index of the last event that consumer applied to your tables.
48
+ Your migrations create both, like any table. A2's own tables can live in the
49
+ same database; see [Postgres](/stores/postgres#with-drizzle).
50
+
51
+ ## Why a position row
52
+
53
+ A2 keeps each consumer's cursor, its place in a session's log, in A2's store,
54
+ and moves it after `run` returns. It fences its own writes by attempt: an
55
+ append, a returned batch, or a cursor move from an attempt that lost its claim
56
+ is refused. Your SQL commits in your own transaction, which A2 can't fence or
57
+ make atomic with its cursor. Two things follow:
58
+
59
+ - **A crash between your commit and the cursor move retries the event.** Your
60
+ rows are in, the cursor isn't, and A2 runs the event again.
61
+ - **An attempt that lost its claim can still commit.** Its claim lapsed while
62
+ it ran, a newer attempt applied the event and the ones after it, and then
63
+ the old attempt commits an older event over newer rows.
64
+
65
+ The position row fences both. It commits in the same transaction as your rows,
66
+ and a transaction writes only when it moves the position forward, so each
67
+ event lands in your tables once, in log order.
68
+
69
+ ## Claim the event first
70
+
71
+ ```ts server/order-list.ts
72
+ import { eq, lt } from 'drizzle-orm'
73
+ import { consumer } from 'experimental-a2/server'
74
+ import { orders } from '@/contracts'
75
+ import { db } from '@/db/client'
76
+ import { orderList, positions } from '@/db/schema'
77
+
78
+ const name = 'order-list-v1'
79
+
80
+ export const orderListTable = consumer({
81
+ contract: orders,
82
+ name,
83
+ lane: 'order-list',
84
+ backfill: true,
85
+ }).run(async ({ event }) => {
86
+ await db.transaction(async (tx) => {
87
+ const claimed = await tx
88
+ .insert(positions)
89
+ .values({ consumer: name, sessionId: event.sessionId, logIndex: event.index })
90
+ .onConflictDoUpdate({
91
+ target: [positions.consumer, positions.sessionId],
92
+ set: { logIndex: event.index },
93
+ setWhere: lt(positions.logIndex, event.index),
94
+ })
95
+ .returning({ logIndex: positions.logIndex })
96
+ // this event, or a later one, is already in:
97
+ if (claimed.length === 0) return
98
+
99
+ if (event.type === 'created') {
100
+ await tx.insert(orderList).values({
101
+ orderId: event.sessionId,
102
+ shopId: event.payload.shopId,
103
+ status: 'created',
104
+ })
105
+ } else {
106
+ await tx
107
+ .update(orderList)
108
+ .set({ status: event.type })
109
+ .where(eq(orderList.orderId, event.sessionId))
110
+ }
111
+ })
112
+ })
113
+ ```
114
+
115
+ The transaction's first statement claims the event. It inserts the session's
116
+ position, or moves it to this event's index if it's behind, and returns the
117
+ row when it did. No row back means this event, or a later one, is already in
118
+ your tables: return, and the transaction changes nothing. Otherwise write your
119
+ rows. They commit with the position, or none of it does.
120
+
121
+ In plain SQL, it's the same statement on Postgres and SQLite:
122
+
123
+ ```sql
124
+ insert into positions (consumer, session_id, log_index)
125
+ values ($1, $2, $3)
126
+ on conflict (consumer, session_id) do update
127
+ set log_index = excluded.log_index
128
+ where positions.log_index < excluded.log_index
129
+ returning 1
130
+ ```
131
+
132
+ It comes first so a duplicate waits before it does any work. On Postgres, at
133
+ the default read committed level, two transactions that claim the same event
134
+ can't both get the row back: the second one's insert waits for the first to
135
+ commit, then checks the condition again against the committed position, finds
136
+ it caught up, and returns nothing. If the first rolls back, the second claims
137
+ the event. Reading the position first with `select ... for update` doesn't
138
+ cover a session's first event: there's no row to lock yet, so both
139
+ transactions read nothing and both apply it.
140
+
141
+ ## The consumer
142
+
143
+ ```ts server/orders.ts
144
+ import { createServer } from 'experimental-a2/server'
145
+ import { postgres } from 'experimental-a2/store-postgres'
146
+ import { orders } from '@/contracts'
147
+ import { orderListTable } from '@/server/order-list'
148
+
149
+ export const ordersServer = createServer({
150
+ contract: orders,
151
+ store: postgres({ connectionString: process.env.DATABASE_URL }),
152
+ consumers: [orderListTable],
153
+ })
154
+ ```
155
+
156
+ - **A string lane runs a session's events in order.** One at a time, in log
157
+ order, one transaction each. Sessions run independently.
158
+ - **`backfill` fills it from history.** It reads each session's log from its
159
+ first event, not only events appended after it exists. A session starts on
160
+ its next append, or when you [consume](#read-your-writes) it. Nothing
161
+ enumerates sessions, so to fill the table for orders that never change
162
+ again, consume each one.
163
+ - **A rebuild is a new name.** See [Rebuild it](#rebuild-it).
164
+
165
+ ## Read it
166
+
167
+ ```ts server/order-list.ts
168
+ export function shopOrders(shopId: string) {
169
+ return db.select().from(orderList).where(eq(orderList.shopId, shopId))
170
+ }
171
+ ```
172
+
173
+ Lists, filters, and joins are queries against your tables.
174
+
175
+ ## Read your writes
176
+
177
+ ```ts server/place-order.ts
178
+ import { orderListTable, shopOrders } from '@/server/order-list'
179
+ import { ordersServer } from '@/server/orders'
180
+
181
+ export async function placeOrder(orderId: string, shopId: string) {
182
+ const order = ordersServer.session(orderId)
183
+ const [created] = await order.append({
184
+ type: 'created',
185
+ payload: { shopId, items: [] },
186
+ })
187
+ await order.consume(orderListTable, { through: created.index })
188
+ // the new order is in the list:
189
+ return shopOrders(shopId)
190
+ }
191
+ ```
192
+
193
+ Your tables lag the log a little. Before a read that has to include a write
194
+ you just made, [consume](/server/consumers#run-a-consumer-now-with-consume)
195
+ the projection through the index the append returned. It returns once your
196
+ tables have that event.
197
+
198
+ A consumer that reads your tables waits the same way:
199
+
200
+ ```ts server/receipts.ts
201
+ import { consumer } from 'experimental-a2/server'
202
+ import { orders } from '@/contracts'
203
+ import { orderListTable } from '@/server/order-list'
204
+
205
+ export const receipts = consumer({
206
+ contract: orders,
207
+ name: 'receipts',
208
+ types: ['shop.notified'],
209
+ }).run(async ({ event, session }) => {
210
+ await session.consume(orderListTable, { through: event.index })
211
+ // the order list has this event; read it:
212
+ // const [order] = await db.select().from(orderList).where(eq(orderList.orderId, event.sessionId))
213
+ })
214
+ ```
215
+
216
+ Pass `event.index - 1` instead when the consumer applies the triggering event
217
+ itself and needs your tables only up to it. A consumer can't consume itself.
218
+
219
+ `consume` takes the session's lock in A2's store on every call, even when your
220
+ tables are caught up, and while another invocation holds the next event, it
221
+ polls until that one finishes. On a hot path, read your position row first,
222
+ and consume only when it's behind the index you need:
223
+
224
+ ```ts server/read-through.ts
225
+ import { and, eq } from 'drizzle-orm'
226
+ import { db } from '@/db/client'
227
+ import { positions } from '@/db/schema'
228
+ import { orderListTable } from '@/server/order-list'
229
+ import { ordersServer } from '@/server/orders'
230
+
231
+ export async function readThrough(orderId: string, index: number) {
232
+ const [position] = await db
233
+ .select({ logIndex: positions.logIndex })
234
+ .from(positions)
235
+ .where(
236
+ and(
237
+ eq(positions.consumer, orderListTable.name),
238
+ eq(positions.sessionId, orderId),
239
+ ),
240
+ )
241
+ if ((position?.logIndex ?? 0) < index) {
242
+ await ordersServer.session(orderId).consume(orderListTable, { through: index })
243
+ }
244
+ }
245
+ ```
246
+
247
+ ## Live views from the table
248
+
249
+ ```ts server/order-view.ts
250
+ import { and, eq } from 'drizzle-orm'
251
+ import { db } from '@/db/client'
252
+ import { orderList, positions } from '@/db/schema'
253
+ import { orderListTable } from '@/server/order-list'
254
+
255
+ /** One order's row, and the log index it reflects. */
256
+ export function orderView(orderId: string) {
257
+ return db.transaction(
258
+ async (tx) => {
259
+ const [position] = await tx
260
+ .select({ logIndex: positions.logIndex })
261
+ .from(positions)
262
+ .where(
263
+ and(
264
+ eq(positions.consumer, orderListTable.name),
265
+ eq(positions.sessionId, orderId),
266
+ ),
267
+ )
268
+ const [order] = await tx
269
+ .select()
270
+ .from(orderList)
271
+ .where(eq(orderList.orderId, orderId))
272
+ return { order, through: position?.logIndex ?? 0 }
273
+ },
274
+ { isolationLevel: 'repeatable read' },
275
+ )
276
+ }
277
+ ```
278
+
279
+ A view of one session can load its rows from your tables and stay live with
280
+ the session's [stream](/client/react). Read the rows and the position in one
281
+ snapshot, a repeatable read transaction on Postgres, and return them together.
282
+ The client follows the stream and applies only the events after `through`, so
283
+ it neither misses an event nor applies one twice.
284
+
285
+ ## SQLite
286
+
287
+ ```ts server/order-list-sqlite.ts
288
+ import { DatabaseSync } from 'node:sqlite'
289
+ import { consumer } from 'experimental-a2/server'
290
+ import { orders } from '@/contracts'
291
+
292
+ // your migrations create order_list and positions in this file:
293
+ const db = new DatabaseSync('.a2/app.db')
294
+ const name = 'order-list-v1'
295
+
296
+ const claim = db.prepare(`
297
+ insert into positions (consumer, session_id, log_index)
298
+ values (?, ?, ?)
299
+ on conflict (consumer, session_id) do update
300
+ set log_index = excluded.log_index
301
+ where positions.log_index < excluded.log_index
302
+ returning 1
303
+ `)
304
+ const insertOrder = db.prepare(
305
+ 'insert into order_list (order_id, shop_id, status) values (?, ?, ?)',
306
+ )
307
+ const setStatus = db.prepare(
308
+ 'update order_list set status = ? where order_id = ?',
309
+ )
310
+
311
+ export const orderListTable = consumer({
312
+ contract: orders,
313
+ name,
314
+ lane: 'order-list',
315
+ backfill: true,
316
+ }).run(async ({ event }) => {
317
+ // synchronous from begin to commit, with nothing awaited in between:
318
+ db.exec('begin immediate')
319
+ try {
320
+ if (claim.get(name, event.sessionId, event.index) !== undefined) {
321
+ if (event.type === 'created') {
322
+ insertOrder.run(event.sessionId, event.payload.shopId, 'created')
323
+ } else {
324
+ setStatus.run(event.type, event.sessionId)
325
+ }
326
+ }
327
+ db.exec('commit')
328
+ } catch (error) {
329
+ db.exec('rollback')
330
+ throw error
331
+ }
332
+ })
333
+ ```
334
+
335
+ The claim is the same statement. Run the transaction synchronously, from
336
+ `begin immediate` to `commit`, with nothing awaited in between:
337
+ `node:sqlite`'s `DatabaseSync` runs each statement on the calling thread, so
338
+ nothing else runs in the middle, and two of your own transactions never
339
+ interleave on one handle.
340
+
341
+ It's required when your tables share a file with A2's
342
+ [SQLite store](/stores/sqlite). A2 writes on the same thread, through its own
343
+ handle, and SQLite lets one writer hold a file at a time. If your transaction
344
+ awaited while it held the file, an A2 write to it would block the thread on
345
+ A2's five-second busy timeout, and your transaction couldn't reach `commit`
346
+ until that write gave up.
347
+
348
+ ## Throughput and lag
349
+
350
+ A string lane runs a session's events one at a time, one transaction each, so
351
+ a projection keeps up with a session as long as its transactions outpace that
352
+ session's appends. In the playground's a2slack drills, with Redis and Postgres
353
+ on the same machine, the feed projection, which keeps a whole workspace in one
354
+ session, ran an event in about 0.4 ms and rebuilt at about 1,800 events a
355
+ second. Posting 10,000 messages in under a second put it up to about six
356
+ seconds behind. A remote database adds at least a round trip to every event.
357
+
358
+ Watch it with each `a2.event` span's `a2.event.lag`, how many events the
359
+ session had appended after the one it claimed, and `a2.event.age_ms`, how old
360
+ that event was when its run started, grouped by `a2.event.consumer`. See
361
+ [Telemetry](/reference/telemetry#the-span-catalogue). When a session's appends
362
+ keep outrunning its projection, keep each transaction to the claim and your
363
+ writes, and do slower work, like calling an API, in a consumer of its own.
364
+
365
+ ## Rebuild it
366
+
367
+ To change what a projection writes, or to fill it again, give its consumer a
368
+ new name, such as `order-list-v2`. A new name is a new consumer: it has no
369
+ cursors in A2 and no rows in `positions`, and `backfill` fills it from each
370
+ session's first event, as each session next runs.
371
+
372
+ Point it at new tables, created by the migration that ships it, and switch
373
+ your reads to them once it has caught up. A deployment still running
374
+ `order-list-v1` keeps writing to the old ones until it's gone. To reuse the
375
+ old tables instead, truncate them, along with the old name's rows in
376
+ `positions`, in a migration that runs once no deployment runs the old name:
377
+ an earlier deployment that still does keeps writing to tables you emptied.
378
+
379
+ ## Point in time
380
+
381
+ The order list holds each order as of its latest event. To read one as of an
382
+ earlier index, such as next to an old event, keep a versions table too: a row
383
+ per event, keyed by session and log index, written in the same transaction as
384
+ the rest. The row as of index N is the newest at or below it:
385
+
386
+ ```sql
387
+ select status from order_versions
388
+ where order_id = $1 and log_index <= $2
389
+ order by log_index desc
390
+ limit 1
391
+ ```