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
@@ -0,0 +1,418 @@
1
+ ---
2
+ title: Consumers
3
+ description: Named readers of every session's log. Each keeps its own place, runs in its own lanes, and retries its own failures.
4
+ ---
5
+
6
+ ```ts server/consumers.ts
7
+ import { createServer } from 'experimental-a2/server'
8
+ import { orders } from '@/contracts'
9
+
10
+ export const ordersServer = createServer({
11
+ contract: orders,
12
+ consumers: [
13
+ {
14
+ name: 'notify-shop',
15
+ types: ['created'],
16
+ run: async ({ event }) => {
17
+ // your side effect, e.g. email the shop, idempotent via event.id:
18
+ // await sendEmailToShop(event.payload.shopId, { idempotencyKey: event.id })
19
+ return { type: 'shop.notified', payload: {} }
20
+ },
21
+ },
22
+ ],
23
+ })
24
+ ```
25
+
26
+ A consumer runs when an event it takes lands: a plain async function that can
27
+ call a database, hit an API, or stream a model response. A2 keeps a durable
28
+ cursor for it in every session, claims the event while `run` works, and
29
+ retries it after a crash.
30
+
31
+ Each entry of `createServer({ consumers })` is a consumer: its `name`, the
32
+ `types` it takes, and `run`, which `event` narrows to those types.
33
+ `createServer` binds the contract to a [store](/stores/overview) and registers
34
+ them. `consumer({ contract, ... }).run(fn)` from `experimental-a2/server`
35
+ defines one on its own, to keep in its own file, to take from a library, or to
36
+ run with [`consume`](#run-a-consumer-now-with-consume); list it the same way.
37
+ Two consumers with the same name throw at construction. `experimental-a2/server` is the only entry point
38
+ that touches a store, and it fails loudly in a browser bundle.
39
+
40
+ ## Its name is its identity
41
+
42
+ A2 keys every cursor on the consumer's name. Rename a consumer and A2 treats it
43
+ as new: it starts without the old one's place, failures, or dead letters.
44
+ Remove one from `consumers` and its cursors wait: nothing claims them, and they
45
+ resume where they stopped when the name comes back. During a rolling deploy,
46
+ each deployment runs only the consumers it has. A consumer with a string lane
47
+ reads the log, so it also picks up events a deployment without it appended, the
48
+ next time a deployment that has it appends an event it takes or
49
+ [consumes it](#run-a-consumer-now-with-consume). A consumer with a lane
50
+ function, or with no lane, gets work only for events appended by a deployment
51
+ that has it: their lanes resolve at append.
52
+
53
+ A consumer's lane is part of its identity too. To change it, or to switch
54
+ between a string and a function, give the consumer a new name.
55
+
56
+ Names are up to 128 letters, digits, and `. _ : -`, starting with a letter or
57
+ digit. Names starting with `a2.` belong to A2: [actors](/actors/introduction)
58
+ and [agents](/agents/introduction) run on A2's own consumers, and their dead
59
+ letters name them, as `a2.actor` or `a2.ai.tools`.
60
+
61
+ ## Which events it takes
62
+
63
+ `types` lists the event types a consumer runs on. Leave it out to take every
64
+ type. An event no consumer takes is still logged, folded into state, and
65
+ streamed; it starts no work, with no claim or scheduler message.
66
+
67
+ Changing `types` applies to events the consumer hasn't looked at yet. A cursor
68
+ with work looks at events as it reaches them; an idle one looks at each batch
69
+ as it's appended. To run a consumer over history with new types, give it a new
70
+ name and `backfill`.
71
+
72
+ ## Servers without consumers
73
+
74
+ ```ts server/order-history.ts
75
+ import { createServer } from 'experimental-a2/server'
76
+ import { postgres } from 'experimental-a2/store-postgres'
77
+ import { orders } from '@/contracts'
78
+
79
+ export const orderHistory = createServer({
80
+ contract: orders,
81
+ store: postgres({ connectionString: process.env.DATABASE_URL }),
82
+ })
83
+ ```
84
+
85
+ Consumers are optional. Without any, a server is a durable event log:
86
+ `append`, `history`, reducers, and live streams. Events settle in their append
87
+ transaction, with no drain, claim, or queue message. A consumer you add later
88
+ starts with the events appended after it exists, or with
89
+ [`backfill`](#start-from-the-beginning-with-backfill), from the beginning of
90
+ the log.
91
+
92
+ ## Lanes
93
+
94
+ ```ts server/lanes.ts
95
+ import { consumer, createServer } from 'experimental-a2/server'
96
+ import { orders } from '@/contracts'
97
+
98
+ // one driver at a time, drivers in parallel
99
+ const dispatch = consumer({
100
+ contract: orders,
101
+ name: 'dispatch',
102
+ types: ['driver.notified'],
103
+ lane: ({ event }) => `driver:${event.payload.driverId}`,
104
+ }).run(async ({ event }) => {
105
+ // your work for this driver, using event.payload.driverId
106
+ })
107
+
108
+ export const lanesServer = createServer({
109
+ contract: orders,
110
+ consumers: [
111
+ // every event it takes, one at a time, in log order
112
+ {
113
+ name: 'order-log',
114
+ lane: 'orders',
115
+ run: async ({ event }) => {
116
+ // your write, keyed by event.index so a retry overwrites it:
117
+ // await db.upsert({ id: event.index, type: event.type })
118
+ },
119
+ },
120
+ dispatch,
121
+ ],
122
+ })
123
+ ```
124
+
125
+ Without `lane`, a consumer runs every event concurrently, each on its own
126
+ cursor: log order stays stable, run order doesn't, and a failure retries that
127
+ event without holding up the rest. Use a lane when events share a resource and
128
+ must not overlap. A string lane runs everything the consumer takes in log
129
+ order, one at a time. A function returns each event's lane, or `null` to run
130
+ that event concurrently.
131
+
132
+ A2 resolves each consumer's lane when the event is appended and stores it, so
133
+ a deployment can't reinterpret pending work. Lanes belong to one session and
134
+ one consumer: they aren't locks across sessions, and two consumers using the
135
+ same lane name never wait on each other. Within a lane, a failing event holds
136
+ up the events behind it until it succeeds or you resolve it.
137
+
138
+ ## One event, many consumers
139
+
140
+ ```ts server/fanout.ts
141
+ import { createServer } from 'experimental-a2/server'
142
+ import { orders } from '@/contracts'
143
+
144
+ export const fanoutServer = createServer({
145
+ contract: orders,
146
+ consumers: [
147
+ {
148
+ name: 'email-shop',
149
+ types: ['created'],
150
+ run: async ({ event }) => {
151
+ // await sendEmailToShop(event.payload.shopId, { idempotencyKey: event.id })
152
+ },
153
+ },
154
+ {
155
+ name: 'reserve-stock',
156
+ types: ['created'],
157
+ run: async ({ event }) => {
158
+ // await reserve(event.payload.items, { idempotencyKey: event.id })
159
+ },
160
+ },
161
+ ],
162
+ })
163
+ ```
164
+
165
+ Every consumer that takes an event gets its own cursor for it. They run
166
+ concurrently, retry separately, and dead-letter separately: an email that keeps
167
+ failing never stops the stock reservation.
168
+
169
+ ## Start from the beginning with `backfill`
170
+
171
+ ```ts server/backfill.ts
172
+ import { consumer, createServer } from 'experimental-a2/server'
173
+ import { orders } from '@/contracts'
174
+
175
+ const ordersTable = consumer({
176
+ contract: orders,
177
+ name: 'orders-table-v1',
178
+ lane: 'orders-table',
179
+ backfill: true,
180
+ }).run(async ({ event }) => {
181
+ // your table, written idempotently by event index:
182
+ // await db.insert(ordersTable).values({ id: event.index, type: event.type }).onConflictDoNothing()
183
+ })
184
+
185
+ export const tableServer = createServer({
186
+ contract: orders,
187
+ consumers: [ordersTable],
188
+ })
189
+ ```
190
+
191
+ A new consumer starts at the first event appended after it exists. With
192
+ `backfill: true`, it starts at the beginning of the log instead, so it sees
193
+ every event already there. Use it to fill a table or index from history, then
194
+ keep it current, as [SQL projections](/server/sql-projections) does. Change what
195
+ it writes, give it a new name, and it fills the new shape from the start.
196
+
197
+ `backfill` needs a string `lane`: a backfill reads the log in order. It can't
198
+ use `abortOn`: cancellation is for work in progress, and checking it while
199
+ replaying history would read the rest of the log for every event. A
200
+ consumer starts in a session the next time an event it takes is appended
201
+ there, or when you [consume](#run-a-consumer-now-with-consume) it.
202
+
203
+ ## Run a consumer now with `consume`
204
+
205
+ ```ts server/read.ts
206
+ import { consumer, createServer } from 'experimental-a2/server'
207
+ import { orders } from '@/contracts'
208
+
209
+ const ordersTable = consumer({
210
+ contract: orders,
211
+ name: 'orders-table-v1',
212
+ lane: 'orders-table',
213
+ backfill: true,
214
+ }).run(async ({ event }) => {
215
+ // await db.insert(ordersTable).values({ id: event.index, type: event.type }).onConflictDoNothing()
216
+ })
217
+
218
+ export const readServer = createServer({
219
+ contract: orders,
220
+ consumers: [ordersTable],
221
+ })
222
+
223
+ export async function readOrder(orderId: string): Promise<void> {
224
+ // for an order the caller just changed, before reading the table:
225
+ await readServer.session(orderId).consume(ordersTable)
226
+ // every event of the order is in your table now:
227
+ // return db.select().from(ordersTable).where(eq(ordersTable.order, orderId))
228
+ }
229
+ ```
230
+
231
+ `session.consume(consumer)` runs a consumer with a string `lane` on that
232
+ session, in this invocation, until it has handled every event it takes up to
233
+ the session's head. Pass `{ through: index }` to stop sooner, as soon as an
234
+ event you know about is in. It returns `{ index }`: the consumer has handled
235
+ every event it takes through that index.
236
+
237
+ - **Read your writes.** A table a consumer keeps lags its log a little.
238
+ Consume it before a read that has to include a write you just made.
239
+ - **Start a backfill now.** A new consumer otherwise starts with the next
240
+ append. `consume(ordersTable, { through: 0 })` creates its cursor and runs
241
+ nothing here; the server carries on with the backfill in the background.
242
+ - **Wait in another consumer.** `ctx.session.consume(ordersTable, { through:
243
+ ctx.event.index })` inside one consumer waits until another's table has the
244
+ triggering event, and then reads it. A consumer can't consume itself. Nor
245
+ can it wait on a consumer that is waiting on it: that `consume` throws,
246
+ unless what it asks for is in already.
247
+
248
+ Consuming a session with nothing in it writes nothing, so a read can consume
249
+ whatever session it's asked about.
250
+
251
+ The server's own consumer keeps one cursor, so `consume` shares it with the
252
+ background: when another invocation holds the next event, `consume` waits for
253
+ it. It runs nothing past `through`, which carries on in the background. A
254
+ consumer the server doesn't run is pulled only by `consume`, and keeps its
255
+ place between calls, like a reader's "since I last looked".
256
+
257
+ Failures count against the same ten-attempt budget as everywhere else, and
258
+ `consume` waits out the backoff between them. Once the next event
259
+ dead-letters for that consumer, `consume` throws `CONSUMER_DEAD_LETTERED`.
260
+ Pass `{ signal }` to stop waiting sooner. An event running here when it fires
261
+ finishes on its own.
262
+
263
+ ## The context
264
+
265
+ | Property | What it is |
266
+ | ------------- | ---------- |
267
+ | `ctx.event` | The triggering event: `{ id, type, payload, index, sessionId, createdAt }`. With `types`, its type narrows to those. |
268
+ | `ctx.attempt` | The durable 1-based claim of this event for this consumer. |
269
+ | `ctx.session` | This session's handle: `id`, `append`, `schedule`, `clear`, `history`, `state`, `stream`, `consume`, and `setPresence` (presence contracts only). |
270
+ | `ctx.signal` | An `AbortSignal`. Fires on matching `abortOn` events and when the attempt loses its claim (`CLAIM_EXPIRED`, `SUPERSEDED_ATTEMPT`). See [Cancellation](/server/cancellation). |
271
+
272
+ `ctx.attempt` counts claims of one event: the next event in a lane starts at
273
+ 1 again. It can skip a number when a process dies between the claim and
274
+ `run`.
275
+
276
+ [`ctx.session.state(reducer)`](/contract/state) folds through
277
+ `ctx.event.index`, so later appends don't change what this event sees. Pass
278
+ `{ through: 'latest' }` to read the current committed frontier instead.
279
+
280
+ ## Return what happens after success
281
+
282
+ ```ts server/return-many.ts
283
+ import { createServer } from 'experimental-a2/server'
284
+ import { orders } from '@/contracts'
285
+
286
+ export const returnServer = createServer({
287
+ contract: orders,
288
+ consumers: [
289
+ {
290
+ name: 'start-shop',
291
+ types: ['shop.started'],
292
+ run: async () => {
293
+ return orders.batch(
294
+ { type: 'shop.notified', payload: {} },
295
+ { type: 'driver.notified', payload: { driverId: 'driver-7' } },
296
+ )
297
+ },
298
+ },
299
+ ],
300
+ })
301
+ ```
302
+
303
+ Return one event or an array when it should exist only if `run` succeeds. A2
304
+ moves the consumer's cursor past the event and appends the batch in one atomic
305
+ operation, at consecutive positions. If `run` throws or the process dies
306
+ first, none of it exists. Returned events get deterministic ids from the
307
+ trigger, the consumer's name, and their position, so a retry after a lost
308
+ acknowledgement doesn't duplicate them.
309
+
310
+ `contract.batch(...)` is a type helper that keeps each event's literal type;
311
+ A2 still validates at commit.
312
+
313
+ ## Append what should happen now
314
+
315
+ ```ts server/append-now.ts
316
+ import { createServer } from 'experimental-a2/server'
317
+ import { orders } from '@/contracts'
318
+
319
+ export const appendServer = createServer({
320
+ contract: orders,
321
+ consumers: [
322
+ {
323
+ name: 'accept-order',
324
+ types: ['created'],
325
+ run: async ({ session }) => {
326
+ await session.append('notify-shop', { type: 'shop.notified', payload: {} })
327
+
328
+ // A consumer of shop.notified may already be running here.
329
+ // await continueCurrentWork()
330
+ },
331
+ },
332
+ ],
333
+ })
334
+ ```
335
+
336
+ `ctx.session.append(name, ...events)` commits immediately, so the appended
337
+ event can start while `run` is still working. Use it for streaming progress,
338
+ independent fan-out, or any fact that stays true even if `run` later fails.
339
+ Each call is atomic.
340
+
341
+ The first argument names the append within the triggering event. A2 derives
342
+ the ids of events without an `id` from the trigger, the consumer's name, the
343
+ append's name, and each event's position, so a retry gets the existing events
344
+ back, whatever order parallel branches finish in. Keep the name stable;
345
+ reusing it with a different batch conflicts. An explicit `id` wins, so
346
+ different triggers can converge on one fact. Top-level
347
+ `server.session(id).append(...events)` takes no name.
348
+
349
+ ## Schedule what happens later
350
+
351
+ `ctx.session.schedule(name, timing, ...events)` appends events later, through
352
+ the [scheduler](/schedulers/overview). In a consumer, a delay counts from the
353
+ triggering event's `createdAt`, and the timer's identity includes the trigger
354
+ and the consumer's name, so a retry arms the same timer. See
355
+ [Timers](/server/timers).
356
+
357
+ ## Remember facts in the log
358
+
359
+ Reducer snapshots are a cache: A2 may discard one and fold the log again. When
360
+ `run` learns something a later run needs, such as "this thread was already
361
+ triaged", append it as an event and read it back with a reducer. Then a
362
+ rebuilt snapshot still knows it, and nothing runs twice to find out.
363
+
364
+ ## Consumers can run twice
365
+
366
+ Consumers are at-least-once. A crash after your side effect but before A2
367
+ records completion runs the event again.
368
+
369
+ - **External side effects take an idempotency key.** `event.id` is stable
370
+ across attempts and unique within its session. Ids your callers pick, and
371
+ the ids A2 derives from them, can repeat in another session: when yours can,
372
+ add the session to the key, and the contract's name if several contracts
373
+ share it, as in `` `orders:${event.sessionId}:${event.id}` ``.
374
+ - **Named appends are deduplicated.** A retry gets the existing events back.
375
+ - **Returned events commit with completion.** They all exist with the
376
+ cursor's advance, or none do.
377
+
378
+ ## When it throws
379
+
380
+ Throwing means "retry me." A2 records the failure on that consumer's cursor and
381
+ retries with backoff. After ten failed attempts the event dead-letters for that
382
+ consumer: its lane waits, while other consumers and lanes continue.
383
+ [Retry or skip it](#resolve-a-dead-letter) once you know why.
384
+
385
+ When someone presses stop, catch the abort and return normally instead. See
386
+ [Cancellation](/server/cancellation).
387
+
388
+ ## Resolve a dead letter
389
+
390
+ A [dead-lettered](/server/guarantees#when-a-consumer-keeps-failing) event waits
391
+ for you. List a session's dead letters, then retry or skip each one for the
392
+ consumer it's dead for:
393
+
394
+ ```ts
395
+ // anywhere on the server, behind your own operator checks:
396
+ import { ordersServer } from '@/server'
397
+
398
+ const order = ordersServer.session(orderId)
399
+ const [dead] = await order.deadLetters()
400
+ if (dead) {
401
+ // dead.lastError says why (null if every attempt timed out or crashed);
402
+ // deploy the fix first, then:
403
+ await order.redrive(
404
+ { consumer: dead.consumer, index: dead.index },
405
+ { action: 'retry' },
406
+ )
407
+ }
408
+ ```
409
+
410
+ - **Retry.** `{ action: 'retry' }` runs the consumer again with a fresh
411
+ ten-failure budget. Once it completes, its lane continues.
412
+ - **Skip.** `{ action: 'skip' }` moves the consumer past the event without
413
+ running it, accepting that its effects never happen for that consumer. The
414
+ next event in its lane runs.
415
+
416
+ Each redrive is one atomic store operation, and it wakes the session like an
417
+ append, so the lane resumes without another write. An event that isn't
418
+ dead-lettered anymore is left alone: `redrive` reports `{ redriven: false }`.
@@ -3,32 +3,42 @@ title: Timers
3
3
  description: Schedule a typed session event for later, through the same scheduler that handles recovery.
4
4
  ---
5
5
 
6
- ## Schedule from a handler
6
+ ## Schedule from a consumer
7
7
 
8
8
  ```ts server/orders.ts
9
- import { createServer } from 'experimental-a2/server'
9
+ import { consumer, createServer } from 'experimental-a2/server'
10
10
  import { vercelQueues } from 'experimental-a2/scheduler-vercel'
11
11
  import { orders } from '@/contracts'
12
12
 
13
13
  export const scheduler = vercelQueues()
14
14
 
15
+ const expire = consumer({
16
+ contract: orders,
17
+ name: 'expire',
18
+ types: ['expired'],
19
+ }).run(async ({ session }) => {
20
+ const events = await session.history()
21
+ if (events.some((event) => event.type === 'shop.started')) return
22
+ // your expiry side effect goes here
23
+ })
24
+
15
25
  export const ordersServer = createServer({
16
26
  contract: orders,
17
27
  scheduler,
18
- handlers: {
19
- created: async ({ session }) => {
20
- await session.schedule(
21
- 'expire-order',
22
- { delay: '5d' },
23
- { type: 'expired', payload: {} },
24
- )
25
- },
26
- expired: async ({ session }) => {
27
- const events = await session.history()
28
- if (events.some((event) => event.type === 'shop.started')) return
29
- // your expiry side effect goes here
28
+ consumers: [
29
+ {
30
+ name: 'arm-expiry',
31
+ types: ['created'],
32
+ run: async ({ session }) => {
33
+ await session.schedule(
34
+ 'expire-order',
35
+ { delay: '5d' },
36
+ { type: 'expired', payload: {} },
37
+ )
38
+ },
30
39
  },
31
- },
40
+ expire,
41
+ ],
32
42
  })
33
43
  ```
34
44
 
@@ -69,14 +79,14 @@ export async function POST(request: Request) {
69
79
 
70
80
  Root sessions have the same method. A root `{ delay: '30s' }` counts from that
71
81
  call's clock, so a retry 10 seconds later delivers 10 seconds later too. Use
72
- `{ at }` when a caller may retry. Inside a handler, a delay counts from the
82
+ `{ at }` when a caller may retry. Inside a consumer, a delay counts from the
73
83
  triggering event's durable `createdAt`, so retries keep the same target.
74
84
 
75
85
  ## Identity and retries
76
86
 
77
87
  `name` identifies one schedule: per contract and session at the root, plus the
78
- triggering event id in a handler. Different events can share a name like
79
- `expire-order`, while retries of one event converge.
88
+ triggering event id and the consumer's name in a consumer. Different events can
89
+ share a name like `expire-order`, while retries of one event converge.
80
90
 
81
91
  Keep the same name, timing, and events on every retry. Omitted event ids are
82
92
  derived and stable, so duplicate deliveries append once per session lifetime,
@@ -95,7 +105,7 @@ fixed ids validates the original input, so a schema transform runs on that
95
105
  input, not on an earlier output.
96
106
 
97
107
  Scheduled events enter the log only when delivered, with `cause: null`. From
98
- there they behave like any server append: handlers, lanes, reducers, streams,
108
+ there they behave like any server append: consumers, lanes, reducers, streams,
99
109
  and live clients see them normally, and their stable ids absorb at-least-once
100
110
  delivery.
101
111
 
@@ -112,8 +122,8 @@ guard from the first example: let the event land, read the session, and return
112
122
  when it no longer applies. The log still records that the timer fired.
113
123
 
114
124
  A2 has no cancellation, reschedule, cron, or timer-listing API. `name` is an
115
- idempotency key, not a handle. For repeating work, have each occurrence's
116
- handler schedule the next one.
125
+ idempotency key, not a handle. For repeating work, have the consumer of each
126
+ occurrence schedule the next one.
117
127
 
118
128
  ## Provider limits
119
129
 
@@ -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