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,6 +1,6 @@
1
1
  import 'server-only'
2
2
  import { vercelQueues } from 'experimental-a2/scheduler-vercel'
3
- import { createServer } from 'experimental-a2/server'
3
+ import { consumer, createServer } from 'experimental-a2/server'
4
4
  import { store } from '@/lib/store'
5
5
  import { orders } from './model'
6
6
 
@@ -14,37 +14,56 @@ export const ordersScheduler =
14
14
  ? vercelQueues({ topic: 'a2-orders' })
15
15
  : undefined
16
16
 
17
+ const notifyShop = consumer({
18
+ contract: orders,
19
+ name: 'notify-shop',
20
+ types: ['created'],
21
+ }).run(async ({ session }) => {
22
+ await wait(800)
23
+ await session.append('notify-shop', {
24
+ type: 'shop.notified',
25
+ payload: {},
26
+ })
27
+ })
28
+
29
+ const notifyDriver = consumer({
30
+ contract: orders,
31
+ name: 'notify-driver',
32
+ types: ['shop.started'],
33
+ }).run(async ({ session }) => {
34
+ await wait(1200)
35
+ const driverId = drivers[Math.floor(Math.random() * drivers.length)]!
36
+ await session.append('notify-driver', {
37
+ type: 'driver.notified',
38
+ payload: { driverId },
39
+ })
40
+ })
41
+
42
+ const pickUp = consumer({
43
+ contract: orders,
44
+ name: 'pick-up',
45
+ types: ['driver.notified'],
46
+ }).run(async ({ session }) => {
47
+ await wait(3000)
48
+ const etaMinutes = 3 + Math.floor(Math.random() * 5)
49
+ await session.append('courier-picked-up', {
50
+ type: 'courier.picked_up',
51
+ payload: { etaMinutes },
52
+ })
53
+ })
54
+
55
+ const deliver = consumer({
56
+ contract: orders,
57
+ name: 'deliver',
58
+ types: ['courier.picked_up'],
59
+ }).run(async ({ session }) => {
60
+ await wait(3500)
61
+ await session.append('delivered', { type: 'delivered', payload: {} })
62
+ })
63
+
17
64
  export const ordersServer = createServer({
18
65
  ...(store ? { store } : {}),
19
66
  ...(ordersScheduler ? { scheduler: ordersScheduler } : {}),
20
67
  contract: orders,
21
- handlers: {
22
- created: async ({ session }) => {
23
- await wait(800)
24
- await session.append('notify-shop', {
25
- type: 'shop.notified',
26
- payload: {},
27
- })
28
- },
29
- 'shop.started': async ({ session }) => {
30
- await wait(1200)
31
- const driverId = drivers[Math.floor(Math.random() * drivers.length)]!
32
- await session.append('notify-driver', {
33
- type: 'driver.notified',
34
- payload: { driverId },
35
- })
36
- },
37
- 'driver.notified': async ({ session }) => {
38
- await wait(3000)
39
- const etaMinutes = 3 + Math.floor(Math.random() * 5)
40
- await session.append('courier-picked-up', {
41
- type: 'courier.picked_up',
42
- payload: { etaMinutes },
43
- })
44
- },
45
- 'courier.picked_up': async ({ session }) => {
46
- await wait(3500)
47
- await session.append('delivered', { type: 'delivered', payload: {} })
48
- },
49
- },
68
+ consumers: [notifyShop, notifyDriver, pickUp, deliver],
50
69
  })
