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
@@ -1,13 +1,24 @@
1
- -- A2's tables for experimental-a2/store-postgres.
1
+ -- A2's tables for experimental-a2/store-postgres. drizzle-schema.ts, next to
2
+ -- this file, shows the same tables as Drizzle definitions, as an example.
2
3
  --
3
- -- Apply this file once before the first deploy, and again whenever a release
4
- -- raises the schema version. Every statement creates only what is missing and
5
- -- leaves existing data alone, so the file is safe to re-run on any A2
6
- -- database, whichever release created it.
4
+ -- Apply this file before the first deploy, and again when a release's
5
+ -- changelog says the tables changed. Every statement creates only what is
6
+ -- missing and leaves existing data alone, so the file is safe to re-run on any
7
+ -- A2 database, whichever release created it. A2 checks nothing up front: a
8
+ -- query that finds a table or column missing throws STORE_NOT_CONFIGURED.
9
+ --
10
+ -- On a database from a release that ran handlers, it drops the work those
11
+ -- handlers hadn't finished, and the columns they dispatched by, so drain
12
+ -- earlier deployments and apply it while none is running.
7
13
  --
8
14
  -- Each index sits behind a catalog check: `create index if not exists` takes
9
15
  -- a share lock on its table, and requires owning it, even when the index
10
16
  -- exists. The check makes a re-run on a live database wait for no writer.
17
+ --
18
+ -- The file works as a migration as it ships: copy it into your migration
19
+ -- tool's folder. The breakpoint comment after each statement splits it for
20
+ -- tools that send one statement per query, like Drizzle on PGlite; to every
21
+ -- other tool, psql included, it's only a comment.
11
22
 
