experimental-a2 0.16.1 → 0.17.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 (333) hide show
  1. package/CHANGELOG.md +352 -0
  2. package/dist/{actor-ohPC-81x.d.ts → actor-DBbe5xeE.d.ts} +21 -11
  3. package/dist/actor-DBbe5xeE.d.ts.map +1 -0
  4. package/dist/actor-client.d.ts +2 -5
  5. package/dist/actor-client.d.ts.map +1 -1
  6. package/dist/actor-client.js +13 -4
  7. package/dist/actor-client.js.map +1 -1
  8. package/dist/actor-react.d.ts +5 -3
  9. package/dist/actor-react.d.ts.map +1 -1
  10. package/dist/actor-react.js +20 -10
  11. package/dist/actor-react.js.map +1 -1
  12. package/dist/{actor-shared-B5tJfzt-.js → actor-shared-t7w8xIKP.js} +12 -7
  13. package/dist/actor-shared-t7w8xIKP.js.map +1 -0
  14. package/dist/actor.d.ts +1 -1
  15. package/dist/actor.js +42 -23
  16. package/dist/actor.js.map +1 -1
  17. package/dist/{ai-DgOBltJ_.js → ai-UrWfKeuv.js} +621 -216
  18. package/dist/ai-UrWfKeuv.js.map +1 -0
  19. package/dist/ai-client.d.ts +27 -0
  20. package/dist/ai-client.d.ts.map +1 -0
  21. package/dist/ai-client.js +29 -0
  22. package/dist/ai-client.js.map +1 -0
  23. package/dist/ai-react.d.ts +96 -0
  24. package/dist/ai-react.d.ts.map +1 -0
  25. package/dist/ai-react.js +142 -0
  26. package/dist/ai-react.js.map +1 -0
  27. package/dist/ai-server.browser.js +2 -2
  28. package/dist/ai-server.browser.js.map +1 -1
  29. package/dist/ai-server.d.ts +80 -36
  30. package/dist/ai-server.d.ts.map +1 -1
  31. package/dist/ai-server.js +432 -258
  32. package/dist/ai-server.js.map +1 -1
  33. package/dist/{ai-CrEf6p_W.d.ts → ai-vpGtcaCB.d.ts} +114 -100
  34. package/dist/ai-vpGtcaCB.d.ts.map +1 -0
  35. package/dist/ai.d.ts +2 -2
  36. package/dist/ai.js +2 -2
  37. package/dist/{client-BYzHjkwU.js → client-COaiaDub.js} +447 -80
  38. package/dist/client-COaiaDub.js.map +1 -0
  39. package/dist/{client-CzyacQpJ.d.ts → client-qRnRUzBS.d.ts} +14 -13
  40. package/dist/client-qRnRUzBS.d.ts.map +1 -0
  41. package/dist/client.d.ts +4 -2
  42. package/dist/client.js +1 -1
  43. package/dist/{contract-CKRg_E4q.js → contract-BYKASlCV.js} +12 -21
  44. package/dist/contract-BYKASlCV.js.map +1 -0
  45. package/dist/{idempotent-replay-DVOlyYbx.js → idempotent-replay-JdgMQRRm.js} +2 -2
  46. package/dist/{idempotent-replay-DVOlyYbx.js.map → idempotent-replay-JdgMQRRm.js.map} +1 -1
  47. package/dist/index.d.ts +6 -5
  48. package/dist/index.d.ts.map +1 -1
  49. package/dist/index.js +4 -3
  50. package/dist/{errors-DvhSXnxk.d.ts → initial-snapshot-BrLso5x_.d.ts} +7 -3
  51. package/dist/initial-snapshot-BrLso5x_.d.ts.map +1 -0
  52. package/dist/{internal-Dq2qYxou.js → internal-CbVvEwad.js} +23 -2
  53. package/dist/internal-CbVvEwad.js.map +1 -0
  54. package/dist/otel.d.ts +1 -1
  55. package/dist/react-BbJ-g6Uu.d.ts +86 -0
  56. package/dist/react-BbJ-g6Uu.d.ts.map +1 -0
  57. package/dist/react.d.ts +2 -122
  58. package/dist/react.js +107 -100
  59. package/dist/react.js.map +1 -1
  60. package/dist/reducer-BAXc8JyF.js +87 -0
  61. package/dist/reducer-BAXc8JyF.js.map +1 -0
  62. package/dist/{reducer-otzHjuJj.d.ts → reducer-BYu6DT7h.d.ts} +45 -29
  63. package/dist/reducer-BYu6DT7h.d.ts.map +1 -0
  64. package/dist/reducer-runtime-DQwz_J59.js +28 -0
  65. package/dist/reducer-runtime-DQwz_J59.js.map +1 -0
  66. package/dist/scheduler-qstash.d.ts +3 -3
  67. package/dist/scheduler-qstash.d.ts.map +1 -1
  68. package/dist/scheduler-qstash.js +33 -10
  69. package/dist/scheduler-qstash.js.map +1 -1
  70. package/dist/{scheduler-task-BpzhPnRS.js → scheduler-task-X1SToDjP.js} +18 -3
  71. package/dist/scheduler-task-X1SToDjP.js.map +1 -0
  72. package/dist/scheduler-vercel.d.ts +4 -9
  73. package/dist/scheduler-vercel.d.ts.map +1 -1
  74. package/dist/scheduler-vercel.js +25 -44
  75. package/dist/scheduler-vercel.js.map +1 -1
  76. package/dist/{server-DbD7IJVK.d.ts → server-CGM8WzRG.d.ts} +87 -26
  77. package/dist/server-CGM8WzRG.d.ts.map +1 -0
  78. package/dist/{server-BD5ckxZb.js → server-Rj64fNvX.js} +854 -202
  79. package/dist/server-Rj64fNvX.js.map +1 -0
  80. package/dist/server.d.ts +4 -4
  81. package/dist/server.js +2 -2
  82. package/dist/{errors-DCk6ch5n.js → session-clear-DGI4jTQ5.js} +17 -2
  83. package/dist/session-clear-DGI4jTQ5.js.map +1 -0
  84. package/dist/session-clear-Dshpuhff.d.ts +12 -0
  85. package/dist/session-clear-Dshpuhff.d.ts.map +1 -0
  86. package/dist/{store-DyZM6fS5.d.ts → store-Gpkq7WBo.d.ts} +44 -3
  87. package/dist/store-Gpkq7WBo.d.ts.map +1 -0
  88. package/dist/store-N8PXxDAS.js.map +1 -1
  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 +182 -69
  92. package/dist/store-memory.js.map +1 -1
  93. package/dist/{store-polling-CmxUbV93.js → store-polling-4_y8lETm.js} +21 -4
  94. package/dist/store-polling-4_y8lETm.js.map +1 -0
  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 +399 -108
  98. package/dist/store-postgres.js.map +1 -1
  99. package/dist/{store-presence-polling-C7-XZyW9.js → store-presence-polling-Q1C-k2dd.js} +3 -3
  100. package/dist/{store-presence-polling-C7-XZyW9.js.map → store-presence-polling-Q1C-k2dd.js.map} +1 -1
  101. package/dist/store-redis-http.d.ts +1 -1
  102. package/dist/store-redis-http.d.ts.map +1 -1
  103. package/dist/store-redis-http.js +293 -11
  104. package/dist/store-redis-http.js.map +1 -1
  105. package/dist/store-redis-notify-tqWNnMRu.js +2374 -0
  106. package/dist/store-redis-notify-tqWNnMRu.js.map +1 -0
  107. package/dist/store-redis.d.ts +1 -1
  108. package/dist/store-redis.d.ts.map +1 -1
  109. package/dist/store-redis.js +11 -11
  110. package/dist/store-redis.js.map +1 -1
  111. package/dist/store-sqlite.d.ts +1 -1
  112. package/dist/store-sqlite.d.ts.map +1 -1
  113. package/dist/store-sqlite.js +166 -32
  114. package/dist/store-sqlite.js.map +1 -1
  115. package/dist/stream-buffer-Cx7AAPGq.js +23 -0
  116. package/dist/stream-buffer-Cx7AAPGq.js.map +1 -0
  117. package/dist/{telemetry-B5jzpy6y.d.ts → telemetry-cZH-cibj.d.ts} +2 -2
  118. package/dist/telemetry-cZH-cibj.d.ts.map +1 -0
  119. package/dist/{wire-BO5wWCb1.js → wire-DjfHOvdW.js} +132 -10
  120. package/dist/wire-DjfHOvdW.js.map +1 -0
  121. package/docs/01-quickstart.mdx +2 -2
  122. package/docs/actors/01-introduction.mdx +4 -0
  123. package/docs/actors/04-routes.mdx +14 -1
  124. package/docs/concepts/01-contracts.mdx +95 -0
  125. package/docs/concepts/04-state.mdx +3 -3
  126. package/docs/guides/01-timers.mdx +22 -14
  127. package/docs/guides/02-cancellation.mdx +1 -1
  128. package/docs/guides/03-react.mdx +268 -328
  129. package/docs/guides/04-local-first.mdx +32 -73
  130. package/docs/guides/05-production.mdx +40 -27
  131. package/docs/guides/06-ai-agents.mdx +671 -581
  132. package/docs/guides/09-presence.mdx +3 -7
  133. package/docs/guides/10-transports.mdx +5 -5
  134. package/docs/index.mdx +10 -18
  135. package/docs/reference/01-api.mdx +656 -194
  136. package/docs/reference/02-errors.mdx +2 -1
  137. package/examples/playground/DEPLOY.md +52 -6
  138. package/examples/playground/app/a0/[appId]/loading.tsx +16 -0
  139. package/examples/playground/app/a0/[appId]/page.tsx +48 -0
  140. package/examples/playground/app/a0/a0.css +818 -0
  141. package/examples/playground/app/a0/app-list.tsx +211 -0
  142. package/examples/playground/app/a0/app-view.tsx +55 -0
  143. package/examples/playground/app/a0/apps/[appId]/preview/route.ts +27 -0
  144. package/examples/playground/app/a0/apps/[appId]/route.ts +23 -0
  145. package/examples/playground/app/a0/apps.test.ts +116 -0
  146. package/examples/playground/app/a0/apps.ts +87 -0
  147. package/examples/playground/app/a0/chat.tsx +364 -0
  148. package/examples/playground/app/a0/columns.tsx +109 -0
  149. package/examples/playground/app/a0/context-menu.tsx +116 -0
  150. package/examples/playground/app/a0/cookies.ts +46 -0
  151. package/examples/playground/app/a0/crash-on-request.ts +72 -0
  152. package/examples/playground/app/a0/crashes/route.ts +3 -0
  153. package/examples/playground/app/a0/crashes.test.ts +33 -0
  154. package/examples/playground/app/a0/crashes.ts +28 -0
  155. package/examples/playground/app/a0/devtools.tsx +164 -0
  156. package/examples/playground/app/a0/events/route.ts +6 -0
  157. package/examples/playground/app/a0/flags.ts +30 -0
  158. package/examples/playground/app/a0/layout.tsx +38 -0
  159. package/examples/playground/app/a0/list/route.ts +10 -0
  160. package/examples/playground/app/a0/model.test.ts +56 -0
  161. package/examples/playground/app/a0/model.ts +58 -0
  162. package/examples/playground/app/a0/page.tsx +14 -0
  163. package/examples/playground/app/a0/preview-pane.tsx +145 -0
  164. package/examples/playground/app/a0/preview.test.ts +53 -0
  165. package/examples/playground/app/a0/preview.ts +26 -0
  166. package/examples/playground/app/a0/sandbox-log.ts +69 -0
  167. package/examples/playground/app/a0/sandbox.ts +191 -0
  168. package/examples/playground/app/a0/sandboxes/scheduler/route.ts +6 -0
  169. package/examples/playground/app/a0/scheduler/route.ts +7 -0
  170. package/examples/playground/app/a0/server.ts +158 -0
  171. package/examples/playground/app/a0/session.ts +47 -0
  172. package/examples/playground/app/a0/shell.tsx +150 -0
  173. package/examples/playground/app/a0/short/events/route.ts +9 -0
  174. package/examples/playground/app/a0/splitter.tsx +120 -0
  175. package/examples/playground/app/agent/[agentId]/agent-client.tsx +144 -174
  176. package/examples/playground/app/agent/[agentId]/command-receipt.test.tsx +65 -0
  177. package/examples/playground/app/agent/[agentId]/command-receipt.tsx +27 -0
  178. package/examples/playground/app/agent/[agentId]/compaction-event.tsx +25 -24
  179. package/examples/playground/app/agent/[agentId]/compaction-panel.test.tsx +119 -0
  180. package/examples/playground/app/agent/[agentId]/compaction-panel.tsx +208 -165
  181. package/examples/playground/app/agent/[agentId]/page.tsx +2 -16
  182. package/examples/playground/app/agent/events/route.ts +5 -2
  183. package/examples/playground/app/agent/ingress.test.ts +98 -0
  184. package/examples/playground/app/agent/model-input.test.tsx +58 -0
  185. package/examples/playground/app/agent/model-input.tsx +115 -0
  186. package/examples/playground/app/agent/model.test.ts +73 -0
  187. package/examples/playground/app/agent/model.ts +58 -26
  188. package/examples/playground/app/agent/new-agent-session.tsx +37 -19
  189. package/examples/playground/app/agent/page.tsx +4 -4
  190. package/examples/playground/app/agent/server.ts +15 -14
  191. package/examples/playground/app/agent/session.ts +5 -6
  192. package/examples/playground/app/agent/telemetry.test.ts +168 -0
  193. package/examples/playground/app/agent/telemetry.ts +30 -0
  194. package/examples/playground/app/agent/trace.test.ts +23 -0
  195. package/examples/playground/app/agent/trace.ts +18 -0
  196. package/examples/playground/app/canvas/[canvasId]/canvas-client.tsx +11 -11
  197. package/examples/playground/app/canvas/[canvasId]/page.tsx +1 -2
  198. package/examples/playground/app/canvas/[canvasId]/session.ts +2 -2
  199. package/examples/playground/app/canvas/model.ts +53 -53
  200. package/examples/playground/app/chat/[chatId]/agent-state.test.ts +176 -0
  201. package/examples/playground/app/chat/[chatId]/agent-stream-drawer.tsx +16 -1
  202. package/examples/playground/app/chat/[chatId]/chat-client.tsx +55 -77
  203. package/examples/playground/app/chat/[chatId]/chat-view.ts +37 -3
  204. package/examples/playground/app/chat/[chatId]/page.tsx +7 -15
  205. package/examples/playground/app/chat/[chatId]/session.ts +2 -19
  206. package/examples/playground/app/chat/agent/route.ts +9 -0
  207. package/examples/playground/app/chat/model.test.ts +46 -119
  208. package/examples/playground/app/chat/model.ts +82 -128
  209. package/examples/playground/app/chat/scheduler/route.ts +2 -2
  210. package/examples/playground/app/chat/server.test.ts +108 -0
  211. package/examples/playground/app/chat/server.ts +69 -73
  212. package/examples/playground/app/components/activity-feed.tsx +30 -1
  213. package/examples/playground/app/components/activity-history.test.tsx +35 -0
  214. package/examples/playground/app/components/demo-icons.tsx +89 -0
  215. package/examples/playground/app/components/demos.tsx +33 -0
  216. package/examples/playground/app/components/fast-link.test.tsx +58 -0
  217. package/examples/playground/app/components/fast-link.tsx +55 -0
  218. package/examples/playground/app/components/site-header.tsx +96 -0
  219. package/examples/playground/app/counter/counter-client.tsx +3 -3
  220. package/examples/playground/app/counter/model.test.ts +3 -3
  221. package/examples/playground/app/counter/model.ts +11 -21
  222. package/examples/playground/app/counter/page.tsx +2 -15
  223. package/examples/playground/app/counter/session.ts +2 -2
  224. package/examples/playground/app/documents/[documentId]/code-editor.tsx +12 -1
  225. package/examples/playground/app/documents/[documentId]/document-client.tsx +10 -11
  226. package/examples/playground/app/documents/[documentId]/page.tsx +1 -2
  227. package/examples/playground/app/documents/[documentId]/session.ts +2 -2
  228. package/examples/playground/app/documents/model.ts +27 -27
  229. package/examples/playground/app/globals.css +420 -207
  230. package/examples/playground/app/initial-state.test.tsx +54 -0
  231. package/examples/playground/app/layout.tsx +7 -23
  232. package/examples/playground/app/orders/[orderId]/page.tsx +1 -2
  233. package/examples/playground/app/orders/[orderId]/session.ts +2 -2
  234. package/examples/playground/app/orders/model.ts +49 -49
  235. package/examples/playground/app/page.tsx +13 -102
  236. package/examples/playground/app/recovery/[recoveryId]/page.tsx +1 -2
  237. package/examples/playground/app/recovery/[recoveryId]/session.ts +2 -2
  238. package/examples/playground/app/recovery/model.ts +30 -30
  239. package/examples/playground/instrumentation.ts +5 -0
  240. package/examples/playground/package.json +10 -2
  241. package/examples/playground/scripts/a0-template.ts +77 -0
  242. package/examples/playground/vercel.json +18 -0
  243. package/package.json +3 -1
  244. package/src/actor-client.ts +16 -4
  245. package/src/actor-react.ts +25 -11
  246. package/src/actor-shared.ts +21 -6
  247. package/src/actor.ts +76 -31
  248. package/src/ai-agent-actions.ts +219 -0
  249. package/src/ai-agent.ts +2343 -0
  250. package/src/ai-app-state.ts +91 -0
  251. package/src/ai-client-state.ts +6 -3
  252. package/src/ai-client.ts +62 -0
  253. package/src/ai-context-schema.ts +25 -5
  254. package/src/ai-context.ts +90 -40
  255. package/src/ai-control-server.ts +92 -72
  256. package/src/ai-coordinator.ts +23 -22
  257. package/src/ai-message-base.ts +209 -0
  258. package/src/ai-message-projection.ts +262 -64
  259. package/src/ai-progress-batches.ts +26 -0
  260. package/src/ai-projector.ts +95 -14
  261. package/src/ai-react.ts +146 -0
  262. package/src/ai-server.browser.ts +2 -2
  263. package/src/ai-server.ts +20 -1964
  264. package/src/ai-stored-state.ts +48 -163
  265. package/src/ai.ts +339 -155
  266. package/src/bootstrap-stream.ts +72 -0
  267. package/src/bounded-cache.ts +55 -0
  268. package/src/client-render-session.ts +49 -0
  269. package/src/client.ts +688 -151
  270. package/src/contract.ts +92 -42
  271. package/src/deterministic-id.ts +37 -0
  272. package/src/equal-json.ts +32 -0
  273. package/src/errors.ts +13 -1
  274. package/src/index.ts +5 -1
  275. package/src/initial-snapshot.ts +39 -0
  276. package/src/internal.ts +45 -0
  277. package/src/postgres-notification-scope.ts +14 -3
  278. package/src/react.ts +171 -206
  279. package/src/redis-http-reply.ts +256 -0
  280. package/src/redis-http-stream.ts +73 -0
  281. package/src/redis-http-subscriptions.ts +13 -5
  282. package/src/reducer-runtime.ts +52 -0
  283. package/src/reducer.ts +92 -10
  284. package/src/scheduler-qstash.ts +49 -20
  285. package/src/scheduler-task.ts +34 -2
  286. package/src/scheduler-vercel.ts +32 -84
  287. package/src/server-fetch.ts +139 -35
  288. package/src/server.ts +957 -237
  289. package/src/session-clear.ts +23 -0
  290. package/src/session-socket.ts +154 -49
  291. package/src/sse.ts +71 -4
  292. package/src/store-memory.ts +220 -78
  293. package/src/store-polling.ts +18 -4
  294. package/src/store-postgres.ts +461 -114
  295. package/src/store-redis-core.ts +1260 -434
  296. package/src/store-redis-http.ts +18 -2
  297. package/src/store-redis-notify.ts +260 -85
  298. package/src/store-redis.ts +19 -9
  299. package/src/store-sqlite.ts +259 -45
  300. package/src/store.ts +46 -1
  301. package/src/stream-buffer.ts +43 -0
  302. package/src/telemetry.ts +13 -1
  303. package/src/wire.ts +241 -13
  304. package/dist/actor-ohPC-81x.d.ts.map +0 -1
  305. package/dist/actor-shared-B5tJfzt-.js.map +0 -1
  306. package/dist/ai-CrEf6p_W.d.ts.map +0 -1
  307. package/dist/ai-DgOBltJ_.js.map +0 -1
  308. package/dist/client-BYzHjkwU.js.map +0 -1
  309. package/dist/client-CzyacQpJ.d.ts.map +0 -1
  310. package/dist/contract-CKRg_E4q.js.map +0 -1
  311. package/dist/errors-DCk6ch5n.js.map +0 -1
  312. package/dist/errors-DvhSXnxk.d.ts.map +0 -1
  313. package/dist/internal-Dq2qYxou.js.map +0 -1
  314. package/dist/react.d.ts.map +0 -1
  315. package/dist/reducer-DEMjEY_O.js +0 -29
  316. package/dist/reducer-DEMjEY_O.js.map +0 -1
  317. package/dist/reducer-otzHjuJj.d.ts.map +0 -1
  318. package/dist/scheduler-task-BpzhPnRS.js.map +0 -1
  319. package/dist/server-BD5ckxZb.js.map +0 -1
  320. package/dist/server-DbD7IJVK.d.ts.map +0 -1
  321. package/dist/store-DyZM6fS5.d.ts.map +0 -1
  322. package/dist/store-polling-CmxUbV93.js.map +0 -1
  323. package/dist/store-redis-notify-BVLUfI3j.js +0 -1583
  324. package/dist/store-redis-notify-BVLUfI3j.js.map +0 -1
  325. package/dist/telemetry-B5jzpy6y.d.ts.map +0 -1
  326. package/dist/validate-XKT4FSNn.js +0 -30
  327. package/dist/validate-XKT4FSNn.js.map +0 -1
  328. package/dist/wire-BO5wWCb1.js.map +0 -1
  329. package/examples/playground/app/agent/[agentId]/compaction/route.ts +0 -14
  330. package/examples/playground/app/agent/compaction-settings.test.ts +0 -144
  331. package/examples/playground/app/agent/compaction-settings.ts +0 -49
  332. package/examples/playground/app/agent/compaction-timeline.test.ts +0 -377
  333. package/examples/playground/app/agent/compaction-timeline.ts +0 -208
