experimental-a2 0.19.0 → 0.21.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (320) hide show
  1. package/CHANGELOG.md +50 -0
  2. package/README.md +1 -1
  3. package/dist/{actor-C7b-1arn.d.ts → actor-HnGmrOZZ.d.ts} +5 -5
  4. package/dist/{actor-C7b-1arn.d.ts.map → actor-HnGmrOZZ.d.ts.map} +1 -1
  5. package/dist/actor-client.d.ts +1 -1
  6. package/dist/actor-client.js +2 -2
  7. package/dist/actor-react.d.ts +3 -3
  8. package/dist/actor-react.js +5 -5
  9. package/dist/{actor-shared-t7w8xIKP.js → actor-shared-DFET5q16.js} +3 -3
  10. package/dist/{actor-shared-t7w8xIKP.js.map → actor-shared-DFET5q16.js.map} +1 -1
  11. package/dist/actor.d.ts +1 -1
  12. package/dist/actor.js +54 -43
  13. package/dist/actor.js.map +1 -1
  14. package/dist/{ai-Cz3hqVhk.d.ts → ai-CvEnqVfo.d.ts} +8 -8
  15. package/dist/{ai-Cz3hqVhk.d.ts.map → ai-CvEnqVfo.d.ts.map} +1 -1
  16. package/dist/{ai-mMiWul1T.js → ai-CzE672Q3.js} +9 -9
  17. package/dist/ai-CzE672Q3.js.map +1 -0
  18. package/dist/ai-client.d.ts +2 -2
  19. package/dist/ai-client.js +2 -2
  20. package/dist/ai-fx.d.ts +1 -1
  21. package/dist/ai-fx.d.ts.map +1 -1
  22. package/dist/ai-fx.js +36 -9
  23. package/dist/ai-fx.js.map +1 -1
  24. package/dist/{ai-harness-CV6rXbB3.d.ts → ai-harness-Bfk-Lycn.d.ts} +2 -2
  25. package/dist/ai-harness-Bfk-Lycn.d.ts.map +1 -0
  26. package/dist/{ai-harness-BuuC-gJ3.js → ai-harness-dQeJaR1t.js} +17 -4
  27. package/dist/ai-harness-dQeJaR1t.js.map +1 -0
  28. package/dist/ai-pi.d.ts +1 -1
  29. package/dist/ai-pi.js +4 -4
  30. package/dist/ai-pi.js.map +1 -1
  31. package/dist/ai-react.d.ts +5 -5
  32. package/dist/ai-react.js +2 -2
  33. package/dist/ai-server.d.ts +14 -17
  34. package/dist/ai-server.d.ts.map +1 -1
  35. package/dist/ai-server.js +84 -75
  36. package/dist/ai-server.js.map +1 -1
  37. package/dist/ai.d.ts +2 -2
  38. package/dist/ai.js +2 -2
  39. package/dist/{client-BhHLoNKF.js → client-9IeTsiAs.js} +5 -5
  40. package/dist/{client-BhHLoNKF.js.map → client-9IeTsiAs.js.map} +1 -1
  41. package/dist/{client-h_DKFAjj.d.ts → client-CUMbS_38.d.ts} +3 -3
  42. package/dist/{client-h_DKFAjj.d.ts.map → client-CUMbS_38.d.ts.map} +1 -1
  43. package/dist/client.d.ts +3 -3
  44. package/dist/client.js +1 -1
  45. package/dist/{contract-BYKASlCV.js → contract-CWukt01d.js} +2 -2
  46. package/dist/contract-CWukt01d.js.map +1 -0
  47. package/dist/index.d.ts +5 -5
  48. package/dist/index.js +3 -3
  49. package/dist/{initial-snapshot-Bx-c0pDP.d.ts → initial-snapshot-D3Lve43o.d.ts} +4 -4
  50. package/dist/initial-snapshot-D3Lve43o.d.ts.map +1 -0
  51. package/dist/{internal-CscSY7K_.js → internal-Bc4PvfHk.js} +10 -3
  52. package/dist/internal-Bc4PvfHk.js.map +1 -0
  53. package/dist/otel.d.ts +1 -1
  54. package/dist/otel.js +1 -1
  55. package/dist/otel.js.map +1 -1
  56. package/dist/{react-BBXgHWFc.d.ts → react-BoHvC3v9.d.ts} +4 -4
  57. package/dist/{react-BBXgHWFc.d.ts.map → react-BoHvC3v9.d.ts.map} +1 -1
  58. package/dist/react.d.ts +1 -1
  59. package/dist/react.js +1 -1
  60. package/dist/{reducer-CqA59Q6U.d.ts → reducer-CYPHsSmd.d.ts} +2 -2
  61. package/dist/{reducer-CqA59Q6U.d.ts.map → reducer-CYPHsSmd.d.ts.map} +1 -1
  62. package/dist/{reducer-BAXc8JyF.js → reducer-CjmvZSAb.js} +2 -2
  63. package/dist/{reducer-BAXc8JyF.js.map → reducer-CjmvZSAb.js.map} +1 -1
  64. package/dist/runtime-consumer-BWmht60z.js +12 -0
  65. package/dist/runtime-consumer-BWmht60z.js.map +1 -0
  66. package/dist/scheduler-qstash.d.ts +2 -2
  67. package/dist/scheduler-qstash.d.ts.map +1 -1
  68. package/dist/scheduler-qstash.js +7 -7
  69. package/dist/scheduler-qstash.js.map +1 -1
  70. package/dist/scheduler-task-X1SToDjP.js.map +1 -1
  71. package/dist/scheduler-vercel.d.ts +2 -2
  72. package/dist/scheduler-vercel.d.ts.map +1 -1
  73. package/dist/scheduler-vercel.js +5 -4
  74. package/dist/scheduler-vercel.js.map +1 -1
  75. package/dist/{server-DYAATFuT.d.ts → server-BshFZ0eL.d.ts} +108 -41
  76. package/dist/server-BshFZ0eL.d.ts.map +1 -0
  77. package/dist/{server-D-Y4pqwu.js → server-DvIB8kj5.js} +423 -135
  78. package/dist/server-DvIB8kj5.js.map +1 -0
  79. package/dist/server.browser.js +1 -1
  80. package/dist/server.browser.js.map +1 -1
  81. package/dist/server.d.ts +4 -4
  82. package/dist/server.js +2 -2
  83. package/dist/{session-clear-C-QwTXV1.d.ts → session-clear-Bk_29eEw.d.ts} +2 -2
  84. package/dist/session-clear-Bk_29eEw.d.ts.map +1 -0
  85. package/dist/{session-clear-DGI4jTQ5.js → session-clear-wHd5GCpZ.js} +5 -4
  86. package/dist/session-clear-wHd5GCpZ.js.map +1 -0
  87. package/dist/store-N8PXxDAS.js.map +1 -1
  88. package/dist/store-Z2qjw50n.d.ts +523 -0
  89. package/dist/store-Z2qjw50n.d.ts.map +1 -0
  90. package/dist/store-codec-DwrWlgQx.js +65 -0
  91. package/dist/store-codec-DwrWlgQx.js.map +1 -0
  92. package/dist/store-memory.d.ts +1 -1
  93. package/dist/store-memory.d.ts.map +1 -1
  94. package/dist/store-memory.js +393 -210
  95. package/dist/store-memory.js.map +1 -1
  96. package/dist/{store-polling-7WJV0YWP.js → store-polling-DYNyxCev.js} +2 -2
  97. package/dist/{store-polling-7WJV0YWP.js.map → store-polling-DYNyxCev.js.map} +1 -1
  98. package/dist/store-postgres.d.ts +1 -1
  99. package/dist/store-postgres.d.ts.map +1 -1
  100. package/dist/store-postgres.js +1595 -673
  101. package/dist/store-postgres.js.map +1 -1
  102. package/dist/{store-presence-polling-B1ixeqIS.js → store-presence-polling-CzQ0wdtM.js} +3 -3
  103. package/dist/{store-presence-polling-B1ixeqIS.js.map → store-presence-polling-CzQ0wdtM.js.map} +1 -1
  104. package/dist/store-redis-http.d.ts +1 -1
  105. package/dist/store-redis-http.js +4 -4
  106. package/dist/store-redis-notify-IiVdlYHc.js +2919 -0
  107. package/dist/store-redis-notify-IiVdlYHc.js.map +1 -0
  108. package/dist/store-redis.d.ts +1 -1
  109. package/dist/store-redis.js +2 -2
  110. package/dist/store-sqlite.d.ts +1 -1
  111. package/dist/store-sqlite.d.ts.map +1 -1
  112. package/dist/store-sqlite.js +517 -287
  113. package/dist/store-sqlite.js.map +1 -1
  114. package/dist/{telemetry-Cyfqo6Qt.d.ts → telemetry-CWwsh-Gx.d.ts} +3 -3
  115. package/dist/telemetry-CWwsh-Gx.d.ts.map +1 -0
  116. package/dist/{wire-BKxH1vbP.js → wire-CZGqNZRx.js} +5 -4
  117. package/dist/wire-CZGqNZRx.js.map +1 -0
  118. package/docs/01-quickstart.mdx +28 -19
  119. package/docs/02-examples.mdx +1 -1
  120. package/docs/actors/01-introduction.mdx +9 -1
  121. package/docs/actors/02-concurrency.mdx +1 -1
  122. package/docs/actors/meta.ts +1 -1
  123. package/docs/agents/01-introduction.mdx +1 -1
  124. package/docs/agents/02-tools.mdx +13 -12
  125. package/docs/agents/03-conversations.mdx +31 -21
  126. package/docs/agents/04-harnesses.mdx +7 -3
  127. package/docs/agents/meta.ts +1 -1
  128. package/docs/client/03-presence.mdx +18 -14
  129. package/docs/client/meta.ts +1 -1
  130. package/docs/contract/01-events.mdx +2 -2
  131. package/docs/contract/02-state.mdx +18 -14
  132. package/docs/contract/03-sessions.mdx +32 -25
  133. package/docs/index.mdx +31 -26
  134. package/docs/reference/01-core.mdx +12 -12
  135. package/docs/reference/02-server.mdx +247 -192
  136. package/docs/reference/04-ai.mdx +29 -20
  137. package/docs/reference/05-telemetry.mdx +146 -0
  138. package/docs/reference/06-errors.mdx +24 -18
  139. package/docs/reference/meta.ts +1 -1
  140. package/docs/schedulers/01-overview.mdx +112 -0
  141. package/docs/schedulers/02-vercel-queues.mdx +87 -0
  142. package/docs/schedulers/03-qstash.mdx +123 -0
  143. package/docs/schedulers/meta.ts +1 -0
  144. package/docs/server/01-consumers.mdx +415 -0
  145. package/docs/server/02-timers.mdx +33 -23
  146. package/docs/server/03-cancellation.mdx +52 -44
  147. package/docs/server/04-routes.mdx +4 -1
  148. package/docs/server/05-sql-projections.mdx +391 -0
  149. package/docs/server/06-guarantees.mdx +60 -50
  150. package/docs/stores/01-overview.mdx +37 -0
  151. package/docs/stores/02-redis.mdx +57 -0
  152. package/docs/stores/03-redis-http.mdx +56 -0
  153. package/docs/stores/04-postgres.mdx +172 -0
  154. package/docs/stores/05-sqlite.mdx +36 -0
  155. package/docs/stores/06-memory.mdx +22 -0
  156. package/docs/stores/meta.ts +1 -0
  157. package/docs/upgrades/01-overview.mdx +30 -0
  158. package/docs/upgrades/79-0-21.mdx +165 -0
  159. package/docs/upgrades/80-0-20.mdx +43 -0
  160. package/docs/upgrades/81-0-19.mdx +33 -0
  161. package/docs/upgrades/82-0-18.mdx +33 -0
  162. package/docs/upgrades/83-0-17.mdx +69 -0
  163. package/docs/upgrades/meta.ts +3 -0
  164. package/examples/playground/DEPLOY.md +48 -11
  165. package/examples/playground/app/a0/apps/[appId]/route.ts +6 -10
  166. package/examples/playground/app/a0/chat.test.tsx +421 -330
  167. package/examples/playground/app/a0/sandbox-log.ts +17 -15
  168. package/examples/playground/app/a0/server.ts +4 -4
  169. package/examples/playground/app/a0/shell.tsx +1 -1
  170. package/examples/playground/app/a0/uploads/route.test.ts +59 -0
  171. package/examples/playground/app/a2slack/a2slack.css +1067 -0
  172. package/examples/playground/app/a2slack/c/[channel]/page.tsx +20 -0
  173. package/examples/playground/app/a2slack/consumers/agent.ts +63 -0
  174. package/examples/playground/app/a2slack/consumers/feed.ts +164 -0
  175. package/examples/playground/app/a2slack/consumers/notify.ts +116 -0
  176. package/examples/playground/app/a2slack/consumers/search.ts +37 -0
  177. package/examples/playground/app/a2slack/consumers/triage.ts +31 -0
  178. package/examples/playground/app/a2slack/consumers/welcome.ts +25 -0
  179. package/examples/playground/app/a2slack/db/a2.ts +206 -0
  180. package/examples/playground/app/a2slack/db/index.ts +73 -0
  181. package/examples/playground/app/a2slack/db/migrations/0000_extensions.sql +4 -0
  182. package/examples/playground/app/a2slack/db/migrations/0001_workspace.sql +74 -0
  183. package/examples/playground/app/a2slack/db/migrations/0002_a2.sql +215 -0
  184. package/examples/playground/app/a2slack/db/migrations/0003_a2_batch.sql +67 -0
  185. package/examples/playground/app/a2slack/db/migrations/meta/0000_snapshot.json +18 -0
  186. package/examples/playground/app/a2slack/db/migrations/meta/0001_snapshot.json +500 -0
  187. package/examples/playground/app/a2slack/db/migrations/meta/0002_snapshot.json +1159 -0
  188. package/examples/playground/app/a2slack/db/migrations/meta/0003_snapshot.json +1159 -0
  189. package/examples/playground/app/a2slack/db/migrations/meta/_journal.json +34 -0
  190. package/examples/playground/app/a2slack/db/queries.ts +263 -0
  191. package/examples/playground/app/a2slack/db/schema.ts +145 -0
  192. package/examples/playground/app/a2slack/drills/a2slack.drill.ts +708 -0
  193. package/examples/playground/app/a2slack/drills/harness.ts +244 -0
  194. package/examples/playground/app/a2slack/events/route.ts +36 -0
  195. package/examples/playground/app/a2slack/identity.ts +44 -0
  196. package/examples/playground/app/a2slack/inbox/route.ts +8 -0
  197. package/examples/playground/app/a2slack/lag/data/route.ts +8 -0
  198. package/examples/playground/app/a2slack/lag/page.tsx +101 -0
  199. package/examples/playground/app/a2slack/lag.ts +73 -0
  200. package/examples/playground/app/a2slack/layout.tsx +43 -0
  201. package/examples/playground/app/a2slack/messages/route.ts +17 -0
  202. package/examples/playground/app/a2slack/model.test.ts +208 -0
  203. package/examples/playground/app/a2slack/model.ts +369 -0
  204. package/examples/playground/app/a2slack/models.test.ts +72 -0
  205. package/examples/playground/app/a2slack/page.tsx +10 -0
  206. package/examples/playground/app/a2slack/scheduler/route.ts +5 -0
  207. package/examples/playground/app/a2slack/search/route.ts +40 -0
  208. package/examples/playground/app/a2slack/search-index.ts +108 -0
  209. package/examples/playground/app/a2slack/server.test.ts +315 -0
  210. package/examples/playground/app/a2slack/server.ts +55 -0
  211. package/examples/playground/app/a2slack/sessions.ts +50 -0
  212. package/examples/playground/app/a2slack/thread/route.ts +8 -0
  213. package/examples/playground/app/a2slack/ui/ChannelView.tsx +87 -0
  214. package/examples/playground/app/a2slack/ui/Composer.tsx +117 -0
  215. package/examples/playground/app/a2slack/ui/DotsShadow.tsx +42 -0
  216. package/examples/playground/app/a2slack/ui/Feed.tsx +157 -0
  217. package/examples/playground/app/a2slack/ui/InboxContext.tsx +63 -0
  218. package/examples/playground/app/a2slack/ui/InboxView.tsx +82 -0
  219. package/examples/playground/app/a2slack/ui/Menu.tsx +128 -0
  220. package/examples/playground/app/a2slack/ui/MessageRow.tsx +314 -0
  221. package/examples/playground/app/a2slack/ui/NameGate.tsx +35 -0
  222. package/examples/playground/app/a2slack/ui/SearchContext.tsx +146 -0
  223. package/examples/playground/app/a2slack/ui/SearchResults.tsx +97 -0
  224. package/examples/playground/app/a2slack/ui/Shell.tsx +34 -0
  225. package/examples/playground/app/a2slack/ui/Sidebar.tsx +199 -0
  226. package/examples/playground/app/a2slack/ui/ThreadPanel.tsx +134 -0
  227. package/examples/playground/app/a2slack/ui/useInbox.ts +71 -0
  228. package/examples/playground/app/agent/[agentId]/agent-queue.test.tsx +116 -90
  229. package/examples/playground/app/agent/[agentId]/compaction-panel.test.tsx +107 -76
  230. package/examples/playground/app/agent/ingress.test.ts +41 -17
  231. package/examples/playground/app/agent/model-input.test.tsx +92 -43
  232. package/examples/playground/app/agent/server.ts +3 -3
  233. package/examples/playground/app/chat/[chatId]/agent-stream-drawer.test.tsx +2 -2
  234. package/examples/playground/app/chat/[chatId]/chat-client.tsx +1 -1
  235. package/examples/playground/app/chat/server.test.ts +38 -65
  236. package/examples/playground/app/chat/server.ts +45 -35
  237. package/examples/playground/app/components/activity-history.test.tsx +60 -18
  238. package/examples/playground/app/components/demo-icons.tsx +7 -0
  239. package/examples/playground/app/components/demos.tsx +7 -0
  240. package/examples/playground/app/components/fast-link.test.tsx +3 -5
  241. package/examples/playground/app/initial-state.test.tsx +86 -45
  242. package/examples/playground/app/orders/[orderId]/order-client.tsx +2 -2
  243. package/examples/playground/app/orders/page.tsx +1 -1
  244. package/examples/playground/app/orders/server.ts +49 -30
  245. package/examples/playground/app/recovery/[recoveryId]/recovery-client.tsx +14 -15
  246. package/examples/playground/app/recovery/page.tsx +4 -4
  247. package/examples/playground/app/recovery/server.ts +23 -32
  248. package/examples/playground/drizzle.config.ts +14 -0
  249. package/examples/playground/next.config.ts +6 -1
  250. package/examples/playground/package.json +13 -1
  251. package/examples/playground/scripts/migrate.mjs +31 -0
  252. package/examples/playground/test/agent.ts +35 -0
  253. package/examples/playground/test/model.ts +149 -0
  254. package/examples/playground/test/serve.ts +184 -0
  255. package/examples/playground/test/server-only.ts +1 -0
  256. package/examples/playground/vercel.json +9 -0
  257. package/examples/playground/vitest.config.ts +27 -0
  258. package/examples/playground/vitest.drills.config.ts +32 -0
  259. package/package.json +7 -2
  260. package/sql/postgres/drizzle-schema.ts +206 -0
  261. package/sql/postgres/notifications.sql +24 -0
  262. package/sql/postgres/schema.sql +276 -0
  263. package/src/actor.ts +101 -71
  264. package/src/ai-agent.ts +154 -115
  265. package/src/ai-control-server.ts +8 -8
  266. package/src/ai-fx.ts +41 -9
  267. package/src/ai-harness.ts +28 -2
  268. package/src/ai-pi.ts +2 -2
  269. package/src/ai-server.ts +2 -2
  270. package/src/ai.ts +7 -7
  271. package/src/contract.ts +1 -1
  272. package/src/deterministic-id.ts +26 -3
  273. package/src/errors.ts +9 -6
  274. package/src/idempotent-replay.ts +0 -1
  275. package/src/index.ts +1 -1
  276. package/src/internal.ts +17 -6
  277. package/src/otel.ts +1 -1
  278. package/src/postgres-notifications.ts +0 -22
  279. package/src/postgres-resources.ts +6 -9
  280. package/src/postgres-schema.ts +121 -0
  281. package/src/push-envelope.ts +20 -0
  282. package/src/runtime-consumer.ts +10 -0
  283. package/src/scheduler-qstash.ts +12 -5
  284. package/src/scheduler-task.ts +2 -2
  285. package/src/scheduler-vercel.ts +10 -4
  286. package/src/server.browser.ts +1 -1
  287. package/src/server.ts +839 -238
  288. package/src/session-socket.ts +1 -1
  289. package/src/store-codec.ts +66 -0
  290. package/src/store-memory.ts +581 -255
  291. package/src/store-postgres.ts +1984 -943
  292. package/src/store-redis-core.ts +1624 -1152
  293. package/src/store-redis-notify.ts +2 -0
  294. package/src/store-sqlite.ts +891 -486
  295. package/src/store.ts +227 -97
  296. package/src/telemetry.ts +2 -1
  297. package/src/wire.ts +1 -0
  298. package/dist/ai-harness-BuuC-gJ3.js.map +0 -1
  299. package/dist/ai-harness-CV6rXbB3.d.ts.map +0 -1
  300. package/dist/ai-mMiWul1T.js.map +0 -1
  301. package/dist/contract-BYKASlCV.js.map +0 -1
  302. package/dist/idempotent-replay-JdgMQRRm.js +0 -21
  303. package/dist/idempotent-replay-JdgMQRRm.js.map +0 -1
  304. package/dist/initial-snapshot-Bx-c0pDP.d.ts.map +0 -1
  305. package/dist/internal-CscSY7K_.js.map +0 -1
  306. package/dist/server-D-Y4pqwu.js.map +0 -1
  307. package/dist/server-DYAATFuT.d.ts.map +0 -1
  308. package/dist/session-clear-C-QwTXV1.d.ts.map +0 -1
  309. package/dist/session-clear-DGI4jTQ5.js.map +0 -1
  310. package/dist/store-CyLUy86v.d.ts +0 -404
  311. package/dist/store-CyLUy86v.d.ts.map +0 -1
  312. package/dist/store-codec-DTG0Ftek.js +0 -10
  313. package/dist/store-codec-DTG0Ftek.js.map +0 -1
  314. package/dist/store-redis-notify-CM0AfLY0.js +0 -2544
  315. package/dist/store-redis-notify-CM0AfLY0.js.map +0 -1
  316. package/dist/telemetry-Cyfqo6Qt.d.ts.map +0 -1
  317. package/dist/wire-BKxH1vbP.js.map +0 -1
  318. package/docs/reference/05-adapters.mdx +0 -351
  319. package/docs/server/01-handlers.mdx +0 -200
  320. package/docs/server/05-production.mdx +0 -323