12
23
  create table if not exists a2_events (
13
24
  session_id text not null,
@@ -17,47 +28,53 @@ create table if not exists a2_events (
17
28
  event_id text not null,
18
29
  created_at bigint not null,
19
30
  cause jsonb,
31
+ primary key (session_id, idx)
32
+ );
33
+ --> statement-breakpoint
34
+
35
+ -- One row per run of a cursor: an event cursor's one event, a lane's run of
36
+ -- queued events, or a log cursor's position. A run is keyed by the head it
37
+ -- started at (`epoch`), so a clear starts new runs instead of rewriting old
38
+ -- ones.
39
+ create table if not exists a2_cursors (
40
+ session_id text not null,
41
+ consumer text not null,
42
+ cursor_key text not null,
43
+ epoch bigint not null,
20
44
  lane text,
21
- lane_ready boolean not null default false,
22
- processed_at bigint,
23
- processed_by_attempt integer,
24
- returned_event_ids jsonb,
45
+ kind text not null,
46
+ position bigint not null,
47
+ head bigint,
48
+ types jsonb,
49
+ attempt_count integer not null default 0,
25
50
  first_claimed_at bigint,
26
51
  last_claimed_at bigint,
27
- attempt_count integer not null default 0,
52
+ claim_holder text,
53
+ claim_expires_at bigint,
28
54
  failure_count integer not null default 0,
29
55
  last_failed_at bigint,
30
56
  last_failed_attempt integer,
31
57
  last_error text,
32
58
  failed_at bigint,
33
- claim_holder text,
34
- claim_expires_at bigint,
35
- primary key (session_id, idx)
59
+ completed_idx bigint,
60
+ completed_attempt integer,
61
+ returned_event_ids jsonb,
62
+ primary key (session_id, cursor_key, consumer, epoch)
36
63
  );
64
+ --> statement-breakpoint
37
65
 
38
- do $$ begin
39
- if to_regclass('a2_events_event_id') is null then
40
- create unique index if not exists a2_events_event_id on a2_events (event_id);
41
- end if;
42
- if to_regclass('a2_events_unprocessed') is null then
43
- create index if not exists a2_events_unprocessed
44
- on a2_events (session_id, idx) where processed_at is null;
45
- end if;
46
- if to_regclass('a2_events_lane_pending') is null then
47
- create index if not exists a2_events_lane_pending
48
- on a2_events (session_id, lane, idx) where processed_at is null;
49
- end if;
50
- if to_regclass('a2_events_dispatch_ready') is null then
51
- create index if not exists a2_events_dispatch_ready
52
- on a2_events (session_id, idx)
53
- where lane_ready and processed_at is null and failed_at is null;
54
- end if;
55
- if to_regclass('a2_events_claim_expiry') is null then
56
- create index if not exists a2_events_claim_expiry
57
- on a2_events (session_id, claim_expires_at)
58
- where lane_ready and processed_at is null and failed_at is null;
59
- end if;
60
- end $$;
66
+ -- A lane cursor's queue: the events appended for the lane, in log order.
67
+ create table if not exists a2_lane_events (
68
+ session_id text not null,
69
+ consumer text not null,
70
+ lane text not null,
71
+ idx bigint not null,
72
+ done boolean not null default false,
73
+ completed_attempt integer,
74
+ returned_event_ids jsonb,
75
+ primary key (session_id, consumer, lane, idx)
76
+ );
77
+ --> statement-breakpoint
61
78
 
62
79
  create table if not exists a2_snapshots (
63
80
  session_id text not null,
@@ -67,6 +84,7 @@ create table if not exists a2_snapshots (
67
84
  updated_at bigint not null,
68
85
  primary key (session_id, reducer_name)
69
86
  );
87
+ --> statement-breakpoint
70
88
 
71
89
  create table if not exists a2_snapshot_history (
72
90
  session_id text not null,
@@ -76,26 +94,37 @@ create table if not exists a2_snapshot_history (
76
94
  updated_at bigint not null,
77
95
  primary key (session_id, reducer_name, up_to_index)
78
96
  );
97
+ --> statement-breakpoint
98
+
99
+ -- Earlier releases pinned snapshots per event; this one pins them per
100
+ -- cursor head. Pins only hold history for work in flight, so old ones go.
101
+ do $$ begin
102
+ if to_regclass('a2_snapshot_pins') is not null and not exists (
103
+ select 1 from pg_attribute
104
+ where attrelid = to_regclass('a2_snapshot_pins')
105
+ and attname = 'cursor_key' and not attisdropped
106
+ ) then
107
+ drop table a2_snapshot_pins;
108
+ end if;
109
+ end $$;
110
+ --> statement-breakpoint
79
111
 
80
112
  create table if not exists a2_snapshot_pins (
81
113
  session_id text not null,
82
114
  event_index bigint not null,
115
+ consumer text not null,
116
+ cursor_key text not null,
83
117
  reducer_name text not null,
84
118
  up_to_index bigint not null,
85
- primary key (session_id, event_index, reducer_name, up_to_index)
119
+ primary key (session_id, event_index, consumer, cursor_key, reducer_name, up_to_index)
86
120
  );
87
-
88
- do $$ begin
89
- if to_regclass('a2_snapshot_pins_checkpoint') is null then
90
- create index if not exists a2_snapshot_pins_checkpoint
91
- on a2_snapshot_pins (session_id, reducer_name, up_to_index);
92
- end if;
93
- end $$;
121
+ --> statement-breakpoint
94
122
 
95
123
  create table if not exists a2_clears (
96
124
  session_id text primary key,
97
125
  clear_index bigint not null
98
126
  );
127
+ --> statement-breakpoint
99
128
 
100
129
  create table if not exists a2_presence (
101
130
  ns text not null,
@@ -107,31 +136,162 @@ create table if not exists a2_presence (
107
136
  expires_at bigint not null,
108
137
  primary key (ns, participant, field)
109
138
  );
139
+ --> statement-breakpoint
110
140
 
111
- create table if not exists a2_schema (
112
- only_row boolean primary key default true check (only_row),
113
- version integer not null
114
- );
141
+ -- From an earlier release: work its handlers hadn't finished is dropped,
142
+ -- since this release runs no handlers to finish it. Every event is kept.
143
+ -- Causes gain the dispatcher that appended them, the handlers table
144
+ -- (`a2.handlers`), and its lane. Then the old dispatch columns go, and
145
+ -- their indexes with them. The block locks the events first, so an earlier
146
+ -- release still writing waits and then fails, rather than committing between
147
+ -- its statements, and it commits whole, or not at all.
148
+ do $$ begin
149
+ if exists (
150
+ select 1 from pg_attribute
151
+ where attrelid = to_regclass('a2_events')
152
+ and attname = 'processed_at' and not attisdropped
153
+ ) then
154
+ lock table a2_events in access exclusive mode;
155
+
156
+ update a2_events child
157
+ set cause = jsonb_build_object(
158
+ 'consumer', 'a2.handlers',
159
+ 'lane', (select parent.lane from a2_events parent
160
+ where parent.session_id = child.session_id
161
+ and parent.idx = (child.cause ->> 'index')::bigint)
162
+ ) || child.cause
163
+ where child.cause is not null and not child.cause ? 'consumer';
115
164
 
116
- -- The version is recorded last, and only once everything above exists, so a
117
- -- run that skipped a failed statement records nothing, whichever tool ran it.
165
+ alter table a2_events
166
+ drop column lane,
167
+ drop column lane_ready,
168
+ drop column processed_at,
169
+ drop column processed_by_attempt,
170
+ drop column returned_event_ids,
171
+ drop column first_claimed_at,
172
+ drop column last_claimed_at,
173
+ drop column attempt_count,
174
+ drop column failure_count,
175
+ drop column last_failed_at,
176
+ drop column last_failed_attempt,
177
+ drop column last_error,
178
+ drop column failed_at,
179
+ drop column claim_holder,
180
+ drop column claim_expires_at;
181
+ end if;
182
+ end $$;
183
+ --> statement-breakpoint
184
+
185
+ -- Earlier releases recorded a schema version here and checked it before
186
+ -- their first query. This one checks nothing up front.
187
+ drop table if exists a2_schema;
188
+ --> statement-breakpoint
189
+
190
+ -- Runs an operation's statements in one round trip: A2 sends each write that
191
+ -- takes a session's lock as one call, the lock and then the statements that
192
+ -- need it, so it holds the lock only while the server runs them. Each
193
+ -- statement takes its snapshot when it starts, so those after the lock see
194
+ -- what its last holder committed; a single statement can't, since its
195
+ -- snapshot predates the lock. Each statement is a select of jsonb rows: a row
196
+ -- with an `abort` key fails the call and rolls it back, and one with a `stop`
197
+ -- key ends it. It needs read committed, the default: under a stricter level
198
+ -- every statement would share the first one's snapshot, so it refuses to run.
199
+ -- Created only if missing, as the indexes are: a later release that changes it
200
+ -- adds a function with a new name.
201
+ do $$ declare grantee text; begin
202
+ if to_regprocedure('a2_batch(text[], jsonb)') is null then
203
+ create function a2_batch(statements text[], params jsonb)
204
+ returns setof jsonb
205
+ language plpgsql
206
+ volatile
207
+ as $batch$
208
+ declare
209
+ statement text;
210
+ result jsonb;
211
+ begin
212
+ if current_setting('transaction_isolation') <> 'read committed' then
213
+ raise exception using errcode = 'A2002',
214
+ message = 'a2_batch needs the read committed isolation level';
215
+ end if;
216
+ foreach statement in array statements loop
217
+ for result in execute statement using params loop
218
+ if result ? 'abort' then
219
+ raise exception using errcode = 'A2001',
220
+ message = 'a2_batch aborted', detail = result::text;
221
+ end if;
222
+ return next result;
223
+ if result ? 'stop' then
224
+ return;
225
+ end if;
226
+ end loop;
227
+ end loop;
228
+ end
229
+ $batch$;
230
+ -- Default privileges can grant a new function to roles besides PUBLIC, as
231
+ -- platforms that serve functions over an API grant theirs: it starts with
232
+ -- none but its owner's. Grants made after it exist are left alone.
233
+ for grantee in
234
+ select acl.grantee::regrole::text
235
+ from pg_proc proc, aclexplode(proc.proacl) acl
236
+ where proc.oid = to_regprocedure('a2_batch(text[], jsonb)')
237
+ and acl.grantee not in (0, proc.proowner)
238
+ loop
239
+ execute format('revoke execute on function a2_batch(text[], jsonb) from %s', grantee);
240
+ end loop;
241
+ end if;
242
+ -- It runs any statement it's handed, as its caller: only its owner, the role
243
+ -- that applies this file, may call it, and not every role, as Postgres lets
244
+ -- by default. Platforms that serve functions over an API would serve it too.
245
+ if pg_has_role(
246
+ current_user,
247
+ (select proowner from pg_proc
248
+ where oid = to_regprocedure('a2_batch(text[], jsonb)')),
249
+ 'usage'
250
+ ) then
251
+ revoke execute on function a2_batch(text[], jsonb) from public;
252
+ end if;
253
+ end $$;
254
+ --> statement-breakpoint
255
+
256
+ do $$ begin
257
+ if to_regclass('a2_events_types') is null then
258
+ create index if not exists a2_events_types
259
+ on a2_events (session_id, event_type, idx);
260
+ end if;
261
+ if to_regclass('a2_cursors_heads') is null then
262
+ create index if not exists a2_cursors_heads
263
+ on a2_cursors (session_id, head) where head is not null;
264
+ end if;
265
+ if to_regclass('a2_cursors_lanes') is null then
266
+ create index if not exists a2_cursors_lanes
267
+ on a2_cursors (session_id) where kind = 'lane';
268
+ end if;
269
+ if to_regclass('a2_snapshot_pins_checkpoint') is null then
270
+ create index if not exists a2_snapshot_pins_checkpoint
271
+ on a2_snapshot_pins (session_id, reducer_name, up_to_index);
272
+ end if;
273
+ end $$;
274
+ --> statement-breakpoint
275
+
276
+ -- Event ids are unique per session, through the index on (session_id,
277
+ -- event_id). It replaces a2_events_event_id, on event_id alone. The swap
278
+ -- locks the table before it builds, so it waits for running queries instead
279
+ -- of deadlocking with a writer when the whole file runs as one transaction.
280
+ -- The old index goes only once the new one is valid, so a session's ids
281
+ -- always have a unique index.
118
282
  do $$ begin
119
- if to_regclass('a2_events') is null
120
- or to_regclass('a2_events_event_id') is null
121
- or to_regclass('a2_events_unprocessed') is null
122
- or to_regclass('a2_events_lane_pending') is null
123
- or to_regclass('a2_events_dispatch_ready') is null
124
- or to_regclass('a2_events_claim_expiry') is null
125
- or to_regclass('a2_snapshots') is null
126
- or to_regclass('a2_snapshot_history') is null
127
- or to_regclass('a2_snapshot_pins') is null
128
- or to_regclass('a2_snapshot_pins_checkpoint') is null
129
- or to_regclass('a2_clears') is null
130
- or to_regclass('a2_presence') is null
131
- then
132
- raise exception 'A2 schema incomplete: an earlier statement in schema.sql failed. Fix it and apply the file again.';
283
+ if to_regclass('a2_events_session_event_id') is null then
284
+ if to_regclass('a2_events_event_id') is not null then
285
+ lock table a2_events in access exclusive mode;
286
+ end if;
287
+ create unique index if not exists a2_events_session_event_id
288
+ on a2_events (session_id, event_id);
289
+ end if;
290
+ if to_regclass('a2_events_event_id') is not null and exists (
291
+ select 1 from pg_index
292
+ where indexrelid = to_regclass('a2_events_session_event_id')
293
+ and indisvalid and indisunique
294
+ ) then
295
+ drop index a2_events_event_id;
133
296
  end if;
134
- insert into a2_schema (version) values (1)
135
- on conflict (only_row)
136
- do update set version = greatest(a2_schema.version, excluded.version);
137
297
  end $$;
@@ -131,7 +131,11 @@ export type ActorScheduleSend<E> = {
131
131
  export type ActorContext<D extends ActorProtocol> = {
132
132
  /** Mutable draft, committed atomically with the handler's completion. */
133
133
  state: D['state']
134
- /** The message id — stable across re-runs; the idempotency key for external I/O. */
134
+ /**
135
+ * The message id — stable across re-runs and unique within the instance;
136
+ * the idempotency key for external I/O. An explicit send id that repeats
137
+ * in another instance repeats here too.
138
+ */
135
139
  id: string
136
140
  /** Durable 1-based dispatch ordinal of this message. */
137
141
  attempt: number
@@ -150,7 +154,11 @@ export type ActorContext<D extends ActorProtocol> = {
150
154
  * whose serial handlers decide against fresh state.
151
155
  */
152
156
  export type ActorConcurrentContext<D extends ActorProtocol> = {
153
- /** The message id — stable across re-runs; the idempotency key for external I/O. */
157
+ /**
158
+ * The message id — stable across re-runs and unique within the instance;
159
+ * the idempotency key for external I/O. An explicit send id that repeats
160
+ * in another instance repeats here too.
161
+ */
154
162
  id: string
155
163
  /** Durable 1-based dispatch ordinal of this message. */
156
164
  attempt: number
package/src/actor.ts CHANGED
@@ -82,7 +82,14 @@ import { setServerFetchHooks } from './server-fetch.ts'
82
82
  import { sseResponse } from './sse.ts'
83
83
  import { isSessionCleared, sessionClearedError } from './session-clear.ts'
84
84
  import type { SessionCleared } from './session-clear.ts'
85
- import type { A2Scheduler, A2Server, HandlerEntry, Session } from './server.ts'
85
+ import type {
86
+ A2Scheduler,
87
+ A2Server,
88
+ ConsumerContext,
89
+ ConsumerResult,
90
+ Session,
91
+ } from './server.ts'
92
+ import { runtimeConsumer } from './runtime-consumer.ts'
86
93
  import type { A2Store } from './store.ts'
87
94
  import type { StandardSchemaV1 } from './standard-schema.ts'
88
95
  import type { A2Telemetry } from './telemetry.ts'
@@ -460,14 +467,16 @@ export function actor<D extends ActorProtocol>(
460
467
  return send as ActorSend<D['events']>
461
468
  }
462
469
 
463
- const handlers: Record<string, HandlerEntry<EventDefs>> = {}
470
+ type Run = (ctx: ConsumerContext<EventDefs>) => ConsumerResult<EventDefs>
471
+ const concurrentRuns = new Map<string, Run>()
472
+ const serialRuns = new Map<string, Run>()
464
473
  for (const [eventName, def] of handlersByEvent) {
465
474
  if (def.kind === 'concurrent') {
466
475
  // No lane: concurrent handlers run in parallel — with the lane
467
476
  // and with each other. They cannot write state (no draft to
468
477
  // commit), so no serialization is needed for correctness; slow
469
478
  // I/O lives here without blocking the actor.
470
- handlers[eventName] = async (ctx) => {
479
+ concurrentRuns.set(eventName, async (ctx) => {
471
480
  const sends: { type: string; payload: unknown }[] = []
472
481
  try {
473
482
  await def.handle(
@@ -509,78 +518,70 @@ export function actor<D extends ActorProtocol>(
509
518
  }
510
519
  // Sends commit atomically with settlement; re-runs converge on
511
520
  // the same deterministic message ids.
512
- return sends
513
- }
521
+ return sends as never
522
+ })
514
523
  continue
515
524
  }
516
- handlers[eventName] = {
517
- // One lane for every serial handler of one instance: the next
518
- // message starts only after the previous one completed, and
519
- // completion commits the state event atomically —
520
- // read-modify-write on `ctx.state` is safe by exclusion, not by
521
- // liveness.
522
- lane: ACTOR_LANE,
523
- handler: async (ctx) => {
524
- // Explicitly 'latest', not the event-relative default: prior
525
- // lane messages commit *after* this message was appended, so
526
- // their state commits carry higher indexes than the trigger —
527
- // an event-relative read would miss them and lose updates.
528
- // 'latest' is deterministic here because the lane makes this
529
- // attempt the only possible writer while it runs.
530
- const { state, clearIndex } = await ctx.session.state(serverReducer, {
531
- through: 'latest',
532
- })
533
- const draft = structuredClone(state)
534
- const sends: { type: string; payload: unknown }[] = []
535
- try {
536
- await def.handle(
537
- {
538
- state: draft,
539
- id: ctx.event.id,
540
- attempt: ctx.attempt,
541
- signal: ctx.signal,
542
- send: makeSend(sends),
543
- schedule: makeSchedule((taskName, timing, event) =>
544
- ctx.session.schedule(
545
- taskName,
546
- timing as Parameters<typeof ctx.session.schedule>[1],
547
- event,
548
- ),
525
+ serialRuns.set(eventName, async (ctx) => {
526
+ // Explicitly 'latest', not the event-relative default: prior
527
+ // lane messages commit *after* this message was appended, so
528
+ // their state commits carry higher indexes than the trigger —
529
+ // an event-relative read would miss them and lose updates.
530
+ // 'latest' is deterministic here because the lane makes this
531
+ // attempt the only possible writer while it runs.
532
+ const { state, clearIndex } = await ctx.session.state(serverReducer, {
533
+ through: 'latest',
534
+ })
535
+ const draft = structuredClone(state)
536
+ const sends: { type: string; payload: unknown }[] = []
537
+ try {
538
+ await def.handle(
539
+ {
540
+ state: draft,
541
+ id: ctx.event.id,
542
+ attempt: ctx.attempt,
543
+ signal: ctx.signal,
544
+ send: makeSend(sends),
545
+ schedule: makeSchedule((taskName, timing, event) =>
546
+ ctx.session.schedule(
547
+ taskName,
548
+ timing as Parameters<typeof ctx.session.schedule>[1],
549
+ event,
549
550
  ),
550
- },
551
- ctx.event.payload,
552
- )
553
- } catch (error) {
554
- // A NonRetriableError is an answer: settle the message,
555
- // record the refusal, deliver it to the waiting caller —
556
- // no retry, no failure budget spent (buffered sends are
557
- // discarded with the draft: refusals are total). Every
558
- // other throw is an ordinary A2 handler failure: rethrown
559
- // into core's retry machinery (backoff, ten-failure
560
- // budget, dead letter) — including surrendered lease
561
- // aborts, which core recognizes by identity.
562
- if (!(error instanceof NonRetriableError)) throw error
563
- const payload: ActorFailedPayload = {
564
- message: ctx.event.id,
565
- event: eventName,
566
- error: describeError(error),
567
- }
568
- return { type: ACTOR_FAILED_EVENT, payload }
569
- }
570
- // One returned batch: buffered sends plus the state commit —
571
- // committed atomically with completion, so a reserve and its
572
- // trigger can never half-happen. The state event stays the
573
- // outcome marker a waiting call resolves on. (Unconditional
574
- // for now; a dirty check is a compatible later optimization.)
575
- const payload: ActorStatePayload<D['state']> = {
576
- state: draft,
577
- event: eventName,
551
+ ),
552
+ },
553
+ ctx.event.payload,
554
+ )
555
+ } catch (error) {
556
+ // A NonRetriableError is an answer: settle the message,
557
+ // record the refusal, deliver it to the waiting caller —
558
+ // no retry, no failure budget spent (buffered sends are
559
+ // discarded with the draft: refusals are total). Every
560
+ // other throw is an ordinary A2 handler failure: rethrown
561
+ // into core's retry machinery (backoff, ten-failure
562
+ // budget, dead letter) — including surrendered lease
563
+ // aborts, which core recognizes by identity.
564
+ if (!(error instanceof NonRetriableError)) throw error
565
+ const payload: ActorFailedPayload = {
578
566
  message: ctx.event.id,
579
- ...(clearIndex === undefined ? {} : { clearIndex }),
567
+ event: eventName,
568
+ error: describeError(error),
580
569
  }
581
- return [...sends, { type: ACTOR_STATE_EVENT, payload }]
582
- },
583
- }
570
+ return { type: ACTOR_FAILED_EVENT, payload }
571
+ }
572
+ // One returned batch: buffered sends plus the state commit —
573
+ // committed atomically with completion, so a reserve and its
574
+ // trigger can never half-happen. The state event stays the
575
+ // outcome marker a waiting call resolves on. (Unconditional
576
+ // for now; a dirty check is a compatible later optimization.)
577
+ const payload: ActorStatePayload<D['state']> = {
578
+ state: draft,
579
+ event: eventName,
580
+ message: ctx.event.id,
581
+ ...(clearIndex === undefined ? {} : { clearIndex }),
582
+ }
583
+ return [...sends, { type: ACTOR_STATE_EVENT, payload }] as never
584
+ })
584
585
  }
585
586
 
586
587
  const presenceEnabled = (options as { presence?: unknown }).presence === true
@@ -590,9 +591,38 @@ export function actor<D extends ActorProtocol>(
590
591
  const contract = presenceEnabled
591
592
  ? createContract({ name, events, presence: openPresenceDefs })
592
593
  : createContract({ name, events })
594
+ // Serial messages share one lane per instance: the next starts only after
595
+ // the previous one completed, and completion commits the state event
596
+ // atomically, so read-modify-write on `ctx.state` is safe by exclusion,
597
+ // not by liveness. The lane is a function, so each message is queued on
598
+ // it as it's appended. Concurrent messages run alongside it and each
599
+ // other: they write no state.
600
+ const serialTypes = [...serialRuns.keys()]
601
+ const concurrentTypes = [...concurrentRuns.keys()]
602
+ const consumers = [
603
+ ...(serialTypes.length === 0
604
+ ? []
605
+ : [
606
+ runtimeConsumer({
607
+ contract,
608
+ name: 'a2.actor',
609
+ types: serialTypes,
610
+ lane: () => ACTOR_LANE,
611
+ }).run((ctx) => serialRuns.get(ctx.event.type)!(ctx as never)),
612
+ ]),
613
+ ...(concurrentTypes.length === 0
614
+ ? []
615
+ : [
616
+ runtimeConsumer({
617
+ contract,
618
+ name: 'a2.actor.concurrent',
619
+ types: concurrentTypes,
620
+ }).run((ctx) => concurrentRuns.get(ctx.event.type)!(ctx as never)),
621
+ ]),
622
+ ]
593
623
  const server = createServer({
594
624
  contract,
595
- handlers,
625
+ consumers,
596
626
  ...(options.store ? { store: options.store } : {}),
597
627
  ...(options.scheduler ? { scheduler: options.scheduler } : {}),
598
628
  ...(options.telemetry ? { telemetry: options.telemetry } : {}),