@@ -45,7 +45,7 @@ export function RecoveryClient({
45
45
  return (
46
46
  <article>
47
47
  <header className="session-header">
48
- <h1>Cross-invocation handler chain</h1>
48
+ <h1>Cross-invocation step chain</h1>
49
49
  <ConnectionPill connection={connection} />
50
50
  <span
51
51
  className={`badge${completed ? ' done' : running ? ' waiting' : ''}`}
@@ -59,14 +59,14 @@ export function RecoveryClient({
59
59
  </header>
60
60
 
61
61
  <p className="lede">
62
- Four handlers fit individually, but their 32-second chain does not fit
63
- in one 20-second function. Every invocation has the same limit.
62
+ Four steps fit individually, but their 32-second chain does not fit in
63
+ one 20-second function. Every invocation has the same limit.
64
64
  </p>
65
65
 
66
66
  <dl className="facts">
67
67
  <dt>Function limit</dt>
68
68
  <dd>{FUNCTION_LIMIT_SECONDS} seconds</dd>
69
- <dt>Each handler</dt>
69
+ <dt>Each step</dt>
70
70
  <dd>{STEP_SECONDS} seconds</dd>
71
71
  <dt>Whole chain</dt>
72
72
  <dd>
@@ -94,15 +94,14 @@ export function RecoveryClient({
94
94
  <h2>The chain is crossing function invocations</h2>
95
95
  <p>
96
96
  Each step waits eight seconds before appending its completion fact.
97
- A2 does not predict whether the next handler fits. The first
98
- invocation completes two handlers, starts the third, and is stopped
99
- by Vercel at 20 seconds. That event stays pending. Its claim
100
- watchdog acquires the expired claim and retries the same event in a
101
- fresh function.
97
+ A2 does not predict whether the next step fits. The first invocation
98
+ completes two steps, starts the third, and is stopped by Vercel at
99
+ 20 seconds. That event stays pending. Its claim watchdog acquires
100
+ the expired claim and retries the same event in a fresh function.
102
101
  </p>
103
102
  <p className="hint">
104
103
  Keep this page open. Claim heartbeats keep scheduling watchdogs
105
- while a handler is alive. The known function deadline caps the last
104
+ while a step is running. The known function deadline caps the last
106
105
  claim window, so the scheduler arrives just after Vercel stops the
107
106
  worker. Reloading can also trigger A2&apos;s separate
108
107
  interaction-healing path.
@@ -112,8 +111,8 @@ export function RecoveryClient({
112
111
 
113
112
  {completed ? (
114
113
  <p className="proof">
115
- All {state.completedSteps} bounded handlers finished. The scheduler
116
- added no event of its own; it only continued draining the pending
114
+ All {state.completedSteps} bounded steps finished. The scheduler added
115
+ no event of its own; it only continued draining the pending
117
116
  application events.
118
117
  </p>
119
118
  ) : null}
@@ -127,12 +126,12 @@ export function RecoveryClient({
127
126
  state.completedSteps < STEP_COUNT ? (
128
127
  <>
129
128
  Waiting for step {state.completedSteps + 1} of {STEP_COUNT}. The
130
- event log stays quiet while its {STEP_SECONDS}-second handler
131
- runs; the next row is the handler&apos;s real completion fact.
129
+ event log stays quiet while its {STEP_SECONDS}-second step runs;
130
+ the next row is the step&apos;s real completion fact.
132
131
  </>
133
132
  ) : (
134
133
  <>
135
- All four handlers finished. Waiting for the final{' '}
134
+ All four steps finished. Waiting for the final{' '}
136
135
  <code>pipeline.completed</code> fact from the active drain.
137
136
  </>
138
137
  )
@@ -6,10 +6,10 @@ export default function RecoveryPage(): ReactNode {
6
6
  <article>
7
7
  <h1>Recovery lab</h1>
8
8
  <p className="lede">
9
- Run four 8-second handlers in a chain. Each fits inside a 20-second
10
- Vercel function, but all 32 seconds cannot. Watch Vercel stop one
11
- mid-handler, then let A2 retry that pending event in a fresh invocation
12
- with the same limit.
9
+ Run four 8-second steps in a chain. Each fits inside a 20-second Vercel
10
+ function, but all 32 seconds cannot. Watch Vercel stop one mid-step,
11
+ then let A2 retry that pending event in a fresh invocation with the same
12
+ limit.
13
13
  </p>
14
14
  <NewRecoverySession />
15
15
  <p className="hint">
@@ -1,6 +1,6 @@
1
1
  import 'server-only'
2
2
  import { vercelQueues } from 'experimental-a2/scheduler-vercel'
3
- import { createServer } from 'experimental-a2/server'
3
+ import { consumer, createServer } from 'experimental-a2/server'
4
4
  import { store } from '@/lib/store'
5
5
  import { recovery } from './model'
6
6
 
@@ -15,39 +15,30 @@ export const recoveryScheduler =
15
15
  ? vercelQueues({ topic: 'a2-recovery' })
16
16
  : undefined
17
17
 
18
+ /** Each step waits its 8 seconds, then returns the next one, in one lane. */
19
+ const runPipeline = consumer({
20
+ contract: recovery,
21
+ name: 'run-pipeline',
22
+ types: ['pipeline.started', 'step.completed'],
23
+ lane: 'pipeline',
24
+ }).run(async ({ event }) => {
25
+ const step = event.type === 'step.completed' ? event.payload.step : 0
26
+ if (step === stepCount) {
27
+ return {
28
+ type: 'pipeline.completed',
29
+ payload: { stepCount },
30
+ }
31
+ }
32
+ await wait(stepDurationSeconds * 1_000)
33
+ return {
34
+ type: 'step.completed',
35
+ payload: { step: step + 1, durationSeconds: stepDurationSeconds },
36
+ }
37
+ })
38
+
18
39
  export const recoveryServer = createServer({
19
40
  ...(store ? { store } : {}),
20
41
  ...(recoveryScheduler ? { scheduler: recoveryScheduler } : {}),
21
42
  contract: recovery,
22
- handlers: {
23
- 'pipeline.started': {
24
- lane: 'pipeline',
25
- handler: async () => {
26
- await wait(stepDurationSeconds * 1_000)
27
- return {
28
- type: 'step.completed',
29
- payload: { step: 1, durationSeconds: stepDurationSeconds },
30
- }
31
- },
32
- },
33
- 'step.completed': {
34
- lane: 'pipeline',
35
- handler: async ({ event }) => {
36
- if (event.payload.step === stepCount) {
37
- return {
38
- type: 'pipeline.completed',
39
- payload: { stepCount },
40
- }
41
- }
42
- await wait(stepDurationSeconds * 1_000)
43
- return {
44
- type: 'step.completed',
45
- payload: {
46
- step: event.payload.step + 1,
47
- durationSeconds: stepDurationSeconds,
48
- },
49
- }
50
- },
51
- },
52
- },
43
+ consumers: [runPipeline],
53
44
  })
@@ -0,0 +1,14 @@
1
+ // The Postgres at DATABASE_URL: a2slack's tables, and A2's (db/a2.ts, a copy
2
+ // of experimental-a2's drizzle-schema.ts). tablesFilter keeps push and
3
+ // studio to these, since other apps share the database.
4
+ import { defineConfig } from 'drizzle-kit'
5
+
6
+ export default defineConfig({
7
+ dialect: 'postgresql',
8
+ schema: './app/a2slack/db/schema.ts',
9
+ out: './app/a2slack/db/migrations',
10
+ casing: 'snake_case',
11
+ dbCredentials: { url: process.env.DATABASE_URL ?? '' },
12
+ tablesFilter: ['a2slack_*', 'a2_*'],
13
+ migrations: { table: 'a2slack_migrations', schema: 'public' },
14
+ })
@@ -9,7 +9,7 @@ const postgresUrl = process.env.DATABASE_URL?.replace(
9
9
  )
10
10
 
11
11
  export const store: RedisStore | PostgresStore | undefined = redisUrl
12
- ? redis({ url: redisUrl })
12
+ ? redis({ url: redisUrl, keyPrefix: 'a2-playground-tagged' })
13
13
  : postgresUrl
14
14
  ? postgres({ connectionString: postgresUrl })
15
15
  : undefined
@@ -12,7 +12,12 @@ const nextConfig: NextConfig = {
12
12
  // Turbopack panic on its WebAssembly), so it finds photon through this
13
13
  // app: package.json depends on photon at the version Pi pins, and the two
14
14
  // move together.
15
- serverExternalPackages: ['libfx', '@silvia-odwyer/photon-node'],
15
+ serverExternalPackages: [
16
+ 'libfx',
17
+ '@silvia-odwyer/photon-node',
18
+ // a2slack's development database, PGlite, loads its WebAssembly by path.
19
+ '@electric-sql/pglite',
20
+ ],
16
21
  turbopack: {
17
22
  // WebAssembly Pi's code reaches that the bundler cannot trace.
18
23
  resolveAlias: {
@@ -11,18 +11,26 @@
11
11
  "scripts": {
12
12
  "dev": "next dev",
13
13
  "build": "next build",
14
+ "vercel-build": "node scripts/migrate.mjs && turbo run build",
14
15
  "test": "vitest run",
16
+ "drills": "vitest run --config vitest.drills.config.ts",
15
17
  "typecheck": "tsc --noEmit",
18
+ "db:generate": "drizzle-kit generate",
19
+ "db:migrate": "node scripts/migrate.mjs",
16
20
  "a0:template": "node --env-file-if-exists=.env.local scripts/a0-template.ts"
17
21
  },
18
22
  "dependencies": {
19
23
  "@ai-sdk/otel": "1.0.58",
24
+ "@base-ui/react": "^1.7.0",
20
25
  "@codemirror/state": "^6.7.1",
21
26
  "@codemirror/view": "^6.43.8",
27
+ "@electric-sql/pglite": "^0.3.14",
22
28
  "@earendil-works/pi-ai": "1.0.3",
23
29
  "@earendil-works/pi-coding-agent": "1.0.2",
24
30
  "@opentelemetry/api": "^1.9.0",
31
+ "@paper-design/shaders-react": "^0.0.80",
25
32
  "@silvia-odwyer/photon-node": "0.3.4",
33
+ "@upstash/redis": "^1.39.0",
26
34
  "@vercel/blob": "^2.8.0",
27
35
  "@vercel/functions": "^3.9.3",
28
36
  "@vercel/otel": "^2.1.0",
@@ -30,7 +38,8 @@
30
38
  "@vercel/sandbox": "^3.0.0",
31
39
  "ai": "^7.0.58",
32
40
  "codemirror": "^6.0.2",
33
- "experimental-a2": "0.20.0",
41
+ "drizzle-orm": "^0.45.3",
42
+ "experimental-a2": "0.22.0",
34
43
  "geist": "^1.7.2",
35
44
  "ioredis": "^5.9.0",
36
45
  "libfx": "0.0.12",
@@ -52,9 +61,11 @@
52
61
  "@testing-library/react": "^16.1.0",
53
62
  "jsdom": "^26.0.0",
54
63
  "@types/node": "^24.0.0",
64
+ "@types/pg": "^8.15.0",
55
65
  "@types/react": "^19.0.0",
56
66
  "@types/react-dom": "^19.0.0",
57
67
  "@types/ws": "^8.18.1",
68
+ "drizzle-kit": "^0.31.11",
58
69
  "typescript": "^7.0.2",
59
70
  "undici": "^8.10.2",
60
71
  "vitest": "^4.1.10"
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Applies app/a2slack/db/migrations to the Postgres at DATABASE_URL_UNPOOLED
3
+ * (the Neon integration's direct connection) or DATABASE_URL. drizzle-kit
4
+ * migrate does the same, but hides the database's error when one fails.
5
+ *
6
+ * DATABASE_URL=postgres://… pnpm --filter playground db:migrate
7
+ */
8
+ import { drizzle } from 'drizzle-orm/node-postgres'
9
+ import { migrate } from 'drizzle-orm/node-postgres/migrator'
10
+
11
+ const url = process.env.DATABASE_URL_UNPOOLED || process.env.DATABASE_URL
12
+ if (!url) {
13
+ console.error('error: set DATABASE_URL to the Postgres to migrate')
14
+ process.exit(1)
15
+ }
16
+
17
+ const db = drizzle({ connection: url })
18
+ try {
19
+ await migrate(db, {
20
+ migrationsFolder: new URL('../app/a2slack/db/migrations', import.meta.url)
21
+ .pathname,
22
+ migrationsTable: 'a2slack_migrations',
23
+ migrationsSchema: 'public',
24
+ })
25
+ console.log('migrations applied')
26
+ } catch (error) {
27
+ console.error('error: migrations failed, and none were applied:', error)
28
+ process.exitCode = 1
29
+ } finally {
30
+ await db.$client.end()
31
+ }
@@ -71,6 +71,15 @@
71
71
  "retryAfterSeconds": 30
72
72
  }
73
73
  ]
74
+ },
75
+ "app/a2slack/scheduler/route.ts": {
76
+ "experimentalTriggers": [
77
+ {
78
+ "type": "queue/v2beta",
79
+ "topic": "a2-a2slack",
80
+ "retryAfterSeconds": 30
81
+ }
82
+ ]
74
83
  }
75
84
  }
76
85
  }
@@ -0,0 +1,32 @@
1
+ // The a2slack drills (app/a2slack/drills): load, rebuild, and failure runs
2
+ // against a throwaway Redis and Postgres they start themselves. Not part of
3
+ // `pnpm test`; run them with `pnpm drills`.
4
+ import { fileURLToPath } from 'node:url'
5
+ import { defineConfig } from 'vitest/config'
6
+
7
+ export default defineConfig({
8
+ resolve: {
9
+ tsconfigPaths: true,
10
+ alias: {
11
+ 'server-only': fileURLToPath(
12
+ new URL('./test/server-only.ts', import.meta.url),
13
+ ),
14
+ },
15
+ },
16
+ test: {
17
+ include: ['app/a2slack/drills/*.drill.ts'],
18
+ fileParallelism: false,
19
+ testTimeout: 15 * 60_000,
20
+ hookTimeout: 120_000,
21
+ env: {
22
+ VERCEL: '',
23
+ A2_QUEUES: '',
24
+ AI_GATEWAY_API_KEY: '',
25
+ VERCEL_OIDC_TOKEN: '',
26
+ KV_REST_API_URL: '',
27
+ KV_REST_API_TOKEN: '',
28
+ REDIS_KV_REST_API_URL: '',
29
+ REDIS_KV_REST_API_TOKEN: '',
30
+ },
31
+ },
32
+ })
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "experimental-a2",
3
- "version": "0.20.0",
3
+ "version": "0.22.0",
4
4
  "description": "Syncs your immutable event log, from server to browser: one contract, handlers, derived state, and a live client per session.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -113,6 +113,9 @@
113
113
  },
114
114
  "devDependencies": {
115
115
  "@electric-sql/pglite": "^0.3.14",
116
+ "experimental-a2-0-20": "npm:experimental-a2@0.20.0",
117
+ "drizzle-kit": "^0.31.11",
118
+ "drizzle-orm": "^0.45.3",
116
119
  "@upstash/qstash": "^2.11.3",
117
120
  "ai": "^7.0.58",
118
121
  "ioredis": "^5.9.0",
@@ -146,7 +149,7 @@
146
149
  "scripts": {
147
150
  "build": "tsdown",
148
151
  "dev": "tsdown --watch",
149
- "typecheck": "tsc --noEmit",
152
+ "typecheck": "tsc --noEmit && tsc --noEmit -p tsconfig.reference.json",
150
153
  "test": "vitest run",
151
154
  "test:watch": "vitest"
152
155
  }
@@ -0,0 +1,209 @@
1
+ /**
2
+ * An example: A2's tables for experimental-a2/store-postgres as Drizzle
3
+ * definitions, the same tables schema.sql next to this file creates. It isn't
4
+ * an API, and nothing imports it. Use it however fits your setup, for example
5
+ * as a copy next to your own schema, so your migrations create A2's tables
6
+ * with yours. Adapt it to your Drizzle version, your ORM, and how you lay out
7
+ * your schema.
8
+ *
9
+ * Whatever creates them, A2 relies on the table and column names, the column
10
+ * types, the primary keys, and the unique index on a2_events (session_id,
11
+ * event_id). The other indexes keep its queries fast. Every name is spelled
12
+ * out, so Drizzle's `casing` setting changes none of them.
13
+ *
14
+ * A2's tables change only when a release's changelog says so; compare your
15
+ * copy with that release's file. Drizzle can't declare functions or
16
+ * triggers: add schema.sql's `a2_batch` function, which A2 runs its writes
17
+ * through, as a custom migration (`drizzle-kit generate --custom`), and the
18
+ * triggers `listenConnectionString` needs are in notifications.sql.
19
+ */
20
+ import { sql } from 'drizzle-orm'
21
+ import {
22
+ bigint,
23
+ boolean,
24
+ index,
25
+ integer,
26
+ jsonb,
27
+ pgTable,
28
+ primaryKey,
29
+ text,
30
+ uniqueIndex,
31
+ } from 'drizzle-orm/pg-core'
32
+
33
+ const int8 = (name: string) => bigint(name, { mode: 'number' })
34
+
35
+ export const a2Events = pgTable(
36
+ 'a2_events',
37
+ {
38
+ sessionId: text('session_id').notNull(),
39
+ idx: int8('idx').notNull(),
40
+ eventType: text('event_type').notNull(),
41
+ payload: jsonb('payload').notNull(),
42
+ eventId: text('event_id').notNull(),
43
+ createdAt: int8('created_at').notNull(),
44
+ cause: jsonb('cause'),
45
+ },
46
+ (table) => [
47
+ primaryKey({
48
+ name: 'a2_events_pkey',
49
+ columns: [table.sessionId, table.idx],
50
+ }),
51
+ uniqueIndex('a2_events_session_event_id').on(
52
+ table.sessionId,
53
+ table.eventId,
54
+ ),
55
+ index('a2_events_types').on(table.sessionId, table.eventType, table.idx),
56
+ ],
57
+ )
58
+
59
+ /**
60
+ * One row per run of a cursor: an event cursor's one event, a lane's run of
61
+ * queued events, or a log cursor's position.
62
+ */
63
+ export const a2Cursors = pgTable(
64
+ 'a2_cursors',
65
+ {
66
+ sessionId: text('session_id').notNull(),
67
+ consumer: text('consumer').notNull(),
68
+ cursorKey: text('cursor_key').notNull(),
69
+ epoch: int8('epoch').notNull(),
70
+ lane: text('lane'),
71
+ kind: text('kind').notNull(),
72
+ position: int8('position').notNull(),
73
+ head: int8('head'),
74
+ types: jsonb('types'),
75
+ attemptCount: integer('attempt_count').notNull().default(0),
76
+ firstClaimedAt: int8('first_claimed_at'),
77
+ lastClaimedAt: int8('last_claimed_at'),
78
+ claimHolder: text('claim_holder'),
79
+ claimExpiresAt: int8('claim_expires_at'),
80
+ failureCount: integer('failure_count').notNull().default(0),
81
+ lastFailedAt: int8('last_failed_at'),
82
+ lastFailedAttempt: integer('last_failed_attempt'),
83
+ lastError: text('last_error'),
84
+ failedAt: int8('failed_at'),
85
+ completedIdx: int8('completed_idx'),
86
+ completedAttempt: integer('completed_attempt'),
87
+ returnedEventIds: jsonb('returned_event_ids'),
88
+ },
89
+ (table) => [
90
+ primaryKey({
91
+ name: 'a2_cursors_pkey',
92
+ columns: [table.sessionId, table.cursorKey, table.consumer, table.epoch],
93
+ }),
94
+ index('a2_cursors_heads')
95
+ .on(table.sessionId, table.head)
96
+ .where(sql`head is not null`),
97
+ index('a2_cursors_lanes')
98
+ .on(table.sessionId)
99
+ .where(sql`kind = 'lane'`),
100
+ ],
101
+ )
102
+
103
+ /** A lane cursor's queue: the events appended for the lane, in log order. */
104
+ export const a2LaneEvents = pgTable(
105
+ 'a2_lane_events',
106
+ {
107
+ sessionId: text('session_id').notNull(),
108
+ consumer: text('consumer').notNull(),
109
+ lane: text('lane').notNull(),
110
+ idx: int8('idx').notNull(),
111
+ done: boolean('done').notNull().default(false),
112
+ completedAttempt: integer('completed_attempt'),
113
+ returnedEventIds: jsonb('returned_event_ids'),
114
+ },
115
+ (table) => [
116
+ primaryKey({
117
+ name: 'a2_lane_events_pkey',
118
+ columns: [table.sessionId, table.consumer, table.lane, table.idx],
119
+ }),
120
+ ],
121
+ )
122
+
123
+ export const a2Snapshots = pgTable(
124
+ 'a2_snapshots',
125
+ {
126
+ sessionId: text('session_id').notNull(),
127
+ reducerName: text('reducer_name').notNull(),
128
+ upToIndex: int8('up_to_index').notNull(),
129
+ state: jsonb('state').notNull(),
130
+ updatedAt: int8('updated_at').notNull(),
131
+ },
132
+ (table) => [
133
+ primaryKey({
134
+ name: 'a2_snapshots_pkey',
135
+ columns: [table.sessionId, table.reducerName],
136
+ }),
137
+ ],
138
+ )
139
+
140
+ export const a2SnapshotHistory = pgTable(
141
+ 'a2_snapshot_history',
142
+ {
143
+ sessionId: text('session_id').notNull(),
144
+ reducerName: text('reducer_name').notNull(),
145
+ upToIndex: int8('up_to_index').notNull(),
146
+ state: jsonb('state').notNull(),
147
+ updatedAt: int8('updated_at').notNull(),
148
+ },
149
+ (table) => [
150
+ primaryKey({
151
+ name: 'a2_snapshot_history_pkey',
152
+ columns: [table.sessionId, table.reducerName, table.upToIndex],
153
+ }),
154
+ ],
155
+ )
156
+
157
+ export const a2SnapshotPins = pgTable(
158
+ 'a2_snapshot_pins',
159
+ {
160
+ sessionId: text('session_id').notNull(),
161
+ eventIndex: int8('event_index').notNull(),
162
+ consumer: text('consumer').notNull(),
163
+ cursorKey: text('cursor_key').notNull(),
164
+ reducerName: text('reducer_name').notNull(),
165
+ upToIndex: int8('up_to_index').notNull(),
166
+ },
167
+ (table) => [
168
+ primaryKey({
169
+ name: 'a2_snapshot_pins_pkey',
170
+ columns: [
171
+ table.sessionId,
172
+ table.eventIndex,
173
+ table.consumer,
174
+ table.cursorKey,
175
+ table.reducerName,
176
+ table.upToIndex,
177
+ ],
178
+ }),
179
+ index('a2_snapshot_pins_checkpoint').on(
180
+ table.sessionId,
181
+ table.reducerName,
182
+ table.upToIndex,
183
+ ),
184
+ ],
185
+ )
186
+
187
+ export const a2Clears = pgTable('a2_clears', {
188
+ sessionId: text('session_id').primaryKey(),
189
+ clearIndex: int8('clear_index').notNull(),
190
+ })
191
+
192
+ export const a2Presence = pgTable(
193
+ 'a2_presence',
194
+ {
195
+ ns: text('ns').notNull(),
196
+ participant: text('participant').notNull(),
197
+ field: text('field').notNull(),
198
+ value: jsonb('value').notNull(),
199
+ seen: int8('seen').notNull(),
200
+ at: int8('at').notNull(),
201
+ expiresAt: int8('expires_at').notNull(),
202
+ },
203
+ (table) => [
204
+ primaryKey({
205
+ name: 'a2_presence_pkey',
206
+ columns: [table.ns, table.participant, table.field],
207
+ }),
208
+ ],
209
+ )