package/src/server.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { decodeState, encodeState } from './reducer-runtime.ts'
1
2
  // oxlint-disable no-await-in-loop -- scheduler advances after durable outcomes
2
3
  /**
3
4
  * experimental-a2/server — where a contract is implemented. `createServer({
@@ -25,27 +26,52 @@ import {
25
26
  POLL_TIMINGS,
26
27
  MAX_DATE_MS,
27
28
  RESERVED_PARTICIPANT_IDS,
29
+ STATE_CACHE,
28
30
  markSchedulerSendFailure,
31
+ stateTailReaders,
29
32
  nullProtoRecord,
30
33
  serverInternals,
31
34
  type DrainOutcome,
32
35
  type DrainResult,
33
36
  } from './internal.ts'
37
+ import {
38
+ isSessionCleared,
39
+ sessionClearedError,
40
+ type SessionCleared,
41
+ } from './session-clear.ts'
42
+ import { boundedCache } from './bounded-cache.ts'
34
43
  import { defaultSleep, type Sleeper } from './store-polling.ts'
35
44
  import { createStreamActivity, streamActivities } from './stream-activity.ts'
45
+ import {
46
+ bufferedEvents,
47
+ isSerializedEvent,
48
+ takeBuffered,
49
+ } from './stream-buffer.ts'
36
50
  import type {
37
51
  A2Store,
38
52
  AppendEvent,
39
53
  Event,
40
54
  EventCause,
41
55
  StoreClaimAvailableResult,
56
+ StoreClearResult,
57
+ StoreReclaimResult,
42
58
  StoreSnapshotWrite,
43
59
  StoreStateRead,
44
60
  PresenceRow,
45
61
  ReturnedEvent,
46
62
  StoredEvent,
47
63
  } from './store.ts'
48
- import type { Reducer } from './reducer.ts'
64
+ import {
65
+ cloneInitial,
66
+ foldEvent,
67
+ type Reducer,
68
+ type RegisteredReducer,
69
+ } from './reducer.ts'
70
+ type PublicState<R> = undefined extends R
71
+ ? never
72
+ : R extends { initialState: infer S }
73
+ ? S
74
+ : never
49
75
  import {
50
76
  createServerFetch,
51
77
  type A2Operation,
@@ -59,6 +85,7 @@ import {
59
85
  deterministicEventId,
60
86
  deterministicReturnedEventId,
61
87
  deterministicScheduledEventId,
88
+ deterministicScheduleClearId,
62
89
  deterministicScheduleId,
63
90
  } from './deterministic-id.ts'
64
91
  import { invocationDeadlineMs, platformWaitUntil } from './platform.ts'
@@ -66,6 +93,7 @@ import { retryableLazy } from './retryable-lazy.ts'
66
93
  import type {
67
94
  ScheduledEvent,
68
95
  SchedulerAppendTask,
96
+ SchedulerClearTask,
69
97
  SchedulerTask,
70
98
  } from './scheduler-task.ts'
71
99
  import {
@@ -79,13 +107,14 @@ export type HandlerContext<
79
107
  D extends EventDefs,
80
108
  K extends keyof D & string = keyof D & string,
81
109
  P extends PresenceDefs = Record<never, never>,
110
+ S = never,
82
111
  > = {
83
112
  /** The triggering event. */