package/src/server.ts CHANGED
@@ -2,7 +2,7 @@ import { decodeState, encodeState } from './reducer-runtime.ts'
2
2
  // oxlint-disable no-await-in-loop -- scheduler advances after durable outcomes
3
3
  /**
4
4
  * experimental-a2/server — where a contract is implemented. `createServer({
5
- * contract, store, handlers, ... })` binds the vocabulary to storage and
5
+ * contract, store, consumers, ... })` binds the vocabulary to storage and
6
6
  * reactions; sessions, drains, claims, and scheduler all live here.
7
7
  * Server-only by construction: this entry point is the only one that
8
8
  * can reach a store backend, and its exports map fails loudly in browser
@@ -31,6 +31,8 @@ import {
31
31
  markSchedulerSendFailure,
32
32
  stateTailReaders,
33
33
  nullProtoRecord,
34
+ runtimeConsumerFactory,
35
+ runtimeConsumers,
34
36
  serverInternals,
35
37
  type DrainOutcome,
36
38
  type DrainResult,
@@ -50,9 +52,14 @@ import {
50
52
  } from './stream-buffer.ts'
51
53
  import type {
52
54
  A2Store,
55
+ CompleteAttemptResult,
53
56
  AppendEvent,
54
57
  Event,
55
58
  EventCause,
59
+ EventTarget,
60
+ HeadEvent,
61
+ HeadRef,
62
+ StoreConsumer,
56
63
  StoreClaimAvailableResult,
57
64
  StoreClearResult,
58
65
  StoreReclaimResult,
@@ -104,8 +111,8 @@ import {
104
111
  type A2Telemetry,
105
112
  } from './telemetry.ts'
106
113
 
107
- /** What every handler receives. */
108
- export type HandlerContext<
114
+ /** What every consumer's `run` receives. */
115
+ export type ConsumerContext<
109
116
  D extends EventDefs,
110
117
  K extends keyof D & string = keyof D & string,
111
118
  P extends PresenceDefs = Record<never, never>,
@@ -115,21 +122,12 @@ export type HandlerContext<
115
122
  event: ContractEvent<D, K>
116
123
  /** Durable, 1-based dispatch ordinal for this event. */
117
124
  attempt: number
118
- /** This session, with handler-scoped idempotent append. */
119
- session: Session<D, HandlerAppend<D>, P, S>
125
+ /** This session, with consumer-scoped idempotent append. */
126
+ session: Session<D, ConsumerAppend<D>, P, S>
120
127
  /** Fires on `abortOn` events (cancellation slice); dormant otherwise. */
121
128
  signal: AbortSignal
122
129
  }
123
130
 
124
- export type Handler<
125
- D extends EventDefs,
126
- K extends keyof D & string = keyof D & string,
127
- P extends PresenceDefs = Record<never, never>,
128
- S = never,
129
- > = (
130
- ctx: HandlerContext<D, K, P, S>,
131
- ) => Promise<void | AppendInput<D> | readonly AppendInput<D>[]>
132
-
133
131
  export type LaneContext<
134
132
  D extends EventDefs,
135
133
  K extends keyof D & string = keyof D & string,
@@ -138,10 +136,15 @@ export type LaneContext<
138
136
  event: Pick<ContractEvent<D, K>, 'type' | 'payload'> & { id?: string }
139
137
  }
140
138
 
141
- export type Lane<
139
+ /**
140
+ * A consumer's lane: a string runs every event it takes in log order on one
141
+ * cursor; a function returns each event's lane, or null to run it
142
+ * concurrently.
143
+ */
144
+ export type ConsumerLane<
142
145
  D extends EventDefs,
