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
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,7 +31,10 @@ import {
31
31
  markSchedulerSendFailure,
32
32
  stateTailReaders,
33
33
  nullProtoRecord,
34
+ runtimeConsumerFactory,
35
+ runtimeConsumers,
34
36
  serverInternals,
37
+ drainSettledHooks,
35
38
  type DrainOutcome,
36
39
  type DrainResult,
37
40
  } from './internal.ts'
@@ -41,6 +44,7 @@ import {
41
44
  type SessionCleared,
42
45
  } from './session-clear.ts'
43
46
  import { boundedCache } from './bounded-cache.ts'
47
+ import { presenceTombstones } from './presence-tombstones.ts'
44
48
  import { defaultSleep, type Sleeper } from './store-polling.ts'
45
49
  import { createStreamActivity, streamActivities } from './stream-activity.ts'
46
50
  import {
@@ -54,6 +58,10 @@ import type {
54
58
  AppendEvent,
55
59
  Event,
56
60
  EventCause,
61
+ EventTarget,
62
+ HeadEvent,
63
+ HeadRef,
64
+ StoreConsumer,
57
65
  StoreClaimAvailableResult,
58
66
  StoreClearResult,
59
67
  StoreReclaimResult,
@@ -105,8 +113,8 @@ import {
105
113
  type A2Telemetry,
106
114
  } from './telemetry.ts'
107
115
 
108
- /** What every handler receives. */
109
- export type HandlerContext<
116
+ /** What every consumer's `run` receives. */
117
+ export type ConsumerContext<
110
118
  D extends EventDefs,
111
119
  K extends keyof D & string = keyof D & string,
112
120
  P extends PresenceDefs = Record<never, never>,
@@ -116,21 +124,12 @@ export type HandlerContext<
116
124
  event: ContractEvent<D, K>
117
125
  /** Durable, 1-based dispatch ordinal for this event. */
118
126
  attempt: number
119
- /** This session, with handler-scoped idempotent append. */
120
- session: Session<D, HandlerAppend<D>, P, S>
127
+ /** This session, with consumer-scoped idempotent append. */
128
+ session: Session<D, ConsumerAppend<D>, P, S>
121
129
  /** Fires on `abortOn` events (cancellation slice); dormant otherwise. */
122
130
  signal: AbortSignal
123
131
  }
124
132
 
125
- export type Handler<
126
- D extends EventDefs,
127
- K extends keyof D & string = keyof D & string,
128
- P extends PresenceDefs = Record<never, never>,
129
- S = never,
130
- > = (
131
- ctx: HandlerContext<D, K, P, S>,
132
- ) => Promise<void | AppendInput<D> | readonly AppendInput<D>[]>
133
-
134
133
  export type LaneContext<
135
134
  D extends EventDefs,
136
135
  K extends keyof D & string = keyof D & string,
@@ -139,10 +138,15 @@ export type LaneContext<
139
138
  event: Pick<ContractEvent<D, K>, 'type' | 'payload'> & { id?: string }
140
139
  }
141
140
 
142
- export type Lane<
141
+ /**
142
+ * A consumer's lane: a string runs every event it takes in log order on one
143
+ * cursor; a function returns each event's lane, or null to run it
144
+ * concurrently.
145
+ */
146
+ export type ConsumerLane<
143
147
  D extends EventDefs,
144
148
  K extends keyof D & string = keyof D & string,
145
- > = string | ((context: LaneContext<D, K>) => string)
149
+ > = string | ((context: LaneContext<D, K>) => string | null)
146
150
 