84
113
  event: ContractEvent<D, K>
85
114
  /** Durable, 1-based dispatch ordinal for this event. */
86
115
  attempt: number
87
116
  /** This session, with handler-scoped idempotent append. */
88
- session: Session<D, HandlerAppend<D>, P>
117
+ session: Session<D, HandlerAppend<D>, P, S>
89
118
  /** Fires on `abortOn` events (cancellation slice); dormant otherwise. */
90
119
  signal: AbortSignal
91
120
  }
@@ -94,8 +123,9 @@ export type Handler<
94
123
  D extends EventDefs,
95
124
  K extends keyof D & string = keyof D & string,
96
125
  P extends PresenceDefs = Record<never, never>,
126
+ S = never,
97
127
  > = (
98
- ctx: HandlerContext<D, K, P>,
128
+ ctx: HandlerContext<D, K, P, S>,
99
129
  ) => Promise<void | AppendInput<D> | readonly AppendInput<D>[]>
100
130
 
101
131
  export type LaneContext<
@@ -130,16 +160,34 @@ export type ScheduleDelay = `${number}${'ms' | 's' | 'm' | 'h' | 'd'}`
130
160
  export type ScheduleTiming =
131
161
  { delay: ScheduleDelay; at?: never } | { at: Date; delay?: never }
132
162
 
133
- export type SessionSchedule<D extends EventDefs> = (
134
- name: string,
135
- timing: ScheduleTiming,
136
- ...events: AppendInput<D>[]
137
- ) => Promise<void>
163
+ export type SessionSchedule<D extends EventDefs> = {
164
+ (
165
+ name: string,
166
+ timing: ScheduleTiming,
167
+ ...events: AppendInput<D>[]
168
+ ): Promise<void>
169
+ /**
170
+ * Publish a durable conditional clear. Delivery clears only while
171
+ * `ifLastIndex` is still the head, and finishes deleting before it
172
+ * acknowledges.
173
+ */
174
+ clear(
175
+ name: string,
176
+ options: ScheduleTiming & { ifLastIndex: number },
177
+ ): Promise<void>
178
+ }
138
179
 
139
180
  export type StateOptions = {
140
181
  through?: number | 'latest'
141
182
  }
142
183
 
184
+ export type SessionClearOptions = {
185
+ /** Clear only while this is the head; a mismatch changes nothing. */
186
+ ifLastIndex?: number
187
+ }
188
+
189
+ export type SessionClearResult = { cleared: boolean; index: number }
190
+
143
191
  /**
144
192
  * The presence members of a session — intersected in via
145
193
  * `WithPresence`, so they exist exactly when the contract declares
@@ -156,7 +204,9 @@ export type SessionPresence<D extends EventDefs, P extends PresenceDefs> = {
156
204
  stream(opts: {
157
205
  startAfter?: number
158
206
  presence: true
159
- }): AsyncIterable<ContractEvent<D> | PresencePatch<P> | PresenceSnapshot<P>>
207
+ }): AsyncIterable<
208
+ ContractEvent<D> | SessionCleared | PresencePatch<P> | PresenceSnapshot<P>
209
+ >
160
210
  /**
161
211
  * Validate against the contract's presence schemas, then broadcast.
162
212
  * Never an append: no log row, no dispatch, no scheduler arm. `at`
@@ -178,20 +228,33 @@ export type Session<
178
228
  D extends EventDefs,
179
229
  Append = SessionAppend<D>,
180
230
  P extends PresenceDefs = Record<never, never>,
231
+ S = never,
181
232
  > = WithPresence<P, SessionPresence<D, P>> & {
182
233
  readonly id: string
183
234
  append: Append
184
235
  schedule: SessionSchedule<D>
236
+ /**
237
+ * End the current lifetime: atomically append a clear marker, then
238
+ * delete the history below it. Indexes never restart.
239
+ */
240
+ clear(options?: SessionClearOptions): Promise<SessionClearResult>
185
241
  history(options?: { gte?: number; lte?: number }): Promise<ContractEvent<D>[]>
186
- state<S, V = S>(
187
- reducer: Reducer<D, S, Record<never, never>, V>,
188
- options?: StateOptions,
189
- ): Promise<{ state: S; index: number }>
242
+ state: {
243
+ <T>(
244
+ reducer: Reducer<D, T, Record<never, never>>,
245
+ options?: StateOptions,
246
+ ): Promise<{ state: T; index: number; clearIndex?: number }>
247
+ } & ([S] extends [never]
248
+ ? unknown
249
+ : { (): Promise<{ state: S; index: number; clearIndex?: number }> })
190
250
  /**
191
251
  * A live feed of this session's events, starting after `startAfter`
192
252
  * (exclusive). Server-side only. `server.fetch` exposes it over SSE.
253
+ * A clear arrives as a `SessionCleared` marker.
193
254
  */
194
- stream(opts?: { startAfter?: number }): AsyncIterable<ContractEvent<D>>
255
+ stream(opts?: {
256
+ startAfter?: number
257
+ }): AsyncIterable<ContractEvent<D> | SessionCleared>
195
258
  }
196
259
 
197
260
  /**
@@ -221,14 +284,16 @@ export type A2Scheduler = {
221
284
  export type A2Server<
222
285
  D extends EventDefs,
223
286
  P extends PresenceDefs = Record<never, never>,
287
+ R extends RegisteredReducer<D, P> | undefined =
288
+ RegisteredReducer<D, P> | undefined,
224
289
  > = {
225
290
  /** The contract this server implements. */
226
- readonly contract: Contract<D, P>
291
+ readonly contract: Contract<D, P, R>
227
292
  readonly fetch: {
228
293
  (request: Request): Promise<Response>
229
294
  (request: Request, options: ServerFetchOptions<D, P>): Promise<Response>
230
295
  }
231
- session(id: string): Session<D, SessionAppend<D>, P>
296
+ session(id: string): Session<D, SessionAppend<D>, P, PublicState<R>>
232
297
  /**
233
298
  * Process every currently eligible event. `settled` means nothing
234
299
  * actionable remains, including work blocked behind a dead letter.
@@ -259,13 +324,58 @@ export async function deliverSchedulerAppend(
259
324
  `a2 scheduler: append task for contract '${task.contract}' cannot be delivered to '${server.contract.name}'`,
260
325
  )
261
326
  }
327
+ const { originIndex } = task
328
+ // A malformed origin would read as cleared and drop the events silently.
329
+ if (
330
+ originIndex !== undefined &&
331
+ (!Number.isSafeInteger(originIndex) || originIndex < 1)
332
+ ) {
333
+ throw new TypeError(
334
+ 'a2 scheduler: append task originIndex must be a positive safe integer',
335
+ )
336
+ }
262
337
  const internals = serverInternals.get(server)
263
338
  if (!internals) {
264
339
  throw new TypeError(
265
340
  `a2 scheduler: server for contract '${task.contract}' cannot receive scheduled appends`,
266
341
  )
267
342
  }
268
- await internals.schedulerAppend(task.sessionId, task.events)
343
+ await internals.schedulerAppend(task.sessionId, task.events, originIndex)
344
+ }
345
+
346
+ /**
347
+ * Deliver one authenticated scheduler clear: a conditional
348
+ * `clear({ ifLastIndex })` that resolves after its deletion finishes, so a
349
+ * provider retry resumes an interrupted deletion. Custom scheduler adapters
350
+ * call this after validating their transport envelope; application code uses
351
+ * `session.schedule.clear`.
352
+ */
353
+ export async function deliverSchedulerClear(
354
+ server: DrainableServer,
355
+ task: SchedulerClearTask,
356
+ ): Promise<void> {
357
+ if (server.contract.name !== task.contract) {
358
+ throw new TypeError(
359
+ `a2 scheduler: clear task for contract '${task.contract}' cannot be delivered to '${server.contract.name}'`,
360
+ )
361
+ }
362
+ // Without its condition a clear task would erase the whole session.
363
+ if (
364
+ task.kind !== 'clear' ||
365
+ !Number.isSafeInteger(task.ifLastIndex) ||
366
+ task.ifLastIndex < 0
367
+ ) {
368
+ throw new TypeError(
369
+ 'a2 scheduler: a clear task needs kind "clear" and a non-negative safe integer ifLastIndex',
370
+ )
371
+ }
372
+ const internals = serverInternals.get(server)
373
+ if (!internals) {
374
+ throw new TypeError(
375
+ `a2 scheduler: server for contract '${task.contract}' cannot receive scheduled clears`,
376
+ )
377
+ }
378
+ await internals.schedulerClear(task.sessionId, task.ifLastIndex)
269
379
  }
270
380
 
271
381
  /**
@@ -292,13 +402,14 @@ export type HandlerEntry<
292
402
  D extends EventDefs,
293
403
  K extends keyof D & string = keyof D & string,
294
404
  P extends PresenceDefs = Record<never, never>,
405
+ S = never,
295
406
  > =
296
- | Handler<D, K, P>
407
+ | Handler<D, K, P, S>
297
408
  | {
298
409
  abortOn?: AbortSpec<D, K>
299
410
  /** Session-scoped FIFO key, resolved and persisted when the event lands. */