143
146
  K extends keyof D & string = keyof D & string,
144
- > = string | ((context: LaneContext<D, K>) => string)
147
+ > = string | ((context: LaneContext<D, K>) => string | null)
145
148
 
146
149
  export type SessionDispatch<D extends EventDefs> = (
147
150
  ...events: AppendInput<D>[]
@@ -152,7 +155,7 @@ export type SessionAppend<D extends EventDefs> = SessionDispatch<D> & {
152
155
  dispatch: SessionDispatch<D>
153
156
  }
154
157
 
155
- export type HandlerAppend<D extends EventDefs> = (
158
+ export type ConsumerAppend<D extends EventDefs> = (
156
159
  name: string,
157
160
  ...events: AppendInput<D>[]
158
161
  ) => Promise<ContractEvent<D>[]>
@@ -191,7 +194,7 @@ export type SessionClearOptions = {
191
194
  export type SessionClearResult = { cleared: boolean; index: number }
192
195
 
193
196
  export type SessionRedriveOptions = {
194
- /** `retry` runs the handler again with a fresh failure budget; `skip` settles the event without running it. */
197
+ /** `retry` runs the consumer again with a fresh failure budget; `skip` settles the event without running it. */
195
198
  action: 'retry' | 'skip'
196
199
  }
197
200
 
@@ -200,18 +203,36 @@ export type SessionRedriveResult = {
200
203
  redriven: boolean
201
204
  }
202
205
 
206
+ export type SessionConsumeOptions = {
207
+ /**
208
+ * Run until the consumer has handled every event it takes through this
209
+ * index. Default: the session's head when the call starts. An index past
210
+ * the head counts as the head.
211
+ */
212
+ through?: number
213
+ /** Stops waiting: the call throws the signal's reason. */
214
+ signal?: AbortSignal
215
+ }
216
+
217
+ export type SessionConsumeResult = {
218
+ /** The consumer has handled every event it takes through this index. */
219
+ index: number
220
+ }
221
+
203
222
  /** A dead-lettered event, with what an operator needs to resolve it. */
204
223
  export type DeadLetter<D extends EventDefs> = ContractEvent<D> & {
224
+ /** The consumer this event is dead for; it may be done for others. */
225
+ consumer: string
205
226
  lane: string | null
206
227
  /** Durable dispatch claims, including claims abandoned by hard kills. */
207
228
  attemptCount: number
208
229
  /**
209
230
  * Failed attempts since the event was appended or last retried: caught
210
- * handler failures, and attempts that ended before they finished.
231
+ * consumer failures, and attempts that ended before they finished.
211
232
  */
212
233
  failureCount: number
213
234
  /**
214
- * The last error a handler threw, stringified; null when every failed
235
+ * The last error the consumer threw, stringified; null when every failed
215
236
  * attempt ended before it finished.
216
237
  */
217
238
  lastError: string | null
@@ -227,7 +248,7 @@ export type SessionDeadLetters<D extends EventDefs> = {
227
248
  * an append. Any other event is left alone and reports `redriven: false`.
228
249
  */
229
250
  redrive(
230
- index: number,
251
+ target: { consumer: string; index: number },
231
252
  options: SessionRedriveOptions,
232
253
  ): Promise<SessionRedriveResult>
233
254
  }
@@ -283,6 +304,17 @@ export type Session<
283
304
  */
284
305
  clear(options?: SessionClearOptions): Promise<SessionClearResult>
285
306
  history(options?: { gte?: number; lte?: number }): Promise<ContractEvent<D>[]>
307
+ /**
308
+ * Run a consumer with a string lane on this session, here, until it has
309
+ * handled every event it takes through `through`. The server's own
310
+ * consumer shares its cursor with background work, so this waits for
311
+ * whichever invocation holds it. A consumer the server doesn't run is
312
+ * pulled only by `consume`, and keeps its place between calls.
313
+ */
314
+ consume(
315
+ consumer: Consumer<D>,
316
+ options?: SessionConsumeOptions,
317
+ ): Promise<SessionConsumeResult>
286
318
  state: {
287
319
  <T>(
288
320
  reducer: Reducer<D, T, Record<never, never>>,
@@ -302,7 +334,7 @@ export type Session<
302
334
  }
303
335
 
304
336
  /**
305
- * The public server shape scheduler handlers accept. It stays structural so
337
+ * The public server shape scheduler consumers accept. It stays structural so
306
338
  * servers of any contract mix in one route; A2's private server internals add
307
339
  * delayed-append delivery without exposing an untyped append method here.
308
340
  */
@@ -425,11 +457,11 @@ export async function deliverSchedulerClear(
425
457
  }
426
458
 
427
459
  /**
428
- * Which events fire `ctx.signal` while a handler runs — the preemption
460
+ * Which events fire `ctx.signal` while a consumer runs — the preemption
429
461
  * channel for user cancellation. The array form matches by type; the
430
462
  * object form takes per-type predicates for targeted cancellation
431
- * (`(event, trigger) => event.payload.of === trigger.id`). Handlers
432
- * without `abortOn` pay nothing. An aborted handler should catch and
463
+ * (`(event, trigger) => event.payload.of === trigger.id`). Consumers
464
+ * without `abortOn` pay nothing. An aborted consumer should catch and
433
465
  * return normally; throwing means "retry me".
434
466
  */
435
467
  export type AbortSpec<D extends EventDefs, K extends keyof D & string> =
@@ -444,36 +476,209 @@ export type AbortSpec<D extends EventDefs, K extends keyof D & string> =
444
476
  ) => boolean)
445
477
  }
446
478
 
447
- export type HandlerEntry<
479
+ /** What a consumer's `run` returns: nothing, one event, or a batch. */
480
+ export type ConsumerResult<D extends EventDefs> = Promise<
481
+ void | AppendInput<D> | readonly AppendInput<D>[]
482
+ >
483
+
484
+ /** How `createServer` runs a consumer. Built by `consumer().run()`. */
485
+ export type ConsumerSpec<D extends EventDefs> = {
486
+ readonly types: readonly (keyof D & string)[] | null
487
+ readonly lane: ConsumerLane<D> | null
488
+ readonly backfill: boolean
489
+ readonly abortOn: AbortSpec<D, keyof D & string> | null
490
+ readonly run: (ctx: ConsumerContext<D>) => ConsumerResult<D>
491
+ }
492
+
493
+ /** A named reader of a session's log with its own durable cursors. */
494
+ export type Consumer<D extends EventDefs> = {
495
+ readonly name: string
496
+ readonly contract: { readonly name: string }
497
+ readonly spec: ConsumerSpec<D>
498
+ }
499
+
500
+ export type ConsumerOptions<
448
501
  D extends EventDefs,
449
- K extends keyof D & string = keyof D & string,
450
- P extends PresenceDefs = Record<never, never>,
451
- S = never,
452
- > =
453
- | Handler<D, K, P, S>
454
- | {
455
- abortOn?: AbortSpec<D, K>
456
- /** Session-scoped FIFO key, resolved and persisted when the event lands. */
457
- lane?: Lane<D, K>
458
- handler: Handler<D, K, P, S>
459
- }
502
+ P extends PresenceDefs,
503
+ R extends RegisteredReducer<D, P> | undefined,
504
+ K extends keyof D & string,
505
+ > = {
506
+ /** The contract whose events it reads. */
507
+ contract: Contract<D, P, R>
508
+ /** Its identity, unique per server. A new name is a new consumer. */
509
+ name: string
510
+ /** The event types it runs on; default every type. */
511
+ types?: readonly K[]
512
+ /** Omitted: concurrent. A string: one ordered lane. A function: per event. */
513
+ lane?: ConsumerLane<D, K>
514
+ /** A new consumer starts at the beginning of the log. Needs a string lane. */
515
+ backfill?: boolean
516
+ abortOn?: AbortSpec<D, K>
517
+ }
518
+
519
+ /**
520
+ * A consumer written in `createServer({ consumers })`: `consumer()`'s
521
+ * options without the contract, which is the server's, and its `run`.
522
+ */
523
+ export type InlineConsumer<
524
+ D extends EventDefs,
525
+ P extends PresenceDefs,
526
+ R extends RegisteredReducer<D, P> | undefined,
527
+ K extends keyof D & string,
528
+ > = Omit<ConsumerOptions<D, P, R, K>, 'contract'> & {
529
+ run: (ctx: ConsumerContext<D, K, P, PublicState<R>>) => ConsumerResult<D>
530
+ }
531
+
532
+ /** The types an inline consumer takes: the ones it lists, or every type. */
533
+ type InlineTypes<D extends EventDefs, X> = unknown extends X
534
+ ? keyof D & string
535
+ : Extract<X, keyof D & string>
536
+
537
+ /**
538
+ * `createServer`'s consumers: each a `consumer()` or written inline. `C` is
539
+ * inferred per entry, so an inline `run` sees only the types it lists.
540
+ */
541
+ export type ServerConsumers<
542
+ D extends EventDefs,
543
+ P extends PresenceDefs,
544
+ R extends RegisteredReducer<D, P> | undefined,
545
+ C extends readonly unknown[],
546
+ > = {
547
+ readonly [I in keyof C]:
548
+ InlineConsumer<D, P, R, InlineTypes<D, C[I]>> | Extract<C[I], Consumer<D>>
549
+ }
460
550
 
461
- type HandlerFunction = (ctx: never) => Promise<unknown>
551
+ const CONSUMER_NAME = /^[a-z0-9][a-z0-9._:-]{0,127}$/i
462
552
 
463
- export function handler<
464
- H extends HandlerFunction | { handler: HandlerFunction },
465
- >(entry: H): H extends HandlerFunction ? { handler: H } : H
466
- export function handler(
467
- entry: HandlerFunction | { handler: HandlerFunction },
468
- ): { handler: HandlerFunction } {
469
- return typeof entry === 'function' ? { handler: entry } : entry
553
+ /** Define a consumer: a named reader of each session's log. */
554
+ export function consumer<
555
+ D extends EventDefs,
556
+ P extends PresenceDefs,
557
+ R extends RegisteredReducer<D, P> | undefined,
558
+ const K extends keyof D & string = keyof D & string,
559
+ >(
560
+ options: ConsumerOptions<D, P, R, K>,
561
+ ): {
562
+ run(
563
+ fn: (ctx: ConsumerContext<D, K, P, PublicState<R>>) => ConsumerResult<D>,
564
+ ): Consumer<D>
565
+ } {
566
+ return defineConsumer(options, false)
567
+ }
568
+
569
+ /**
570
+ * `consumer()`, and for A2's own runtimes (`runtime`) a name under `a2.` and
571
+ * child ids without the name: their types are disjoint, so the event alone
572
+ * keys them, and regrouping A2's consumers never changes an id.
573
+ */
574
+ function defineConsumer<
575
+ D extends EventDefs,
576
+ P extends PresenceDefs,
577
+ R extends RegisteredReducer<D, P> | undefined,
578
+ const K extends keyof D & string = keyof D & string,
579
+ >(
580
+ options: ConsumerOptions<D, P, R, K>,
581
+ runtime: boolean,
582
+ ): {
583
+ run(
584
+ fn: (ctx: ConsumerContext<D, K, P, PublicState<R>>) => ConsumerResult<D>,
585
+ ): Consumer<D>
586
+ } {
587
+ const contract = options?.contract
588
+ if (!contract || typeof contract.name !== 'string') {
589
+ throw new TypeError('consumer expects options with a contract')
590
+ }
591
+ const consumerName = options.name
592
+ if (typeof consumerName !== 'string' || !CONSUMER_NAME.test(consumerName)) {
593
+ throw new TypeError(
594
+ 'consumer name must be 1-128 letters, digits, and . _ : - characters',
595
+ )
596
+ }
597
+ if (consumerName.startsWith('a2.') !== runtime) {
598
+ throw new TypeError(
599
+ runtime
600
+ ? `A2's own consumers are named under 'a2.'`
601
+ : `consumer names starting with 'a2.' are reserved`,
602
+ )
603
+ }
604
+ const types = options.types === undefined ? null : [...options.types]
605
+ if (types?.length === 0) {
606
+ throw new TypeError(
607
+ `consumer '${consumerName}' lists no types; leave types out to take every type`,
608
+ )
609
+ }
610
+ for (const type of types ?? []) {
611
+ if (!Object.hasOwn(contract.events, type)) {
612
+ throw new TypeError(
613
+ `contract '${contract.name}' has no event type '${String(type)}'`,
614
+ )
615
+ }
616
+ }
617
+ const lane = options.lane ?? null
618
+ if (
619
+ lane !== null &&
620
+ typeof lane !== 'function' &&
621
+ (typeof lane !== 'string' || lane.length === 0)
622
+ ) {
623
+ throw new TypeError(
624
+ `lane for consumer '${consumerName}' must be a non-empty string or a function`,
625
+ )
626
+ }
627
+ const backfill = options.backfill ?? false
628
+ if (backfill && typeof lane !== 'string') {
629
+ throw new TypeError(
630
+ `consumer '${consumerName}' backfills, so its lane must be a string`,
631
+ )
632
+ }
633
+ // Cancellation is for work in progress. Checking it while replaying
634
+ // history would read the rest of the log for every event replayed.
635
+ if (backfill && options.abortOn !== undefined) {
636
+ throw new TypeError(
637
+ `consumer '${consumerName}' backfills, so it can't use abortOn`,
638
+ )
639
+ }
640
+ return {
641
+ run(fn) {
642
+ if (typeof fn !== 'function') {
643
+ throw new TypeError(`consumer '${consumerName}' needs a run function`)
644
+ }
645
+ const defined: Consumer<D> = {
646
+ name: consumerName,
647
+ contract: { name: contract.name },
648
+ spec: {
649
+ types,
650
+ lane: lane as ConsumerLane<D> | null,
651
+ backfill,
652
+ abortOn: (options.abortOn ?? null) as AbortSpec<
653
+ D,
654
+ keyof D & string
655
+ > | null,
656
+ run: fn as unknown as (ctx: ConsumerContext<D>) => ConsumerResult<D>,
657
+ },
658
+ }
659
+ if (runtime) runtimeConsumers.add(defined)
660
+ return defined
661
+ },
662
+ }
470
663
  }
664
+ runtimeConsumerFactory.define = (options) =>
665
+ defineConsumer(options as Parameters<typeof consumer>[0], true)
666
+
667
+ /** A registered consumer as the store needs to know it. */
668
+ const storeConsumerOf = (registered: {
669
+ name: string
670
+ log: StoreConsumer['log']
671
+ }): StoreConsumer =>
672
+ registered.log
673
+ ? { name: registered.name, log: registered.log }
674
+ : { name: registered.name }
471
675
 
472
676
  export type ServerOptions<
473
677
  D extends EventDefs,
474
678
  P extends PresenceDefs = Record<never, never>,
475
679
  R extends RegisteredReducer<D, P> | undefined =
476
680
  RegisteredReducer<D, P> | undefined,
681
+ C extends readonly unknown[] = readonly Consumer<D>[],
477
682
  > = {
478
683
  /** The contract this server implements (see `a2.contract`). */
479
684
  contract: Contract<D, P, R>
@@ -495,16 +700,39 @@ export type ServerOptions<
495
700
  */
496
701
  presence?: { ttlMs?: number }
497
702
  /**
498
- * The reactions, keyed by event type — all present at construction,
499
- * so a handler can never be silently missing because its module
500
- * wasn't imported. Compose across files by spreading objects (note:
501
- * a duplicate key under spread silently last-wins).
703
+ * The consumers, each a named reader of every session's log with its own
704
+ * cursors: a `consumer()`, or its options and `run` written inline, on this
705
+ * contract. Names are unique; a duplicate throws.
502
706
  */
503
- handlers?: { [K in keyof D & string]?: HandlerEntry<D, K, P, PublicState<R>> }
707
+ consumers?: ServerConsumers<D, P, R, C> | readonly Consumer<D>[]
504
708
  }
505
709
 
506
710
  const MAX_FAILURES = 10
507
711
 
712
+ /** How long `consume` waits before looking again at a head it can't run. */
713
+ const CONSUME_WAIT_MIN_MS = 25
714
+ const CONSUME_WAIT_MAX_MS = 1_000
715
+
716
+ /**
717
+ * `task`, or a rejection with `signal`'s reason once it aborts. The task runs
718
+ * on either way, and its own failure is caught, so nothing goes unhandled.
719
+ */
720
+ const untilAborted = <T>(
721
+ task: Promise<T>,
722
+ signal: AbortSignal | undefined,
723
+ ): Promise<T> => {
724
+ if (!signal) return task
725
+ task.catch(() => {})
726
+ if (signal.aborted) return Promise.reject(signal.reason)
727
+ return new Promise<T>((resolve, reject) => {
728
+ const abort = (): void => reject(signal.reason)
729
+ signal.addEventListener('abort', abort, { once: true })
730
+ task.then(resolve, reject).finally(() => {
731
+ signal.removeEventListener('abort', abort)
732
+ })
733
+ })
734
+ }
735
+
508
736
  /** How long an event waits after its `failureCount`th failed attempt. */
509
737
  const failureRetryDelayMs = (failureCount: number): number =>
510
738
  Math.min(
@@ -512,7 +740,7 @@ const failureRetryDelayMs = (failureCount: number): number =>
512
740
  DRAIN_TIMINGS.failureRetryMaxMs,
513
741
  )
514
742
 
515
- /** Abort a lost attempt's handler and release its drain slot. */
743
+ /** Abort a lost attempt's consumer and release its drain slot. */
516
744
  function endSuperseded(
517
745
  entry: {
518
746
  supersede: AbortController
@@ -724,30 +952,47 @@ function foldPresenceRows(
724
952
 
725
953
  type SnapshotWrites = Map<
726
954
  number,
727
- { state: unknown; pinEventIndexes: Set<number> }
955
+ { state: unknown; pins: Map<string, HeadRef> }
728
956
  >
729
957
 
958
+ /** One cursor head, as a map key. */
959
+ const headKey = (ref: HeadRef): string =>
960
+ JSON.stringify([ref.consumer, ref.lane, ref.index])
961
+
962
+ const headRefOf = (head: HeadEvent): HeadRef => ({
963
+ consumer: head.consumer,
964
+ lane: head.lane,
965
+ index: head.index,
966
+ })
967
+
730
968
  const addSnapshotWrite = (
731
969
  writes: SnapshotWrites,
732
970
  index: number,
733
971
  state: unknown,
734
- pinEventIndex?: number,
972
+ pin?: HeadRef,
735
973
  ): void => {
736
974
  let write = writes.get(index)
737
975
  if (!write) {
738
- write = { state: cloneInitial(state), pinEventIndexes: new Set() }
976
+ write = { state: cloneInitial(state), pins: new Map() }
739
977
  writes.set(index, write)
740
978
  }
741
- if (pinEventIndex !== undefined) write.pinEventIndexes.add(pinEventIndex)
979
+ if (pin !== undefined) write.pins.set(headKey(pin), pin)
742
980
  }
743
981
 
982
+ /** Whether a registered consumer runs events of this type. */
983
+ const takes = (
984
+ registered: { readonly types: ReadonlySet<string> | null },
985
+ type: string,
986
+ ): boolean => registered.types === null || registered.types.has(type)
987
+
744
988
  /** Implement a contract: bind its vocabulary to storage and reactions. */
745
989
  export function createServer<
746
990
  D extends EventDefs,
747
991
  P extends PresenceDefs = Record<never, never>,
748
992
  R extends RegisteredReducer<D, P> | undefined =
749
993
  RegisteredReducer<D, P> | undefined,
750
- >(options: ServerOptions<D, P, R>): A2Server<D, P, R> {
994
+ const C extends readonly unknown[] = readonly Consumer<D>[],
995
+ >(options: ServerOptions<D, P, R, C>): A2Server<D, P, R> {
751
996
  const serverContract = options?.contract
752
997
  if (
753
998
  serverContract === null ||
@@ -759,6 +1004,12 @@ export function createServer<
759
1004
  'createServer expects options with a contract (see a2.contract)',
760
1005
  )
761
1006
  }
1007
+ // Ignored, a handler table would run nothing and say nothing.
1008
+ if (Object.hasOwn(options, 'handlers')) {
1009
+ throw new TypeError(
1010
+ 'createServer no longer takes handlers: write each as a consumer in `consumers` (see https://www.a2.dev/upgrades/0-21)',
1011
+ )
1012
+ }
762
1013
  const name = serverContract.name
763
1014
  const defs = serverContract.events
764
1015
  const presenceDefs: Readonly<PresenceDefs> = serverContract.presence
@@ -847,24 +1098,11 @@ export function createServer<
847
1098
  context: { attempt: number },
848
1099
  ) => boolean)
849
1100
  >
850
- const handlers = new Map<
851
- string,
852
- { handler: Handler<D>; abortOn: AbortMatcher | null; lane: Lane<D> | null }
853
- >()
854
- for (const [type, entry] of Object.entries(options.handlers ?? {})) {
855
- if (entry === undefined) continue
856
- if (!Object.hasOwn(defs, type)) {
857
- throw new TypeError(
858
- `contract '${name}' has no event type '${type}' — cannot register a handler for it`,
859
- )
860
- }
861
- const callback = typeof entry === 'function' ? entry : entry?.handler
862
- if (typeof callback !== 'function') {
863
- throw new TypeError(`handler for '${type}' must be a function`)
864
- }
1101
+ const abortMatcherOf = (
1102
+ spec: AbortSpec<D, keyof D & string> | null | undefined,
1103
+ ): AbortMatcher | null => {
865
1104
  let abortOn: AbortMatcher | null = null
866
- const spec = typeof entry === 'function' ? undefined : entry.abortOn
867
- if (spec !== undefined) {
1105
+ if (spec !== undefined && spec !== null) {
868
1106
  abortOn = new Map()
869
1107
  const pairs = Array.isArray(spec)
870
1108
  ? spec.map((abortType) => [abortType, true as const] as const)
@@ -892,22 +1130,117 @@ export function createServer<
892
1130
  }
893
1131
  if (abortOn.size === 0) abortOn = null
894
1132
  }
895
- const lane = typeof entry === 'function' ? null : (entry.lane ?? null)
1133
+ return abortOn
1134
+ }
1135
+
1136
+ /** A consumer as this server runs it. */
1137
+ type RegisteredConsumer = {
1138
+ name: string
1139
+ types: ReadonlySet<string> | null
1140
+ /** Lane-function and concurrent consumers; log consumers resolve none. */
1141
+ laneOf: ((sessionId: string, event: AppendEvent) => string | null) | null
1142
+ log: StoreConsumer['log']
1143
+ abortOn: (type: string) => AbortMatcher | null
1144
+ run: (ctx: ConsumerContext<D>) => Promise<unknown>
1145
+ /** A2's own consumers: child and timer ids leave out the name. */
1146
+ anonymousIds: boolean
1147
+ }
1148
+ const laneContext = (sessionId: string, event: AppendEvent): LaneContext<D> =>
1149
+ ({
1150
+ sessionId,
1151
+ event: {
1152
+ type: event.type,
1153
+ payload: structuredClone(event.payload),
1154
+ ...(event.id === undefined ? {} : { id: event.id }),
1155
+ },
1156
+ }) as LaneContext<D>
1157
+ const registeredConsumers = new Map<string, RegisteredConsumer>()
1158
+ /** A consumer definition as the server runs it. */
1159
+ const registrationOf = (defined: Consumer<D>): RegisteredConsumer => {
1160
+ const { types, lane, backfill, abortOn, run } = defined.spec
1161
+ const matcher = abortMatcherOf(abortOn)
1162
+ return {
1163
+ name: defined.name,
1164
+ types: types === null ? null : new Set(types),
1165
+ laneOf:
1166
+ typeof lane === 'string'
1167
+ ? null
1168
+ : lane === null
1169
+ ? () => null
1170
+ : (sessionId, event) => {
1171
+ const resolved = lane(laneContext(sessionId, event))
1172
+ if (
1173
+ resolved !== null &&
1174
+ (typeof resolved !== 'string' || resolved.length === 0)
1175
+ ) {
1176
+ throw new TypeError(
1177
+ `lane for consumer '${defined.name}' must resolve to a non-empty string or null`,
1178
+ )
1179
+ }
1180
+ return resolved
1181
+ },
1182
+ log:
1183
+ typeof lane === 'string'
1184
+ ? { lane, types: types === null ? null : [...types], backfill }
1185
+ : undefined,
1186
+ abortOn: () => matcher,
1187
+ run: run as (ctx: ConsumerContext<D>) => Promise<unknown>,
1188
+ anonymousIds: runtimeConsumers.has(defined),
1189
+ }
1190
+ }
1191
+ /** The definitions the server runs, so `consume` can tell its own from another. */
1192
+ const definitions = new Map<string, Consumer<D>>()
1193
+ /** A consumer written inline is `consumer()`'s options and `run`, on this contract. */
1194
+ const definitionOf = (entry: unknown): Consumer<D> => {
896
1195
  if (
897
- lane !== null &&
898
- typeof lane !== 'function' &&
899
- (typeof lane !== 'string' || lane.length === 0)
1196
+ entry === null ||
1197
+ typeof entry !== 'object' ||
1198
+ 'spec' in entry ||
1199
+ !('run' in entry)
900
1200
  ) {
1201
+ return entry as Consumer<D>
1202
+ }
1203
+ const { run, ...rest } = entry as InlineConsumer<D, P, R, keyof D & string>
1204
+ return consumer({ ...rest, contract: serverContract }).run(run)
1205
+ }
1206
+ for (const entry of (options.consumers ?? []) as readonly unknown[]) {
1207
+ const defined = definitionOf(entry)
1208
+ if (defined?.contract?.name !== name || !defined.spec) {
901
1209
  throw new TypeError(
902
- `lane for '${type}' must be a non-empty string or a function`,
1210
+ `createServer for '${name}' got a consumer of another contract`,
903
1211
  )
904
1212
  }
905
- handlers.set(type, {
906
- handler: callback as Handler<D>,
907
- abortOn,
908
- lane: lane as Lane<D> | null,
909
- })
1213
+ if (registeredConsumers.has(defined.name)) {
1214
+ throw new TypeError(`two consumers are named '${defined.name}'`)
1215
+ }
1216
+ registeredConsumers.set(defined.name, registrationOf(defined))
1217
+ definitions.set(defined.name, defined)
1218
+ }
1219
+ // Runtime consumers leave their name out of child ids, so no event may
1220
+ // start two of them: their first returned events would share an id.
1221
+ const anonymousTypes = new Map<string, string>()
1222
+ for (const registered of registeredConsumers.values()) {
1223
+ if (!registered.anonymousIds) continue
1224
+ for (const type of registered.types ?? Object.keys(defs)) {
1225
+ const other = anonymousTypes.get(type)
1226
+ if (other !== undefined) {
1227
+ throw new TypeError(
1228
+ `A2's consumers '${other}' and '${registered.name}' both take '${type}'`,
1229
+ )
1230
+ }
1231
+ anonymousTypes.set(type, registered.name)
1232
+ }
910
1233
  }
1234
+ /** The consumers this server serves, as the store needs to know them. */
1235
+ const storeConsumers: StoreConsumer[] = [...registeredConsumers.values()].map(
1236
+ storeConsumerOf,
1237
+ )
1238
+ /** Whether an event starts cursor work for any consumer. */
1239
+ const startsWork = (event: AppendEvent): boolean =>
1240
+ (event.targets?.length ?? 0) > 0 ||
1241
+ [...registeredConsumers.values()].some(
1242
+ (registered) => registered.log && takes(registered, event.type),
1243
+ )
911
1244
 
912
1245
  const prefix = `${name}${NS}`
913
1246
  const nsId = (sessionId: string): string => `${prefix}${sessionId}`
@@ -1004,7 +1337,7 @@ export function createServer<
1004
1337
  const signal = createDrainSignal()
1005
1338
  const p = new Promise<void>((resolve) => {
1006
1339
  queueMicrotask(() => {
1007
- void drainSession(sessionId, watchdogDueAt, signal)
1340
+ void drainSession(sessionId, { watchdogDueAt, signal })
1008
1341
  .then(() => resolve())
1009
1342
  .catch(() => resolve())
1010
1343
  })
@@ -1047,28 +1380,15 @@ export function createServer<
1047
1380
  const validated: AppendEvent = { type: e.type, payload: result.value }
1048
1381
  if (e.id !== undefined) validated.id = e.id
1049
1382
  if (!resolveDispatchMetadata) return validated
1050
- const registration = handlers.get(e.type)
1051
- if (!registration) {
1052
- validated.settled = true
1053
- } else if (registration.lane !== null) {
1054
- const lane =
1055
- typeof registration.lane === 'function'
1056
- ? registration.lane({
1057
- sessionId,
1058
- event: {
1059
- type: e.type,
1060
- payload: structuredClone(result.value),
1061
- ...(e.id === undefined ? {} : { id: e.id }),
1062
- } as never,
1063
- })
1064
- : registration.lane
1065
- if (typeof lane !== 'string' || lane.length === 0) {
1066
- throw new TypeError(
1067
- `lane for '${e.type}' must resolve to a non-empty string`,
1068
- )
1069
- }
1070
- validated.lane = lane
1383
+ const targets: EventTarget[] = []
1384
+ for (const registered of registeredConsumers.values()) {
1385
+ if (!registered.laneOf || !takes(registered, e.type)) continue
1386
+ targets.push({
1387
+ consumer: registered.name,
1388
+ lane: registered.laneOf(sessionId, validated),
1389
+ })
1071
1390
  }
1391
+ if (targets.length > 0) validated.targets = targets
1072
1392
  return validated
1073
1393
  })
1074
1394
  }
@@ -1081,7 +1401,7 @@ export function createServer<
1081
1401
  const appendCore = (
1082
1402
  sessionId: string,
1083
1403
  events: AppendInput<D>[],
1084
- source: 'external' | 'handler',
1404
+ source: 'external' | 'consumer',
1085
1405
  mode: 'inline' | 'dispatch',
1086
1406
  onAppended: (rows: ContractEvent<D>[], watchdogDueAt?: number) => void,
1087
1407
  cause?: EventCause,
@@ -1118,20 +1438,16 @@ export function createServer<
1118
1438
  let rows: StoredEvent[]
1119
1439
  let shouldHealSession: boolean
1120
1440
  try {
1121
- const result = await store.append(
1122
- nsId(sessionId),
1123
- validated,
1124
- originIndex === undefined ? undefined : { originIndex },
1125
- )
1441
+ const result = await store.append(nsId(sessionId), validated, {
1442
+ consumers: storeConsumers,
1443
+ ...(originIndex === undefined ? {} : { originIndex }),
1444
+ })
1126
1445
  rows = result.events
1127
- // A handler appending only settled events adds no work, and the
1128
- // drain holding its trigger is already running.
1446
+ // A consumer appending events no consumer takes adds no work, and
1447
+ // the drain holding its trigger is already running.
1129
1448
  shouldHealSession =
1130
1449
  result.hasPending &&
1131
- !(
1132
- source === 'handler' &&
1133
- validated.every((event) => event.settled === true)
1134
- )
1450
+ !(source === 'consumer' && !validated.some(startsWork))
1135
1451
  } catch (err) {
1136
1452
  throw asStoreUnavailable(err)
1137
1453
  }
@@ -1242,8 +1558,8 @@ export function createServer<
1242
1558
  throughIndex?: number
1243
1559
  /** The id of the event at `throughIndex`, when the reader knows it. */
1244
1560
  throughEventId?: string
1245
- pinEventIndex?: number
1246
- /** A handler read: rejected once this trigger index has been cleared. */
1561
+ pin?: HeadRef
1562
+ /** A consumer read: rejected once this trigger index has been cleared. */
1247
1563
  triggerIndex?: number
1248
1564
  span: A2SpanHandle
1249
1565
  resolve: (result: StateResult) => void
@@ -1388,9 +1704,7 @@ export function createServer<
1388
1704
  index,
1389
1705
  state: encodeState({ reducer, state: write.state }),
1390
1706
  }
1391
- if (write.pinEventIndexes.size > 0) {
1392
- snapshot.pinEventIndexes = [...write.pinEventIndexes]
1393
- }
1707
+ if (write.pins.size > 0) snapshot.pins = [...write.pins.values()]
1394
1708
  return snapshot
1395
1709
  })
1396
1710
  if (snapshots.length > 0) {
@@ -1503,7 +1817,7 @@ export function createServer<
1503
1817
  /**
1504
1818
  * Serves a plan from the cached state when every read is at or past the
1505
1819
  * cached index. A read bounded at the cached index by an event whose id
1506
- * matches the cached one (a handler's own trigger) needs no store request;
1820
+ * matches the cached one (a consumer's own trigger) needs no store request;
1507
1821
  * any other read fetches the tail from the cached event onward and checks
1508
1822
  * that event's id. Returns false when the store must answer instead.
1509
1823
  */
@@ -1560,8 +1874,8 @@ export function createServer<
1560
1874
  clearIndex: cached.clearIndex,
1561
1875
  snapshot: 'memory',
1562
1876
  visit: (entry, state, index) => {
1563
- if (entry.pinEventIndex !== undefined) {
1564
- addSnapshotWrite(writes, index, state, entry.pinEventIndex)
1877
+ if (entry.pin !== undefined) {
1878
+ addSnapshotWrite(writes, index, state, entry.pin)
1565
1879
  }
1566
1880
  },
1567
1881
  })
@@ -1571,7 +1885,7 @@ export function createServer<
1571
1885
  if (rewritesHead && !writes.has(folded.index)) {
1572
1886
  writes.set(folded.index, {
1573
1887
  state: folded.state,
1574
- pinEventIndexes: new Set(),
1888
+ pins: new Map(),
1575
1889
  })
1576
1890
  }
1577
1891
  const snapshots = writeSnapshots({
@@ -1672,14 +1986,13 @@ export function createServer<
1672
1986
  snapshot: snapshotOutcome,
1673
1987
  visit: (entry, entryState, entryIndex, count) => {
1674
1988
  if (entryIndex === 0) return
1675
- if (count === 0 && entry.pinEventIndex === undefined) return
1989
+ if (count === 0 && entry.pin === undefined) return
1676
1990
  const advancesHead =
1677
1991
  stateRead.headIndex === null || entryIndex > stateRead.headIndex
1678
1992
  const repairsHead =
1679
1993
  snapshotOutcome === 'rejected' && entryIndex === stateRead.headIndex
1680
- if (!advancesHead && !repairsHead && entry.pinEventIndex === undefined)
1681
- return
1682
- addSnapshotWrite(writes, entryIndex, entryState, entry.pinEventIndex)
1994
+ if (!advancesHead && !repairsHead && entry.pin === undefined) return
1995
+ addSnapshotWrite(writes, entryIndex, entryState, entry.pin)
1683
1996
  },
1684
1997
  })
1685
1998
  const snapshots = writeSnapshots({
@@ -1775,7 +2088,7 @@ export function createServer<
1775
2088
  type StateReadBounds = {
1776
2089
  throughIndex?: number
1777
2090
  throughEventId?: string
1778
- pinEventIndex?: number
2091
+ pin?: HeadRef
1779
2092
  triggerIndex?: number
1780
2093
  }
1781
2094
 
@@ -1794,7 +2107,7 @@ export function createServer<
1794
2107
  pendingStateReads.set(reducerName, opened)
1795
2108
  // One microtask is the whole batching window — deliberately NOT
1796
2109
  // DataLoader's post-promise-job nextTick, which would defer
1797
- // handler-internal reads past the entire microtask cascade and
2110
+ // consumer-internal reads past the entire microtask cascade and
1798
2111
  // reorder drain interleaving (and has no edge-runtime primitive).
1799
2112
  // It still coalesces reliably: siblings of one Promise.all
1800
2113
  // traverse identical awaits (store resolution, span wrapper), so
@@ -1836,12 +2149,14 @@ export function createServer<
1836
2149
 
1837
2150
  const makeSchedule = (
1838
2151
  sessionId: string,
1839
- trigger: Pick<
1840
- StoredEvent,
1841
- 'id' | 'createdAt' | 'index' | 'clearIndex'
1842
- > | null,
2152
+ trigger: HeadEvent | null,
1843
2153
  ): SessionSchedule<D> => {
1844
2154
  const triggerEventId = trigger?.id ?? null
2155
+ /** Timers a consumer sets key on its name, except A2's own. */
2156
+ const triggerConsumer =
2157
+ trigger && !registeredConsumers.get(trigger.consumer)?.anonymousIds
2158
+ ? trigger.consumer
2159
+ : undefined
1845
2160
  const triggerCreatedAt = trigger?.createdAt.getTime()
1846
2161
  const clearIndex = trigger?.clearIndex ?? 0
1847
2162
  const schedule = async (
@@ -1878,6 +2193,7 @@ export function createServer<
1878
2193
  triggerEventId,
1879
2194
  scheduleName,
1880
2195
  clearIndex,
2196
+ triggerConsumer,
1881
2197
  )
1882
2198
  const scheduledEvents: ScheduledEvent[] = await Promise.all(
1883
2199
  validated.map(async (event, item) => ({
@@ -1943,6 +2259,7 @@ export function createServer<
1943
2259
  clearIndex,
1944
2260
  name: scheduleName,
1945
2261
  ifLastIndex,
2262
+ ...(triggerConsumer === undefined ? {} : { consumer: triggerConsumer }),
1946
2263
  })
1947
2264
  try {
1948
2265
  await scheduler.schedule({
@@ -2122,7 +2439,7 @@ export function createServer<
2122
2439
  {
2123
2440
  seen: patch.seen ?? fallbackSeen,
2124
2441
  // The sender's stamp is the LWW order; receipt time is the
2125
- // fallback for stampless callers (handlers, old wire
2442
+ // fallback for stampless callers (consumers, old wire
2126
2443
  // clients), where receipt order is sender order — single
2127
2444
  // writer. Storage anchors expiry on its own clock either way.
2128
2445
  at: new Date(patch.at ?? Date.now()),
@@ -2497,7 +2814,7 @@ export function createServer<
2497
2814
  }
2498
2815
  }
2499
2816
 
2500
- /** The handler attempt a `ctx.session.clear()` belongs to. */
2817
+ /** The consumer attempt a `ctx.session.clear()` belongs to. */
2501
2818
  type ClearingAttempt = {
2502
2819
  cause: EventCause
2503
2820
  end: () => void
@@ -2530,7 +2847,7 @@ export function createServer<
2530
2847
  }
2531
2848
  if (result.cleared) {
2532
2849
  // This instance's own reads after the clear, including the clearing
2533
- // handler's, go to the store and see the boundary.
2850
+ // consumer's, go to the store and see the boundary.
2534
2851
  const cachePrefix = stateCacheKey(sessionId, '')
2535
2852
  stateCache.deleteWhere((key) => key.startsWith(cachePrefix))
2536
2853
  }
@@ -2565,7 +2882,7 @@ export function createServer<
2565
2882
  sessionId: string,
2566
2883
  ): Promise<DeadLetter<D>[]> => {
2567
2884
  const store = await redrivingStore('deadLetters')
2568
- let rows: StoredEvent[]
2885
+ let rows: HeadEvent[]
2569
2886
  try {
2570
2887
  rows = await store.deadLetters(nsId(sessionId))
2571
2888
  } catch (err) {
@@ -2577,6 +2894,7 @@ export function createServer<
2577
2894
  : [
2578
2895
  {
2579
2896
  ...toPublic(row),
2897
+ consumer: row.consumer,
2580
2898
  lane: row.lane,
2581
2899
  attemptCount: row.attemptCount,
2582
2900
  failureCount: row.failureCount,
@@ -2589,10 +2907,15 @@ export function createServer<
2589
2907
 
2590
2908
  const redriveEvent = async (
2591
2909
  sessionId: string,
2592
- index: number,
2910
+ target: { consumer: string; index: number },
2593
2911
  redriveOptions: SessionRedriveOptions,
2594
2912
  ): Promise<SessionRedriveResult> => {
2913
+ const index = target?.index
2914
+ const redriven = target?.consumer
2595
2915
  assertRedriveIndex(index)
2916
+ if (typeof redriven !== 'string' || redriven.length === 0) {
2917
+ throw new TypeError('redrive requires a consumer name')
2918
+ }
2596
2919
  const action = redriveOptions?.action
2597
2920
  if (action !== 'retry' && action !== 'skip') {
2598
2921
  throw new TypeError("redrive requires action 'retry' or 'skip'")
@@ -2603,6 +2926,7 @@ export function createServer<
2603
2926
  'a2.contract': name,
2604
2927
  'a2.session_id': sessionId,
2605
2928
  'a2.redrive.index': index,
2929
+ 'a2.redrive.consumer': redriven,
2606
2930
  'a2.redrive.action': action,
2607
2931
  },
2608
2932
  async (span) => {
@@ -2611,8 +2935,10 @@ export function createServer<
2611
2935
  try {
2612
2936
  result = await store.redrive({
2613
2937
  sessionId: nsId(sessionId),
2938
+ consumer: redriven,
2614
2939
  index,
2615
2940
  action,
2941
+ consumers: storeConsumers,
2616
2942
  })
2617
2943
  } catch (err) {
2618
2944
  throw asStoreUnavailable(err)
@@ -2643,18 +2969,169 @@ export function createServer<
2643
2969
  )
2644
2970
  }
2645
2971
 
2972
+ /**
2973
+ * `session.consume`: wakes the consumer's cursor, then drains only that
2974
+ * consumer until its head lies past `through`. A head another invocation
2975
+ * holds, or one waiting out a failure, is waited for; a dead-lettered head
2976
+ * throws.
2977
+ */
2978
+ /** Per session, which consumer's running head is waiting in `consume` on which: a cycle never ends. */
2979
+ const consumeWaits = new Map<string, Map<string, string>>()
2980
+
2981
+ const consumeSession = (
2982
+ sessionId: string,
2983
+ defined: Consumer<D>,
2984
+ consumeOptions: SessionConsumeOptions = {},
2985
+ trigger: HeadEvent | null = null,
2986
+ ): Promise<SessionConsumeResult> => {
2987
+ assertSessionId(sessionId)
2988
+ if (defined?.contract?.name !== name || !defined.spec) {
2989
+ throw new TypeError(
2990
+ `session.consume for '${name}' got a consumer of another contract`,
2991
+ )
2992
+ }
2993
+ const own = definitions.get(defined.name)
2994
+ if (own !== undefined && own !== defined) {
2995
+ throw new TypeError(
2996
+ `this server runs a different consumer named '${defined.name}'`,
2997
+ )
2998
+ }
2999
+ if (defined.name === trigger?.consumer) {
3000
+ throw new TypeError(
3001
+ `consumer '${defined.name}' can't consume itself: its own head is running`,
3002
+ )
3003
+ }
3004
+ const registered = own
3005
+ ? registeredConsumers.get(defined.name)!
3006
+ : registrationOf(defined)
3007
+ const log = registered.log
3008
+ if (!log) {
3009
+ throw new TypeError(
3010
+ `consumer '${defined.name}' needs a string lane to be consumed: only then does it have one position`,
3011
+ )
3012
+ }
3013
+ const { through: requested, signal } = consumeOptions
3014
+ if (requested !== undefined) assertConsumeIndex(requested)
3015
+ // A run that waits on a consumer already waiting on it, here, would wait
3016
+ // forever. Checked and recorded before any await, so two can't both pass.
3017
+ // One that would close that cycle isn't recorded: it returns only if what
3018
+ // it asks for is done already, since the other's cursor can't move.
3019
+ const waiter = trigger?.consumer
3020
+ const waits =
3021
+ waiter === undefined
3022
+ ? undefined
3023
+ : (consumeWaits.get(sessionId) ??
3024
+ consumeWaits.set(sessionId, new Map()).get(sessionId)!)
3025
+ let cycle = false
3026
+ if (waiter !== undefined && waits) {
3027
+ for (
3028
+ let next: string | undefined = defined.name;
3029
+ next !== undefined && !cycle;
3030
+ next = waits.get(next)
3031
+ ) {
3032
+ cycle = next === waiter
3033
+ }
3034
+ if (!cycle) waits.set(waiter, defined.name)
3035
+ }
3036
+ const storeConsumer = { name: registered.name, log }
3037
+ const consumed = telemetry.span(
3038
+ 'a2.consume',
3039
+ {
3040
+ 'a2.contract': name,
3041
+ 'a2.session_id': sessionId,
3042
+ 'a2.consume.consumer': registered.name,
3043
+ },
3044
+ async (span) => {
3045
+ const store = await resolveStore()
3046
+ const ns = nsId(sessionId)
3047
+ let woken = await store.wakeLog({
3048
+ sessionId: ns,
3049
+ consumer: storeConsumer,
3050
+ })
3051
+ const through = Math.min(requested ?? woken.head, woken.head)
3052
+ span.setAttribute('a2.consume.through', through)
3053
+ const done = (): boolean =>
3054
+ woken.cursor.head === null || woken.cursor.head > through
3055
+ if (cycle && !done()) {
3056
+ throw new TypeError(
3057
+ `consumer '${waiter}' can't consume '${defined.name}' through ${through}: '${defined.name}' is waiting on it in session '${sessionId}'`,
3058
+ )
3059
+ }
3060
+ let drains = 0
3061
+ let wait = CONSUME_WAIT_MIN_MS
3062
+ while (!done()) {
3063
+ signal?.throwIfAborted()
3064
+ const { cursor } = woken
3065
+ if (cursor.failedAt !== null) {
3066
+ throw new A2Error(
3067
+ 'CONSUMER_DEAD_LETTERED',
3068
+ `consumer '${registered.name}' is dead-lettered at event ${cursor.head} in session '${sessionId}': ${cursor.lastError ?? 'every attempt ended before it finished'}`,
3069
+ )
3070
+ }
3071
+ drains += 1
3072
+ const drained = drainSession(sessionId, { only: registered, through })
3073
+ try {
3074
+ await untilAborted(drained, signal)
3075
+ } catch (error) {
3076
+ // An abort stops the wait at once; a run in progress carries on,
3077
+ // and what it appends still reaches the server's consumers.
3078
+ if (signal?.aborted)
3079
+ void drained.then(
3080
+ () => scheduleDrain(sessionId),
3081
+ () => {},
3082
+ )
3083
+ throw error
3084
+ }
3085
+ const before = cursor
3086
+ woken = await store.wakeLog({
3087
+ sessionId: ns,
3088
+ consumer: storeConsumer,
3089
+ })
3090
+ const moved =
3091
+ woken.cursor.head !== before.head ||
3092
+ woken.cursor.attemptCount !== before.attemptCount
3093
+ if (done() || moved) {
3094
+ wait = CONSUME_WAIT_MIN_MS
3095
+ continue
3096
+ }
3097
+ // Another invocation holds the head, or it waits out a failure.
3098
+ const sleep = defaultSleep(wait)
3099
+ const abort = (): void => sleep.cancel()
3100
+ signal?.addEventListener('abort', abort, { once: true })
3101
+ await sleep.promise
3102
+ signal?.removeEventListener('abort', abort)
3103
+ wait = Math.min(wait * 2, CONSUME_WAIT_MAX_MS)
3104
+ }
3105
+ // Work past `through` carries on in the background, as after an append,
3106
+ // and so do the server's consumers of what this consumer's runs appended.
3107
+ if (drains > 0 || (own && woken.cursor.head !== null))
3108
+ scheduleDrain(sessionId)
3109
+ const index =
3110
+ woken.cursor.head === null ? woken.head : woken.cursor.head - 1
3111
+ span.setAttribute('a2.consume.index', index)
3112
+ span.setAttribute('a2.consume.drains', drains)
3113
+ return { index }
3114
+ },
3115
+ )
3116
+ return waiter === undefined || cycle
3117
+ ? consumed
3118
+ : consumed.finally(() => {
3119
+ waits?.delete(waiter)
3120
+ if (waits?.size === 0) consumeWaits.delete(sessionId)
3121
+ })
3122
+ }
3123
+
2646
3124
  const makeSession = <Append>(
2647
3125
  id: string,
2648
3126
  append: Append,
2649
- trigger: Pick<
2650
- StoredEvent,
2651
- 'id' | 'createdAt' | 'index' | 'clearIndex'
2652
- > | null,
3127
+ trigger: HeadEvent | null,
2653
3128
  attempt?: ClearingAttempt,
2654
3129
  ): Session<D, Append, P> =>
2655
3130
  ({
2656
3131
  id,
2657
3132
  append,
3133
+ consume: (defined: Consumer<D>, consumeOptions?: SessionConsumeOptions) =>
3134
+ consumeSession(id, defined, consumeOptions, trigger),
2658
3135
  schedule: makeSchedule(id, trigger),
2659
3136
  clear: (clearOptions?: SessionClearOptions) =>
2660
3137
  clearSession(id, clearOptions, attempt),
@@ -2683,7 +3160,7 @@ export function createServer<
2683
3160
  const bounds: StateReadBounds = {}
2684
3161
  if (through !== undefined && through !== 'latest') {
2685
3162
  bounds.throughIndex = through
2686
- if (trigger) bounds.pinEventIndex = trigger.index
3163
+ if (trigger) bounds.pin = headRefOf(trigger)
2687
3164
  // Only a live attempt may be answered from memory with no store
2688
3165
  // request; a superseded one (including by a clear) reads the store.
2689
3166
  if (through === trigger?.index && !attempt?.signal.aborted)
@@ -2716,42 +3193,60 @@ export function createServer<
2716
3193
 
2717
3194
  // ── drain ────────────────────────────────────────────────────────
2718
3195
 
3196
+ /** The consumer name a trigger's child ids key on, if any. */
3197
+ const idConsumerOf = (trigger: HeadEvent): string | undefined =>
3198
+ registeredConsumers.get(trigger.consumer)?.anonymousIds
3199
+ ? undefined
3200
+ : trigger.consumer
3201
+
2719
3202
  const makeCtx = (
2720
- trigger: StoredEvent,
3203
+ trigger: HeadEvent,
2721
3204
  attempt: number,
2722
3205
  signal: AbortSignal,
2723
3206
  endAttempt: () => void,
2724
- ): HandlerContext<D> => {
3207
+ ): ConsumerContext<D> => {
2725
3208
  const publicSessionId = stripNs(trigger.sessionId)
2726
- const append: HandlerAppend<D> = async (appendName, ...events) => {
3209
+ const idConsumer = idConsumerOf(trigger)
3210
+ const cause: EventCause = {
3211
+ consumer: trigger.consumer,
3212
+ lane: trigger.lane,
3213
+ index: trigger.index,
3214
+ attempt,
3215
+ }
3216
+ const append: ConsumerAppend<D> = async (appendName, ...events) => {
2727
3217
  assertAppendName(appendName)
2728
3218
  const generatedIds = events.map((event) => event.id === undefined)
2729
3219
  const withIds = await Promise.all(
2730
3220
  // oxlint-disable-next-line no-map-spread
2731
3221
  events.map(async (event, item) => {
2732
3222
  if (event.id !== undefined) return event
2733
- const id = await deterministicEventId(trigger.id, appendName, item)
3223
+ const id = await deterministicEventId(
3224
+ trigger.id,
3225
+ appendName,
3226
+ item,
3227
+ idConsumer,
3228
+ )
2734
3229
  return { ...event, id }
2735
3230
  }),
2736
3231
  )
2737
3232
  return appendCore(
2738
3233
  publicSessionId,
2739
3234
  withIds,
2740
- 'handler',
3235
+ 'consumer',
2741
3236
  'inline',
2742
3237
  (_rows, watchdogDueAt) => scheduleDrain(publicSessionId, watchdogDueAt),
2743
- { index: trigger.index, attempt },
3238
+ cause,
2744
3239
  generatedIds,
2745
3240
  )
2746
3241
  }
2747
- const ctx: HandlerContext<D> = {
3242
+ const ctx: ConsumerContext<D> = {
2748
3243
  event: toPublic(trigger),
2749
3244
  attempt,
2750
- // The handler-scoped `setPresence` stamps `seen` with the
2751
- // triggering event's index by default — the frontier the handler
3245
+ // The consumer-scoped `setPresence` stamps `seen` with the
3246
+ // triggering event's index by default — the frontier the consumer
2752
3247
  // provably reflects.
2753
3248
  session: makeSession(publicSessionId, append, trigger, {
2754
- cause: { index: trigger.index, attempt },
3249
+ cause,
2755
3250
  end: endAttempt,
2756
3251
  signal,
2757
3252
  }),
@@ -2857,7 +3352,7 @@ export function createServer<
2857
3352
  const outcome = await Promise.race([
2858
3353
  operation.then(
2859
3354
  (value) => ({ type: 'completed' as const, value }),
2860
- (error: unknown) => ({ type: 'handler-failed' as const, error }),
3355
+ (error: unknown) => ({ type: 'run-failed' as const, error }),
2861
3356
  ),
2862
3357
  hub.watch!.then(() =>
2863
3358
  hub.error === null
@@ -2866,7 +3361,7 @@ export function createServer<
2866
3361
  ),
2867
3362
  ])
2868
3363
  if (outcome.type === 'completed') return outcome.value
2869
- if (outcome.type === 'handler-failed') throw outcome.error
3364
+ if (outcome.type === 'run-failed') throw outcome.error
2870
3365
  if (outcome.type === 'monitor-failed') {
2871
3366
  for (const active of hub.subscriptions) {
2872
3367
  active.controller.abort(outcome.error)
@@ -2875,7 +3370,7 @@ export function createServer<
2875
3370
  throw outcome.error
2876
3371
  }
2877
3372
  await operation.catch(() => {})
2878
- throw new Error('abort monitor closed while handlers were active')
3373
+ throw new Error('abort monitor closed while consumers were active')
2879
3374
  },
2880
3375
  close: async () => {
2881
3376
  hub.subscriptions.delete(subscription)
@@ -2894,18 +3389,22 @@ export function createServer<
2894
3389
 
2895
3390
  const returnedEvents = async (
2896
3391
  sessionId: string,
2897
- trigger: StoredEvent,
2898
- result: void | AppendInput<D> | readonly AppendInput<D>[],
3392
+ trigger: HeadEvent,
3393
+ result: unknown,
2899
3394
  ): Promise<ReturnedEvent[]> => {
2900
3395
  if (result === undefined) return []
2901
- const events = Array.isArray(result) ? [...result] : [result]
3396
+ const events = (
3397
+ Array.isArray(result) ? [...result] : [result]
3398
+ ) as AppendInput<D>[]
2902
3399
  if (events.length === 0) return []
3400
+ const idConsumer = idConsumerOf(trigger)
2903
3401
  const withIds = await Promise.all(
2904
- // oxlint-disable-next-line oxc/no-map-spread -- handler results remain caller-owned
3402
+ // oxlint-disable-next-line oxc/no-map-spread -- consumer results remain caller-owned
2905
3403
  events.map(async (event, item) => {
2906
3404
  const withId = { ...event }
2907
3405
  withId.id =
2908
- event.id ?? (await deterministicReturnedEventId(trigger.id, item))
3406
+ event.id ??
3407
+ (await deterministicReturnedEventId(trigger.id, item, idConsumer))
2909
3408
  return withId
2910
3409
  }),
2911
3410
  )
@@ -2913,6 +3412,8 @@ export function createServer<
2913
3412
  Object.assign(event, {
2914
3413
  id: event.id!,
2915
3414
  cause: {
3415
+ consumer: trigger.consumer,
3416
+ lane: trigger.lane,
2916
3417
  index: trigger.index,
2917
3418
  attempt: trigger.attemptCount,
2918
3419
  },
@@ -2931,8 +3432,22 @@ export function createServer<
2931
3432
 
2932
3433
  const drainSession = (
2933
3434
  sessionId: string,
2934
- watchdogDueAt?: number,
2935
- signal?: DrainSignal,
3435
+ {
3436
+ watchdogDueAt,
3437
+ signal,
3438
+ handOffFailure = false,
3439
+ only,
3440
+ through,
3441
+ }: {
3442
+ watchdogDueAt?: number | undefined
3443
+ signal?: DrainSignal | undefined
3444
+ /** Return a failure after arming as `failed` instead of throwing it. */
3445
+ handOffFailure?: boolean
3446
+ /** Claim and run only this consumer's heads, for `consume`. */
3447
+ only?: RegisteredConsumer | undefined
3448
+ /** With `only`: claim no head past it; what's past it is the background's. */
3449
+ through?: number | undefined
3450
+ } = {},
2936
3451
  ): Promise<DrainResult> =>
2937
3452
  telemetry.span(
2938
3453
  'a2.drain',
@@ -2942,6 +3457,7 @@ export function createServer<
2942
3457
  const ns = nsId(sessionId)
2943
3458
  const holder = crypto.randomUUID()
2944
3459
  type ActiveEvent = {
3460
+ ref: HeadRef
2945
3461
  attempt: number
2946
3462
  execution: Promise<EventOutcome>
2947
3463
  supersede: AbortController
@@ -2950,9 +3466,15 @@ export function createServer<
2950
3466
  /** Local lease estimate: request time + ttl — at or before the store's. */
2951
3467
  expiresAtMs: number
2952
3468
  lapsed: boolean
3469
+ /**
3470
+ * Its completion is committing. A renewal may then find the cursor
3471
+ * already past the head; completion is the fence, not that report.
3472
+ */
3473
+ completing: boolean
2953
3474
  }
2954
- const active = new Map<number, ActiveEvent>()
2955
- const excluded = new Set<number>()
3475
+ /** Running heads, keyed by `headKey`: one event can run for several consumers. */
3476
+ const active = new Map<string, ActiveEvent>()
3477
+ const excluded = new Map<string, HeadRef>()
2956
3478
  let processed = 0
2957
3479
  let sawFailure = false
2958
3480
  let schedulerArm: Promise<void> | undefined
@@ -2969,13 +3491,13 @@ export function createServer<
2969
3491
  const lapseDue = (): void => {
2970
3492
  lapseTimer = null
2971
3493
  const nowMs = Date.now()
2972
- for (const [index, entry] of active) {
3494
+ for (const entry of active.values()) {
2973
3495
  if (entry.lapsed || entry.expiresAtMs > nowMs) continue
2974
3496
  entry.lapsed = true
2975
3497
  entry.supersede.abort(
2976
3498
  new A2Error(
2977
3499
  'CLAIM_EXPIRED',
2978
- `the claim on event ${index} in session '${sessionId}' lapsed without renewal`,
3500
+ `the claim on event ${entry.ref.index} for consumer '${entry.ref.consumer}' in session '${sessionId}' lapsed without renewal`,
2979
3501
  ),
2980
3502
  )
2981
3503
  }
@@ -3036,8 +3558,8 @@ export function createServer<
3036
3558
  }
3037
3559
  const window = nextClaimWindow()
3038
3560
  requestWatchdog(window.watchdogAtMs)
3039
- const claims = [...active.entries()].map(([index, entry]) => ({
3040
- index,
3561
+ const claims = [...active.values()].map((entry) => ({
3562
+ ...entry.ref,
3041
3563
  attempt: entry.attempt,
3042
3564
  }))
3043
3565
  const operation = store
@@ -3052,20 +3574,20 @@ export function createServer<
3052
3574
  })
3053
3575
  .then(
3054
3576
  (result) => {
3055
- for (const index of result.renewed) {
3056
- const entry = active.get(index)
3577
+ for (const ref of result.renewed) {
3578
+ const entry = active.get(headKey(ref))
3057
3579
  if (entry && !entry.lapsed) {
3058
3580
  entry.expiresAtMs = window.expiresAtMs
3059
3581
  }
3060
3582
  }
3061
- for (const index of result.superseded) {
3062
- const entry = active.get(index)
3063
- if (!entry) continue
3583
+ for (const ref of result.superseded) {
3584
+ const entry = active.get(headKey(ref))
3585
+ if (!entry || entry.completing) continue
3064
3586
  // Idempotent: the local lapse normally aborted this signal
3065
3587
  // already — this is the proof, whose job is the release.
3066
3588
  endSuperseded(
3067
3589
  entry,
3068
- `a newer attempt owns event ${index} in session '${sessionId}'`,
3590
+ `a newer attempt owns event ${ref.index} for consumer '${ref.consumer}' in session '${sessionId}'`,
3069
3591
  )
3070
3592
  }
3071
3593
  if (window.deadlineCapped) stopRenewing = true
@@ -3085,8 +3607,8 @@ export function createServer<
3085
3607
  return tracked
3086
3608
  }
3087
3609
 
3088
- /** Records an event the store dead-lettered on takeover; it never runs. */
3089
- const reportExhausted = (event: StoredEvent): Promise<void> =>
3610
+ /** Records a head the store dead-lettered on takeover; it never runs. */
3611
+ const reportExhausted = (event: HeadEvent): Promise<void> =>
3090
3612
  telemetry.span(
3091
3613
  'a2.event',
3092
3614
  {
@@ -3095,8 +3617,9 @@ export function createServer<
3095
3617
  'a2.event.type': event.type,
3096
3618
  'a2.event.index': event.index,
3097
3619
  'a2.event.id': event.id,
3620
+ 'a2.event.consumer': event.consumer,
3098
3621
  'a2.event.attempt': event.attemptCount,
3099
- 'a2.event.handled': handlers.has(event.type),
3622
+ 'a2.event.handled': registeredConsumers.has(event.consumer),
3100
3623
  ...(event.lane === null ? {} : { 'a2.event.lane': event.lane }),
3101
3624
  },
3102
3625
  async (eventSpan) => {
@@ -3105,11 +3628,17 @@ export function createServer<
3105
3628
  )
3106
3629
 
3107
3630
  const runEvent = async (
3108
- event: StoredEvent,
3631
+ event: HeadEvent,
3109
3632
  supersedeSignal: AbortSignal,
3110
3633
  endAttempt: () => void,
3634
+ beginCompleting: () => void,
3635
+ sessionHead: number | undefined,
3111
3636
  ): Promise<EventOutcome> => {
3112
- const registration = handlers.get(event.type)
3637
+ const registration =
3638
+ only?.name === event.consumer
3639
+ ? only
3640
+ : registeredConsumers.get(event.consumer)
3641
+ const abortOn = registration?.abortOn(event.type) ?? null
3113
3642
  return telemetry.span(
3114
3643
  'a2.event',
3115
3644
  {
@@ -3118,8 +3647,16 @@ export function createServer<
3118
3647
  'a2.event.type': event.type,
3119
3648
  'a2.event.index': event.index,
3120
3649
  'a2.event.id': event.id,
3650
+ 'a2.event.consumer': event.consumer,
3121
3651
  'a2.event.attempt': event.attemptCount,
3122
3652
  'a2.event.handled': registration !== undefined,
3653
+ 'a2.event.age_ms': Math.max(
3654
+ 0,
3655
+ Date.now() - event.createdAt.getTime(),
3656
+ ),
3657
+ ...(sessionHead === undefined
3658
+ ? {}
3659
+ : { 'a2.event.lag': Math.max(0, sessionHead - event.index) }),
3123
3660
  ...(event.lane === null ? {} : { 'a2.event.lane': event.lane }),
3124
3661
  },
3125
3662
  async (eventSpan) => {
@@ -3130,24 +3667,25 @@ export function createServer<
3130
3667
  close(): Promise<void>
3131
3668
  }
3132
3669
  | undefined
3133
- let handlerSignal = supersedeSignal
3134
- let returned: ReturnedEvent[]
3670
+ let runSignal = supersedeSignal
3671
+ let completion: CompleteAttemptResult
3672
+ let completing = false
3135
3673
  try {
3136
- if (registration?.abortOn) {
3674
+ if (abortOn) {
3137
3675
  subscription = await subscribeAbort(
3138
3676
  store,
3139
3677
  ns,
3140
3678
  event,
3141
- registration.abortOn,
3679
+ abortOn,
3142
3680
  event.attemptCount,
3143
3681
  )
3144
- handlerSignal = AbortSignal.any([
3682
+ runSignal = AbortSignal.any([
3145
3683
  supersedeSignal,
3146
3684
  subscription.signal,
3147
3685
  ])
3148
3686
  }
3149
- const operation = registration?.handler(
3150
- makeCtx(event, event.attemptCount, handlerSignal, endAttempt),
3687
+ const operation = registration?.run(
3688
+ makeCtx(event, event.attemptCount, runSignal, endAttempt),
3151
3689
  )
3152
3690
  const result = operation
3153
3691
  ? await (subscription
@@ -3158,12 +3696,41 @@ export function createServer<
3158
3696
  await subscription.close()
3159
3697
  subscription = undefined
3160
3698
  }
3161
- returned = await returnedEvents(sessionId, event, result)
3699
+ const returned = await returnedEvents(sessionId, event, result)
3700
+ await renewal
3701
+ completing = true
3702
+ beginCompleting()
3703
+ completion = await telemetry.span(
3704
+ 'a2.commit',
3705
+ {
3706
+ 'a2.contract': name,
3707
+ 'a2.session_id': sessionId,
3708
+ 'a2.commit.operation': 'complete',
3709
+ 'a2.commit.index': event.index,
3710
+ 'a2.commit.attempt': event.attemptCount,
3711
+ },
3712
+ async (commitSpan) => {
3713
+ const committed = await store.completeAttempt({
3714
+ sessionId: ns,
3715
+ consumer: event.consumer,
3716
+ lane: event.lane,
3717
+ index: event.index,
3718
+ attempt: event.attemptCount,
3719
+ events: returned,
3720
+ consumers: storeConsumers,
3721
+ })
3722
+ commitSpan.setAttribute(
3723
+ 'a2.commit.outcome',
3724
+ committed.outcome,
3725
+ )
3726
+ return committed
3727
+ },
3728
+ )
3162
3729
  } catch (err) {
3163
3730
  if (supersedeSignal.aborted && err === supersedeSignal.reason) {
3164
- // The handler surrendered to its own abort — identity, not
3731
+ // The consumer surrendered to its own abort — identity, not
3165
3732
  // code, so it cannot arrive from another context. A lapse
3166
- // is not a handler failure: no completion; the claim simply
3733
+ // is not a consumer failure: no completion; the claim simply
3167
3734
  // expires, and the claim that takes it over counts it.
3168
3735
  eventSpan.setAttribute('a2.event.outcome', 'surrendered')
3169
3736
  return {
@@ -3172,6 +3739,14 @@ export function createServer<
3172
3739
  appendsEvents: false,
3173
3740
  }
3174
3741
  }
3742
+ const refused =
3743
+ completing &&
3744
+ err instanceof A2Error &&
3745
+ err.code === 'PARTIAL_DUPLICATE_BATCH'
3746
+ // A store that cannot record the completion is not the
3747
+ // consumer failing: the claim lapses, and the claim that takes
3748
+ // it over counts it. A returned batch it refuses is.
3749
+ if (completing && !refused) throw err
3175
3750
  // Everything else — including a store fence rejection — is
3176
3751
  // adjudicated by failAttempt, which is itself fenced: a
3177
3752
  // genuinely superseded attempt gets 'superseded' back with
@@ -3189,14 +3764,19 @@ export function createServer<
3189
3764
  async (commitSpan) => {
3190
3765
  const result = await store.failAttempt({
3191
3766
  sessionId: ns,
3767
+ consumer: event.consumer,
3768
+ lane: event.lane,
3192
3769
  index: event.index,
3193
3770
  attempt: event.attemptCount,
3194
3771
  error: describeError(err),
3195
- // A NonRetriableError is terminal on its first throw:
3196
- // deterministic failures should not burn the retry
3197
- // budget before dead-lettering.
3772
+ // A NonRetriableError is terminal on its first throw,
3773
+ // and so is a refused batch, whose retry returns the
3774
+ // same ids: deterministic failures should not burn the
3775
+ // retry budget before dead-lettering.
3198
3776
  maxFailures:
3199
- err instanceof NonRetriableError ? 1 : MAX_FAILURES,
3777
+ err instanceof NonRetriableError || refused
3778
+ ? 1
3779
+ : MAX_FAILURES,
3200
3780
  retryAfterMs: failureRetryDelayMs(event.failureCount + 1),
3201
3781
  })
3202
3782
  commitSpan.setAttribute('a2.commit.outcome', result.outcome)
@@ -3212,27 +3792,6 @@ export function createServer<
3212
3792
  } finally {
3213
3793
  await subscription?.close()
3214
3794
  }
3215
- await renewal
3216
- const completion = await telemetry.span(
3217
- 'a2.commit',
3218
- {
3219
- 'a2.contract': name,
3220
- 'a2.session_id': sessionId,
3221
- 'a2.commit.operation': 'complete',
3222
- 'a2.commit.index': event.index,
3223
- 'a2.commit.attempt': event.attemptCount,
3224
- },
3225
- async (commitSpan) => {
3226
- const result = await store.completeAttempt({
3227
- sessionId: ns,
3228
- index: event.index,
3229
- attempt: event.attemptCount,
3230
- events: returned,
3231
- })
3232
- commitSpan.setAttribute('a2.commit.outcome', result.outcome)
3233
- return result
3234
- },
3235
- )
3236
3795
  eventSpan.setAttribute(
3237
3796
  'a2.event.outcome',
3238
3797
  completion.outcome === 'completed'
@@ -3246,13 +3805,13 @@ export function createServer<
3246
3805
  appendsEvents: false,
3247
3806
  }
3248
3807
  }
3249
- if (handlerSignal.aborted) {
3808
+ if (runSignal.aborted) {
3250
3809
  eventSpan.setAttribute('a2.event.aborted', true)
3251
3810
  }
3252
3811
  return {
3253
3812
  status: 'processed',
3254
3813
  opensLane: event.lane !== null,
3255
- appendsEvents: returned.length > 0,
3814
+ appendsEvents: completion.events.length > 0,
3256
3815
  }
3257
3816
  },
3258
3817
  )
@@ -3260,16 +3819,22 @@ export function createServer<
3260
3819
 
3261
3820
  let claimAgain = false
3262
3821
 
3263
- const start = (event: StoredEvent, expiresAtMs: number): void => {
3264
- // Re-claiming an index this drain still tracks is durable proof
3822
+ const start = (
3823
+ event: HeadEvent,
3824
+ expiresAtMs: number,
3825
+ sessionHead: number | undefined,
3826
+ ): void => {
3827
+ const ref = headRefOf(event)
3828
+ const key = headKey(ref)
3829
+ // Re-claiming a head this drain still tracks is durable proof
3265
3830
  // that the old attempt lost: our own new claim advanced the
3266
3831
  // ordinal. Fence the old entry before it can be confused with
3267
3832
  // the new one.
3268
- const previous = active.get(event.index)
3833
+ const previous = active.get(key)
3269
3834
  if (previous) {
3270
3835
  endSuperseded(
3271
3836
  previous,
3272
- `a newer attempt owns event ${event.index} in session '${sessionId}'`,
3837
+ `a newer attempt owns event ${event.index} for consumer '${event.consumer}' in session '${sessionId}'`,
3273
3838
  )
3274
3839
  }
3275
3840
  const supersede = new AbortController()
@@ -3277,40 +3842,50 @@ export function createServer<
3277
3842
  const fenced = new Promise<EventOutcome>((resolve) => {
3278
3843
  fence = resolve
3279
3844
  })
3280
- // A handler's own committed clear supersedes it immediately.
3845
+ // A consumer's own committed clear supersedes it immediately.
3281
3846
  const endAttempt = (): void =>
3282
3847
  endSuperseded(
3283
3848
  { supersede, fence },
3284
- `event ${event.index} in session '${sessionId}' was cleared`,
3849
+ `event ${event.index} for consumer '${event.consumer}' in session '${sessionId}' was cleared`,
3285
3850
  )
3286
3851
  // The race lets a durable supersession release this slot while the
3287
- // zombie handler keeps running — its completion and failure are
3852
+ // zombie consumer keeps running — its completion and failure are
3288
3853
  // fenced by the store, so nothing it does from here is counted.
3289
3854
  let entry!: ActiveEvent
3290
3855
  const execution = Promise.race([
3291
- runEvent(event, supersede.signal, endAttempt),
3856
+ runEvent(
3857
+ event,
3858
+ supersede.signal,
3859
+ endAttempt,
3860
+ () => {
3861
+ entry.completing = true
3862
+ },
3863
+ sessionHead,
3864
+ ),
3292
3865
  fenced,
3293
3866
  ]).then((eventOutcome) => {
3294
3867
  // Identity-guarded: a fenced predecessor's continuation must
3295
3868
  // not delete the entry of the attempt that replaced it.
3296
- if (active.get(event.index) === entry) active.delete(event.index)
3869
+ if (active.get(key) === entry) active.delete(key)
3297
3870
  if (eventOutcome.status === 'processed') processed += 1
3298
3871
  if (eventOutcome.status === 'failed') {
3299
- excluded.add(event.index)
3872
+ excluded.set(key, ref)
3300
3873
  sawFailure = true
3301
3874
  }
3302
3875
  claimAgain ||= eventOutcome.opensLane || eventOutcome.appendsEvents
3303
3876
  return eventOutcome
3304
3877
  })
3305
3878
  entry = {
3879
+ ref,
3306
3880
  attempt: event.attemptCount,
3307
3881
  execution,
3308
3882
  supersede,
3309
3883
  fence,
3310
3884
  expiresAtMs,
3311
3885
  lapsed: false,
3886
+ completing: false,
3312
3887
  }
3313
- active.set(event.index, entry)
3888
+ active.set(key, entry)
3314
3889
  scheduleLapse()
3315
3890
  if (!heartbeat && !stopRenewing) {
3316
3891
  heartbeat = setInterval(
@@ -3345,9 +3920,11 @@ export function createServer<
3345
3920
  ...(window.deadlineCapped
3346
3921
  ? { expiresAtMs: window.expiresAtMs }
3347
3922
  : {}),
3923
+ consumers: only ? [storeConsumerOf(only)] : storeConsumers,
3924
+ ...(through === undefined ? {} : { through }),
3348
3925
  ...(excluded.size === 0
3349
3926
  ? {}
3350
- : { excludeIndexes: [...excluded] }),
3927
+ : { exclude: [...excluded.values()] }),
3351
3928
  })
3352
3929
  claimSpan.setAttribute('a2.claim.outcome', result.outcome)
3353
3930
  claimSpan.setAttribute(
@@ -3367,7 +3944,8 @@ export function createServer<
3367
3944
  if (event.failedAt !== null) await reportExhausted(event)
3368
3945
  if (runnable.length > 0) requestWatchdog(window.watchdogAtMs)
3369
3946
  if (window.deadlineCapped) stopRenewing = true
3370
- for (const event of runnable) start(event, window.expiresAtMs)
3947
+ for (const event of runnable)
3948
+ start(event, window.expiresAtMs, claim.head)
3371
3949
  shouldClaim = false
3372
3950
  }
3373
3951
  }
@@ -3413,6 +3991,12 @@ export function createServer<
3413
3991
  }
3414
3992
  if (claim === undefined) continue
3415
3993
  if (signal && signal.version !== signalVersion) continue
3994
+ // A run that finished while the claim was in flight may have
3995
+ // moved its cursor to a head the claim's snapshot missed.
3996
+ if (claimAgain) {
3997
+ claimAgain = false
3998
+ continue
3999
+ }
3416
4000
  if (claim.outcome === 'busy') {
3417
4001
  requestWatchdog(
3418
4002
  claim.retryAt.getTime() + DRAIN_TIMINGS.watchdogGraceMs,
@@ -3424,6 +4008,12 @@ export function createServer<
3424
4008
  if (signal) signal.closed = true
3425
4009
  break
3426
4010
  }
4011
+ } catch (error) {
4012
+ // The successor armed for this drain's claims drains the session
4013
+ // again, so the scheduler delivery need not stay as a second retry.
4014
+ if (!handOffFailure || schedulerArm === undefined) throw error
4015
+ span.recordError(error)
4016
+ outcome = 'failed'
3427
4017
  } finally {
3428
4018
  if (signal) signal.closed = true
3429
4019
  stopRenewing = true
@@ -3481,8 +4071,10 @@ export function createServer<
3481
4071
  }) as SessionAppend<D>
3482
4072
  return Object.assign(makeSession(id, append, null), {
3483
4073
  deadLetters: () => listDeadLetters(id),
3484
- redrive: (index: number, redriveOptions: SessionRedriveOptions) =>
3485
- redriveEvent(id, index, redriveOptions),
4074
+ redrive: (
4075
+ target: { consumer: string; index: number },
4076
+ redriveOptions: SessionRedriveOptions,
4077
+ ) => redriveEvent(id, target, redriveOptions),
3486
4078
  })
3487
4079
  }
3488
4080
 
@@ -3525,7 +4117,10 @@ export function createServer<
3525
4117
  },
3526
4118
  schedulerDrain: (sessionId, opts) => {
3527
4119
  assertSessionId(sessionId)
3528
- return drainSession(sessionId, opts?.watchdogDueAt)
4120
+ return drainSession(sessionId, {
4121
+ watchdogDueAt: opts?.watchdogDueAt,
4122
+ handOffFailure: true,
4123
+ })
3529
4124
  },
3530
4125
  schedulerAppend: async (sessionId, events, originIndex) => {
3531
4126
  if (!scheduler) {
@@ -3543,7 +4138,7 @@ export function createServer<
3543
4138
  originIndex,
3544
4139
  )
3545
4140
  } catch (error) {
3546
- // The scheduling handler's lifetime was cleared: an acknowledged no-op.
4141
+ // The scheduling consumer's lifetime was cleared: an acknowledged no-op.
3547
4142
  if (
3548
4143
  originIndex !== undefined &&
3549
4144
  error instanceof A2Error &&
@@ -3591,6 +4186,12 @@ function assertStateIndex(value: number): void {
3591
4186
  }
3592
4187
  }
3593
4188
 
4189
+ function assertConsumeIndex(value: number): void {
4190
+ if (!Number.isSafeInteger(value) || value < 0) {
4191
+ throw new TypeError('consume through must be a non-negative safe integer')
4192
+ }
4193
+ }
4194
+
3594
4195
  function assertRedriveIndex(value: number): void {
3595
4196
  if (!Number.isSafeInteger(value) || value < 1) {
3596
4197
  throw new TypeError('redrive requires a positive safe-integer index')
@@ -3605,7 +4206,7 @@ function assertClearIndex(value: number): void {
3605
4206
 
3606
4207
  function assertAppendName(name: string): void {
3607
4208
  if (typeof name !== 'string' || name.length === 0) {
3608
- throw new TypeError('handler append name must be a non-empty string')
4209
+ throw new TypeError('consumer append name must be a non-empty string')
3609
4210
  }
3610
4211
  }
3611
4212