147
151
  export type SessionDispatch<D extends EventDefs> = (
148
152
  ...events: AppendInput<D>[]
@@ -153,7 +157,7 @@ export type SessionAppend<D extends EventDefs> = SessionDispatch<D> & {
153
157
  dispatch: SessionDispatch<D>
154
158
  }
155
159
 
156
- export type HandlerAppend<D extends EventDefs> = (
160
+ export type ConsumerAppend<D extends EventDefs> = (
157
161
  name: string,
158
162
  ...events: AppendInput<D>[]
159
163
  ) => Promise<ContractEvent<D>[]>
@@ -192,7 +196,7 @@ export type SessionClearOptions = {
192
196
  export type SessionClearResult = { cleared: boolean; index: number }
193
197
 
194
198
  export type SessionRedriveOptions = {
195
- /** `retry` runs the handler again with a fresh failure budget; `skip` settles the event without running it. */
199
+ /** `retry` runs the consumer again with a fresh failure budget; `skip` settles the event without running it. */
196
200
  action: 'retry' | 'skip'
197
201
  }
198
202
 
@@ -201,18 +205,36 @@ export type SessionRedriveResult = {
201
205
  redriven: boolean
202
206
  }
203
207
 
208
+ export type SessionConsumeOptions = {
209
+ /**
210
+ * Run until the consumer has handled every event it takes through this
211
+ * index. Default: the session's head when the call starts. An index past
212
+ * the head counts as the head.
213
+ */
214
+ through?: number
215
+ /** Stops waiting: the call throws the signal's reason. */
216
+ signal?: AbortSignal
217
+ }
218
+
219
+ export type SessionConsumeResult = {
220
+ /** The consumer has handled every event it takes through this index. */
221
+ index: number
222
+ }
223
+
204
224
  /** A dead-lettered event, with what an operator needs to resolve it. */
205
225
  export type DeadLetter<D extends EventDefs> = ContractEvent<D> & {
226
+ /** The consumer this event is dead for; it may be done for others. */
227
+ consumer: string
206
228
  lane: string | null
207
229
  /** Durable dispatch claims, including claims abandoned by hard kills. */
208
230
  attemptCount: number
209
231
  /**
210
232
  * Failed attempts since the event was appended or last retried: caught
211
- * handler failures, and attempts that ended before they finished.
233
+ * consumer failures, and attempts that ended before they finished.
212
234
  */
213
235
  failureCount: number
214
236
  /**
215
- * The last error a handler threw, stringified; null when every failed
237
+ * The last error the consumer threw, stringified; null when every failed
216
238
  * attempt ended before it finished.
217
239
  */
218
240
  lastError: string | null
@@ -228,7 +250,7 @@ export type SessionDeadLetters<D extends EventDefs> = {
228
250
  * an append. Any other event is left alone and reports `redriven: false`.
229
251
  */
230
252
  redrive(
231
- index: number,
253
+ target: { consumer: string; index: number },
232
254
  options: SessionRedriveOptions,
233
255
  ): Promise<SessionRedriveResult>
234
256
  }
@@ -284,6 +306,17 @@ export type Session<
284
306
  */
285
307
  clear(options?: SessionClearOptions): Promise<SessionClearResult>
286
308
  history(options?: { gte?: number; lte?: number }): Promise<ContractEvent<D>[]>
309
+ /**
310
+ * Run a consumer with a string lane on this session, here, until it has
311
+ * handled every event it takes through `through`. The server's own
312
+ * consumer shares its cursor with background work, so this waits for
313
+ * whichever invocation holds it. A consumer the server doesn't run is
314
+ * pulled only by `consume`, and keeps its place between calls.
315
+ */
316
+ consume(
317
+ consumer: Consumer<D>,
318
+ options?: SessionConsumeOptions,
319
+ ): Promise<SessionConsumeResult>
287
320
  state: {
288
321
  <T>(
289
322
  reducer: Reducer<D, T, Record<never, never>>,
@@ -303,7 +336,7 @@ export type Session<
303
336
  }
304
337
 
305
338
  /**
306
- * The public server shape scheduler handlers accept. It stays structural so
339
+ * The public server shape scheduler consumers accept. It stays structural so
307
340
  * servers of any contract mix in one route; A2's private server internals add
308
341
  * delayed-append delivery without exposing an untyped append method here.
309
342
  */
@@ -426,11 +459,11 @@ export async function deliverSchedulerClear(
426
459
  }
427
460
 
428
461
  /**
429
- * Which events fire `ctx.signal` while a handler runs — the preemption
462
+ * Which events fire `ctx.signal` while a consumer runs — the preemption
430
463
  * channel for user cancellation. The array form matches by type; the
431
464
  * object form takes per-type predicates for targeted cancellation
432
- * (`(event, trigger) => event.payload.of === trigger.id`). Handlers
433
- * without `abortOn` pay nothing. An aborted handler should catch and
465
+ * (`(event, trigger) => event.payload.of === trigger.id`). Consumers
466
+ * without `abortOn` pay nothing. An aborted consumer should catch and
434
467
  * return normally; throwing means "retry me".
435
468
  */
436
469
  export type AbortSpec<D extends EventDefs, K extends keyof D & string> =
@@ -445,36 +478,209 @@ export type AbortSpec<D extends EventDefs, K extends keyof D & string> =
445
478
  ) => boolean)
446
479
  }
447
480
 
448
- export type HandlerEntry<
481
+ /** What a consumer's `run` returns: nothing, one event, or a batch. */
482
+ export type ConsumerResult<D extends EventDefs> = Promise<
483
+ void | AppendInput<D> | readonly AppendInput<D>[]
484
+ >
485
+
486
+ /** How `createServer` runs a consumer. Built by `consumer().run()`. */
487
+ export type ConsumerSpec<D extends EventDefs> = {
488
+ readonly types: readonly (keyof D & string)[] | null
489
+ readonly lane: ConsumerLane<D> | null
490
+ readonly backfill: boolean
491
+ readonly abortOn: AbortSpec<D, keyof D & string> | null
492
+ readonly run: (ctx: ConsumerContext<D>) => ConsumerResult<D>
493
+ }
494
+
495
+ /** A named reader of a session's log with its own durable cursors. */
496
+ export type Consumer<D extends EventDefs> = {
497
+ readonly name: string
498
+ readonly contract: { readonly name: string }
499
+ readonly spec: ConsumerSpec<D>
500
+ }
501
+
502
+ export type ConsumerOptions<
449
503
  D extends EventDefs,
450
- K extends keyof D & string = keyof D & string,
451
- P extends PresenceDefs = Record<never, never>,
452
- S = never,
453
- > =
454
- | Handler<D, K, P, S>
455
- | {
456
- abortOn?: AbortSpec<D, K>
457
- /** Session-scoped FIFO key, resolved and persisted when the event lands. */
458
- lane?: Lane<D, K>
459
- handler: Handler<D, K, P, S>
460
- }
504
+ P extends PresenceDefs,
505
+ R extends RegisteredReducer<D, P> | undefined,
506
+ K extends keyof D & string,
507
+ > = {
508
+ /** The contract whose events it reads. */
509
+ contract: Contract<D, P, R>
510
+ /** Its identity, unique per server. A new name is a new consumer. */
511
+ name: string
512
+ /** The event types it runs on; default every type. */
513
+ types?: readonly K[]
514
+ /** Omitted: concurrent. A string: one ordered lane. A function: per event. */
515
+ lane?: ConsumerLane<D, K>
516
+ /** A new consumer starts at the beginning of the log. Needs a string lane. */
517
+ backfill?: boolean
518
+ abortOn?: AbortSpec<D, K>
519
+ }
461
520
 
462
- type HandlerFunction = (ctx: never) => Promise<unknown>
521
+ /**
522
+ * A consumer written in `createServer({ consumers })`: `consumer()`'s
523
+ * options without the contract, which is the server's, and its `run`.
524
+ */
525
+ export type InlineConsumer<
526
+ D extends EventDefs,
527
+ P extends PresenceDefs,
528
+ R extends RegisteredReducer<D, P> | undefined,
529
+ K extends keyof D & string,
530
+ > = Omit<ConsumerOptions<D, P, R, K>, 'contract'> & {
531
+ run: (ctx: ConsumerContext<D, K, P, PublicState<R>>) => ConsumerResult<D>
532
+ }
533
+
534
+ /** The types an inline consumer takes: the ones it lists, or every type. */
535
+ type InlineTypes<D extends EventDefs, X> = unknown extends X
536
+ ? keyof D & string
537
+ : Extract<X, keyof D & string>
463
538
 
464
- export function handler<
465
- H extends HandlerFunction | { handler: HandlerFunction },
466
- >(entry: H): H extends HandlerFunction ? { handler: H } : H
467
- export function handler(
468
- entry: HandlerFunction | { handler: HandlerFunction },
469
- ): { handler: HandlerFunction } {
470
- return typeof entry === 'function' ? { handler: entry } : entry
539
+ /**
540
+ * `createServer`'s consumers: each a `consumer()` or written inline. `C` is
541
+ * inferred per entry, so an inline `run` sees only the types it lists.
542
+ */
543
+ export type ServerConsumers<
544
+ D extends EventDefs,
545
+ P extends PresenceDefs,
546
+ R extends RegisteredReducer<D, P> | undefined,
547
+ C extends readonly unknown[],
548
+ > = {
549
+ readonly [I in keyof C]:
550
+ InlineConsumer<D, P, R, InlineTypes<D, C[I]>> | Extract<C[I], Consumer<D>>
551
+ }
552
+
553
+ const CONSUMER_NAME = /^[a-z0-9][a-z0-9._:-]{0,127}$/i
554
+
555
+ /** Define a consumer: a named reader of each session's log. */
556
+ export function consumer<
557
+ D extends EventDefs,
558
+ P extends PresenceDefs,
559
+ R extends RegisteredReducer<D, P> | undefined,
560
+ const K extends keyof D & string = keyof D & string,
561
+ >(
562
+ options: ConsumerOptions<D, P, R, K>,
563
+ ): {
564
+ run(
565
+ fn: (ctx: ConsumerContext<D, K, P, PublicState<R>>) => ConsumerResult<D>,
566
+ ): Consumer<D>
567
+ } {
568
+ return defineConsumer(options, false)
569
+ }
570
+
571
+ /**
572
+ * `consumer()`, and for A2's own runtimes (`runtime`) a name under `a2.` and
573
+ * child ids without the name: their types are disjoint, so the event alone
574
+ * keys them, and regrouping A2's consumers never changes an id.
575
+ */
576
+ function defineConsumer<
577
+ D extends EventDefs,
578
+ P extends PresenceDefs,
579
+ R extends RegisteredReducer<D, P> | undefined,
580
+ const K extends keyof D & string = keyof D & string,
581
+ >(
582
+ options: ConsumerOptions<D, P, R, K>,
583
+ runtime: boolean,
584
+ ): {
585
+ run(
586
+ fn: (ctx: ConsumerContext<D, K, P, PublicState<R>>) => ConsumerResult<D>,
587
+ ): Consumer<D>
588
+ } {
589
+ const contract = options?.contract
590
+ if (!contract || typeof contract.name !== 'string') {
591
+ throw new TypeError('consumer expects options with a contract')
592
+ }
593
+ const consumerName = options.name
594
+ if (typeof consumerName !== 'string' || !CONSUMER_NAME.test(consumerName)) {
595
+ throw new TypeError(
596
+ 'consumer name must be 1-128 letters, digits, and . _ : - characters',
597
+ )
598
+ }
599
+ if (consumerName.startsWith('a2.') !== runtime) {
600
+ throw new TypeError(
601
+ runtime
602
+ ? `A2's own consumers are named under 'a2.'`
603
+ : `consumer names starting with 'a2.' are reserved`,
604
+ )
605
+ }
606
+ const types = options.types === undefined ? null : [...options.types]
607
+ if (types?.length === 0) {
608
+ throw new TypeError(
609
+ `consumer '${consumerName}' lists no types; leave types out to take every type`,
610
+ )
611
+ }
612
+ for (const type of types ?? []) {
613
+ if (!Object.hasOwn(contract.events, type)) {
614
+ throw new TypeError(
615
+ `contract '${contract.name}' has no event type '${String(type)}'`,
616
+ )
617
+ }
618
+ }
619
+ const lane = options.lane ?? null
620
+ if (
621
+ lane !== null &&
622
+ typeof lane !== 'function' &&
623
+ (typeof lane !== 'string' || lane.length === 0)
624
+ ) {
625
+ throw new TypeError(
626
+ `lane for consumer '${consumerName}' must be a non-empty string or a function`,
627
+ )
628
+ }
629
+ const backfill = options.backfill ?? false
630
+ if (backfill && typeof lane !== 'string') {
631
+ throw new TypeError(
632
+ `consumer '${consumerName}' backfills, so its lane must be a string`,
633
+ )
634
+ }
635
+ // Cancellation is for work in progress. Checking it while replaying
636
+ // history would read the rest of the log for every event replayed.
637
+ if (backfill && options.abortOn !== undefined) {
638
+ throw new TypeError(
639
+ `consumer '${consumerName}' backfills, so it can't use abortOn`,
640
+ )
641
+ }
642
+ return {
643
+ run(fn) {
644
+ if (typeof fn !== 'function') {
645
+ throw new TypeError(`consumer '${consumerName}' needs a run function`)
646
+ }
647
+ const defined: Consumer<D> = {
648
+ name: consumerName,
649
+ contract: { name: contract.name },
650
+ spec: {
651
+ types,
652
+ lane: lane as ConsumerLane<D> | null,
653
+ backfill,
654
+ abortOn: (options.abortOn ?? null) as AbortSpec<
655
+ D,
656
+ keyof D & string
657
+ > | null,
658
+ run: fn as unknown as (ctx: ConsumerContext<D>) => ConsumerResult<D>,
659
+ },
660
+ }
661
+ if (runtime) runtimeConsumers.add(defined)
662
+ return defined
663
+ },
664
+ }
471
665
  }
666
+ runtimeConsumerFactory.define = (options) =>
667
+ defineConsumer(options as Parameters<typeof consumer>[0], true)
668
+
669
+ /** A registered consumer as the store needs to know it. */
670
+ const storeConsumerOf = (registered: {
671
+ name: string
672
+ log: StoreConsumer['log']
673
+ }): StoreConsumer =>
674
+ registered.log
675
+ ? { name: registered.name, log: registered.log }
676
+ : { name: registered.name }
472
677
 
473
678
  export type ServerOptions<
474
679
  D extends EventDefs,
475
680
  P extends PresenceDefs = Record<never, never>,
476
681
  R extends RegisteredReducer<D, P> | undefined =
477
682
  RegisteredReducer<D, P> | undefined,
683
+ C extends readonly unknown[] = readonly Consumer<D>[],
478
684
  > = {
479
685
  /** The contract this server implements (see `a2.contract`). */
480
686
  contract: Contract<D, P, R>
@@ -496,16 +702,39 @@ export type ServerOptions<
496
702
  */
497
703
  presence?: { ttlMs?: number }
498
704
  /**
499
- * The reactions, keyed by event type — all present at construction,
500
- * so a handler can never be silently missing because its module
501
- * wasn't imported. Compose across files by spreading objects (note:
502
- * a duplicate key under spread silently last-wins).
705
+ * The consumers, each a named reader of every session's log with its own
706
+ * cursors: a `consumer()`, or its options and `run` written inline, on this
707
+ * contract. Names are unique; a duplicate throws.
503
708
  */
504
- handlers?: { [K in keyof D & string]?: HandlerEntry<D, K, P, PublicState<R>> }
709
+ consumers?: ServerConsumers<D, P, R, C> | readonly Consumer<D>[]
505
710
  }
506
711
 
507
712
  const MAX_FAILURES = 10
508
713
 
714
+ /** How long `consume` waits before looking again at a head it can't run. */
715
+ const CONSUME_WAIT_MIN_MS = 25
716
+ const CONSUME_WAIT_MAX_MS = 1_000
717
+
718
+ /**
719
+ * `task`, or a rejection with `signal`'s reason once it aborts. The task runs
720
+ * on either way, and its own failure is caught, so nothing goes unhandled.
721
+ */
722
+ const untilAborted = <T>(
723
+ task: Promise<T>,
724
+ signal: AbortSignal | undefined,
725
+ ): Promise<T> => {
726
+ if (!signal) return task
727
+ task.catch(() => {})
728
+ if (signal.aborted) return Promise.reject(signal.reason)
729
+ return new Promise<T>((resolve, reject) => {
730
+ const abort = (): void => reject(signal.reason)
731
+ signal.addEventListener('abort', abort, { once: true })
732
+ task.then(resolve, reject).finally(() => {
733
+ signal.removeEventListener('abort', abort)
734
+ })
735
+ })
736
+ }
737
+
509
738
  /** How long an event waits after its `failureCount`th failed attempt. */
510
739
  const failureRetryDelayMs = (failureCount: number): number =>
511
740
  Math.min(
@@ -513,7 +742,7 @@ const failureRetryDelayMs = (failureCount: number): number =>
513
742
  DRAIN_TIMINGS.failureRetryMaxMs,
514
743
  )
515
744
 
516
- /** Abort a lost attempt's handler and release its drain slot. */
745
+ /** Abort a lost attempt's consumer and release its drain slot. */
517
746
  function endSuperseded(
518
747
  entry: {
519
748
  supersede: AbortController
@@ -725,30 +954,47 @@ function foldPresenceRows(
725
954
 
726
955
  type SnapshotWrites = Map<
727
956
  number,
728
- { state: unknown; pinEventIndexes: Set<number> }
957
+ { state: unknown; pins: Map<string, HeadRef> }
729
958
  >
730
959
 
960
+ /** One cursor head, as a map key. */
961
+ const headKey = (ref: HeadRef): string =>
962
+ JSON.stringify([ref.consumer, ref.lane, ref.index])
963
+
964
+ const headRefOf = (head: HeadEvent): HeadRef => ({
965
+ consumer: head.consumer,
966
+ lane: head.lane,
967
+ index: head.index,
968
+ })
969
+
731
970
  const addSnapshotWrite = (
732
971
  writes: SnapshotWrites,
733
972
  index: number,
734
973
  state: unknown,
735
- pinEventIndex?: number,
974
+ pin?: HeadRef,
736
975
  ): void => {
737
976
  let write = writes.get(index)
738
977
  if (!write) {
739
- write = { state: cloneInitial(state), pinEventIndexes: new Set() }
978
+ write = { state: cloneInitial(state), pins: new Map() }
740
979
  writes.set(index, write)
741
980
  }
742
- if (pinEventIndex !== undefined) write.pinEventIndexes.add(pinEventIndex)
981
+ if (pin !== undefined) write.pins.set(headKey(pin), pin)
743
982
  }
744
983
 
984
+ /** Whether a registered consumer runs events of this type. */
985
+ const takes = (
986
+ registered: { readonly types: ReadonlySet<string> | null },
987
+ type: string,
988
+ ): boolean => registered.types === null || registered.types.has(type)
989
+
745
990
  /** Implement a contract: bind its vocabulary to storage and reactions. */
746
991
  export function createServer<
747
992
  D extends EventDefs,
748
993
  P extends PresenceDefs = Record<never, never>,
749
994
  R extends RegisteredReducer<D, P> | undefined =
750
995
  RegisteredReducer<D, P> | undefined,
751
- >(options: ServerOptions<D, P, R>): A2Server<D, P, R> {
996
+ const C extends readonly unknown[] = readonly Consumer<D>[],
997
+ >(options: ServerOptions<D, P, R, C>): A2Server<D, P, R> {
752
998
  const serverContract = options?.contract
753
999
  if (
754
1000
  serverContract === null ||
@@ -760,6 +1006,12 @@ export function createServer<
760
1006
  'createServer expects options with a contract (see a2.contract)',
761
1007
  )
762
1008
  }
1009
+ // Ignored, a handler table would run nothing and say nothing.
1010
+ if (Object.hasOwn(options, 'handlers')) {
1011
+ throw new TypeError(
1012
+ 'createServer no longer takes handlers: write each as a consumer in `consumers` (see https://www.a2.dev/upgrades/0-21)',
1013
+ )
1014
+ }
763
1015
  const name = serverContract.name
764
1016
  const defs = serverContract.events
765
1017
  const presenceDefs: Readonly<PresenceDefs> = serverContract.presence
@@ -848,24 +1100,11 @@ export function createServer<
848
1100
  context: { attempt: number },
849
1101
  ) => boolean)
850
1102
  >
851
- const handlers = new Map<
852
- string,
853
- { handler: Handler<D>; abortOn: AbortMatcher | null; lane: Lane<D> | null }
854
- >()
855
- for (const [type, entry] of Object.entries(options.handlers ?? {})) {
856
- if (entry === undefined) continue
857
- if (!Object.hasOwn(defs, type)) {
858
- throw new TypeError(
859
- `contract '${name}' has no event type '${type}' — cannot register a handler for it`,
860
- )
861
- }
862
- const callback = typeof entry === 'function' ? entry : entry?.handler
863
- if (typeof callback !== 'function') {
864
- throw new TypeError(`handler for '${type}' must be a function`)
865
- }
1103
+ const abortMatcherOf = (
1104
+ spec: AbortSpec<D, keyof D & string> | null | undefined,
1105
+ ): AbortMatcher | null => {
866
1106
  let abortOn: AbortMatcher | null = null
867
- const spec = typeof entry === 'function' ? undefined : entry.abortOn
868
- if (spec !== undefined) {
1107
+ if (spec !== undefined && spec !== null) {
869
1108
  abortOn = new Map()
870
1109
  const pairs = Array.isArray(spec)
871
1110
  ? spec.map((abortType) => [abortType, true as const] as const)
@@ -893,22 +1132,117 @@ export function createServer<
893
1132
  }
894
1133
  if (abortOn.size === 0) abortOn = null
895
1134
  }
896
- const lane = typeof entry === 'function' ? null : (entry.lane ?? null)
1135
+ return abortOn
1136
+ }
1137
+
1138
+ /** A consumer as this server runs it. */
1139
+ type RegisteredConsumer = {
1140
+ name: string
1141
+ types: ReadonlySet<string> | null
1142
+ /** Lane-function and concurrent consumers; log consumers resolve none. */
1143
+ laneOf: ((sessionId: string, event: AppendEvent) => string | null) | null
1144
+ log: StoreConsumer['log']
1145
+ abortOn: (type: string) => AbortMatcher | null
1146
+ run: (ctx: ConsumerContext<D>) => Promise<unknown>
1147
+ /** A2's own consumers: child and timer ids leave out the name. */
1148
+ anonymousIds: boolean
1149
+ }
1150
+ const laneContext = (sessionId: string, event: AppendEvent): LaneContext<D> =>
1151
+ ({
1152
+ sessionId,
1153
+ event: {
1154
+ type: event.type,
1155
+ payload: structuredClone(event.payload),
1156
+ ...(event.id === undefined ? {} : { id: event.id }),
1157
+ },
1158
+ }) as LaneContext<D>
1159
+ const registeredConsumers = new Map<string, RegisteredConsumer>()
1160
+ /** A consumer definition as the server runs it. */
1161
+ const registrationOf = (defined: Consumer<D>): RegisteredConsumer => {
1162
+ const { types, lane, backfill, abortOn, run } = defined.spec
1163
+ const matcher = abortMatcherOf(abortOn)
1164
+ return {
1165
+ name: defined.name,
1166
+ types: types === null ? null : new Set(types),
1167
+ laneOf:
1168
+ typeof lane === 'string'
1169
+ ? null
1170
+ : lane === null
1171
+ ? () => null
1172
+ : (sessionId, event) => {
1173
+ const resolved = lane(laneContext(sessionId, event))
1174
+ if (
1175
+ resolved !== null &&
1176
+ (typeof resolved !== 'string' || resolved.length === 0)
1177
+ ) {
1178
+ throw new TypeError(
1179
+ `lane for consumer '${defined.name}' must resolve to a non-empty string or null`,
1180
+ )
1181
+ }
1182
+ return resolved
1183
+ },
1184
+ log:
1185
+ typeof lane === 'string'
1186
+ ? { lane, types: types === null ? null : [...types], backfill }
1187
+ : undefined,
1188
+ abortOn: () => matcher,
1189
+ run: run as (ctx: ConsumerContext<D>) => Promise<unknown>,
1190
+ anonymousIds: runtimeConsumers.has(defined),
1191
+ }
1192
+ }
1193
+ /** The definitions the server runs, so `consume` can tell its own from another. */
1194
+ const definitions = new Map<string, Consumer<D>>()
1195
+ /** A consumer written inline is `consumer()`'s options and `run`, on this contract. */
1196
+ const definitionOf = (entry: unknown): Consumer<D> => {
897
1197
  if (
898
- lane !== null &&
899
- typeof lane !== 'function' &&
900
- (typeof lane !== 'string' || lane.length === 0)
1198
+ entry === null ||
1199
+ typeof entry !== 'object' ||
1200
+ 'spec' in entry ||
1201
+ !('run' in entry)
901
1202
  ) {
1203
+ return entry as Consumer<D>
1204
+ }
1205
+ const { run, ...rest } = entry as InlineConsumer<D, P, R, keyof D & string>
1206
+ return consumer({ ...rest, contract: serverContract }).run(run)
1207
+ }
1208
+ for (const entry of (options.consumers ?? []) as readonly unknown[]) {
1209
+ const defined = definitionOf(entry)
1210
+ if (defined?.contract?.name !== name || !defined.spec) {
902
1211
  throw new TypeError(
903
- `lane for '${type}' must be a non-empty string or a function`,
1212
+ `createServer for '${name}' got a consumer of another contract`,
904
1213
  )
905
1214
  }
906
- handlers.set(type, {
907
- handler: callback as Handler<D>,
908
- abortOn,
909
- lane: lane as Lane<D> | null,
910
- })
1215
+ if (registeredConsumers.has(defined.name)) {
1216
+ throw new TypeError(`two consumers are named '${defined.name}'`)
1217
+ }
1218
+ registeredConsumers.set(defined.name, registrationOf(defined))
1219
+ definitions.set(defined.name, defined)
1220
+ }
1221
+ // Runtime consumers leave their name out of child ids, so no event may
1222
+ // start two of them: their first returned events would share an id.
1223
+ const anonymousTypes = new Map<string, string>()
1224
+ for (const registered of registeredConsumers.values()) {
1225
+ if (!registered.anonymousIds) continue
1226
+ for (const type of registered.types ?? Object.keys(defs)) {
1227
+ const other = anonymousTypes.get(type)
1228
+ if (other !== undefined) {
1229
+ throw new TypeError(
1230
+ `A2's consumers '${other}' and '${registered.name}' both take '${type}'`,
1231
+ )
1232
+ }
1233
+ anonymousTypes.set(type, registered.name)
1234
+ }
911
1235
  }
1236
+ /** The consumers this server serves, as the store needs to know them. */
1237
+ const storeConsumers: StoreConsumer[] = [...registeredConsumers.values()].map(
1238
+ storeConsumerOf,
1239
+ )
1240
+ /** Whether an event starts cursor work for any consumer. */
1241
+ const startsWork = (event: AppendEvent): boolean =>
1242
+ (event.targets?.length ?? 0) > 0 ||
1243
+ [...registeredConsumers.values()].some(
1244
+ (registered) => registered.log && takes(registered, event.type),
1245
+ )
912
1246
 
913
1247
  const prefix = `${name}${NS}`
914
1248
  const nsId = (sessionId: string): string => `${prefix}${sessionId}`
@@ -1048,28 +1382,15 @@ export function createServer<
1048
1382
  const validated: AppendEvent = { type: e.type, payload: result.value }
1049
1383
  if (e.id !== undefined) validated.id = e.id
1050
1384
  if (!resolveDispatchMetadata) return validated
1051
- const registration = handlers.get(e.type)
1052
- if (!registration) {
1053
- validated.settled = true
1054
- } else if (registration.lane !== null) {
1055
- const lane =
1056
- typeof registration.lane === 'function'
1057
- ? registration.lane({
1058
- sessionId,
1059
- event: {
1060
- type: e.type,
1061
- payload: structuredClone(result.value),
1062
- ...(e.id === undefined ? {} : { id: e.id }),
1063
- } as never,
1064
- })
1065
- : registration.lane
1066
- if (typeof lane !== 'string' || lane.length === 0) {
1067
- throw new TypeError(
1068
- `lane for '${e.type}' must resolve to a non-empty string`,
1069
- )
1070
- }
1071
- validated.lane = lane
1385
+ const targets: EventTarget[] = []
1386
+ for (const registered of registeredConsumers.values()) {
1387
+ if (!registered.laneOf || !takes(registered, e.type)) continue
1388
+ targets.push({
1389
+ consumer: registered.name,
1390
+ lane: registered.laneOf(sessionId, validated),
1391
+ })
1072
1392
  }
1393
+ if (targets.length > 0) validated.targets = targets
1073
1394
  return validated
1074
1395
  })
1075
1396
  }
@@ -1082,7 +1403,7 @@ export function createServer<
1082
1403
  const appendCore = (
1083
1404
  sessionId: string,
1084
1405
  events: AppendInput<D>[],
1085
- source: 'external' | 'handler',
1406
+ source: 'external' | 'consumer',
1086
1407
  mode: 'inline' | 'dispatch',
1087
1408
  onAppended: (rows: ContractEvent<D>[], watchdogDueAt?: number) => void,
1088
1409
  cause?: EventCause,
@@ -1119,20 +1440,16 @@ export function createServer<
1119
1440
  let rows: StoredEvent[]
1120
1441
  let shouldHealSession: boolean
1121
1442
  try {
1122
- const result = await store.append(
1123
- nsId(sessionId),
1124
- validated,
1125
- originIndex === undefined ? undefined : { originIndex },
1126
- )
1443
+ const result = await store.append(nsId(sessionId), validated, {
1444
+ consumers: storeConsumers,
1445
+ ...(originIndex === undefined ? {} : { originIndex }),
1446
+ })
1127
1447
  rows = result.events
1128
- // A handler appending only settled events adds no work, and the
1129
- // drain holding its trigger is already running.
1448
+ // A consumer appending events no consumer takes adds no work, and
1449
+ // the drain holding its trigger is already running.
1130
1450
  shouldHealSession =
1131
1451
  result.hasPending &&
1132
- !(
1133
- source === 'handler' &&
1134
- validated.every((event) => event.settled === true)
1135
- )
1452
+ !(source === 'consumer' && !validated.some(startsWork))
1136
1453
  } catch (err) {
1137
1454
  throw asStoreUnavailable(err)
1138
1455
  }
@@ -1243,8 +1560,8 @@ export function createServer<
1243
1560
  throughIndex?: number
1244
1561
  /** The id of the event at `throughIndex`, when the reader knows it. */
1245
1562
  throughEventId?: string
1246
- pinEventIndex?: number
1247
- /** A handler read: rejected once this trigger index has been cleared. */
1563
+ pin?: HeadRef
1564
+ /** A consumer read: rejected once this trigger index has been cleared. */
1248
1565
  triggerIndex?: number
1249
1566
  span: A2SpanHandle
1250
1567
  resolve: (result: StateResult) => void
@@ -1389,9 +1706,7 @@ export function createServer<
1389
1706
  index,
1390
1707
  state: encodeState({ reducer, state: write.state }),
1391
1708
  }
1392
- if (write.pinEventIndexes.size > 0) {
1393
- snapshot.pinEventIndexes = [...write.pinEventIndexes]
1394
- }
1709
+ if (write.pins.size > 0) snapshot.pins = [...write.pins.values()]
1395
1710
  return snapshot
1396
1711
  })
1397
1712
  if (snapshots.length > 0) {
@@ -1504,7 +1819,7 @@ export function createServer<
1504
1819
  /**
1505
1820
  * Serves a plan from the cached state when every read is at or past the
1506
1821
  * cached index. A read bounded at the cached index by an event whose id
1507
- * matches the cached one (a handler's own trigger) needs no store request;
1822
+ * matches the cached one (a consumer's own trigger) needs no store request;
1508
1823
  * any other read fetches the tail from the cached event onward and checks
1509
1824
  * that event's id. Returns false when the store must answer instead.
1510
1825
  */
@@ -1561,8 +1876,8 @@ export function createServer<
1561
1876
  clearIndex: cached.clearIndex,
1562
1877
  snapshot: 'memory',
1563
1878
  visit: (entry, state, index) => {
1564
- if (entry.pinEventIndex !== undefined) {
1565
- addSnapshotWrite(writes, index, state, entry.pinEventIndex)
1879
+ if (entry.pin !== undefined) {
1880
+ addSnapshotWrite(writes, index, state, entry.pin)
1566
1881
  }
1567
1882
  },
1568
1883
  })
@@ -1572,7 +1887,7 @@ export function createServer<
1572
1887
  if (rewritesHead && !writes.has(folded.index)) {
1573
1888
  writes.set(folded.index, {
1574
1889
  state: folded.state,
1575
- pinEventIndexes: new Set(),
1890
+ pins: new Map(),
1576
1891
  })
1577
1892
  }
1578
1893
  const snapshots = writeSnapshots({
@@ -1673,14 +1988,13 @@ export function createServer<
1673
1988
  snapshot: snapshotOutcome,
1674
1989
  visit: (entry, entryState, entryIndex, count) => {
1675
1990
  if (entryIndex === 0) return
1676
- if (count === 0 && entry.pinEventIndex === undefined) return
1991
+ if (count === 0 && entry.pin === undefined) return
1677
1992
  const advancesHead =
1678
1993
  stateRead.headIndex === null || entryIndex > stateRead.headIndex
1679
1994
  const repairsHead =
1680
1995
  snapshotOutcome === 'rejected' && entryIndex === stateRead.headIndex
1681
- if (!advancesHead && !repairsHead && entry.pinEventIndex === undefined)
1682
- return
1683
- addSnapshotWrite(writes, entryIndex, entryState, entry.pinEventIndex)
1996
+ if (!advancesHead && !repairsHead && entry.pin === undefined) return
1997
+ addSnapshotWrite(writes, entryIndex, entryState, entry.pin)
1684
1998
  },
1685
1999
  })
1686
2000
  const snapshots = writeSnapshots({
@@ -1776,7 +2090,7 @@ export function createServer<
1776
2090
  type StateReadBounds = {
1777
2091
  throughIndex?: number
1778
2092
  throughEventId?: string
1779
- pinEventIndex?: number
2093
+ pin?: HeadRef
1780
2094
  triggerIndex?: number
1781
2095
  }
1782
2096
 
@@ -1795,7 +2109,7 @@ export function createServer<
1795
2109
  pendingStateReads.set(reducerName, opened)
1796
2110
  // One microtask is the whole batching window — deliberately NOT
1797
2111
  // DataLoader's post-promise-job nextTick, which would defer
1798
- // handler-internal reads past the entire microtask cascade and
2112
+ // consumer-internal reads past the entire microtask cascade and
1799
2113
  // reorder drain interleaving (and has no edge-runtime primitive).
1800
2114
  // It still coalesces reliably: siblings of one Promise.all
1801
2115
  // traverse identical awaits (store resolution, span wrapper), so
@@ -1837,12 +2151,14 @@ export function createServer<
1837
2151
 
1838
2152
  const makeSchedule = (
1839
2153
  sessionId: string,
1840
- trigger: Pick<
1841
- StoredEvent,
1842
- 'id' | 'createdAt' | 'index' | 'clearIndex'
1843
- > | null,
2154
+ trigger: HeadEvent | null,
1844
2155
  ): SessionSchedule<D> => {
1845
2156
  const triggerEventId = trigger?.id ?? null
2157
+ /** Timers a consumer sets key on its name, except A2's own. */
2158
+ const triggerConsumer =
2159
+ trigger && !registeredConsumers.get(trigger.consumer)?.anonymousIds
2160
+ ? trigger.consumer
2161
+ : undefined
1846
2162
  const triggerCreatedAt = trigger?.createdAt.getTime()
1847
2163
  const clearIndex = trigger?.clearIndex ?? 0
1848
2164
  const schedule = async (
@@ -1879,6 +2195,7 @@ export function createServer<
1879
2195
  triggerEventId,
1880
2196
  scheduleName,
1881
2197
  clearIndex,
2198
+ triggerConsumer,
1882
2199
  )
1883
2200
  const scheduledEvents: ScheduledEvent[] = await Promise.all(
1884
2201
  validated.map(async (event, item) => ({
@@ -1944,6 +2261,7 @@ export function createServer<
1944
2261
  clearIndex,
1945
2262
  name: scheduleName,
1946
2263
  ifLastIndex,
2264
+ ...(triggerConsumer === undefined ? {} : { consumer: triggerConsumer }),
1947
2265
  })
1948
2266
  try {
1949
2267
  await scheduler.schedule({
@@ -2123,7 +2441,7 @@ export function createServer<
2123
2441
  {
2124
2442
  seen: patch.seen ?? fallbackSeen,
2125
2443
  // The sender's stamp is the LWW order; receipt time is the
2126
- // fallback for stampless callers (handlers, old wire
2444
+ // fallback for stampless callers (consumers, old wire
2127
2445
  // clients), where receipt order is sender order — single
2128
2446
  // writer. Storage anchors expiry on its own clock either way.
2129
2447
  at: new Date(patch.at ?? Date.now()),
@@ -2213,11 +2531,13 @@ export function createServer<
2213
2531
  }
2214
2532
 
2215
2533
  // What this feed has already painted, per (participant, field) —
2216
- // the comparison state for re-read diffs.
2534
+ // the comparison state for re-read diffs and for queued patches,
2535
+ // with the stamps of painted nulls kept beside it.
2217
2536
  const painted = new Map<
2218
2537
  string,
2219
2538
  Map<string, { seen: number; atMs: number; value: string }>
2220
2539
  >()
2540
+ const paintedDeletes = presenceTombstones()
2221
2541
 
2222
2542
  // One patch per (participant, field), never merged — a merged
2223
2543
  // stamp would let a stale field inherit a fresher sibling's
@@ -2266,10 +2586,12 @@ export function createServer<
2266
2586
  patches.push({
2267
2587
  participant,
2268
2588
  values: { [field]: null },
2269
- // A cleared field has no surviving row; its last painted
2270
- // `seen` and the observation time are the honest stamps.
2589
+ // A cleared field has no surviving row, so it keeps its
2590
+ // last painted stamps: only senders' clocks order the
2591
+ // plane, and an observer-stamped delete could outrank the
2592
+ // sender's next set.
2271
2593
  seen: prior.seen,
2272
- at: new Date(),
2594
+ at: new Date(prior.atMs),
2273
2595
  })
2274
2596
  }
2275
2597
  if (fields.size === 0) painted.delete(participant)
@@ -2280,10 +2602,13 @@ export function createServer<
2280
2602
  const paint = (patch: PresencePatch): void => {
2281
2603
  let fields = painted.get(patch.participant)
2282
2604
  for (const [field, value] of Object.entries(patch.values)) {
2605
+ const write = { participant: patch.participant, field, at: patch.at }
2283
2606
  if (value === null || value === undefined) {
2284
2607
  fields?.delete(field)
2608
+ if (value === null) paintedDeletes.keep(write)
2285
2609
  continue
2286
2610
  }
2611
+ paintedDeletes.drop(write)
2287
2612
  if (!fields) {
2288
2613
  fields = new Map()
2289
2614
  painted.set(patch.participant, fields)
@@ -2328,11 +2653,23 @@ export function createServer<
2328
2653
  }
2329
2654
  wakeQueue?.()
2330
2655
  }
2656
+ // A queued patch older than what this feed painted for its field —
2657
+ // the live value or its deletion — would repaint the past.
2658
+ const outdated = (
2659
+ write: Pick<PresenceRow, 'participant' | 'field' | 'at'>,
2660
+ ): boolean => {
2661
+ const prior = painted.get(write.participant)?.get(write.field)
2662
+ return (
2663
+ (prior !== undefined && prior.atMs > write.at.getTime()) ||
2664
+ paintedDeletes.outranks(write)
2665
+ )
2666
+ }
2331
2667
  const dequeue = (): PresencePatch | null => {
2332
2668
  for (const [participant, fields] of queue) {
2333
2669
  for (const [field, stamp] of fields) {
2334
2670
  fields.delete(field)
2335
2671
  if (fields.size === 0) queue.delete(participant)
2672
+ if (outdated({ participant, field, at: stamp.at })) continue
2336
2673
  return {
2337
2674
  participant,
2338
2675
  values: { [field]: stamp.value },
@@ -2498,7 +2835,7 @@ export function createServer<
2498
2835
  }
2499
2836
  }
2500
2837
 
2501
- /** The handler attempt a `ctx.session.clear()` belongs to. */
2838
+ /** The consumer attempt a `ctx.session.clear()` belongs to. */
2502
2839
  type ClearingAttempt = {
2503
2840
  cause: EventCause
2504
2841
  end: () => void
@@ -2531,7 +2868,7 @@ export function createServer<
2531
2868
  }
2532
2869
  if (result.cleared) {
2533
2870
  // This instance's own reads after the clear, including the clearing
2534
- // handler's, go to the store and see the boundary.
2871
+ // consumer's, go to the store and see the boundary.
2535
2872
  const cachePrefix = stateCacheKey(sessionId, '')
2536
2873
  stateCache.deleteWhere((key) => key.startsWith(cachePrefix))
2537
2874
  }
@@ -2566,7 +2903,7 @@ export function createServer<
2566
2903
  sessionId: string,
2567
2904
  ): Promise<DeadLetter<D>[]> => {
2568
2905
  const store = await redrivingStore('deadLetters')
2569
- let rows: StoredEvent[]
2906
+ let rows: HeadEvent[]
2570
2907
  try {
2571
2908
  rows = await store.deadLetters(nsId(sessionId))
2572
2909
  } catch (err) {
@@ -2578,6 +2915,7 @@ export function createServer<
2578
2915
  : [
2579
2916
  {
2580
2917
  ...toPublic(row),
2918
+ consumer: row.consumer,
2581
2919
  lane: row.lane,
2582
2920
  attemptCount: row.attemptCount,
2583
2921
  failureCount: row.failureCount,
@@ -2590,10 +2928,15 @@ export function createServer<
2590
2928
 
2591
2929
  const redriveEvent = async (
2592
2930
  sessionId: string,
2593
- index: number,
2931
+ target: { consumer: string; index: number },
2594
2932
  redriveOptions: SessionRedriveOptions,
2595
2933
  ): Promise<SessionRedriveResult> => {
2934
+ const index = target?.index
2935
+ const redriven = target?.consumer
2596
2936
  assertRedriveIndex(index)
2937
+ if (typeof redriven !== 'string' || redriven.length === 0) {
2938
+ throw new TypeError('redrive requires a consumer name')
2939
+ }
2597
2940
  const action = redriveOptions?.action
2598
2941
  if (action !== 'retry' && action !== 'skip') {
2599
2942
  throw new TypeError("redrive requires action 'retry' or 'skip'")
@@ -2604,6 +2947,7 @@ export function createServer<
2604
2947
  'a2.contract': name,
2605
2948
  'a2.session_id': sessionId,
2606
2949
  'a2.redrive.index': index,
2950
+ 'a2.redrive.consumer': redriven,
2607
2951
  'a2.redrive.action': action,
2608
2952
  },
2609
2953
  async (span) => {
@@ -2612,8 +2956,10 @@ export function createServer<
2612
2956
  try {
2613
2957
  result = await store.redrive({
2614
2958
  sessionId: nsId(sessionId),
2959
+ consumer: redriven,
2615
2960
  index,
2616
2961
  action,
2962
+ consumers: storeConsumers,
2617
2963
  })
2618
2964
  } catch (err) {
2619
2965
  throw asStoreUnavailable(err)
@@ -2644,18 +2990,169 @@ export function createServer<
2644
2990
  )
2645
2991
  }
2646
2992
 
2993
+ /**
2994
+ * `session.consume`: wakes the consumer's cursor, then drains only that
2995
+ * consumer until its head lies past `through`. A head another invocation
2996
+ * holds, or one waiting out a failure, is waited for; a dead-lettered head
2997
+ * throws.
2998
+ */
2999
+ /** Per session, which consumer's running head is waiting in `consume` on which: a cycle never ends. */
3000
+ const consumeWaits = new Map<string, Map<string, string>>()
3001
+
3002
+ const consumeSession = (
3003
+ sessionId: string,
3004
+ defined: Consumer<D>,
3005
+ consumeOptions: SessionConsumeOptions = {},
3006
+ trigger: HeadEvent | null = null,
3007
+ ): Promise<SessionConsumeResult> => {
3008
+ assertSessionId(sessionId)
3009
+ if (defined?.contract?.name !== name || !defined.spec) {
3010
+ throw new TypeError(
3011
+ `session.consume for '${name}' got a consumer of another contract`,
3012
+ )
3013
+ }
3014
+ const own = definitions.get(defined.name)
3015
+ if (own !== undefined && own !== defined) {
3016
+ throw new TypeError(
3017
+ `this server runs a different consumer named '${defined.name}'`,
3018
+ )
3019
+ }
3020
+ if (defined.name === trigger?.consumer) {
3021
+ throw new TypeError(
3022
+ `consumer '${defined.name}' can't consume itself: its own head is running`,
3023
+ )
3024
+ }
3025
+ const registered = own
3026
+ ? registeredConsumers.get(defined.name)!
3027
+ : registrationOf(defined)
3028
+ const log = registered.log
3029
+ if (!log) {
3030
+ throw new TypeError(
3031
+ `consumer '${defined.name}' needs a string lane to be consumed: only then does it have one position`,
3032
+ )
3033
+ }
3034
+ const { through: requested, signal } = consumeOptions
3035
+ if (requested !== undefined) assertConsumeIndex(requested)
3036
+ // A run that waits on a consumer already waiting on it, here, would wait
3037
+ // forever. Checked and recorded before any await, so two can't both pass.
3038
+ // One that would close that cycle isn't recorded: it returns only if what
3039
+ // it asks for is done already, since the other's cursor can't move.
3040
+ const waiter = trigger?.consumer
3041
+ const waits =
3042
+ waiter === undefined
3043
+ ? undefined
3044
+ : (consumeWaits.get(sessionId) ??
3045
+ consumeWaits.set(sessionId, new Map()).get(sessionId)!)
3046
+ let cycle = false
3047
+ if (waiter !== undefined && waits) {
3048
+ for (
3049
+ let next: string | undefined = defined.name;
3050
+ next !== undefined && !cycle;
3051
+ next = waits.get(next)
3052
+ ) {
3053
+ cycle = next === waiter
3054
+ }
3055
+ if (!cycle) waits.set(waiter, defined.name)
3056
+ }
3057
+ const storeConsumer = { name: registered.name, log }
3058
+ const consumed = telemetry.span(
3059
+ 'a2.consume',
3060
+ {
3061
+ 'a2.contract': name,
3062
+ 'a2.session_id': sessionId,
3063
+ 'a2.consume.consumer': registered.name,
3064
+ },
3065
+ async (span) => {
3066
+ const store = await resolveStore()
3067
+ const ns = nsId(sessionId)
3068
+ let woken = await store.wakeLog({
3069
+ sessionId: ns,
3070
+ consumer: storeConsumer,
3071
+ })
3072
+ const through = Math.min(requested ?? woken.head, woken.head)
3073
+ span.setAttribute('a2.consume.through', through)
3074
+ const done = (): boolean =>
3075
+ woken.cursor.head === null || woken.cursor.head > through
3076
+ if (cycle && !done()) {
3077
+ throw new TypeError(
3078
+ `consumer '${waiter}' can't consume '${defined.name}' through ${through}: '${defined.name}' is waiting on it in session '${sessionId}'`,
3079
+ )
3080
+ }
3081
+ let drains = 0
3082
+ let wait = CONSUME_WAIT_MIN_MS
3083
+ while (!done()) {
3084
+ signal?.throwIfAborted()
3085
+ const { cursor } = woken
3086
+ if (cursor.failedAt !== null) {
3087
+ throw new A2Error(
3088
+ 'CONSUMER_DEAD_LETTERED',
3089
+ `consumer '${registered.name}' is dead-lettered at event ${cursor.head} in session '${sessionId}': ${cursor.lastError ?? 'every attempt ended before it finished'}`,
3090
+ )
3091
+ }
3092
+ drains += 1
3093
+ const drained = drainSession(sessionId, { only: registered, through })
3094
+ try {
3095
+ await untilAborted(drained, signal)
3096
+ } catch (error) {
3097
+ // An abort stops the wait at once; a run in progress carries on,
3098
+ // and what it appends still reaches the server's consumers.
3099
+ if (signal?.aborted)
3100
+ void drained.then(
3101
+ () => scheduleDrain(sessionId),
3102
+ () => {},
3103
+ )
3104
+ throw error
3105
+ }
3106
+ const before = cursor
3107
+ woken = await store.wakeLog({
3108
+ sessionId: ns,
3109
+ consumer: storeConsumer,
3110
+ })
3111
+ const moved =
3112
+ woken.cursor.head !== before.head ||
3113
+ woken.cursor.attemptCount !== before.attemptCount
3114
+ if (done() || moved) {
3115
+ wait = CONSUME_WAIT_MIN_MS
3116
+ continue
3117
+ }
3118
+ // Another invocation holds the head, or it waits out a failure.
3119
+ const sleep = defaultSleep(wait)
3120
+ const abort = (): void => sleep.cancel()
3121
+ signal?.addEventListener('abort', abort, { once: true })
3122
+ await sleep.promise
3123
+ signal?.removeEventListener('abort', abort)
3124
+ wait = Math.min(wait * 2, CONSUME_WAIT_MAX_MS)
3125
+ }
3126
+ // Work past `through` carries on in the background, as after an append,
3127
+ // and so do the server's consumers of what this consumer's runs appended.
3128
+ if (drains > 0 || (own && woken.cursor.head !== null))
3129
+ scheduleDrain(sessionId)
3130
+ const index =
3131
+ woken.cursor.head === null ? woken.head : woken.cursor.head - 1
3132
+ span.setAttribute('a2.consume.index', index)
3133
+ span.setAttribute('a2.consume.drains', drains)
3134
+ return { index }
3135
+ },
3136
+ )
3137
+ return waiter === undefined || cycle
3138
+ ? consumed
3139
+ : consumed.finally(() => {
3140
+ waits?.delete(waiter)
3141
+ if (waits?.size === 0) consumeWaits.delete(sessionId)
3142
+ })
3143
+ }
3144
+
2647
3145
  const makeSession = <Append>(
2648
3146
  id: string,
2649
3147
  append: Append,
2650
- trigger: Pick<
2651
- StoredEvent,
2652
- 'id' | 'createdAt' | 'index' | 'clearIndex'
2653
- > | null,
3148
+ trigger: HeadEvent | null,
2654
3149
  attempt?: ClearingAttempt,
2655
3150
  ): Session<D, Append, P> =>
2656
3151
  ({
2657
3152
  id,
2658
3153
  append,
3154
+ consume: (defined: Consumer<D>, consumeOptions?: SessionConsumeOptions) =>
3155
+ consumeSession(id, defined, consumeOptions, trigger),
2659
3156
  schedule: makeSchedule(id, trigger),
2660
3157
  clear: (clearOptions?: SessionClearOptions) =>
2661
3158
  clearSession(id, clearOptions, attempt),
@@ -2684,7 +3181,7 @@ export function createServer<
2684
3181
  const bounds: StateReadBounds = {}
2685
3182
  if (through !== undefined && through !== 'latest') {
2686
3183
  bounds.throughIndex = through
2687
- if (trigger) bounds.pinEventIndex = trigger.index
3184
+ if (trigger) bounds.pin = headRefOf(trigger)
2688
3185
  // Only a live attempt may be answered from memory with no store
2689
3186
  // request; a superseded one (including by a clear) reads the store.
2690
3187
  if (through === trigger?.index && !attempt?.signal.aborted)
@@ -2717,42 +3214,60 @@ export function createServer<
2717
3214
 
2718
3215
  // ── drain ────────────────────────────────────────────────────────
2719
3216
 
3217
+ /** The consumer name a trigger's child ids key on, if any. */
3218
+ const idConsumerOf = (trigger: HeadEvent): string | undefined =>
3219
+ registeredConsumers.get(trigger.consumer)?.anonymousIds
3220
+ ? undefined
3221
+ : trigger.consumer
3222
+
2720
3223
  const makeCtx = (
2721
- trigger: StoredEvent,
3224
+ trigger: HeadEvent,
2722
3225
  attempt: number,
2723
3226
  signal: AbortSignal,
2724
3227
  endAttempt: () => void,
2725
- ): HandlerContext<D> => {
3228
+ ): ConsumerContext<D> => {
2726
3229
  const publicSessionId = stripNs(trigger.sessionId)
2727
- const append: HandlerAppend<D> = async (appendName, ...events) => {
3230
+ const idConsumer = idConsumerOf(trigger)
3231
+ const cause: EventCause = {
3232
+ consumer: trigger.consumer,
3233
+ lane: trigger.lane,
3234
+ index: trigger.index,
3235
+ attempt,
3236
+ }
3237
+ const append: ConsumerAppend<D> = async (appendName, ...events) => {
2728
3238
  assertAppendName(appendName)
2729
3239
  const generatedIds = events.map((event) => event.id === undefined)
2730
3240
  const withIds = await Promise.all(
2731
3241
  // oxlint-disable-next-line no-map-spread
2732
3242
  events.map(async (event, item) => {
2733
3243
  if (event.id !== undefined) return event
2734
- const id = await deterministicEventId(trigger.id, appendName, item)
3244
+ const id = await deterministicEventId(
3245
+ trigger.id,
3246
+ appendName,
3247
+ item,
3248
+ idConsumer,
3249
+ )
2735
3250
  return { ...event, id }
2736
3251
  }),
2737
3252
  )
2738
3253
  return appendCore(
2739
3254
  publicSessionId,
2740
3255
  withIds,
2741
- 'handler',
3256
+ 'consumer',
2742
3257
  'inline',
2743
3258
  (_rows, watchdogDueAt) => scheduleDrain(publicSessionId, watchdogDueAt),
2744
- { index: trigger.index, attempt },
3259
+ cause,
2745
3260
  generatedIds,
2746
3261
  )
2747
3262
  }
2748
- const ctx: HandlerContext<D> = {
3263
+ const ctx: ConsumerContext<D> = {
2749
3264
  event: toPublic(trigger),
2750
3265
  attempt,
2751
- // The handler-scoped `setPresence` stamps `seen` with the
2752
- // triggering event's index by default — the frontier the handler
3266
+ // The consumer-scoped `setPresence` stamps `seen` with the
3267
+ // triggering event's index by default — the frontier the consumer
2753
3268
  // provably reflects.
2754
3269
  session: makeSession(publicSessionId, append, trigger, {
2755
- cause: { index: trigger.index, attempt },
3270
+ cause,
2756
3271
  end: endAttempt,
2757
3272
  signal,
2758
3273
  }),
@@ -2858,7 +3373,7 @@ export function createServer<
2858
3373
  const outcome = await Promise.race([
2859
3374
  operation.then(
2860
3375
  (value) => ({ type: 'completed' as const, value }),
2861
- (error: unknown) => ({ type: 'handler-failed' as const, error }),
3376
+ (error: unknown) => ({ type: 'run-failed' as const, error }),
2862
3377
  ),
2863
3378
  hub.watch!.then(() =>
2864
3379
  hub.error === null
@@ -2867,7 +3382,7 @@ export function createServer<
2867
3382
  ),
2868
3383
  ])
2869
3384
  if (outcome.type === 'completed') return outcome.value
2870
- if (outcome.type === 'handler-failed') throw outcome.error
3385
+ if (outcome.type === 'run-failed') throw outcome.error
2871
3386
  if (outcome.type === 'monitor-failed') {
2872
3387
  for (const active of hub.subscriptions) {
2873
3388
  active.controller.abort(outcome.error)
@@ -2876,7 +3391,7 @@ export function createServer<
2876
3391
  throw outcome.error
2877
3392
  }
2878
3393
  await operation.catch(() => {})
2879
- throw new Error('abort monitor closed while handlers were active')
3394
+ throw new Error('abort monitor closed while consumers were active')
2880
3395
  },
2881
3396
  close: async () => {
2882
3397
  hub.subscriptions.delete(subscription)
@@ -2895,18 +3410,22 @@ export function createServer<
2895
3410
 
2896
3411
  const returnedEvents = async (
2897
3412
  sessionId: string,
2898
- trigger: StoredEvent,
2899
- result: void | AppendInput<D> | readonly AppendInput<D>[],
3413
+ trigger: HeadEvent,
3414
+ result: unknown,
2900
3415
  ): Promise<ReturnedEvent[]> => {
2901
3416
  if (result === undefined) return []
2902
- const events = Array.isArray(result) ? [...result] : [result]
3417
+ const events = (
3418
+ Array.isArray(result) ? [...result] : [result]
3419
+ ) as AppendInput<D>[]
2903
3420
  if (events.length === 0) return []
3421
+ const idConsumer = idConsumerOf(trigger)
2904
3422
  const withIds = await Promise.all(
2905
- // oxlint-disable-next-line oxc/no-map-spread -- handler results remain caller-owned
3423
+ // oxlint-disable-next-line oxc/no-map-spread -- consumer results remain caller-owned
2906
3424
  events.map(async (event, item) => {
2907
3425
  const withId = { ...event }
2908
3426
  withId.id =
2909
- event.id ?? (await deterministicReturnedEventId(trigger.id, item))
3427
+ event.id ??
3428
+ (await deterministicReturnedEventId(trigger.id, item, idConsumer))
2910
3429
  return withId
2911
3430
  }),
2912
3431
  )
@@ -2914,6 +3433,8 @@ export function createServer<
2914
3433
  Object.assign(event, {
2915
3434
  id: event.id!,
2916
3435
  cause: {
3436
+ consumer: trigger.consumer,
3437
+ lane: trigger.lane,
2917
3438
  index: trigger.index,
2918
3439
  attempt: trigger.attemptCount,
2919
3440
  },
@@ -2930,18 +3451,37 @@ export function createServer<
2930
3451
  appendsEvents: boolean
2931
3452
  }
2932
3453
 
2933
- const drainSession = (
3454
+ type DrainOptions = {
3455
+ watchdogDueAt?: number | undefined
3456
+ signal?: DrainSignal | undefined
3457
+ /** Return a failure after arming as `failed` instead of throwing it. */
3458
+ handOffFailure?: boolean
3459
+ /** Claim and run only this consumer's heads, for `consume`. */
3460
+ only?: RegisteredConsumer | undefined
3461
+ /** With `only`: claim no head past it; what's past it is the background's. */
3462
+ through?: number | undefined
3463
+ }
3464
+
3465
+ const drainSession = async (
3466
+ sessionId: string,
3467
+ drainOptions: DrainOptions = {},
3468
+ ): Promise<DrainResult> => {
3469
+ const result = await runDrain(sessionId, drainOptions)
3470
+ // A settled `only` drain finished one consumer's heads, not the session's.
3471
+ if (result.settled && drainOptions.only === undefined)
3472
+ drainSettledHooks.get(self)?.(sessionId)
3473
+ return result
3474
+ }
3475
+
3476
+ const runDrain = (
2934
3477
  sessionId: string,
2935
3478
  {
2936
3479
  watchdogDueAt,
2937
3480
  signal,
2938
3481
  handOffFailure = false,
2939
- }: {
2940
- watchdogDueAt?: number | undefined
2941
- signal?: DrainSignal | undefined
2942
- /** Return a failure after arming as `failed` instead of throwing it. */
2943
- handOffFailure?: boolean
2944
- } = {},
3482
+ only,
3483
+ through,
3484
+ }: DrainOptions = {},
2945
3485
  ): Promise<DrainResult> =>
2946
3486
  telemetry.span(
2947
3487
  'a2.drain',
@@ -2951,6 +3491,7 @@ export function createServer<
2951
3491
  const ns = nsId(sessionId)
2952
3492
  const holder = crypto.randomUUID()
2953
3493
  type ActiveEvent = {
3494
+ ref: HeadRef
2954
3495
  attempt: number
2955
3496
  execution: Promise<EventOutcome>
2956
3497
  supersede: AbortController
@@ -2959,9 +3500,15 @@ export function createServer<
2959
3500
  /** Local lease estimate: request time + ttl — at or before the store's. */
2960
3501
  expiresAtMs: number
2961
3502
  lapsed: boolean
3503
+ /**
3504
+ * Its completion is committing. A renewal may then find the cursor
3505
+ * already past the head; completion is the fence, not that report.
3506
+ */
3507
+ completing: boolean
2962
3508
  }
2963
- const active = new Map<number, ActiveEvent>()
2964
- const excluded = new Set<number>()
3509
+ /** Running heads, keyed by `headKey`: one event can run for several consumers. */
3510
+ const active = new Map<string, ActiveEvent>()
3511
+ const excluded = new Map<string, HeadRef>()
2965
3512
  let processed = 0
2966
3513
  let sawFailure = false
2967
3514
  let schedulerArm: Promise<void> | undefined
@@ -2978,13 +3525,13 @@ export function createServer<
2978
3525
  const lapseDue = (): void => {
2979
3526
  lapseTimer = null
2980
3527
  const nowMs = Date.now()
2981
- for (const [index, entry] of active) {
3528
+ for (const entry of active.values()) {
2982
3529
  if (entry.lapsed || entry.expiresAtMs > nowMs) continue
2983
3530
  entry.lapsed = true
2984
3531
  entry.supersede.abort(
2985
3532
  new A2Error(
2986
3533
  'CLAIM_EXPIRED',
2987
- `the claim on event ${index} in session '${sessionId}' lapsed without renewal`,
3534
+ `the claim on event ${entry.ref.index} for consumer '${entry.ref.consumer}' in session '${sessionId}' lapsed without renewal`,
2988
3535
  ),
2989
3536
  )
2990
3537
  }
@@ -3045,8 +3592,8 @@ export function createServer<
3045
3592
  }
3046
3593
  const window = nextClaimWindow()
3047
3594
  requestWatchdog(window.watchdogAtMs)
3048
- const claims = [...active.entries()].map(([index, entry]) => ({
3049
- index,
3595
+ const claims = [...active.values()].map((entry) => ({
3596
+ ...entry.ref,
3050
3597
  attempt: entry.attempt,
3051
3598
  }))
3052
3599
  const operation = store
@@ -3061,20 +3608,20 @@ export function createServer<
3061
3608
  })
3062
3609
  .then(
3063
3610
  (result) => {
3064
- for (const index of result.renewed) {
3065
- const entry = active.get(index)
3611
+ for (const ref of result.renewed) {
3612
+ const entry = active.get(headKey(ref))
3066
3613
  if (entry && !entry.lapsed) {
3067
3614
  entry.expiresAtMs = window.expiresAtMs
3068
3615
  }
3069
3616
  }
3070
- for (const index of result.superseded) {
3071
- const entry = active.get(index)
3072
- if (!entry) continue
3617
+ for (const ref of result.superseded) {
3618
+ const entry = active.get(headKey(ref))
3619
+ if (!entry || entry.completing) continue
3073
3620
  // Idempotent: the local lapse normally aborted this signal
3074
3621
  // already — this is the proof, whose job is the release.
3075
3622
  endSuperseded(
3076
3623
  entry,
3077
- `a newer attempt owns event ${index} in session '${sessionId}'`,
3624
+ `a newer attempt owns event ${ref.index} for consumer '${ref.consumer}' in session '${sessionId}'`,
3078
3625
  )
3079
3626
  }
3080
3627
  if (window.deadlineCapped) stopRenewing = true
@@ -3094,8 +3641,8 @@ export function createServer<
3094
3641
  return tracked
3095
3642
  }
3096
3643
 
3097
- /** Records an event the store dead-lettered on takeover; it never runs. */
3098
- const reportExhausted = (event: StoredEvent): Promise<void> =>
3644
+ /** Records a head the store dead-lettered on takeover; it never runs. */
3645
+ const reportExhausted = (event: HeadEvent): Promise<void> =>
3099
3646
  telemetry.span(
3100
3647
  'a2.event',
3101
3648
  {
@@ -3104,8 +3651,9 @@ export function createServer<
3104
3651
  'a2.event.type': event.type,
3105
3652
  'a2.event.index': event.index,
3106
3653
  'a2.event.id': event.id,
3654
+ 'a2.event.consumer': event.consumer,
3107
3655
  'a2.event.attempt': event.attemptCount,
3108
- 'a2.event.handled': handlers.has(event.type),
3656
+ 'a2.event.handled': registeredConsumers.has(event.consumer),
3109
3657
  ...(event.lane === null ? {} : { 'a2.event.lane': event.lane }),
3110
3658
  },
3111
3659
  async (eventSpan) => {
@@ -3114,11 +3662,17 @@ export function createServer<
3114
3662
  )
3115
3663
 
3116
3664
  const runEvent = async (
3117
- event: StoredEvent,
3665
+ event: HeadEvent,
3118
3666
  supersedeSignal: AbortSignal,
3119
3667
  endAttempt: () => void,
3668
+ beginCompleting: () => void,
3669
+ sessionHead: number | undefined,
3120
3670
  ): Promise<EventOutcome> => {
3121
- const registration = handlers.get(event.type)
3671
+ const registration =
3672
+ only?.name === event.consumer
3673
+ ? only
3674
+ : registeredConsumers.get(event.consumer)
3675
+ const abortOn = registration?.abortOn(event.type) ?? null
3122
3676
  return telemetry.span(
3123
3677
  'a2.event',
3124
3678
  {
@@ -3127,8 +3681,16 @@ export function createServer<
3127
3681
  'a2.event.type': event.type,
3128
3682
  'a2.event.index': event.index,
3129
3683
  'a2.event.id': event.id,
3684
+ 'a2.event.consumer': event.consumer,
3130
3685
  'a2.event.attempt': event.attemptCount,
3131
3686
  'a2.event.handled': registration !== undefined,
3687
+ 'a2.event.age_ms': Math.max(
3688
+ 0,
3689
+ Date.now() - event.createdAt.getTime(),
3690
+ ),
3691
+ ...(sessionHead === undefined
3692
+ ? {}
3693
+ : { 'a2.event.lag': Math.max(0, sessionHead - event.index) }),
3132
3694
  ...(event.lane === null ? {} : { 'a2.event.lane': event.lane }),
3133
3695
  },
3134
3696
  async (eventSpan) => {
@@ -3139,25 +3701,25 @@ export function createServer<
3139
3701
  close(): Promise<void>
3140
3702
  }
3141
3703
  | undefined
3142
- let handlerSignal = supersedeSignal
3704
+ let runSignal = supersedeSignal
3143
3705
  let completion: CompleteAttemptResult
3144
3706
  let completing = false
3145
3707
  try {
3146
- if (registration?.abortOn) {
3708
+ if (abortOn) {
3147
3709
  subscription = await subscribeAbort(
3148
3710
  store,
3149
3711
  ns,
3150
3712
  event,
3151
- registration.abortOn,
3713
+ abortOn,
3152
3714
  event.attemptCount,
3153
3715
  )
3154
- handlerSignal = AbortSignal.any([
3716
+ runSignal = AbortSignal.any([
3155
3717
  supersedeSignal,
3156
3718
  subscription.signal,
3157
3719
  ])
3158
3720
  }
3159
- const operation = registration?.handler(
3160
- makeCtx(event, event.attemptCount, handlerSignal, endAttempt),
3721
+ const operation = registration?.run(
3722
+ makeCtx(event, event.attemptCount, runSignal, endAttempt),
3161
3723
  )
3162
3724
  const result = operation
3163
3725
  ? await (subscription
@@ -3171,6 +3733,7 @@ export function createServer<
3171
3733
  const returned = await returnedEvents(sessionId, event, result)
3172
3734
  await renewal
3173
3735
  completing = true
3736
+ beginCompleting()
3174
3737
  completion = await telemetry.span(
3175
3738
  'a2.commit',
3176
3739
  {
@@ -3183,9 +3746,12 @@ export function createServer<
3183
3746
  async (commitSpan) => {
3184
3747
  const committed = await store.completeAttempt({
3185
3748
  sessionId: ns,
3749
+ consumer: event.consumer,
3750
+ lane: event.lane,
3186
3751
  index: event.index,
3187
3752
  attempt: event.attemptCount,
3188
3753
  events: returned,
3754
+ consumers: storeConsumers,
3189
3755
  })
3190
3756
  commitSpan.setAttribute(
3191
3757
  'a2.commit.outcome',
@@ -3196,9 +3762,9 @@ export function createServer<
3196
3762
  )
3197
3763
  } catch (err) {
3198
3764
  if (supersedeSignal.aborted && err === supersedeSignal.reason) {
3199
- // The handler surrendered to its own abort — identity, not
3765
+ // The consumer surrendered to its own abort — identity, not
3200
3766
  // code, so it cannot arrive from another context. A lapse
3201
- // is not a handler failure: no completion; the claim simply
3767
+ // is not a consumer failure: no completion; the claim simply
3202
3768
  // expires, and the claim that takes it over counts it.
3203
3769
  eventSpan.setAttribute('a2.event.outcome', 'surrendered')
3204
3770
  return {
@@ -3212,7 +3778,7 @@ export function createServer<
3212
3778
  err instanceof A2Error &&
3213
3779
  err.code === 'PARTIAL_DUPLICATE_BATCH'
3214
3780
  // A store that cannot record the completion is not the
3215
- // handler failing: the claim lapses, and the claim that takes
3781
+ // consumer failing: the claim lapses, and the claim that takes
3216
3782
  // it over counts it. A returned batch it refuses is.
3217
3783
  if (completing && !refused) throw err
3218
3784
  // Everything else — including a store fence rejection — is
@@ -3232,6 +3798,8 @@ export function createServer<
3232
3798
  async (commitSpan) => {
3233
3799
  const result = await store.failAttempt({
3234
3800
  sessionId: ns,
3801
+ consumer: event.consumer,
3802
+ lane: event.lane,
3235
3803
  index: event.index,
3236
3804
  attempt: event.attemptCount,
3237
3805
  error: describeError(err),
@@ -3271,7 +3839,7 @@ export function createServer<
3271
3839
  appendsEvents: false,
3272
3840
  }
3273
3841
  }
3274
- if (handlerSignal.aborted) {
3842
+ if (runSignal.aborted) {
3275
3843
  eventSpan.setAttribute('a2.event.aborted', true)
3276
3844
  }
3277
3845
  return {
@@ -3285,16 +3853,22 @@ export function createServer<
3285
3853
 
3286
3854
  let claimAgain = false
3287
3855
 
3288
- const start = (event: StoredEvent, expiresAtMs: number): void => {
3289
- // Re-claiming an index this drain still tracks is durable proof
3856
+ const start = (
3857
+ event: HeadEvent,
3858
+ expiresAtMs: number,
3859
+ sessionHead: number | undefined,
3860
+ ): void => {
3861
+ const ref = headRefOf(event)
3862
+ const key = headKey(ref)
3863
+ // Re-claiming a head this drain still tracks is durable proof
3290
3864
  // that the old attempt lost: our own new claim advanced the
3291
3865
  // ordinal. Fence the old entry before it can be confused with
3292
3866
  // the new one.
3293
- const previous = active.get(event.index)
3867
+ const previous = active.get(key)
3294
3868
  if (previous) {
3295
3869
  endSuperseded(
3296
3870
  previous,
3297
- `a newer attempt owns event ${event.index} in session '${sessionId}'`,
3871
+ `a newer attempt owns event ${event.index} for consumer '${event.consumer}' in session '${sessionId}'`,
3298
3872
  )
3299
3873
  }
3300
3874
  const supersede = new AbortController()
@@ -3302,40 +3876,50 @@ export function createServer<
3302
3876
  const fenced = new Promise<EventOutcome>((resolve) => {
3303
3877
  fence = resolve
3304
3878
  })
3305
- // A handler's own committed clear supersedes it immediately.
3879
+ // A consumer's own committed clear supersedes it immediately.
3306
3880
  const endAttempt = (): void =>
3307
3881
  endSuperseded(
3308
3882
  { supersede, fence },
3309
- `event ${event.index} in session '${sessionId}' was cleared`,
3883
+ `event ${event.index} for consumer '${event.consumer}' in session '${sessionId}' was cleared`,
3310
3884
  )
3311
3885
  // The race lets a durable supersession release this slot while the
3312
- // zombie handler keeps running — its completion and failure are
3886
+ // zombie consumer keeps running — its completion and failure are
3313
3887
  // fenced by the store, so nothing it does from here is counted.
3314
3888
  let entry!: ActiveEvent
3315
3889
  const execution = Promise.race([
3316
- runEvent(event, supersede.signal, endAttempt),
3890
+ runEvent(
3891
+ event,
3892
+ supersede.signal,
3893
+ endAttempt,
3894
+ () => {
3895
+ entry.completing = true
3896
+ },
3897
+ sessionHead,
3898
+ ),
3317
3899
  fenced,
3318
3900
  ]).then((eventOutcome) => {
3319
3901
  // Identity-guarded: a fenced predecessor's continuation must
3320
3902
  // not delete the entry of the attempt that replaced it.
3321
- if (active.get(event.index) === entry) active.delete(event.index)
3903
+ if (active.get(key) === entry) active.delete(key)
3322
3904
  if (eventOutcome.status === 'processed') processed += 1
3323
3905
  if (eventOutcome.status === 'failed') {
3324
- excluded.add(event.index)
3906
+ excluded.set(key, ref)
3325
3907
  sawFailure = true
3326
3908
  }
3327
3909
  claimAgain ||= eventOutcome.opensLane || eventOutcome.appendsEvents
3328
3910
  return eventOutcome
3329
3911
  })
3330
3912
  entry = {
3913
+ ref,
3331
3914
  attempt: event.attemptCount,
3332
3915
  execution,
3333
3916
  supersede,
3334
3917
  fence,
3335
3918
  expiresAtMs,
3336
3919
  lapsed: false,
3920
+ completing: false,
3337
3921
  }
3338
- active.set(event.index, entry)
3922
+ active.set(key, entry)
3339
3923
  scheduleLapse()
3340
3924
  if (!heartbeat && !stopRenewing) {
3341
3925
  heartbeat = setInterval(
@@ -3370,9 +3954,11 @@ export function createServer<
3370
3954
  ...(window.deadlineCapped
3371
3955
  ? { expiresAtMs: window.expiresAtMs }
3372
3956
  : {}),
3957
+ consumers: only ? [storeConsumerOf(only)] : storeConsumers,
3958
+ ...(through === undefined ? {} : { through }),
3373
3959
  ...(excluded.size === 0
3374
3960
  ? {}
3375
- : { excludeIndexes: [...excluded] }),
3961
+ : { exclude: [...excluded.values()] }),
3376
3962
  })
3377
3963
  claimSpan.setAttribute('a2.claim.outcome', result.outcome)
3378
3964
  claimSpan.setAttribute(
@@ -3392,7 +3978,8 @@ export function createServer<
3392
3978
  if (event.failedAt !== null) await reportExhausted(event)
3393
3979
  if (runnable.length > 0) requestWatchdog(window.watchdogAtMs)
3394
3980
  if (window.deadlineCapped) stopRenewing = true
3395
- for (const event of runnable) start(event, window.expiresAtMs)
3981
+ for (const event of runnable)
3982
+ start(event, window.expiresAtMs, claim.head)
3396
3983
  shouldClaim = false
3397
3984
  }
3398
3985
  }
@@ -3438,6 +4025,12 @@ export function createServer<
3438
4025
  }
3439
4026
  if (claim === undefined) continue
3440
4027
  if (signal && signal.version !== signalVersion) continue
4028
+ // A run that finished while the claim was in flight may have
4029
+ // moved its cursor to a head the claim's snapshot missed.
4030
+ if (claimAgain) {
4031
+ claimAgain = false
4032
+ continue
4033
+ }
3441
4034
  if (claim.outcome === 'busy') {
3442
4035
  requestWatchdog(
3443
4036
  claim.retryAt.getTime() + DRAIN_TIMINGS.watchdogGraceMs,
@@ -3512,8 +4105,10 @@ export function createServer<
3512
4105
  }) as SessionAppend<D>
3513
4106
  return Object.assign(makeSession(id, append, null), {
3514
4107
  deadLetters: () => listDeadLetters(id),
3515
- redrive: (index: number, redriveOptions: SessionRedriveOptions) =>
3516
- redriveEvent(id, index, redriveOptions),
4108
+ redrive: (
4109
+ target: { consumer: string; index: number },
4110
+ redriveOptions: SessionRedriveOptions,
4111
+ ) => redriveEvent(id, target, redriveOptions),
3517
4112
  })
3518
4113
  }
3519
4114
 
@@ -3577,7 +4172,7 @@ export function createServer<
3577
4172
  originIndex,
3578
4173
  )
3579
4174
  } catch (error) {
3580
- // The scheduling handler's lifetime was cleared: an acknowledged no-op.
4175
+ // The scheduling consumer's lifetime was cleared: an acknowledged no-op.
3581
4176
  if (
3582
4177
  originIndex !== undefined &&
3583
4178
  error instanceof A2Error &&
@@ -3625,6 +4220,12 @@ function assertStateIndex(value: number): void {
3625
4220
  }
3626
4221
  }
3627
4222
 
4223
+ function assertConsumeIndex(value: number): void {
4224
+ if (!Number.isSafeInteger(value) || value < 0) {
4225
+ throw new TypeError('consume through must be a non-negative safe integer')
4226
+ }
4227
+ }
4228
+
3628
4229
  function assertRedriveIndex(value: number): void {
3629
4230
  if (!Number.isSafeInteger(value) || value < 1) {
3630
4231
  throw new TypeError('redrive requires a positive safe-integer index')
@@ -3639,7 +4240,7 @@ function assertClearIndex(value: number): void {
3639
4240
 
3640
4241
  function assertAppendName(name: string): void {
3641
4242
  if (typeof name !== 'string' || name.length === 0) {
3642
- throw new TypeError('handler append name must be a non-empty string')
4243
+ throw new TypeError('consumer append name must be a non-empty string')
3643
4244
  }
3644
4245
  }
3645
4246