300
411
  lane?: Lane<D, K>
301
- handler: Handler<D, K, P>
412
+ handler: Handler<D, K, P, S>
302
413
  }
303
414
 
304
415
  type HandlerFunction = (ctx: never) => Promise<unknown>
@@ -315,9 +426,11 @@ export function handler(
315
426
  export type ServerOptions<
316
427
  D extends EventDefs,
317
428
  P extends PresenceDefs = Record<never, never>,
429
+ R extends RegisteredReducer<D, P> | undefined =
430
+ RegisteredReducer<D, P> | undefined,
318
431
  > = {
319
432
  /** The contract this server implements (see `a2.contract`). */
320
- contract: Contract<D, P>
433
+ contract: Contract<D, P, R>
321
434
  /** Where events live. Defaults: sqlite in dev, memory in tests, required in prod. */
322
435
  store?: A2Store
323
436
  /**
@@ -341,11 +454,31 @@ export type ServerOptions<
341
454
  * wasn't imported. Compose across files by spreading objects (note:
342
455
  * a duplicate key under spread silently last-wins).
343
456
  */
344
- handlers?: { [K in keyof D & string]?: HandlerEntry<D, K, P> }
457
+ handlers?: { [K in keyof D & string]?: HandlerEntry<D, K, P, PublicState<R>> }
345
458
  }
346
459
 
347
460
  const MAX_FAILURES = 10
348
461
 
462
+ /** Abort a lost attempt's handler and release its drain slot. */
463
+ function endSuperseded(
464
+ entry: {
465
+ supersede: AbortController
466
+ fence: (outcome: {
467
+ status: 'superseded'
468
+ opensLane: false
469
+ appendsEvents: false
470
+ }) => void
471
+ },
472
+ message: string,
473
+ ): void {
474
+ entry.supersede.abort(new A2Error('SUPERSEDED_ATTEMPT', message))
475
+ entry.fence({ status: 'superseded', opensLane: false, appendsEvents: false })
476
+ }
477
+
478
+ /** Pause between clear-deletion batches that found only locked rows. */
479
+ const RECLAIM_STALL_MS = 20
480
+ const RECLAIM_STALL_LIMIT = 50
481
+
349
482
  /** Default: how long a presence value survives without a refreshing set. */
350
483
  const PRESENCE_TTL_MS = 60_000
351
484
 
@@ -511,11 +644,32 @@ function foldPresenceRows(
511
644
  return map
512
645
  }
513
646
 
647
+ type SnapshotWrites = Map<
648
+ number,
649
+ { state: unknown; pinEventIndexes: Set<number> }
650
+ >
651
+
652
+ const addSnapshotWrite = (
653
+ writes: SnapshotWrites,
654
+ index: number,
655
+ state: unknown,
656
+ pinEventIndex?: number,
657
+ ): void => {
658
+ let write = writes.get(index)
659
+ if (!write) {
660
+ write = { state: cloneInitial(state), pinEventIndexes: new Set() }
661
+ writes.set(index, write)
662
+ }
663
+ if (pinEventIndex !== undefined) write.pinEventIndexes.add(pinEventIndex)
664
+ }
665
+
514
666
  /** Implement a contract: bind its vocabulary to storage and reactions. */
515
667
  export function createServer<
516
668
  D extends EventDefs,
517
669
  P extends PresenceDefs = Record<never, never>,
518
- >(options: ServerOptions<D, P>): A2Server<D, P> {
670
+ R extends RegisteredReducer<D, P> | undefined =
671
+ RegisteredReducer<D, P> | undefined,
672
+ >(options: ServerOptions<D, P, R>): A2Server<D, P, R> {
519
673
  const serverContract = options?.contract
520
674
  if (
521
675
  serverContract === null ||
@@ -855,6 +1009,7 @@ export function createServer<
855
1009
  cause?: EventCause,
856
1010
  generatedIds?: readonly boolean[],
857
1011
  requireDurableInitialArm = false,
1012
+ originIndex?: number,
858
1013
  ): Promise<ContractEvent<D>[]> =>
859
1014
  telemetry.span(
860
1015
  'a2.append',
@@ -885,9 +1040,20 @@ export function createServer<
885
1040
  let rows: StoredEvent[]
886
1041
  let shouldHealSession: boolean
887
1042
  try {
888
- const result = await store.append(nsId(sessionId), validated)
1043
+ const result = await store.append(
1044
+ nsId(sessionId),
1045
+ validated,
1046
+ originIndex === undefined ? undefined : { originIndex },
1047
+ )
889
1048
  rows = result.events
890
- shouldHealSession = result.hasPending
1049
+ // A handler appending only settled events adds no work, and the
1050
+ // drain holding its trigger is already running.
1051
+ shouldHealSession =
1052
+ result.hasPending &&
1053
+ !(
1054
+ source === 'handler' &&
1055
+ validated.every((event) => event.settled === true)
1056
+ )
891
1057
  } catch (err) {
892
1058
  throw asStoreUnavailable(err)
893
1059
  }
@@ -1005,12 +1171,18 @@ export function createServer<
1005
1171
  }
1006
1172
  }
1007
1173
 
1174
+ type StateResult = { state: unknown; index: number; clearIndex?: number }
1175
+
1008
1176
  type PendingStateRead = {
1009
1177
  sessionId: string
1010
1178
  throughIndex?: number
1179
+ /** The id of the event at `throughIndex`, when the reader knows it. */
1180
+ throughEventId?: string
1011
1181
  pinEventIndex?: number
1182
+ /** A handler read: rejected once this trigger index has been cleared. */
1183
+ triggerIndex?: number
1012
1184
  span: A2SpanHandle
1013
- resolve: (result: { state: unknown; index: number }) => void
1185
+ resolve: (result: StateResult) => void
1014
1186
  reject: (reason: unknown) => void
1015
1187
  }
1016
1188
 
@@ -1045,6 +1217,331 @@ export function createServer<
1045
1217
  return plans
1046
1218
  }
1047
1219
 
1220
+ const persistSnapshots = ({
1221
+ store,
1222
+ reducer,
1223
+ sessionId,
1224
+ snapshots,
1225
+ }: {
1226
+ store: A2Store
1227
+ reducer: Reducer<D, unknown>
1228
+ sessionId: string
1229
+ snapshots: StoreSnapshotWrite[]
1230
+ }): void => {
1231
+ const persistence = Promise.resolve()
1232
+ .then(() =>
1233
+ telemetry.span(
1234
+ 'a2.snapshot',
1235
+ {
1236
+ 'a2.contract': name,
1237
+ 'a2.session_id': sessionId,
1238
+ 'a2.snapshot.reducer': reducer.name,
1239
+ 'a2.snapshot.index': snapshots.at(-1)!.index,
1240
+ 'a2.snapshot.count': snapshots.length,
1241
+ },
1242
+ async (span) => {
1243
+ try {
1244
+ await store.putSnapshots(nsId(sessionId), reducer.name, snapshots)
1245
+ } catch (error) {
1246
+ try {
1247
+ span.recordError(error)
1248
+ } catch (telemetryError) {
1249
+ // oxlint-disable-next-line preserve-caught-error -- AggregateError preserves both failures.
1250
+ throw new AggregateError(
1251
+ [error, telemetryError],
1252
+ 'Snapshot write and error reporting failed',
1253
+ { cause: telemetryError },
1254
+ )
1255
+ }
1256
+ }
1257
+ },
1258
+ ),
1259
+ )
1260
+ .catch((error: unknown) => {
1261
+ // oxlint-disable-next-line no-console -- telemetry failure must not hide failed background work.
1262
+ console.error(
1263
+ '[a2] error in a2.snapshot telemetry',
1264
+ {
1265
+ sessionId,
1266
+ reducer: reducer.name,
1267
+ index: snapshots.at(-1)!.index,
1268
+ },
1269
+ error,
1270
+ )
1271
+ })
1272
+ track(persistence)
1273
+ platformWaitUntil(persistence)
1274
+ }
1275
+
1276
+ // ── in-memory reducer state ──────────────────────────────────────
1277
+ /**
1278
+ * The latest folded state per (session, reducer) this process has seen,
1279
+ * with the index and id of the last event it reflects. An entry serves
1280
+ * only the reducer object that folded it, so a same-named reducer with
1281
+ * different logic reads the store. Cached states are never handed out;
1282
+ * readers get clones.
1283
+ */
1284
+ type CachedState = {
1285
+ reducer: Reducer<D, unknown>
1286
+ index: number
1287
+ lastId: string
1288
+ state: unknown
1289
+ bytes: number
1290
+ /** Events folded since `bytes` was last measured. */
1291
+ unmeasured: number
1292
+ persistedIndex: number
1293
+ /** The latest clear marker at or below `index`, or zero. */
1294
+ clearIndex: number
1295
+ }
1296
+ const stateCache = boundedCache<string, CachedState>(STATE_CACHE)
1297
+ const cacheState = (key: string, next: CachedState): void => {
1298
+ const current = stateCache.get(key)
1299
+ if (
1300
+ current?.reducer === next.reducer &&
1301
+ (current.index > next.index ||
1302
+ (current.index === next.index &&
1303
+ current.persistedIndex >= next.persistedIndex))
1304
+ )
1305
+ return
1306
+ stateCache.set(key, next, next.bytes)
1307
+ }
1308
+
1309
+ const writeSnapshots = ({
1310
+ store,
1311
+ reducer,
1312
+ sessionId,
1313
+ writes,
1314
+ }: {
1315
+ store: A2Store
1316
+ reducer: Reducer<D, unknown>
1317
+ sessionId: string
1318
+ writes: SnapshotWrites
1319
+ }): StoreSnapshotWrite[] => {
1320
+ const snapshots = [...writes]
1321
+ .toSorted(([a], [b]) => a - b)
1322
+ .map(([index, write]) => {
1323
+ const snapshot: StoreSnapshotWrite = {
1324
+ index,
1325
+ state: encodeState({ reducer, state: write.state }),
1326
+ }
1327
+ if (write.pinEventIndexes.size > 0) {
1328
+ snapshot.pinEventIndexes = [...write.pinEventIndexes]
1329
+ }
1330
+ return snapshot
1331
+ })
1332
+ if (snapshots.length > 0) {
1333
+ persistSnapshots({ store, reducer, sessionId, snapshots })
1334
+ }
1335
+ return snapshots
1336
+ }
1337
+
1338
+ /**
1339
+ * Folds `rows` from `position` in bound order, resolving each entry with a
1340
+ * clone once the rows through its bound are folded. `visit` sees each
1341
+ * entry's state before later rows are folded into it.
1342
+ */
1343
+ const foldEntries = ({
1344
+ reducer,
1345
+ entries,
1346
+ rows,
1347
+ position,
1348
+ state,
1349
+ index,
1350
+ clearIndex,
1351
+ snapshot,
1352
+ visit,
1353
+ }: {
1354
+ reducer: Reducer<D, unknown>
1355
+ entries: PendingStateRead[]
1356
+ rows: Event[]
1357
+ position: number
1358
+ state: unknown
1359
+ index: number
1360
+ /** The latest clear marker the rows reflect, or zero. */
1361
+ clearIndex: number
1362
+ snapshot: string
1363
+ visit: (
1364
+ entry: PendingStateRead,
1365
+ state: unknown,
1366
+ index: number,
1367
+ folded: number,
1368
+ ) => void
1369
+ }): { state: unknown; index: number; position: number } => {
1370
+ const start = position
1371
+ const ordered = entries.toSorted(
1372
+ (a, b) =>
1373
+ (a.throughIndex ?? Number.POSITIVE_INFINITY) -
1374
+ (b.throughIndex ?? Number.POSITIVE_INFINITY),
1375
+ )
1376
+ for (const entry of ordered) {
1377
+ const target = entry.throughIndex ?? Number.POSITIVE_INFINITY
1378
+ while (position < rows.length && rows[position]!.index <= target) {
1379
+ const row = rows[position]!
1380
+ state = foldEvent(reducer, state, toPublic(row))
1381
+ index = row.index
1382
+ position += 1
1383
+ }
1384
+ if (
1385
+ (entry.triggerIndex !== undefined &&
1386
+ entry.triggerIndex <= clearIndex) ||
1387
+ (entry.throughIndex !== undefined &&
1388
+ entry.throughIndex > 0 &&
1389
+ entry.throughIndex < clearIndex)
1390
+ ) {
1391
+ entry.reject(
1392
+ sessionClearedError(
1393
+ `session '${entry.sessionId}' was cleared at index ${clearIndex}`,
1394
+ ),
1395
+ )
1396
+ continue
1397
+ }
1398
+ entry.span.setAttribute('a2.state.snapshot', snapshot)
1399
+ entry.span.setAttribute('a2.state.folded', position - start)
1400
+ entry.span.setAttribute('a2.state.index', index)
1401
+ visit(entry, state, index, position - start)
1402
+ // A result below the marker (the explicit seed) predates this lifetime.
1403
+ entry.resolve({
1404
+ state: cloneInitial(state),
1405
+ index,
1406
+ ...(clearIndex > 0 && index >= clearIndex ? { clearIndex } : {}),
1407
+ })
1408
+ }
1409
+ return { state, index, position }
1410
+ }
1411
+
1412
+ /**
1413
+ * Events from `fromIndex` (inclusive) through `throughIndex` or the newest
1414
+ * event, or null when the tail is too long to be worth folding in memory.
1415
+ */
1416
+ const readCachedTail = async (
1417
+ store: A2Store,
1418
+ sessionId: string,
1419
+ fromIndex: number,
1420
+ throughIndex?: number,
1421
+ ): Promise<Event[] | null> => {
1422
+ const readTail = stateTailReaders.get(store)
1423
+ if (readTail) {
1424
+ try {
1425
+ return await readTail(nsId(sessionId), fromIndex, throughIndex)
1426
+ } catch (err) {
1427
+ throw asStoreUnavailable(err)
1428
+ }
1429
+ }
1430
+ const limit = fromIndex + STATE_CACHE.maxTail
1431
+ const capped = throughIndex === undefined || throughIndex > limit
1432
+ const rows = await readHistory(sessionId, {
1433
+ gte: fromIndex,
1434
+ lte: capped ? limit : throughIndex,
1435
+ })
1436
+ return capped && rows.at(-1)?.index === limit ? null : rows
1437
+ }
1438
+
1439
+ /**
1440
+ * Serves a plan from the cached state when every read is at or past the
1441
+ * cached index. A read bounded at the cached index by an event whose id
1442
+ * matches the cached one (a handler's own trigger) needs no store request;
1443
+ * any other read fetches the tail from the cached event onward and checks
1444
+ * that event's id. Returns false when the store must answer instead.
1445
+ */
1446
+ const foldCachedPlan = async (
1447
+ store: A2Store,
1448
+ reducer: Reducer<D, unknown>,
1449
+ plan: StateReadPlan,
1450
+ ): Promise<boolean> => {
1451
+ const key = stateCacheKey(plan.sessionId, reducer.name)
1452
+ const cached = stateCache.get(key)
1453
+ if (cached?.reducer !== reducer) return false
1454
+ if (
1455
+ plan.entries.some(
1456
+ (entry) =>
1457
+ entry.throughIndex !== undefined && entry.throughIndex < cached.index,
1458
+ )
1459
+ )
1460
+ return false
1461
+ let rows: Event[] = []
1462
+ if (
1463
+ plan.entries.some(
1464
+ (entry) =>
1465
+ entry.throughIndex !== cached.index ||
1466
+ entry.throughEventId !== cached.lastId,
1467
+ )
1468
+ ) {
1469
+ const tail = await readCachedTail(
1470
+ store,
1471
+ plan.sessionId,
1472
+ cached.index,
1473
+ plan.throughIndex,
1474
+ )
1475
+ if (!tail) return false
1476
+ // A clear after the cached event, or a rewritten log, needs the store:
1477
+ // it applies the boundary and its fences.
1478
+ if (
1479
+ tail[0]?.index !== cached.index ||
1480
+ tail[0].id !== cached.lastId ||
1481
+ tail.some((row, position) => position > 0 && isSessionCleared(row))
1482
+ ) {
1483
+ stateCache.delete(key)
1484
+ return false
1485
+ }
1486
+ rows = tail
1487
+ }
1488
+ const writes: SnapshotWrites = new Map()
1489
+ const folded = foldEntries({
1490
+ reducer,
1491
+ entries: plan.entries,
1492
+ rows,
1493
+ position: 1,
1494
+ state: rows.length > 1 ? cloneInitial(cached.state) : cached.state,
1495
+ index: cached.index,
1496
+ clearIndex: cached.clearIndex,
1497
+ snapshot: 'memory',
1498
+ visit: (entry, state, index) => {
1499
+ if (entry.pinEventIndex !== undefined) {
1500
+ addSnapshotWrite(writes, index, state, entry.pinEventIndex)
1501
+ }
1502
+ },
1503
+ })
1504
+ const added = Math.max(0, folded.position - 1)
1505
+ const rewritesHead =
1506
+ folded.index - cached.persistedIndex >= STATE_CACHE.snapshotEvery
1507
+ if (rewritesHead && !writes.has(folded.index)) {
1508
+ writes.set(folded.index, {
1509
+ state: folded.state,
1510
+ pinEventIndexes: new Set(),
1511
+ })
1512
+ }
1513
+ const snapshots = writeSnapshots({
1514
+ store,
1515
+ reducer,
1516
+ sessionId: plan.sessionId,
1517
+ writes,
1518
+ })
1519
+ const measure =
1520
+ rewritesHead || cached.unmeasured + added >= STATE_CACHE.measureEvery
1521
+ let grown = 0
1522
+ if (!measure) {
1523
+ for (let position = 1; position < folded.position; position += 1) {
1524
+ grown += JSON.stringify(rows[position]!.payload)?.length ?? 0
1525
+ }
1526
+ }
1527
+ cacheState(key, {
1528
+ reducer,
1529
+ index: folded.index,
1530
+ lastId: added > 0 ? rows[folded.position - 1]!.id : cached.lastId,
1531
+ state: folded.state,
1532
+ bytes: measure
1533
+ ? measuredBytes(reducer, folded.state, folded.index, snapshots)
1534
+ : cached.bytes + grown,
1535
+ unmeasured: measure ? 0 : cached.unmeasured + added,
1536
+ persistedIndex: Math.max(
1537
+ cached.persistedIndex,
1538
+ snapshots.at(-1)?.index ?? 0,
1539
+ ),
1540
+ clearIndex: cached.clearIndex,
1541
+ })
1542
+ return true
1543
+ }
1544
+
1048
1545
  // The cache is untrusted: schema rejection refolds from the raw store.
1049
1546
  const foldStatePlan = async (
1050
1547
  store: A2Store,
@@ -1057,11 +1554,18 @@ export function createServer<
1057
1554
  let snapshotOutcome = 'miss'
1058
1555
  const snap = stateRead.snapshot
1059
1556
  let rows = stateRead.events
1557
+ let clearIndex = stateRead.clearIndex ?? 0
1060
1558
  if (snap) {
1061
- if (reducer.stateSchema) {
1559
+ let decoded: unknown
1560
+ try {
1561
+ decoded = decodeState({ reducer, state: snap.state })
1562
+ } catch {
1563
+ snapshotOutcome = 'rejected'
1564
+ }
1565
+ if (snapshotOutcome !== 'rejected' && reducer.stateSchema) {
1062
1566
  const result = validateSync(
1063
1567
  reducer.stateSchema,
1064
- snap.state,
1568
+ decoded,
1065
1569
  'the stateSchema',
1066
1570
  )
1067
1571
  if (result.issues) {
@@ -1071,8 +1575,8 @@ export function createServer<
1071
1575
  index = snap.index
1072
1576
  snapshotOutcome = 'hit'
1073
1577
  }
1074
- } else {
1075
- state = snap.state
1578
+ } else if (snapshotOutcome !== 'rejected') {
1579
+ state = decoded
1076
1580
  index = snap.index
1077
1581
  snapshotOutcome = 'hit'
1078
1582
  }
@@ -1088,116 +1592,52 @@ export function createServer<
1088
1592
  } catch (err) {
1089
1593
  throw asStoreUnavailable(err)
1090
1594
  }
1595
+ // Reads start at the latest marker, so a clear since readState leads.
1596
+ if (rows[0] && isSessionCleared(rows[0]))
1597
+ clearIndex = Math.max(clearIndex, rows[0].index)
1091
1598
  }
1092
- const ordered = plan.entries.toSorted(
1093
- (a, b) =>
1094
- (a.throughIndex ?? Number.POSITIVE_INFINITY) -
1095
- (b.throughIndex ?? Number.POSITIVE_INFINITY),
1096
- )
1097
- const writes = new Map<
1098
- number,
1099
- { state: unknown; pinEventIndexes: Set<number> }
1100
- >()
1101
- let rowPosition = 0
1102
- for (const entry of ordered) {
1103
- const target = entry.throughIndex ?? Number.POSITIVE_INFINITY
1104
- while (rowPosition < rows.length && rows[rowPosition]!.index <= target) {
1105
- const row = rows[rowPosition]!
1106
- state = reducer.fold(state, toPublic(row) as never)
1107
- index = row.index
1108
- rowPosition += 1
1109
- }
1110
- const resultState = cloneInitial(state)
1111
- const folded = rowPosition
1112
- entry.span.setAttribute('a2.state.snapshot', snapshotOutcome)
1113
- entry.span.setAttribute('a2.state.folded', folded)
1114
- entry.span.setAttribute('a2.state.index', index)
1115
- if (index > 0 && (folded > 0 || entry.pinEventIndex !== undefined)) {
1599
+ const writes: SnapshotWrites = new Map()
1600
+ const folded = foldEntries({
1601
+ reducer,
1602
+ entries: plan.entries,
1603
+ rows,
1604
+ position: 0,
1605
+ state,
1606
+ index,
1607
+ clearIndex,
1608
+ snapshot: snapshotOutcome,
1609
+ visit: (entry, entryState, entryIndex, count) => {
1610
+ if (entryIndex === 0) return
1611
+ if (count === 0 && entry.pinEventIndex === undefined) return
1116
1612
  const advancesHead =
1117
- stateRead.headIndex === null || index > stateRead.headIndex
1613
+ stateRead.headIndex === null || entryIndex > stateRead.headIndex
1118
1614
  const repairsHead =
1119
- snapshotOutcome === 'rejected' && index === stateRead.headIndex
1120
- if (
1121
- !advancesHead &&
1122
- !repairsHead &&
1123
- entry.pinEventIndex === undefined
1124
- ) {
1125
- entry.resolve({ state: resultState, index })
1126
- continue
1127
- }
1128
- let write = writes.get(index)
1129
- if (!write) {
1130
- write = {
1131
- state: cloneInitial(resultState),
1132
- pinEventIndexes: new Set(),
1133
- }
1134
- writes.set(index, write)
1135
- }
1136
- if (entry.pinEventIndex !== undefined) {
1137
- write.pinEventIndexes.add(entry.pinEventIndex)
1138
- }
1139
- }
1140
- entry.resolve({ state: resultState, index })
1141
- }
1142
- if (writes.size > 0) {
1143
- const snapshots: StoreSnapshotWrite[] = []
1144
- for (const [snapshotIndex, write] of writes) {
1145
- const snapshot: StoreSnapshotWrite = {
1146
- index: snapshotIndex,
1147
- state: write.state,
1148
- }
1149
- if (write.pinEventIndexes.size > 0) {
1150
- snapshot.pinEventIndexes = [...write.pinEventIndexes]
1151
- }
1152
- snapshots.push(snapshot)
1153
- }
1154
- const persistence = Promise.resolve()
1155
- .then(() =>
1156
- telemetry.span(
1157
- 'a2.snapshot',
1158
- {
1159
- 'a2.contract': name,
1160
- 'a2.session_id': plan.sessionId,
1161
- 'a2.snapshot.reducer': reducer.name,
1162
- 'a2.snapshot.index': snapshots.at(-1)!.index,
1163
- 'a2.snapshot.count': snapshots.length,
1164
- },
1165
- async (span) => {
1166
- try {
1167
- await store.putSnapshots(
1168
- nsId(plan.sessionId),
1169
- reducer.name,
1170
- snapshots,
1171
- )
1172
- } catch (error) {
1173
- try {
1174
- span.recordError(error)
1175
- } catch (telemetryError) {
1176
- // oxlint-disable-next-line preserve-caught-error -- AggregateError preserves both failures.
1177
- throw new AggregateError(
1178
- [error, telemetryError],
1179
- 'Snapshot write and error reporting failed',
1180
- { cause: telemetryError },
1181
- )
1182
- }
1183
- }
1184
- },
1185
- ),
1186
- )
1187
- .catch((error: unknown) => {
1188
- // oxlint-disable-next-line no-console -- telemetry failure must not hide failed background work.
1189
- console.error(
1190
- '[a2] error in a2.snapshot telemetry',
1191
- {
1192
- sessionId: plan.sessionId,
1193
- reducer: reducer.name,
1194
- index: snapshots.at(-1)!.index,
1195
- },
1196
- error,
1197
- )
1198
- })
1199
- track(persistence)
1200
- platformWaitUntil(persistence)
1615
+ snapshotOutcome === 'rejected' && entryIndex === stateRead.headIndex
1616
+ if (!advancesHead && !repairsHead && entry.pinEventIndex === undefined)
1617
+ return
1618
+ addSnapshotWrite(writes, entryIndex, entryState, entry.pinEventIndex)
1619
+ },
1620
+ })
1621
+ const snapshots = writeSnapshots({
1622
+ store,
1623
+ reducer,
1624
+ sessionId: plan.sessionId,
1625
+ writes,
1626
+ })
1627
+ if (folded.position > 0 && STATE_CACHE.maxEntries > 0) {
1628
+ cacheState(stateCacheKey(plan.sessionId, reducer.name), {
1629
+ reducer,
1630
+ index: folded.index,
1631
+ lastId: rows[folded.position - 1]!.id,
1632
+ state: folded.state,
1633
+ bytes: measuredBytes(reducer, folded.state, folded.index, snapshots),
1634
+ unmeasured: 0,
1635
+ persistedIndex: Math.min(
1636
+ Math.max(stateRead.headIndex ?? 0, ...writes.keys()),
1637
+ folded.index,
1638
+ ),
1639
+ clearIndex,
1640
+ })
1201
1641
  }
1202
1642
  }
1203
1643
 
@@ -1212,7 +1652,17 @@ export function createServer<
1212
1652
  reducer: Reducer<D, unknown>,
1213
1653
  batch: PendingStateRead[],
1214
1654
  ): Promise<void> => {
1215
- const plans = planStateReads(batch)
1655
+ const plans: StateReadPlan[] = []
1656
+ await Promise.all(
1657
+ planStateReads(batch).map(async (plan) => {
1658
+ try {
1659
+ if (!(await foldCachedPlan(store, reducer, plan))) plans.push(plan)
1660
+ } catch (error) {
1661
+ for (const entry of plan.entries) entry.reject(error)
1662
+ }
1663
+ }),
1664
+ )
1665
+ if (plans.length === 0) return
1216
1666
  let reads: (StoreStateRead | Promise<StoreStateRead>)[]
1217
1667
  try {
1218
1668
  reads = store.readStates
@@ -1243,7 +1693,8 @@ export function createServer<
1243
1693
  )
1244
1694
  } catch (error) {
1245
1695
  const failure = asStoreUnavailable(error)
1246
- for (const entry of batch) entry.reject(failure)
1696
+ for (const plan of plans)
1697
+ for (const entry of plan.entries) entry.reject(failure)
1247
1698
  return
1248
1699
  }
1249
1700
  await Promise.all(
@@ -1257,14 +1708,20 @@ export function createServer<
1257
1708
  )
1258
1709
  }
1259
1710
 
1711
+ type StateReadBounds = {
1712
+ throughIndex?: number
1713
+ throughEventId?: string
1714
+ pinEventIndex?: number
1715
+ triggerIndex?: number
1716
+ }
1717
+
1260
1718
  const enqueueStateRead = (
1261
1719
  store: A2Store,
1262
1720
  sessionId: string,
1263
1721
  reducer: Reducer<D, unknown>,
1264
- throughIndex?: number,
1265
- pinEventIndex?: number,
1266
- span?: A2SpanHandle,
1267
- ): Promise<{ state: unknown; index: number }> =>
1722
+ bounds: StateReadBounds,
1723
+ span: A2SpanHandle,
1724
+ ): Promise<StateResult> =>
1268
1725
  new Promise((resolve, reject) => {
1269
1726
  const reducerName = reducer.name
1270
1727
  let batch = pendingStateReads.get(reducerName)
@@ -1286,22 +1743,14 @@ export function createServer<
1286
1743
  })
1287
1744
  batch = opened
1288
1745
  }
1289
- batch.push({
1290
- sessionId,
1291
- ...(throughIndex === undefined ? {} : { throughIndex }),
1292
- ...(pinEventIndex === undefined ? {} : { pinEventIndex }),
1293
- span: span!,
1294
- resolve,
1295
- reject,
1296
- })
1746
+ batch.push({ sessionId, ...bounds, span, resolve, reject })
1297
1747
  })
1298
1748
 
1299
- const readState = async <S, V>(
1749
+ const readState = async <S>(
1300
1750
  sessionId: string,
1301
- reducer: Reducer<D, S, Record<never, never>, V>,
1302
- throughIndex?: number,
1303
- pinEventIndex?: number,
1304
- ): Promise<{ state: S; index: number }> => {
1751
+ reducer: Reducer<D, S, Record<never, never>>,
1752
+ bounds: StateReadBounds,
1753
+ ): Promise<{ state: S; index: number; clearIndex?: number }> => {
1305
1754
  const store = await resolveStore()
1306
1755
  return telemetry.span(
1307
1756
  'a2.state',
@@ -1315,20 +1764,27 @@ export function createServer<
1315
1764
  store,
1316
1765
  sessionId,
1317
1766
  reducer as Reducer<D, unknown>,
1318
- throughIndex,
1319
- pinEventIndex,
1767
+ bounds,
1320
1768
  span,
1321
- ) as Promise<{ state: S; index: number }>,
1769
+ ) as Promise<{ state: S; index: number; clearIndex?: number }>,
1322
1770
  )
1323
1771
  }
1324
1772
 
1325
1773
  const makeSchedule = (
1326
1774
  sessionId: string,
1327
- trigger: Pick<StoredEvent, 'id' | 'createdAt'> | null,
1775
+ trigger: Pick<
1776
+ StoredEvent,
1777
+ 'id' | 'createdAt' | 'index' | 'clearIndex'
1778
+ > | null,
1328
1779
  ): SessionSchedule<D> => {
1329
1780
  const triggerEventId = trigger?.id ?? null
1330
1781
  const triggerCreatedAt = trigger?.createdAt.getTime()
1331
- return async (scheduleName, timing, ...events) => {
1782
+ const clearIndex = trigger?.clearIndex ?? 0
1783
+ const schedule = async (
1784
+ scheduleName: string,
1785
+ timing: ScheduleTiming,
1786
+ ...events: AppendInput<D>[]
1787
+ ): Promise<void> => {
1332
1788
  if (!scheduler) {
1333
1789
  throw new TypeError(
1334
1790
  'session.schedule requires a configured scheduler adapter',
@@ -1357,6 +1813,7 @@ export function createServer<
1357
1813
  sessionId,
1358
1814
  triggerEventId,
1359
1815
  scheduleName,
1816
+ clearIndex,
1360
1817
  )
1361
1818
  const scheduledEvents: ScheduledEvent[] = await Promise.all(
1362
1819
  validated.map(async (event, item) => ({
@@ -1384,11 +1841,60 @@ export function createServer<
1384
1841
  sessionId,
1385
1842
  dueAt,
1386
1843
  events: scheduledEvents,
1844
+ ...(trigger ? { originIndex: trigger.index } : {}),
1387
1845
  })
1388
1846
  } catch (error) {
1389
1847
  throw markSchedulerSendFailure(error)
1390
1848
  }
1391
1849
  }
1850
+ const clear = async (
1851
+ scheduleName: string,
1852
+ clearOptions: ScheduleTiming & { ifLastIndex: number },
1853
+ ): Promise<void> => {
1854
+ if (!scheduler) {
1855
+ throw new TypeError(
1856
+ 'session.schedule.clear requires a configured scheduler adapter',
1857
+ )
1858
+ }
1859
+ assertScheduleName(scheduleName)
1860
+ if (clearOptions === null || typeof clearOptions !== 'object') {
1861
+ throw new TypeError('schedule.clear requires an ifLastIndex')
1862
+ }
1863
+ const { ifLastIndex, ...timing } = clearOptions
1864
+ assertClearIndex(ifLastIndex)
1865
+ const dueAt = scheduleDueAt(
1866
+ timing as ScheduleTiming,
1867
+ triggerCreatedAt ?? Date.now(),
1868
+ )
1869
+ const store = await resolveStore()
1870
+ if (!store.clear || !store.reclaim) {
1871
+ throw new TypeError(
1872
+ 'session.schedule.clear requires a store that supports clearing',
1873
+ )
1874
+ }
1875
+ const id = await deterministicScheduleClearId({
1876
+ contract: name,
1877
+ sessionId,
1878
+ triggerEventId,
1879
+ clearIndex,
1880
+ name: scheduleName,
1881
+ ifLastIndex,
1882
+ })
1883
+ try {
1884
+ await scheduler.schedule({
1885
+ version: 1,
1886
+ kind: 'clear',
1887
+ id,
1888
+ contract: name,
1889
+ sessionId,
1890
+ dueAt,
1891
+ ifLastIndex,
1892
+ })
1893
+ } catch (error) {
1894
+ throw markSchedulerSendFailure(error)
1895
+ }
1896
+ }
1897
+ return Object.assign(schedule, { clear })
1392
1898
  }
1393
1899
 
1394
1900
  const toSessionEvent = (id: string, event: Event): ContractEvent<D> =>
@@ -1455,6 +1961,14 @@ export function createServer<
1455
1961
  },
1456
1962
  }
1457
1963
  streamActivities.set(stream, activity)
1964
+ bufferedEvents.set(stream, () => {
1965
+ const event = closed || !inner ? undefined : takeBuffered(inner)
1966
+ if (!event) return undefined
1967
+ if (!isSerializedEvent(event)) return toSessionEvent(id, event)
1968
+ // Taken rows belong to this stream alone.
1969
+ event.sessionId = id
1970
+ return event
1971
+ })
1458
1972
  return stream
1459
1973
  },
1460
1974
  }
@@ -1582,6 +2096,8 @@ export function createServer<
1582
2096
  const ns = nsId(id)
1583
2097
  let closed = false
1584
2098
  let events: AsyncIterator<Event> | undefined
2099
+ // An event read the merge loop started and has not yet yielded.
2100
+ let pendingEvent: Promise<IteratorResult<Event>> | null = null
1585
2101
  let unsubscribe: (() => void) | undefined
1586
2102
  let sleeper: Sleeper | null = null
1587
2103
  let wakeQueue: (() => void) | null = null
@@ -1763,7 +2279,6 @@ export function createServer<
1763
2279
  }
1764
2280
  return null
1765
2281
  }
1766
- let pendingEvent: Promise<IteratorResult<Event>> | null = null
1767
2282
  try {
1768
2283
  // Subscribe before the snapshot read: a patch landing between
1769
2284
  // the two is painted twice, never lost — repaints are idempotent.
@@ -1873,36 +2388,149 @@ export function createServer<
1873
2388
  },
1874
2389
  }
1875
2390
  streamActivities.set(stream, activity)
2391
+ bufferedEvents.set(stream, () => {
2392
+ // A held event may follow only events already yielded; while a
2393
+ // read is outstanding, its event must go first.
2394
+ if (closed || !events || pendingEvent) return undefined
2395
+ const event = takeBuffered(events)
2396
+ if (!event) return undefined
2397
+ if (!isSerializedEvent(event)) return toSessionEvent(id, event)
2398
+ // Taken rows belong to this stream alone.
2399
+ event.sessionId = id
2400
+ return event
2401
+ })
1876
2402
  return stream
1877
2403
  },
1878
2404
  }
1879
2405
  }
1880
2406
 
2407
+ // ── clearing (a2-implementation.md §17) ──────────────────────────
2408
+
2409
+ const reclaimAll = async (store: A2Store, ns: string): Promise<void> => {
2410
+ for (let stalls = 0; ;) {
2411
+ let batch: StoreReclaimResult
2412
+ try {
2413
+ // oxlint-disable-next-line no-await-in-loop -- bounded batches, oldest first
2414
+ batch = await store.reclaim!(ns)
2415
+ } catch (err) {
2416
+ throw asStoreUnavailable(err)
2417
+ }
2418
+ if (batch.done) return
2419
+ if (batch.deleted > 0) {
2420
+ stalls = 0
2421
+ continue
2422
+ }
2423
+ // Only rows other work holds locked remain; they free up shortly.
2424
+ stalls += 1
2425
+ if (stalls === RECLAIM_STALL_LIMIT) {
2426
+ throw new A2Error(
2427
+ 'STORE_UNAVAILABLE',
2428
+ `deleting the cleared history of session '${stripNs(ns)}' made no progress`,
2429
+ )
2430
+ }
2431
+ // oxlint-disable-next-line no-await-in-loop -- paced retry
2432
+ await defaultSleep(RECLAIM_STALL_MS).promise
2433
+ }
2434
+ }
2435
+
2436
+ /** The handler attempt a `ctx.session.clear()` belongs to. */
2437
+ type ClearingAttempt = {
2438
+ cause: EventCause
2439
+ end: () => void
2440
+ signal: AbortSignal
2441
+ }
2442
+
2443
+ const clearSession = async (
2444
+ sessionId: string,
2445
+ clearOptions: SessionClearOptions | undefined,
2446
+ attempt: ClearingAttempt | undefined,
2447
+ ): Promise<SessionClearResult> => {
2448
+ const ifLastIndex = clearOptions?.ifLastIndex
2449
+ if (ifLastIndex !== undefined) assertClearIndex(ifLastIndex)
2450
+ const store = await resolveStore()
2451
+ if (!store.clear || !store.reclaim) {
2452
+ throw new TypeError(
2453
+ 'session.clear requires a store that supports clearing',
2454
+ )
2455
+ }
2456
+ const ns = nsId(sessionId)
2457
+ let result: StoreClearResult
2458
+ try {
2459
+ result = await store.clear({
2460
+ sessionId: ns,
2461
+ ...(ifLastIndex === undefined ? {} : { ifLastIndex }),
2462
+ ...(attempt ? { cause: attempt.cause } : {}),
2463
+ })
2464
+ } catch (err) {
2465
+ throw asStoreUnavailable(err)
2466
+ }
2467
+ if (result.cleared) {
2468
+ // This instance's own reads after the clear, including the clearing
2469
+ // handler's, go to the store and see the boundary.
2470
+ const cachePrefix = stateCacheKey(sessionId, '')
2471
+ stateCache.deleteWhere((key) => key.startsWith(cachePrefix))
2472
+ }
2473
+ const deletion = reclaimAll(store, ns)
2474
+ if (attempt) {
2475
+ if (attempt.cause.index <= result.clearIndex) attempt.end()
2476
+ // Ending the attempt releases its drain slot; the deletion must still
2477
+ // outlive the drain.
2478
+ const kept = deletion.catch(() => undefined)
2479
+ track(kept)
2480
+ platformWaitUntil(kept)
2481
+ }
2482
+ await deletion
2483
+ return { cleared: result.cleared, index: result.index }
2484
+ }
2485
+
1881
2486
  const makeSession = <Append>(
1882
2487
  id: string,
1883
2488
  append: Append,
1884
- trigger: Pick<StoredEvent, 'id' | 'createdAt' | 'index'> | null,
2489
+ trigger: Pick<
2490
+ StoredEvent,
2491
+ 'id' | 'createdAt' | 'index' | 'clearIndex'
2492
+ > | null,
2493
+ attempt?: ClearingAttempt,
1885
2494
  ): Session<D, Append, P> =>
1886
2495
  ({
1887
2496
  id,
1888
2497
  append,
1889
2498
  schedule: makeSchedule(id, trigger),
2499
+ clear: (clearOptions?: SessionClearOptions) =>
2500
+ clearSession(id, clearOptions, attempt),
1890
2501
  history: async (bounds?: { gte?: number; lte?: number }) =>
1891
- (await readHistory(id, bounds)).map(toPublic),
1892
- state: <S, V>(
1893
- reducer: Reducer<D, S, Record<never, never>, V>,
2502
+ (await readHistory(id, bounds))
2503
+ .filter((row) => !isSessionCleared(row))
2504
+ .map(toPublic),
2505
+ state: <S>(
2506
+ reducer: Reducer<
2507
+ D,
2508
+ S,
2509
+ Record<never, never>
2510
+ > = serverContract.reducer as unknown as Reducer<
2511
+ D,
2512
+ S,
2513
+ Record<never, never>
2514
+ >,
1894
2515
  stateOptions?: StateOptions,
1895
2516
  ) => {
2517
+ if (!reducer)
2518
+ throw new A2Error('INVALID_PAYLOAD', 'contract has no public reducer')
1896
2519
  const through = stateOptions?.through ?? trigger?.index
1897
2520
  if (through !== undefined && through !== 'latest') {
1898
2521
  assertStateIndex(through)
1899
2522
  }
1900
- return readState(
1901
- id,
1902
- reducer,
1903
- through === 'latest' ? undefined : through,
1904
- trigger && through !== 'latest' ? trigger.index : undefined,
1905
- )
2523
+ const bounds: StateReadBounds = {}
2524
+ if (through !== undefined && through !== 'latest') {
2525
+ bounds.throughIndex = through
2526
+ if (trigger) bounds.pinEventIndex = trigger.index
2527
+ // Only a live attempt may be answered from memory with no store
2528
+ // request; a superseded one (including by a clear) reads the store.
2529
+ if (through === trigger?.index && !attempt?.signal.aborted)
2530
+ bounds.throughEventId = trigger.id
2531
+ }
2532
+ if (trigger) bounds.triggerIndex = trigger.index
2533
+ return readState(id, reducer, bounds)
1906
2534
  },
1907
2535
  stream: (opts?: { startAfter?: number; presence?: boolean }) => {
1908
2536
  const startAfter = opts?.startAfter ?? 0
@@ -1932,6 +2560,7 @@ export function createServer<
1932
2560
  trigger: StoredEvent,
1933
2561
  attempt: number,
1934
2562
  signal: AbortSignal,
2563
+ endAttempt: () => void,
1935
2564
  ): HandlerContext<D> => {
1936
2565
  const publicSessionId = stripNs(trigger.sessionId)
1937
2566
  const append: HandlerAppend<D> = async (appendName, ...events) => {
@@ -1961,7 +2590,11 @@ export function createServer<
1961
2590
  // The handler-scoped `setPresence` stamps `seen` with the
1962
2591
  // triggering event's index by default — the frontier the handler
1963
2592
  // provably reflects.
1964
- session: makeSession(publicSessionId, append, trigger),
2593
+ session: makeSession(publicSessionId, append, trigger, {
2594
+ cause: { index: trigger.index, attempt },
2595
+ end: endAttempt,
2596
+ signal,
2597
+ }),
1965
2598
  signal,
1966
2599
  }
1967
2600
  }
@@ -2267,17 +2900,10 @@ export function createServer<
2267
2900
  if (!entry) continue
2268
2901
  // Idempotent: the local lapse normally aborted this signal
2269
2902
  // already — this is the proof, whose job is the release.
2270
- entry.supersede.abort(
2271
- new A2Error(
2272
- 'SUPERSEDED_ATTEMPT',
2273
- `a newer attempt owns event ${index} in session '${sessionId}'`,
2274
- ),
2903
+ endSuperseded(
2904
+ entry,
2905
+ `a newer attempt owns event ${index} in session '${sessionId}'`,
2275
2906
  )
2276
- entry.fence({
2277
- status: 'superseded',
2278
- opensLane: false,
2279
- appendsEvents: false,
2280
- })
2281
2907
  }
2282
2908
  if (window.deadlineCapped) stopRenewing = true
2283
2909
  scheduleLapse()
@@ -2299,6 +2925,7 @@ export function createServer<
2299
2925
  const runEvent = async (
2300
2926
  event: StoredEvent,
2301
2927
  supersedeSignal: AbortSignal,
2928
+ endAttempt: () => void,
2302
2929
  ): Promise<EventOutcome> => {
2303
2930
  const registration = handlers.get(event.type)
2304
2931
  return telemetry.span(
@@ -2338,7 +2965,7 @@ export function createServer<
2338
2965
  ])
2339
2966
  }
2340
2967
  const operation = registration?.handler(
2341
- makeCtx(event, event.attemptCount, handlerSignal),
2968
+ makeCtx(event, event.attemptCount, handlerSignal, endAttempt),
2342
2969
  )
2343
2970
  const result = operation
2344
2971
  ? await (subscription
@@ -2369,17 +2996,31 @@ export function createServer<
2369
2996
  // genuinely superseded attempt gets 'superseded' back with
2370
2997
  // no mutation. One adjudicator, not two.
2371
2998
  eventSpan.recordError(err)
2372
- const failure = await store.failAttempt({
2373
- sessionId: ns,
2374
- index: event.index,
2375
- attempt: event.attemptCount,
2376
- error: describeError(err),
2377
- // A NonRetriableError is terminal on its first throw:
2378
- // deterministic failures should not burn the retry
2379
- // budget before dead-lettering.
2380
- maxFailures:
2381
- err instanceof NonRetriableError ? 1 : MAX_FAILURES,
2382
- })
2999
+ const failure = await telemetry.span(
3000
+ 'a2.commit',
3001
+ {
3002
+ 'a2.contract': name,
3003
+ 'a2.session_id': sessionId,
3004
+ 'a2.commit.operation': 'fail',
3005
+ 'a2.commit.index': event.index,
3006
+ 'a2.commit.attempt': event.attemptCount,
3007
+ },
3008
+ async (commitSpan) => {
3009
+ const result = await store.failAttempt({
3010
+ sessionId: ns,
3011
+ index: event.index,
3012
+ attempt: event.attemptCount,
3013
+ error: describeError(err),
3014
+ // A NonRetriableError is terminal on its first throw:
3015
+ // deterministic failures should not burn the retry
3016
+ // budget before dead-lettering.
3017
+ maxFailures:
3018
+ err instanceof NonRetriableError ? 1 : MAX_FAILURES,
3019
+ })
3020
+ commitSpan.setAttribute('a2.commit.outcome', result.outcome)
3021
+ return result
3022
+ },
3023
+ )
2383
3024
  eventSpan.setAttribute('a2.event.outcome', failure.outcome)
2384
3025
  return {
2385
3026
  status: failure.outcome,
@@ -2390,12 +3031,26 @@ export function createServer<
2390
3031
  await subscription?.close()
2391
3032
  }
2392
3033
  await renewal
2393
- const completion = await store.completeAttempt({
2394
- sessionId: ns,
2395
- index: event.index,
2396
- attempt: event.attemptCount,
2397
- events: returned,
2398
- })
3034
+ const completion = await telemetry.span(
3035
+ 'a2.commit',
3036
+ {
3037
+ 'a2.contract': name,
3038
+ 'a2.session_id': sessionId,
3039
+ 'a2.commit.operation': 'complete',
3040
+ 'a2.commit.index': event.index,
3041
+ 'a2.commit.attempt': event.attemptCount,
3042
+ },
3043
+ async (commitSpan) => {
3044
+ const result = await store.completeAttempt({
3045
+ sessionId: ns,
3046
+ index: event.index,
3047
+ attempt: event.attemptCount,
3048
+ events: returned,
3049
+ })
3050
+ commitSpan.setAttribute('a2.commit.outcome', result.outcome)
3051
+ return result
3052
+ },
3053
+ )
2399
3054
  eventSpan.setAttribute(
2400
3055
  'a2.event.outcome',
2401
3056
  completion.outcome === 'completed'
@@ -2430,29 +3085,28 @@ export function createServer<
2430
3085
  // the new one.
2431
3086
  const previous = active.get(event.index)
2432
3087
  if (previous) {
2433
- previous.supersede.abort(
2434
- new A2Error(
2435
- 'SUPERSEDED_ATTEMPT',
2436
- `a newer attempt owns event ${event.index} in session '${sessionId}'`,
2437
- ),
3088
+ endSuperseded(
3089
+ previous,
3090
+ `a newer attempt owns event ${event.index} in session '${sessionId}'`,
2438
3091
  )
2439
- previous.fence({
2440
- status: 'superseded',
2441
- opensLane: false,
2442
- appendsEvents: false,
2443
- })
2444
3092
  }
2445
3093
  const supersede = new AbortController()
2446
3094
  let fence!: (outcome: EventOutcome) => void
2447
3095
  const fenced = new Promise<EventOutcome>((resolve) => {
2448
3096
  fence = resolve
2449
3097
  })
3098
+ // A handler's own committed clear supersedes it immediately.
3099
+ const endAttempt = (): void =>
3100
+ endSuperseded(
3101
+ { supersede, fence },
3102
+ `event ${event.index} in session '${sessionId}' was cleared`,
3103
+ )
2450
3104
  // The race lets a durable supersession release this slot while the
2451
3105
  // zombie handler keeps running — its completion and failure are
2452
3106
  // fenced by the store, so nothing it does from here is counted.
2453
3107
  let entry!: ActiveEvent
2454
3108
  const execution = Promise.race([
2455
- runEvent(event, supersede.signal),
3109
+ runEvent(event, supersede.signal, endAttempt),
2456
3110
  fenced,
2457
3111
  ]).then((eventOutcome) => {
2458
3112
  // Identity-guarded: a fenced predecessor's continuation must
@@ -2493,17 +3147,29 @@ export function createServer<
2493
3147
  let claim: StoreClaimAvailableResult | undefined
2494
3148
  if (shouldClaim) {
2495
3149
  const window = nextClaimWindow()
2496
- claim = await store.claimAvailable({
2497
- sessionId: ns,
2498
- holder,
2499
- ttlMs: window.ttlMs,
2500
- ...(window.deadlineCapped
2501
- ? { expiresAtMs: window.expiresAtMs }
2502
- : {}),
2503
- ...(excluded.size === 0
2504
- ? {}
2505
- : { excludeIndexes: [...excluded] }),
2506
- })
3150
+ claim = await telemetry.span(
3151
+ 'a2.claim',
3152
+ { 'a2.contract': name, 'a2.session_id': sessionId },
3153
+ async (claimSpan) => {
3154
+ const result = await store.claimAvailable({
3155
+ sessionId: ns,
3156
+ holder,
3157
+ ttlMs: window.ttlMs,
3158
+ ...(window.deadlineCapped
3159
+ ? { expiresAtMs: window.expiresAtMs }
3160
+ : {}),
3161
+ ...(excluded.size === 0
3162
+ ? {}
3163
+ : { excludeIndexes: [...excluded] }),
3164
+ })
3165
+ claimSpan.setAttribute('a2.claim.outcome', result.outcome)
3166
+ claimSpan.setAttribute(
3167
+ 'a2.claim.count',
3168
+ result.outcome === 'claimed' ? result.events.length : 0,
3169
+ )
3170
+ return result
3171
+ },
3172
+ )
2507
3173
  if (claim.outcome === 'claimed') {
2508
3174
  requestWatchdog(window.watchdogAtMs)
2509
3175
  if (window.deadlineCapped) stopRenewing = true
@@ -2595,6 +3261,7 @@ export function createServer<
2595
3261
  mode: 'inline' | 'dispatch',
2596
3262
  events: AppendInput<D>[],
2597
3263
  requireDurableInitialArm = false,
3264
+ originIndex?: number,
2598
3265
  ): Promise<ContractEvent<D>[]> => {
2599
3266
  return appendCore(
2600
3267
  sessionId,
@@ -2605,6 +3272,7 @@ export function createServer<
2605
3272
  undefined,
2606
3273
  undefined,
2607
3274
  requireDurableInitialArm,
3275
+ originIndex,
2608
3276
  )
2609
3277
  }
2610
3278
 
@@ -2620,11 +3288,29 @@ export function createServer<
2620
3288
  return makeSession(id, append, null)
2621
3289
  }
2622
3290
 
2623
- const fetch = createServerFetch({ contract: serverContract, session })
2624
- const self: A2Server<D, P> = {
3291
+ const historyPage = async (
3292
+ sessionId: string,
3293
+ bounds: { gte: number; lte: number },
3294
+ ): Promise<{ events: ContractEvent<D>[]; clearIndex?: number }> => {
3295
+ assertSessionId(sessionId)
3296
+ const rows = await readHistory(sessionId, bounds)
3297
+ // Reads start at the latest marker, so it can only lead the page.
3298
+ const marker = rows[0] && isSessionCleared(rows[0]) ? rows[0] : undefined
3299
+ return {
3300
+ events: (marker ? rows.slice(1) : rows).map(toPublic),
3301
+ ...(marker ? { clearIndex: marker.index } : {}),
3302
+ }
3303
+ }
3304
+
3305
+ const fetch = createServerFetch({
2625
3306
  contract: serverContract,
2626
- fetch,
2627
3307
  session,
3308
+ historyPage,
3309
+ })
3310
+ const self: A2Server<D, P, R> = {
3311
+ contract: serverContract,
3312
+ fetch,
3313
+ session: session as A2Server<D, P, R>['session'],
2628
3314
 
2629
3315
  async drain(sessionId) {
2630
3316
  assertSessionId(sessionId)
@@ -2643,14 +3329,38 @@ export function createServer<
2643
3329
  assertSessionId(sessionId)
2644
3330
  return drainSession(sessionId, opts?.watchdogDueAt)
2645
3331
  },
2646
- schedulerAppend: async (sessionId, events) => {
3332
+ schedulerAppend: async (sessionId, events, originIndex) => {
2647
3333
  if (!scheduler) {
2648
3334
  throw new TypeError(
2649
3335
  'scheduled append delivery requires a configured scheduler adapter',
2650
3336
  )
2651
3337
  }
2652
3338
  assertSessionId(sessionId)
2653
- await appendExternal(sessionId, 'inline', [...events], true)
3339
+ try {
3340
+ await appendExternal(
3341
+ sessionId,
3342
+ 'inline',
3343
+ [...events],
3344
+ true,
3345
+ originIndex,
3346
+ )
3347
+ } catch (error) {
3348
+ // The scheduling handler's lifetime was cleared: an acknowledged no-op.
3349
+ if (
3350
+ originIndex !== undefined &&
3351
+ error instanceof A2Error &&
3352
+ error.code === 'SESSION_CLEARED'
3353
+ )
3354
+ return
3355
+ throw error
3356
+ }
3357
+ },
3358
+ historyWithMarkers: async (sessionId, gte) =>
3359
+ (await readHistory(sessionId, { gte })).map(toPublic),
3360
+ schedulerClear: async (sessionId, ifLastIndex) => {
3361
+ assertSessionId(sessionId)
3362
+ assertClearIndex(ifLastIndex)
3363
+ await clearSession(sessionId, { ifLastIndex }, undefined)
2654
3364
  },
2655
3365
  })
2656
3366
  return self
@@ -2683,6 +3393,12 @@ function assertStateIndex(value: number): void {
2683
3393
  }
2684
3394
  }
2685
3395
 
3396
+ function assertClearIndex(value: number): void {
3397
+ if (!Number.isSafeInteger(value) || value < 0) {
3398
+ throw new TypeError('ifLastIndex must be a non-negative safe integer')
3399
+ }
3400
+ }
3401
+
2686
3402
  function assertAppendName(name: string): void {
2687
3403
  if (typeof name !== 'string' || name.length === 0) {
2688
3404
  throw new TypeError('handler append name must be a non-empty string')
@@ -2827,16 +3543,19 @@ function isJsonValue(value: unknown, ancestors: WeakSet<object>): boolean {
2827
3543
  }
2828
3544
  }
2829
3545
 
2830
- function cloneInitial<S>(initial: S): S {
2831
- // Defensive: a reducer that mutates state in place must not corrupt
2832
- // the shared initialState across folds. Fall back to the original for
2833
- // non-cloneable values (functions, class instances).
2834
- try {
2835
- return structuredClone(initial)
2836
- } catch {
2837
- return initial
2838
- }
2839
- }
3546
+ const measuredBytes = (
3547
+ reducer: object,
3548
+ state: unknown,
3549
+ index: number,
3550
+ snapshots: StoreSnapshotWrite[],
3551
+ ): number =>
3552
+ JSON.stringify(
3553
+ snapshots.find((snapshot) => snapshot.index === index)?.state ??
3554
+ encodeState({ reducer, state }),
3555
+ )?.length ?? 0
3556
+
3557
+ const stateCacheKey = (sessionId: string, reducerName: string): string =>
3558
+ `${sessionId}\u0000${reducerName}`
2840
3559
 
2841
3560
  // Server-side types users need when passing custom stores or building
2842
3561
  // transports — re-exported so experimental-a2/server is self-sufficient.
@@ -2857,6 +3576,7 @@ export type {
2857
3576
  export type {
2858
3577
  ScheduledEvent,
2859
3578
  SchedulerAppendTask,
3579
+ SchedulerClearTask,
2860
3580
  SchedulerDrainTask,
2861
3581
  SchedulerTask,
2862
3582
  } from './scheduler-task.ts'