experimental-a2 0.16.1 → 0.18.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 (406) hide show
  1. package/CHANGELOG.md +373 -0
  2. package/README.md +1 -1
  3. package/dist/{actor-ohPC-81x.d.ts → actor-Bjce9_5U.d.ts} +21 -11
  4. package/dist/actor-Bjce9_5U.d.ts.map +1 -0
  5. package/dist/actor-client.d.ts +2 -5
  6. package/dist/actor-client.d.ts.map +1 -1
  7. package/dist/actor-client.js +13 -4
  8. package/dist/actor-client.js.map +1 -1
  9. package/dist/actor-react.d.ts +5 -3
  10. package/dist/actor-react.d.ts.map +1 -1
  11. package/dist/actor-react.js +20 -10
  12. package/dist/actor-react.js.map +1 -1
  13. package/dist/{actor-shared-B5tJfzt-.js → actor-shared-t7w8xIKP.js} +12 -7
  14. package/dist/actor-shared-t7w8xIKP.js.map +1 -0
  15. package/dist/actor.d.ts +1 -1
  16. package/dist/actor.js +42 -23
  17. package/dist/actor.js.map +1 -1
  18. package/dist/{ai-DgOBltJ_.js → ai-BpOGm-zB.js} +621 -216
  19. package/dist/ai-BpOGm-zB.js.map +1 -0
  20. package/dist/{ai-CrEf6p_W.d.ts → ai-DrUaMyRt.d.ts} +114 -100
  21. package/dist/ai-DrUaMyRt.d.ts.map +1 -0
  22. package/dist/ai-client.d.ts +27 -0
  23. package/dist/ai-client.d.ts.map +1 -0
  24. package/dist/ai-client.js +29 -0
  25. package/dist/ai-client.js.map +1 -0
  26. package/dist/ai-fx.browser.d.ts +1 -0
  27. package/dist/ai-fx.browser.js +6 -0
  28. package/dist/ai-fx.browser.js.map +1 -0
  29. package/dist/ai-fx.d.ts +10 -0
  30. package/dist/ai-fx.d.ts.map +1 -0
  31. package/dist/ai-fx.js +634 -0
  32. package/dist/ai-fx.js.map +1 -0
  33. package/dist/ai-harness-BuLh_EYi.js +273 -0
  34. package/dist/ai-harness-BuLh_EYi.js.map +1 -0
  35. package/dist/ai-harness-DvsPeJeB.d.ts +26 -0
  36. package/dist/ai-harness-DvsPeJeB.d.ts.map +1 -0
  37. package/dist/ai-pi.browser.d.ts +1 -0
  38. package/dist/ai-pi.browser.js +6 -0
  39. package/dist/ai-pi.browser.js.map +1 -0
  40. package/dist/ai-pi.d.ts +17 -0
  41. package/dist/ai-pi.d.ts.map +1 -0
  42. package/dist/ai-pi.js +904 -0
  43. package/dist/ai-pi.js.map +1 -0
  44. package/dist/ai-react.d.ts +96 -0
  45. package/dist/ai-react.d.ts.map +1 -0
  46. package/dist/ai-react.js +142 -0
  47. package/dist/ai-react.js.map +1 -0
  48. package/dist/ai-server.browser.js +2 -2
  49. package/dist/ai-server.browser.js.map +1 -1
  50. package/dist/ai-server.d.ts +109 -43
  51. package/dist/ai-server.d.ts.map +1 -1
  52. package/dist/ai-server.js +526 -283
  53. package/dist/ai-server.js.map +1 -1
  54. package/dist/ai.d.ts +2 -2
  55. package/dist/ai.js +2 -2
  56. package/dist/bounded-cache-CPHWF-8h.js +41 -0
  57. package/dist/bounded-cache-CPHWF-8h.js.map +1 -0
  58. package/dist/{client-BYzHjkwU.js → client-BReKqwZC.js} +447 -80
  59. package/dist/client-BReKqwZC.js.map +1 -0
  60. package/dist/{client-CzyacQpJ.d.ts → client-h_DKFAjj.d.ts} +14 -13
  61. package/dist/client-h_DKFAjj.d.ts.map +1 -0
  62. package/dist/client.d.ts +4 -2
  63. package/dist/client.js +1 -1
  64. package/dist/{contract-CKRg_E4q.js → contract-BYKASlCV.js} +12 -21
  65. package/dist/contract-BYKASlCV.js.map +1 -0
  66. package/dist/{idempotent-replay-DVOlyYbx.js → idempotent-replay-JdgMQRRm.js} +2 -2
  67. package/dist/{idempotent-replay-DVOlyYbx.js.map → idempotent-replay-JdgMQRRm.js.map} +1 -1
  68. package/dist/index.d.ts +6 -5
  69. package/dist/index.d.ts.map +1 -1
  70. package/dist/index.js +4 -3
  71. package/dist/{errors-DvhSXnxk.d.ts → initial-snapshot-Bx-c0pDP.d.ts} +7 -3
  72. package/dist/initial-snapshot-Bx-c0pDP.d.ts.map +1 -0
  73. package/dist/{internal-Dq2qYxou.js → internal-B2T3U4Rq.js} +29 -2
  74. package/dist/internal-B2T3U4Rq.js.map +1 -0
  75. package/dist/otel.d.ts +1 -1
  76. package/dist/react-BBXgHWFc.d.ts +86 -0
  77. package/dist/react-BBXgHWFc.d.ts.map +1 -0
  78. package/dist/react.d.ts +2 -122
  79. package/dist/react.js +107 -100
  80. package/dist/react.js.map +1 -1
  81. package/dist/reducer-BAXc8JyF.js +87 -0
  82. package/dist/reducer-BAXc8JyF.js.map +1 -0
  83. package/dist/{reducer-otzHjuJj.d.ts → reducer-CqA59Q6U.d.ts} +46 -30
  84. package/dist/reducer-CqA59Q6U.d.ts.map +1 -0
  85. package/dist/reducer-runtime-DQwz_J59.js +28 -0
  86. package/dist/reducer-runtime-DQwz_J59.js.map +1 -0
  87. package/dist/scheduler-qstash.d.ts +3 -3
  88. package/dist/scheduler-qstash.d.ts.map +1 -1
  89. package/dist/scheduler-qstash.js +33 -10
  90. package/dist/scheduler-qstash.js.map +1 -1
  91. package/dist/{scheduler-task-BpzhPnRS.js → scheduler-task-X1SToDjP.js} +18 -3
  92. package/dist/scheduler-task-X1SToDjP.js.map +1 -0
  93. package/dist/scheduler-vercel.d.ts +4 -9
  94. package/dist/scheduler-vercel.d.ts.map +1 -1
  95. package/dist/scheduler-vercel.js +25 -44
  96. package/dist/scheduler-vercel.js.map +1 -1
  97. package/dist/{server-BD5ckxZb.js → server-Dw_k_7kO.js} +904 -219
  98. package/dist/server-Dw_k_7kO.js.map +1 -0
  99. package/dist/{server-DbD7IJVK.d.ts → server-En7cbi3v.d.ts} +116 -26
  100. package/dist/server-En7cbi3v.d.ts.map +1 -0
  101. package/dist/server.d.ts +4 -4
  102. package/dist/server.js +2 -2
  103. package/dist/session-clear-Bo-XTFEy.d.ts +12 -0
  104. package/dist/session-clear-Bo-XTFEy.d.ts.map +1 -0
  105. package/dist/{errors-DCk6ch5n.js → session-clear-DGI4jTQ5.js} +17 -2
  106. package/dist/session-clear-DGI4jTQ5.js.map +1 -0
  107. package/dist/{store-DyZM6fS5.d.ts → store-Djdv0bnZ.d.ts} +73 -3
  108. package/dist/store-Djdv0bnZ.d.ts.map +1 -0
  109. package/dist/store-N8PXxDAS.js.map +1 -1
  110. package/dist/store-memory.d.ts +1 -1
  111. package/dist/store-memory.d.ts.map +1 -1
  112. package/dist/store-memory.js +205 -66
  113. package/dist/store-memory.js.map +1 -1
  114. package/dist/{store-polling-CmxUbV93.js → store-polling-B5FcwJfo.js} +21 -4
  115. package/dist/store-polling-B5FcwJfo.js.map +1 -0
  116. package/dist/store-postgres.d.ts +1 -1
  117. package/dist/store-postgres.d.ts.map +1 -1
  118. package/dist/store-postgres.js +450 -108
  119. package/dist/store-postgres.js.map +1 -1
  120. package/dist/{store-presence-polling-C7-XZyW9.js → store-presence-polling-BAwS8RWn.js} +3 -3
  121. package/dist/{store-presence-polling-C7-XZyW9.js.map → store-presence-polling-BAwS8RWn.js.map} +1 -1
  122. package/dist/store-redis-http.d.ts +1 -1
  123. package/dist/store-redis-http.d.ts.map +1 -1
  124. package/dist/store-redis-http.js +293 -11
  125. package/dist/store-redis-http.js.map +1 -1
  126. package/dist/store-redis-notify-BRITti_o.js +2493 -0
  127. package/dist/store-redis-notify-BRITti_o.js.map +1 -0
  128. package/dist/store-redis.d.ts +1 -1
  129. package/dist/store-redis.d.ts.map +1 -1
  130. package/dist/store-redis.js +11 -11
  131. package/dist/store-redis.js.map +1 -1
  132. package/dist/store-sqlite.d.ts +1 -1
  133. package/dist/store-sqlite.d.ts.map +1 -1
  134. package/dist/store-sqlite.js +197 -33
  135. package/dist/store-sqlite.js.map +1 -1
  136. package/dist/stream-buffer-Cx7AAPGq.js +23 -0
  137. package/dist/stream-buffer-Cx7AAPGq.js.map +1 -0
  138. package/dist/{telemetry-B5jzpy6y.d.ts → telemetry-Cyfqo6Qt.d.ts} +2 -2
  139. package/dist/telemetry-Cyfqo6Qt.d.ts.map +1 -0
  140. package/dist/{wire-BO5wWCb1.js → wire-rSUglYo8.js} +132 -10
  141. package/dist/wire-rSUglYo8.js.map +1 -0
  142. package/docs/01-quickstart.mdx +50 -84
  143. package/docs/{guides/07-examples.mdx → 02-examples.mdx} +10 -20
  144. package/docs/actors/01-introduction.mdx +48 -82
  145. package/docs/actors/02-concurrency.mdx +43 -70
  146. package/docs/actors/03-timers.mdx +28 -42
  147. package/docs/actors/04-routes.mdx +110 -137
  148. package/docs/actors/meta.ts +1 -1
  149. package/docs/agents/01-introduction.mdx +257 -0
  150. package/docs/agents/02-tools.mdx +247 -0
  151. package/docs/agents/03-conversations.mdx +538 -0
  152. package/docs/agents/04-harnesses.mdx +136 -0
  153. package/docs/agents/meta.ts +1 -0
  154. package/docs/client/01-react.mdx +294 -0
  155. package/docs/client/02-transports.mdx +86 -0
  156. package/docs/client/03-presence.mdx +224 -0
  157. package/docs/client/meta.ts +1 -0
  158. package/docs/contract/01-events.mdx +57 -0
  159. package/docs/contract/02-state.mdx +142 -0
  160. package/docs/contract/03-sessions.mdx +175 -0
  161. package/docs/index.mdx +112 -156
  162. package/docs/reference/01-core.mdx +139 -0
  163. package/docs/reference/02-server.mdx +855 -0
  164. package/docs/reference/03-client.mdx +301 -0
  165. package/docs/reference/04-ai.mdx +722 -0
  166. package/docs/reference/05-adapters.mdx +351 -0
  167. package/docs/reference/06-errors.mdx +138 -0
  168. package/docs/reference/meta.ts +1 -1
  169. package/docs/server/01-handlers.mdx +200 -0
  170. package/docs/server/02-timers.mdx +130 -0
  171. package/docs/server/03-cancellation.mdx +141 -0
  172. package/docs/server/04-routes.mdx +243 -0
  173. package/docs/server/05-production.mdx +322 -0
  174. package/docs/server/06-guarantees.mdx +123 -0
  175. package/examples/playground/DEPLOY.md +53 -7
  176. package/examples/playground/app/a0/[appId]/loading.tsx +16 -0
  177. package/examples/playground/app/a0/[appId]/page.tsx +53 -0
  178. package/examples/playground/app/a0/a0.css +818 -0
  179. package/examples/playground/app/a0/app-list.tsx +218 -0
  180. package/examples/playground/app/a0/app-view.tsx +63 -0
  181. package/examples/playground/app/a0/apps/[appId]/preview/route.ts +27 -0
  182. package/examples/playground/app/a0/apps/[appId]/route.ts +28 -0
  183. package/examples/playground/app/a0/apps.test.ts +128 -0
  184. package/examples/playground/app/a0/apps.ts +102 -0
  185. package/examples/playground/app/a0/chat.tsx +410 -0
  186. package/examples/playground/app/a0/columns.tsx +109 -0
  187. package/examples/playground/app/a0/context-menu.tsx +116 -0
  188. package/examples/playground/app/a0/cookies.ts +46 -0
  189. package/examples/playground/app/a0/crash-on-request.ts +72 -0
  190. package/examples/playground/app/a0/crashes/route.ts +3 -0
  191. package/examples/playground/app/a0/crashes.test.ts +33 -0
  192. package/examples/playground/app/a0/crashes.ts +28 -0
  193. package/examples/playground/app/a0/devtools.tsx +164 -0
  194. package/examples/playground/app/a0/events/route.ts +6 -0
  195. package/examples/playground/app/a0/flags.ts +30 -0
  196. package/examples/playground/app/a0/fx/events/route.ts +9 -0
  197. package/examples/playground/app/a0/fx/scheduler/route.ts +7 -0
  198. package/examples/playground/app/a0/fx/short/events/route.ts +11 -0
  199. package/examples/playground/app/a0/layout.tsx +38 -0
  200. package/examples/playground/app/a0/list/route.ts +10 -0
  201. package/examples/playground/app/a0/model.test.ts +73 -0
  202. package/examples/playground/app/a0/model.ts +95 -0
  203. package/examples/playground/app/a0/no-photon.cjs +4 -0
  204. package/examples/playground/app/a0/no-quickjs.cjs +3 -0
  205. package/examples/playground/app/a0/page.tsx +14 -0
  206. package/examples/playground/app/a0/pi/events/route.ts +9 -0
  207. package/examples/playground/app/a0/pi/scheduler/route.ts +7 -0
  208. package/examples/playground/app/a0/pi/short/events/route.ts +11 -0
  209. package/examples/playground/app/a0/preview-pane.tsx +145 -0
  210. package/examples/playground/app/a0/preview.test.ts +53 -0
  211. package/examples/playground/app/a0/preview.ts +26 -0
  212. package/examples/playground/app/a0/sandbox-log.ts +75 -0
  213. package/examples/playground/app/a0/sandbox-session.ts +152 -0
  214. package/examples/playground/app/a0/sandbox.ts +191 -0
  215. package/examples/playground/app/a0/sandboxes/scheduler/route.ts +6 -0
  216. package/examples/playground/app/a0/scheduler/route.ts +7 -0
  217. package/examples/playground/app/a0/server.ts +213 -0
  218. package/examples/playground/app/a0/session.ts +59 -0
  219. package/examples/playground/app/a0/shell.tsx +158 -0
  220. package/examples/playground/app/a0/short/events/route.ts +9 -0
  221. package/examples/playground/app/a0/splitter.tsx +120 -0
  222. package/examples/playground/app/agent/[agentId]/agent-client.tsx +144 -174
  223. package/examples/playground/app/agent/[agentId]/command-receipt.test.tsx +65 -0
  224. package/examples/playground/app/agent/[agentId]/command-receipt.tsx +27 -0
  225. package/examples/playground/app/agent/[agentId]/compaction-event.tsx +25 -24
  226. package/examples/playground/app/agent/[agentId]/compaction-panel.test.tsx +119 -0
  227. package/examples/playground/app/agent/[agentId]/compaction-panel.tsx +208 -165
  228. package/examples/playground/app/agent/[agentId]/page.tsx +2 -16
  229. package/examples/playground/app/agent/events/route.ts +5 -2
  230. package/examples/playground/app/agent/ingress.test.ts +101 -0
  231. package/examples/playground/app/agent/model-input.test.tsx +58 -0
  232. package/examples/playground/app/agent/model-input.tsx +115 -0
  233. package/examples/playground/app/agent/model.test.ts +73 -0
  234. package/examples/playground/app/agent/model.ts +58 -26
  235. package/examples/playground/app/agent/new-agent-session.tsx +37 -19
  236. package/examples/playground/app/agent/page.tsx +4 -4
  237. package/examples/playground/app/agent/server.ts +19 -15
  238. package/examples/playground/app/agent/session.ts +5 -6
  239. package/examples/playground/app/agent/telemetry.test.ts +189 -0
  240. package/examples/playground/app/agent/telemetry.ts +30 -0
  241. package/examples/playground/app/agent/trace.test.ts +23 -0
  242. package/examples/playground/app/agent/trace.ts +18 -0
  243. package/examples/playground/app/canvas/[canvasId]/canvas-client.tsx +12 -12
  244. package/examples/playground/app/canvas/[canvasId]/page.tsx +1 -2
  245. package/examples/playground/app/canvas/[canvasId]/session.ts +2 -2
  246. package/examples/playground/app/canvas/model.ts +53 -53
  247. package/examples/playground/app/chat/[chatId]/agent-state.test.ts +176 -0
  248. package/examples/playground/app/chat/[chatId]/agent-stream-drawer.tsx +16 -1
  249. package/examples/playground/app/chat/[chatId]/chat-client.tsx +55 -77
  250. package/examples/playground/app/chat/[chatId]/chat-view.ts +37 -3
  251. package/examples/playground/app/chat/[chatId]/page.tsx +7 -15
  252. package/examples/playground/app/chat/[chatId]/session.ts +2 -19
  253. package/examples/playground/app/chat/agent/route.ts +9 -0
  254. package/examples/playground/app/chat/model.test.ts +46 -119
  255. package/examples/playground/app/chat/model.ts +82 -128
  256. package/examples/playground/app/chat/scheduler/route.ts +2 -2
  257. package/examples/playground/app/chat/server.test.ts +112 -0
  258. package/examples/playground/app/chat/server.ts +113 -117
  259. package/examples/playground/app/components/activity-feed.tsx +30 -1
  260. package/examples/playground/app/components/activity-history.test.tsx +35 -0
  261. package/examples/playground/app/components/demo-icons.tsx +89 -0
  262. package/examples/playground/app/components/demos.tsx +33 -0
  263. package/examples/playground/app/components/fast-link.test.tsx +58 -0
  264. package/examples/playground/app/components/fast-link.tsx +55 -0
  265. package/examples/playground/app/components/site-header.tsx +96 -0
  266. package/examples/playground/app/counter/counter-client.tsx +3 -3
  267. package/examples/playground/app/counter/model.test.ts +3 -3
  268. package/examples/playground/app/counter/model.ts +11 -21
  269. package/examples/playground/app/counter/page.tsx +2 -15
  270. package/examples/playground/app/counter/session.ts +2 -2
  271. package/examples/playground/app/documents/[documentId]/code-editor.tsx +12 -1
  272. package/examples/playground/app/documents/[documentId]/document-client.tsx +10 -11
  273. package/examples/playground/app/documents/[documentId]/page.tsx +1 -2
  274. package/examples/playground/app/documents/[documentId]/session.ts +2 -2
  275. package/examples/playground/app/documents/model.ts +27 -27
  276. package/examples/playground/app/globals.css +420 -207
  277. package/examples/playground/app/initial-state.test.tsx +54 -0
  278. package/examples/playground/app/layout.tsx +7 -23
  279. package/examples/playground/app/orders/[orderId]/page.tsx +1 -2
  280. package/examples/playground/app/orders/[orderId]/session.ts +2 -2
  281. package/examples/playground/app/orders/model.ts +49 -49
  282. package/examples/playground/app/page.tsx +13 -102
  283. package/examples/playground/app/recovery/[recoveryId]/page.tsx +1 -2
  284. package/examples/playground/app/recovery/[recoveryId]/session.ts +2 -2
  285. package/examples/playground/app/recovery/model.ts +30 -30
  286. package/examples/playground/instrumentation.ts +5 -0
  287. package/examples/playground/next.config.ts +17 -5
  288. package/examples/playground/package.json +13 -2
  289. package/examples/playground/scripts/a0-template.ts +77 -0
  290. package/examples/playground/vercel.json +36 -0
  291. package/package.json +29 -3
  292. package/src/actor-client.ts +16 -4
  293. package/src/actor-react.ts +25 -11
  294. package/src/actor-shared.ts +21 -6
  295. package/src/actor.ts +76 -31
  296. package/src/ai-agent-actions.ts +219 -0
  297. package/src/ai-agent.ts +2517 -0
  298. package/src/ai-app-state.ts +91 -0
  299. package/src/ai-client-state.ts +6 -3
  300. package/src/ai-client.ts +62 -0
  301. package/src/ai-context-schema.ts +25 -5
  302. package/src/ai-context.ts +90 -40
  303. package/src/ai-control-server.ts +92 -72
  304. package/src/ai-coordinator.ts +23 -22
  305. package/src/ai-fx.browser.ts +5 -0
  306. package/src/ai-fx.ts +833 -0
  307. package/src/ai-harness.ts +518 -0
  308. package/src/ai-message-base.ts +209 -0
  309. package/src/ai-message-projection.ts +262 -64
  310. package/src/ai-model-metadata.ts +11 -6
  311. package/src/ai-pi.browser.ts +5 -0
  312. package/src/ai-pi.ts +1263 -0
  313. package/src/ai-progress-batches.ts +26 -0
  314. package/src/ai-projector.ts +95 -14
  315. package/src/ai-react.ts +146 -0
  316. package/src/ai-sdk-step.ts +2 -2
  317. package/src/ai-server.browser.ts +2 -2
  318. package/src/ai-server.ts +23 -1965
  319. package/src/ai-stored-state.ts +48 -163
  320. package/src/ai.ts +339 -155
  321. package/src/bootstrap-stream.ts +72 -0
  322. package/src/bounded-cache.ts +55 -0
  323. package/src/client-render-session.ts +49 -0
  324. package/src/client.ts +688 -151
  325. package/src/contract.ts +92 -42
  326. package/src/deterministic-id.ts +37 -0
  327. package/src/equal-json.ts +32 -0
  328. package/src/errors.ts +13 -1
  329. package/src/index.ts +5 -1
  330. package/src/initial-snapshot.ts +39 -0
  331. package/src/internal.ts +52 -0
  332. package/src/libfx.d.ts +34 -0
  333. package/src/postgres-notification-scope.ts +14 -3
  334. package/src/react.ts +171 -206
  335. package/src/redis-http-reply.ts +256 -0
  336. package/src/redis-http-stream.ts +73 -0
  337. package/src/redis-http-subscriptions.ts +13 -5
  338. package/src/reducer-runtime.ts +52 -0
  339. package/src/reducer.ts +93 -11
  340. package/src/scheduler-qstash.ts +49 -20
  341. package/src/scheduler-task.ts +34 -2
  342. package/src/scheduler-vercel.ts +32 -84
  343. package/src/server-fetch.ts +139 -35
  344. package/src/server.ts +1133 -255
  345. package/src/session-clear.ts +23 -0
  346. package/src/session-socket.ts +154 -49
  347. package/src/sse.ts +71 -4
  348. package/src/store-memory.ts +252 -75
  349. package/src/store-polling.ts +18 -4
  350. package/src/store-postgres.ts +541 -114
  351. package/src/store-redis-core.ts +1450 -493
  352. package/src/store-redis-http.ts +18 -2
  353. package/src/store-redis-notify.ts +262 -85
  354. package/src/store-redis.ts +19 -9
  355. package/src/store-sqlite.ts +319 -45
  356. package/src/store.ts +79 -1
  357. package/src/stream-buffer.ts +43 -0
  358. package/src/telemetry.ts +14 -1
  359. package/src/wire.ts +241 -13
  360. package/dist/actor-ohPC-81x.d.ts.map +0 -1
  361. package/dist/actor-shared-B5tJfzt-.js.map +0 -1
  362. package/dist/ai-CrEf6p_W.d.ts.map +0 -1
  363. package/dist/ai-DgOBltJ_.js.map +0 -1
  364. package/dist/client-BYzHjkwU.js.map +0 -1
  365. package/dist/client-CzyacQpJ.d.ts.map +0 -1
  366. package/dist/contract-CKRg_E4q.js.map +0 -1
  367. package/dist/errors-DCk6ch5n.js.map +0 -1
  368. package/dist/errors-DvhSXnxk.d.ts.map +0 -1
  369. package/dist/internal-Dq2qYxou.js.map +0 -1
  370. package/dist/react.d.ts.map +0 -1
  371. package/dist/reducer-DEMjEY_O.js +0 -29
  372. package/dist/reducer-DEMjEY_O.js.map +0 -1
  373. package/dist/reducer-otzHjuJj.d.ts.map +0 -1
  374. package/dist/scheduler-task-BpzhPnRS.js.map +0 -1
  375. package/dist/server-BD5ckxZb.js.map +0 -1
  376. package/dist/server-DbD7IJVK.d.ts.map +0 -1
  377. package/dist/store-DyZM6fS5.d.ts.map +0 -1
  378. package/dist/store-polling-CmxUbV93.js.map +0 -1
  379. package/dist/store-redis-notify-BVLUfI3j.js +0 -1583
  380. package/dist/store-redis-notify-BVLUfI3j.js.map +0 -1
  381. package/dist/telemetry-B5jzpy6y.d.ts.map +0 -1
  382. package/dist/validate-XKT4FSNn.js +0 -30
  383. package/dist/validate-XKT4FSNn.js.map +0 -1
  384. package/dist/wire-BO5wWCb1.js.map +0 -1
  385. package/docs/concepts/01-contracts.mdx +0 -142
  386. package/docs/concepts/02-handlers.mdx +0 -284
  387. package/docs/concepts/03-durability.mdx +0 -314
  388. package/docs/concepts/04-state.mdx +0 -209
  389. package/docs/guides/01-timers.mdx +0 -185
  390. package/docs/guides/02-cancellation.mdx +0 -161
  391. package/docs/guides/03-react.mdx +0 -384
  392. package/docs/guides/04-local-first.mdx +0 -88
  393. package/docs/guides/05-production.mdx +0 -521
  394. package/docs/guides/06-ai-agents.mdx +0 -1318
  395. package/docs/guides/08-application-data.mdx +0 -113
  396. package/docs/guides/09-presence.mdx +0 -266
  397. package/docs/guides/10-transports.mdx +0 -221
  398. package/docs/reference/01-api.mdx +0 -1896
  399. package/docs/reference/02-errors.mdx +0 -149
  400. package/examples/playground/app/agent/[agentId]/compaction/route.ts +0 -14
  401. package/examples/playground/app/agent/compaction-settings.test.ts +0 -144
  402. package/examples/playground/app/agent/compaction-settings.ts +0 -49
  403. package/examples/playground/app/agent/compaction-timeline.test.ts +0 -377
  404. package/examples/playground/app/agent/compaction-timeline.ts +0 -208
  405. /package/docs/{concepts → contract}/meta.ts +0 -0
  406. /package/docs/{guides → server}/meta.ts +0 -0
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({
@@ -24,28 +25,55 @@ import {
24
25
  DRAIN_TIMINGS,
25
26
  POLL_TIMINGS,
26
27
  MAX_DATE_MS,
28
+ failedPreviousAttempt,
27
29
  RESERVED_PARTICIPANT_IDS,
30
+ STATE_CACHE,
28
31
  markSchedulerSendFailure,
32
+ stateTailReaders,
29
33
  nullProtoRecord,
30
34
  serverInternals,
31
35
  type DrainOutcome,
32
36
  type DrainResult,
33
37
  } from './internal.ts'
38
+ import {
39
+ isSessionCleared,
40
+ sessionClearedError,
41
+ type SessionCleared,
42
+ } from './session-clear.ts'
43
+ import { boundedCache } from './bounded-cache.ts'
34
44
  import { defaultSleep, type Sleeper } from './store-polling.ts'
35
45
  import { createStreamActivity, streamActivities } from './stream-activity.ts'
46
+ import {
47
+ bufferedEvents,
48
+ isSerializedEvent,
49
+ takeBuffered,
50
+ } from './stream-buffer.ts'
36
51
  import type {
37
52
  A2Store,
38
53
  AppendEvent,
39
54
  Event,
40
55
  EventCause,
41
56
  StoreClaimAvailableResult,
57
+ StoreClearResult,
58
+ StoreReclaimResult,
59
+ StoreRedriveResult,
42
60
  StoreSnapshotWrite,
43
61
  StoreStateRead,
44
62
  PresenceRow,
45
63
  ReturnedEvent,
46
64
  StoredEvent,
47
65
  } from './store.ts'
48
- import type { Reducer } from './reducer.ts'
66
+ import {
67
+ cloneInitial,
68
+ foldEvent,
69
+ type Reducer,
70
+ type RegisteredReducer,
71
+ } from './reducer.ts'
72
+ type PublicState<R> = undefined extends R
73
+ ? never
74
+ : R extends { initialState: infer S }
75
+ ? S
76
+ : never
49
77
  import {
50
78
  createServerFetch,
51
79
  type A2Operation,
@@ -59,6 +87,7 @@ import {
59
87
  deterministicEventId,
60
88
  deterministicReturnedEventId,
61
89
  deterministicScheduledEventId,
90
+ deterministicScheduleClearId,
62
91
  deterministicScheduleId,
63
92
  } from './deterministic-id.ts'
64
93
  import { invocationDeadlineMs, platformWaitUntil } from './platform.ts'
@@ -66,6 +95,7 @@ import { retryableLazy } from './retryable-lazy.ts'
66
95
  import type {
67
96
  ScheduledEvent,
68
97
  SchedulerAppendTask,
98
+ SchedulerClearTask,
69
99
  SchedulerTask,
70
100
  } from './scheduler-task.ts'
71
101
  import {
@@ -79,13 +109,14 @@ export type HandlerContext<
79
109
  D extends EventDefs,
80
110
  K extends keyof D & string = keyof D & string,
81
111
  P extends PresenceDefs = Record<never, never>,
112
+ S = never,
82
113
  > = {
83
114
  /** The triggering event. */
84
115
  event: ContractEvent<D, K>
85
116
  /** Durable, 1-based dispatch ordinal for this event. */
86
117
  attempt: number
87
118
  /** This session, with handler-scoped idempotent append. */
88
- session: Session<D, HandlerAppend<D>, P>
119
+ session: Session<D, HandlerAppend<D>, P, S>
89
120
  /** Fires on `abortOn` events (cancellation slice); dormant otherwise. */
90
121
  signal: AbortSignal
91
122
  }
@@ -94,8 +125,9 @@ export type Handler<
94
125
  D extends EventDefs,
95
126
  K extends keyof D & string = keyof D & string,
96
127
  P extends PresenceDefs = Record<never, never>,
128
+ S = never,
97
129
  > = (
98
- ctx: HandlerContext<D, K, P>,
130
+ ctx: HandlerContext<D, K, P, S>,
99
131
  ) => Promise<void | AppendInput<D> | readonly AppendInput<D>[]>
100
132
 
101
133
  export type LaneContext<
@@ -130,16 +162,70 @@ export type ScheduleDelay = `${number}${'ms' | 's' | 'm' | 'h' | 'd'}`
130
162
  export type ScheduleTiming =
131
163
  { delay: ScheduleDelay; at?: never } | { at: Date; delay?: never }
132
164
 
133
- export type SessionSchedule<D extends EventDefs> = (
134
- name: string,
135
- timing: ScheduleTiming,
136
- ...events: AppendInput<D>[]
137
- ) => Promise<void>
165
+ export type SessionSchedule<D extends EventDefs> = {
166
+ (
167
+ name: string,
168
+ timing: ScheduleTiming,
169
+ ...events: AppendInput<D>[]
170
+ ): Promise<void>
171
+ /**
172
+ * Publish a durable conditional clear. Delivery clears only while
173
+ * `ifLastIndex` is still the head, and finishes deleting before it
174
+ * acknowledges.
175
+ */
176
+ clear(
177
+ name: string,
178
+ options: ScheduleTiming & { ifLastIndex: number },
179
+ ): Promise<void>
180
+ }
138
181
 
139
182
  export type StateOptions = {
140
183
  through?: number | 'latest'
141
184
  }
142
185
 
186
+ export type SessionClearOptions = {
187
+ /** Clear only while this is the head; a mismatch changes nothing. */
188
+ ifLastIndex?: number
189
+ }
190
+
191
+ export type SessionClearResult = { cleared: boolean; index: number }
192
+
193
+ export type SessionRedriveOptions = {
194
+ /** `retry` runs the handler again with a fresh failure budget; `skip` settles the event without running it. */
195
+ action: 'retry' | 'skip'
196
+ }
197
+
198
+ export type SessionRedriveResult = {
199
+ /** Whether the event was dead-lettered and is now resolved. */
200
+ redriven: boolean
201
+ }
202
+
203
+ /** A dead-lettered event, with what an operator needs to resolve it. */
204
+ export type DeadLetter<D extends EventDefs> = ContractEvent<D> & {
205
+ lane: string | null
206
+ /** Durable dispatch claims, including claims abandoned by hard kills. */
207
+ attemptCount: number
208
+ /** Caught handler failures since the event was appended or last retried. */
209
+ failureCount: number
210
+ /** The last handler failure, stringified. */
211
+ lastError: string | null
212
+ failedAt: Date
213
+ }
214
+
215
+ /** Dead-letter resolution; root sessions only. */
216
+ export type SessionDeadLetters<D extends EventDefs> = {
217
+ /** The session's dead-lettered events, oldest first. */
218
+ deadLetters(): Promise<DeadLetter<D>[]>
219
+ /**
220
+ * Atomically resolve one dead-lettered event, then wake the session like
221
+ * an append. Any other event is left alone and reports `redriven: false`.
222
+ */
223
+ redrive(
224
+ index: number,
225
+ options: SessionRedriveOptions,
226
+ ): Promise<SessionRedriveResult>
227
+ }
228
+
143
229
  /**
144
230
  * The presence members of a session — intersected in via
145
231
  * `WithPresence`, so they exist exactly when the contract declares
@@ -156,7 +242,9 @@ export type SessionPresence<D extends EventDefs, P extends PresenceDefs> = {
156
242
  stream(opts: {
157
243
  startAfter?: number
158
244
  presence: true
159
- }): AsyncIterable<ContractEvent<D> | PresencePatch<P> | PresenceSnapshot<P>>
245
+ }): AsyncIterable<
246
+ ContractEvent<D> | SessionCleared | PresencePatch<P> | PresenceSnapshot<P>
247
+ >
160
248
  /**
161
249
  * Validate against the contract's presence schemas, then broadcast.
162
250
  * Never an append: no log row, no dispatch, no scheduler arm. `at`
@@ -178,20 +266,33 @@ export type Session<
178
266
  D extends EventDefs,
179
267
  Append = SessionAppend<D>,
180
268
  P extends PresenceDefs = Record<never, never>,
269
+ S = never,
181
270
  > = WithPresence<P, SessionPresence<D, P>> & {
182
271
  readonly id: string
183
272
  append: Append
184
273
  schedule: SessionSchedule<D>
274
+ /**
275
+ * End the current lifetime: atomically append a clear marker, then
276
+ * delete the history below it. Indexes never restart.
277
+ */
278
+ clear(options?: SessionClearOptions): Promise<SessionClearResult>
185
279
  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 }>
280
+ state: {
281
+ <T>(
282
+ reducer: Reducer<D, T, Record<never, never>>,
283
+ options?: StateOptions,
284
+ ): Promise<{ state: T; index: number; clearIndex?: number }>
285
+ } & ([S] extends [never]
286
+ ? unknown
287
+ : { (): Promise<{ state: S; index: number; clearIndex?: number }> })
190
288
  /**
191
289
  * A live feed of this session's events, starting after `startAfter`
192
290
  * (exclusive). Server-side only. `server.fetch` exposes it over SSE.
291
+ * A clear arrives as a `SessionCleared` marker.
193
292
  */
194
- stream(opts?: { startAfter?: number }): AsyncIterable<ContractEvent<D>>
293
+ stream(opts?: {
294
+ startAfter?: number
295
+ }): AsyncIterable<ContractEvent<D> | SessionCleared>
195
296
  }
196
297
 
197
298
  /**
@@ -221,14 +322,18 @@ export type A2Scheduler = {
221
322
  export type A2Server<
222
323
  D extends EventDefs,
223
324
  P extends PresenceDefs = Record<never, never>,
325
+ R extends RegisteredReducer<D, P> | undefined =
326
+ RegisteredReducer<D, P> | undefined,
224
327
  > = {
225
328
  /** The contract this server implements. */
226
- readonly contract: Contract<D, P>
329
+ readonly contract: Contract<D, P, R>
227
330
  readonly fetch: {
228
331
  (request: Request): Promise<Response>
229
332
  (request: Request, options: ServerFetchOptions<D, P>): Promise<Response>
230
333
  }
231
- session(id: string): Session<D, SessionAppend<D>, P>
334
+ session(
335
+ id: string,
336
+ ): Session<D, SessionAppend<D>, P, PublicState<R>> & SessionDeadLetters<D>
232
337
  /**
233
338
  * Process every currently eligible event. `settled` means nothing
234
339
  * actionable remains, including work blocked behind a dead letter.
@@ -259,13 +364,58 @@ export async function deliverSchedulerAppend(
259
364
  `a2 scheduler: append task for contract '${task.contract}' cannot be delivered to '${server.contract.name}'`,
260
365
  )
261
366
  }
367
+ const { originIndex } = task
368
+ // A malformed origin would read as cleared and drop the events silently.
369
+ if (
370
+ originIndex !== undefined &&
371
+ (!Number.isSafeInteger(originIndex) || originIndex < 1)
372
+ ) {
373
+ throw new TypeError(
374
+ 'a2 scheduler: append task originIndex must be a positive safe integer',
375
+ )
376
+ }
262
377
  const internals = serverInternals.get(server)
263
378
  if (!internals) {
264
379
  throw new TypeError(
265
380
  `a2 scheduler: server for contract '${task.contract}' cannot receive scheduled appends`,
266
381
  )
267
382
  }
268
- await internals.schedulerAppend(task.sessionId, task.events)
383
+ await internals.schedulerAppend(task.sessionId, task.events, originIndex)
384
+ }
385
+
386
+ /**
387
+ * Deliver one authenticated scheduler clear: a conditional
388
+ * `clear({ ifLastIndex })` that resolves after its deletion finishes, so a
389
+ * provider retry resumes an interrupted deletion. Custom scheduler adapters
390
+ * call this after validating their transport envelope; application code uses
391
+ * `session.schedule.clear`.
392
+ */
393
+ export async function deliverSchedulerClear(
394
+ server: DrainableServer,
395
+ task: SchedulerClearTask,
396
+ ): Promise<void> {
397
+ if (server.contract.name !== task.contract) {
398
+ throw new TypeError(
399
+ `a2 scheduler: clear task for contract '${task.contract}' cannot be delivered to '${server.contract.name}'`,
400
+ )
401
+ }
402
+ // Without its condition a clear task would erase the whole session.
403
+ if (
404
+ task.kind !== 'clear' ||
405
+ !Number.isSafeInteger(task.ifLastIndex) ||
406
+ task.ifLastIndex < 0
407
+ ) {
408
+ throw new TypeError(
409
+ 'a2 scheduler: a clear task needs kind "clear" and a non-negative safe integer ifLastIndex',
410
+ )
411
+ }
412
+ const internals = serverInternals.get(server)
413
+ if (!internals) {
414
+ throw new TypeError(
415
+ `a2 scheduler: server for contract '${task.contract}' cannot receive scheduled clears`,
416
+ )
417
+ }
418
+ await internals.schedulerClear(task.sessionId, task.ifLastIndex)
269
419
  }
270
420
 
271
421
  /**
@@ -292,13 +442,14 @@ export type HandlerEntry<
292
442
  D extends EventDefs,
293
443
  K extends keyof D & string = keyof D & string,
294
444
  P extends PresenceDefs = Record<never, never>,
445
+ S = never,
295
446
  > =
296
- | Handler<D, K, P>
447
+ | Handler<D, K, P, S>
297
448
  | {
298
449
  abortOn?: AbortSpec<D, K>
299
450
  /** Session-scoped FIFO key, resolved and persisted when the event lands. */
300
451
  lane?: Lane<D, K>
301
- handler: Handler<D, K, P>
452
+ handler: Handler<D, K, P, S>
302
453
  }
303
454
 
304
455
  type HandlerFunction = (ctx: never) => Promise<unknown>
@@ -315,9 +466,11 @@ export function handler(
315
466
  export type ServerOptions<
316
467
  D extends EventDefs,
317
468
  P extends PresenceDefs = Record<never, never>,
469
+ R extends RegisteredReducer<D, P> | undefined =
470
+ RegisteredReducer<D, P> | undefined,
318
471
  > = {
319
472
  /** The contract this server implements (see `a2.contract`). */
320
- contract: Contract<D, P>
473
+ contract: Contract<D, P, R>
321
474
  /** Where events live. Defaults: sqlite in dev, memory in tests, required in prod. */
322
475
  store?: A2Store
323
476
  /**
@@ -341,11 +494,31 @@ export type ServerOptions<
341
494
  * wasn't imported. Compose across files by spreading objects (note:
342
495
  * a duplicate key under spread silently last-wins).
343
496
  */
344
- handlers?: { [K in keyof D & string]?: HandlerEntry<D, K, P> }
497
+ handlers?: { [K in keyof D & string]?: HandlerEntry<D, K, P, PublicState<R>> }
345
498
  }
346
499
 
347
500
  const MAX_FAILURES = 10
348
501
 
502
+ /** Abort a lost attempt's handler and release its drain slot. */
503
+ function endSuperseded(
504
+ entry: {
505
+ supersede: AbortController
506
+ fence: (outcome: {
507
+ status: 'superseded'
508
+ opensLane: false
509
+ appendsEvents: false
510
+ }) => void
511
+ },
512
+ message: string,
513
+ ): void {
514
+ entry.supersede.abort(new A2Error('SUPERSEDED_ATTEMPT', message))
515
+ entry.fence({ status: 'superseded', opensLane: false, appendsEvents: false })
516
+ }
517
+
518
+ /** Pause between clear-deletion batches that found only locked rows. */
519
+ const RECLAIM_STALL_MS = 20
520
+ const RECLAIM_STALL_LIMIT = 50
521
+
349
522
  /** Default: how long a presence value survives without a refreshing set. */
350
523
  const PRESENCE_TTL_MS = 60_000
351
524
 
@@ -470,6 +643,27 @@ const devDefaultStore = retryableLazy(() =>
470
643
  import('./store-sqlite.ts').then((m) => m.sqlite()),
471
644
  )
472
645
 
646
+ /** Waits for a scheduler arm for at most `schedulerArmTimeoutMs`. */
647
+ async function joinSchedulerArm(arm: Promise<void>): Promise<void> {
648
+ let timeout: ReturnType<typeof setTimeout> | null = null
649
+ try {
650
+ await Promise.race([
651
+ arm,
652
+ new Promise<never>((_, reject) => {
653
+ timeout = setTimeout(() => {
654
+ reject(
655
+ new Error(
656
+ `a2 scheduler arm timed out after ${DRAIN_TIMINGS.schedulerArmTimeoutMs}ms`,
657
+ ),
658
+ )
659
+ }, DRAIN_TIMINGS.schedulerArmTimeoutMs)
660
+ }),
661
+ ])
662
+ } finally {
663
+ if (timeout) clearTimeout(timeout)
664
+ }
665
+ }
666
+
473
667
  function environment(): 'development' | 'test' | 'production' {
474
668
  const env =
475
669
  typeof process === 'undefined' ? undefined : process.env?.['NODE_ENV']
@@ -511,11 +705,32 @@ function foldPresenceRows(
511
705
  return map
512
706
  }
513
707
 
708
+ type SnapshotWrites = Map<
709
+ number,
710
+ { state: unknown; pinEventIndexes: Set<number> }
711
+ >
712
+
713
+ const addSnapshotWrite = (
714
+ writes: SnapshotWrites,
715
+ index: number,
716
+ state: unknown,
717
+ pinEventIndex?: number,
718
+ ): void => {
719
+ let write = writes.get(index)
720
+ if (!write) {
721
+ write = { state: cloneInitial(state), pinEventIndexes: new Set() }
722
+ writes.set(index, write)
723
+ }
724
+ if (pinEventIndex !== undefined) write.pinEventIndexes.add(pinEventIndex)
725
+ }
726
+
514
727
  /** Implement a contract: bind its vocabulary to storage and reactions. */
515
728
  export function createServer<
516
729
  D extends EventDefs,
517
730
  P extends PresenceDefs = Record<never, never>,
518
- >(options: ServerOptions<D, P>): A2Server<D, P> {
731
+ R extends RegisteredReducer<D, P> | undefined =
732
+ RegisteredReducer<D, P> | undefined,
733
+ >(options: ServerOptions<D, P, R>): A2Server<D, P, R> {
519
734
  const serverContract = options?.contract
520
735
  if (
521
736
  serverContract === null ||
@@ -855,6 +1070,7 @@ export function createServer<
855
1070
  cause?: EventCause,
856
1071
  generatedIds?: readonly boolean[],
857
1072
  requireDurableInitialArm = false,
1073
+ originIndex?: number,
858
1074
  ): Promise<ContractEvent<D>[]> =>
859
1075
  telemetry.span(
860
1076
  'a2.append',
@@ -885,9 +1101,20 @@ export function createServer<
885
1101
  let rows: StoredEvent[]
886
1102
  let shouldHealSession: boolean
887
1103
  try {
888
- const result = await store.append(nsId(sessionId), validated)
1104
+ const result = await store.append(
1105
+ nsId(sessionId),
1106
+ validated,
1107
+ originIndex === undefined ? undefined : { originIndex },
1108
+ )
889
1109
  rows = result.events
890
- shouldHealSession = result.hasPending
1110
+ // A handler appending only settled events adds no work, and the
1111
+ // drain holding its trigger is already running.
1112
+ shouldHealSession =
1113
+ result.hasPending &&
1114
+ !(
1115
+ source === 'handler' &&
1116
+ validated.every((event) => event.settled === true)
1117
+ )
891
1118
  } catch (err) {
892
1119
  throw asStoreUnavailable(err)
893
1120
  }
@@ -915,26 +1142,12 @@ export function createServer<
915
1142
  await dispatchArm
916
1143
  }
917
1144
  if (initialArm) {
918
- let timeout: ReturnType<typeof setTimeout> | null = null
919
1145
  try {
920
- await Promise.race([
921
- initialArm,
922
- new Promise<never>((_, reject) => {
923
- timeout = setTimeout(() => {
924
- reject(
925
- new Error(
926
- `a2 scheduler arm timed out after ${DRAIN_TIMINGS.schedulerArmTimeoutMs}ms`,
927
- ),
928
- )
929
- }, DRAIN_TIMINGS.schedulerArmTimeoutMs)
930
- }),
931
- ])
1146
+ await joinSchedulerArm(initialArm)
932
1147
  } catch (err) {
933
1148
  span.setAttribute('a2.append.armed', false)
934
1149
  span.recordError(err)
935
1150
  if (requireDurableInitialArm) throw err
936
- } finally {
937
- if (timeout) clearTimeout(timeout)
938
1151
  }
939
1152
  }
940
1153
  return appended
@@ -1005,12 +1218,18 @@ export function createServer<
1005
1218
  }
1006
1219
  }
1007
1220
 
1221
+ type StateResult = { state: unknown; index: number; clearIndex?: number }
1222
+
1008
1223
  type PendingStateRead = {
1009
1224
  sessionId: string
1010
1225
  throughIndex?: number
1226
+ /** The id of the event at `throughIndex`, when the reader knows it. */
1227
+ throughEventId?: string
1011
1228
  pinEventIndex?: number
1229
+ /** A handler read: rejected once this trigger index has been cleared. */
1230
+ triggerIndex?: number
1012
1231
  span: A2SpanHandle
1013
- resolve: (result: { state: unknown; index: number }) => void
1232
+ resolve: (result: StateResult) => void
1014
1233
  reject: (reason: unknown) => void
1015
1234
  }
1016
1235
 
@@ -1045,6 +1264,331 @@ export function createServer<
1045
1264
  return plans
1046
1265
  }
1047
1266
 
1267
+ const persistSnapshots = ({
1268
+ store,
1269
+ reducer,
1270
+ sessionId,
1271
+ snapshots,
1272
+ }: {
1273
+ store: A2Store
1274
+ reducer: Reducer<D, unknown>
1275
+ sessionId: string
1276
+ snapshots: StoreSnapshotWrite[]
1277
+ }): void => {
1278
+ const persistence = Promise.resolve()
1279
+ .then(() =>
1280
+ telemetry.span(
1281
+ 'a2.snapshot',
1282
+ {
1283
+ 'a2.contract': name,
1284
+ 'a2.session_id': sessionId,
1285
+ 'a2.snapshot.reducer': reducer.name,
1286
+ 'a2.snapshot.index': snapshots.at(-1)!.index,
1287
+ 'a2.snapshot.count': snapshots.length,
1288
+ },
1289
+ async (span) => {
1290
+ try {
1291
+ await store.putSnapshots(nsId(sessionId), reducer.name, snapshots)
1292
+ } catch (error) {
1293
+ try {
1294
+ span.recordError(error)
1295
+ } catch (telemetryError) {
1296
+ // oxlint-disable-next-line preserve-caught-error -- AggregateError preserves both failures.
1297
+ throw new AggregateError(
1298
+ [error, telemetryError],
1299
+ 'Snapshot write and error reporting failed',
1300
+ { cause: telemetryError },
1301
+ )
1302
+ }
1303
+ }
1304
+ },
1305
+ ),
1306
+ )
1307
+ .catch((error: unknown) => {
1308
+ // oxlint-disable-next-line no-console -- telemetry failure must not hide failed background work.
1309
+ console.error(
1310
+ '[a2] error in a2.snapshot telemetry',
1311
+ {
1312
+ sessionId,
1313
+ reducer: reducer.name,
1314
+ index: snapshots.at(-1)!.index,
1315
+ },
1316
+ error,
1317
+ )
1318
+ })
1319
+ track(persistence)
1320
+ platformWaitUntil(persistence)
1321
+ }
1322
+
1323
+ // ── in-memory reducer state ──────────────────────────────────────
1324
+ /**
1325
+ * The latest folded state per (session, reducer) this process has seen,
1326
+ * with the index and id of the last event it reflects. An entry serves
1327
+ * only the reducer object that folded it, so a same-named reducer with
1328
+ * different logic reads the store. Cached states are never handed out;
1329
+ * readers get clones.
1330
+ */
1331
+ type CachedState = {
1332
+ reducer: Reducer<D, unknown>
1333
+ index: number
1334
+ lastId: string
1335
+ state: unknown
1336
+ bytes: number
1337
+ /** Events folded since `bytes` was last measured. */
1338
+ unmeasured: number
1339
+ persistedIndex: number
1340
+ /** The latest clear marker at or below `index`, or zero. */
1341
+ clearIndex: number
1342
+ }
1343
+ const stateCache = boundedCache<string, CachedState>(STATE_CACHE)
1344
+ const cacheState = (key: string, next: CachedState): void => {
1345
+ const current = stateCache.get(key)
1346
+ if (
1347
+ current?.reducer === next.reducer &&
1348
+ (current.index > next.index ||
1349
+ (current.index === next.index &&
1350
+ current.persistedIndex >= next.persistedIndex))
1351
+ )
1352
+ return
1353
+ stateCache.set(key, next, next.bytes)
1354
+ }
1355
+
1356
+ const writeSnapshots = ({
1357
+ store,
1358
+ reducer,
1359
+ sessionId,
1360
+ writes,
1361
+ }: {
1362
+ store: A2Store
1363
+ reducer: Reducer<D, unknown>
1364
+ sessionId: string
1365
+ writes: SnapshotWrites
1366
+ }): StoreSnapshotWrite[] => {
1367
+ const snapshots = [...writes]
1368
+ .toSorted(([a], [b]) => a - b)
1369
+ .map(([index, write]) => {
1370
+ const snapshot: StoreSnapshotWrite = {
1371
+ index,
1372
+ state: encodeState({ reducer, state: write.state }),
1373
+ }
1374
+ if (write.pinEventIndexes.size > 0) {
1375
+ snapshot.pinEventIndexes = [...write.pinEventIndexes]
1376
+ }
1377
+ return snapshot
1378
+ })
1379
+ if (snapshots.length > 0) {
1380
+ persistSnapshots({ store, reducer, sessionId, snapshots })
1381
+ }
1382
+ return snapshots
1383
+ }
1384
+
1385
+ /**
1386
+ * Folds `rows` from `position` in bound order, resolving each entry with a
1387
+ * clone once the rows through its bound are folded. `visit` sees each
1388
+ * entry's state before later rows are folded into it.
1389
+ */
1390
+ const foldEntries = ({
1391
+ reducer,
1392
+ entries,
1393
+ rows,
1394
+ position,
1395
+ state,
1396
+ index,
1397
+ clearIndex,
1398
+ snapshot,
1399
+ visit,
1400
+ }: {
1401
+ reducer: Reducer<D, unknown>
1402
+ entries: PendingStateRead[]
1403
+ rows: Event[]
1404
+ position: number
1405
+ state: unknown
1406
+ index: number
1407
+ /** The latest clear marker the rows reflect, or zero. */
1408
+ clearIndex: number
1409
+ snapshot: string
1410
+ visit: (
1411
+ entry: PendingStateRead,
1412
+ state: unknown,
1413
+ index: number,
1414
+ folded: number,
1415
+ ) => void
1416
+ }): { state: unknown; index: number; position: number } => {
1417
+ const start = position
1418
+ const ordered = entries.toSorted(
1419
+ (a, b) =>
1420
+ (a.throughIndex ?? Number.POSITIVE_INFINITY) -
1421
+ (b.throughIndex ?? Number.POSITIVE_INFINITY),
1422
+ )
1423
+ for (const entry of ordered) {
1424
+ const target = entry.throughIndex ?? Number.POSITIVE_INFINITY
1425
+ while (position < rows.length && rows[position]!.index <= target) {
1426
+ const row = rows[position]!
1427
+ state = foldEvent(reducer, state, toPublic(row))
1428
+ index = row.index
1429
+ position += 1
1430
+ }
1431
+ if (
1432
+ (entry.triggerIndex !== undefined &&
1433
+ entry.triggerIndex <= clearIndex) ||
1434
+ (entry.throughIndex !== undefined &&
1435
+ entry.throughIndex > 0 &&
1436
+ entry.throughIndex < clearIndex)
1437
+ ) {
1438
+ entry.reject(
1439
+ sessionClearedError(
1440
+ `session '${entry.sessionId}' was cleared at index ${clearIndex}`,
1441
+ ),
1442
+ )
1443
+ continue
1444
+ }
1445
+ entry.span.setAttribute('a2.state.snapshot', snapshot)
1446
+ entry.span.setAttribute('a2.state.folded', position - start)
1447
+ entry.span.setAttribute('a2.state.index', index)
1448
+ visit(entry, state, index, position - start)
1449
+ // A result below the marker (the explicit seed) predates this lifetime.
1450
+ entry.resolve({
1451
+ state: cloneInitial(state),
1452
+ index,
1453
+ ...(clearIndex > 0 && index >= clearIndex ? { clearIndex } : {}),
1454
+ })
1455
+ }
1456
+ return { state, index, position }
1457
+ }
1458
+
1459
+ /**
1460
+ * Events from `fromIndex` (inclusive) through `throughIndex` or the newest
1461
+ * event, or null when the tail is too long to be worth folding in memory.
1462
+ */
1463
+ const readCachedTail = async (
1464
+ store: A2Store,
1465
+ sessionId: string,
1466
+ fromIndex: number,
1467
+ throughIndex?: number,
1468
+ ): Promise<Event[] | null> => {
1469
+ const readTail = stateTailReaders.get(store)
1470
+ if (readTail) {
1471
+ try {
1472
+ return await readTail(nsId(sessionId), fromIndex, throughIndex)
1473
+ } catch (err) {
1474
+ throw asStoreUnavailable(err)
1475
+ }
1476
+ }
1477
+ const limit = fromIndex + STATE_CACHE.maxTail
1478
+ const capped = throughIndex === undefined || throughIndex > limit
1479
+ const rows = await readHistory(sessionId, {
1480
+ gte: fromIndex,
1481
+ lte: capped ? limit : throughIndex,
1482
+ })
1483
+ return capped && rows.at(-1)?.index === limit ? null : rows
1484
+ }
1485
+
1486
+ /**
1487
+ * Serves a plan from the cached state when every read is at or past the
1488
+ * cached index. A read bounded at the cached index by an event whose id
1489
+ * matches the cached one (a handler's own trigger) needs no store request;
1490
+ * any other read fetches the tail from the cached event onward and checks
1491
+ * that event's id. Returns false when the store must answer instead.
1492
+ */
1493
+ const foldCachedPlan = async (
1494
+ store: A2Store,
1495
+ reducer: Reducer<D, unknown>,
1496
+ plan: StateReadPlan,
1497
+ ): Promise<boolean> => {
1498
+ const key = stateCacheKey(plan.sessionId, reducer.name)
1499
+ const cached = stateCache.get(key)
1500
+ if (cached?.reducer !== reducer) return false
1501
+ if (
1502
+ plan.entries.some(
1503
+ (entry) =>
1504
+ entry.throughIndex !== undefined && entry.throughIndex < cached.index,
1505
+ )
1506
+ )
1507
+ return false
1508
+ let rows: Event[] = []
1509
+ if (
1510
+ plan.entries.some(
1511
+ (entry) =>
1512
+ entry.throughIndex !== cached.index ||
1513
+ entry.throughEventId !== cached.lastId,
1514
+ )
1515
+ ) {
1516
+ const tail = await readCachedTail(
1517
+ store,
1518
+ plan.sessionId,
1519
+ cached.index,
1520
+ plan.throughIndex,
1521
+ )
1522
+ if (!tail) return false
1523
+ // A clear after the cached event, or a rewritten log, needs the store:
1524
+ // it applies the boundary and its fences.
1525
+ if (
1526
+ tail[0]?.index !== cached.index ||
1527
+ tail[0].id !== cached.lastId ||
1528
+ tail.some((row, position) => position > 0 && isSessionCleared(row))
1529
+ ) {
1530
+ stateCache.delete(key)
1531
+ return false
1532
+ }
1533
+ rows = tail
1534
+ }
1535
+ const writes: SnapshotWrites = new Map()
1536
+ const folded = foldEntries({
1537
+ reducer,
1538
+ entries: plan.entries,
1539
+ rows,
1540
+ position: 1,
1541
+ state: rows.length > 1 ? cloneInitial(cached.state) : cached.state,
1542
+ index: cached.index,
1543
+ clearIndex: cached.clearIndex,
1544
+ snapshot: 'memory',
1545
+ visit: (entry, state, index) => {
1546
+ if (entry.pinEventIndex !== undefined) {
1547
+ addSnapshotWrite(writes, index, state, entry.pinEventIndex)
1548
+ }
1549
+ },
1550
+ })
1551
+ const added = Math.max(0, folded.position - 1)
1552
+ const rewritesHead =
1553
+ folded.index - cached.persistedIndex >= STATE_CACHE.snapshotEvery
1554
+ if (rewritesHead && !writes.has(folded.index)) {
1555
+ writes.set(folded.index, {
1556
+ state: folded.state,
1557
+ pinEventIndexes: new Set(),
1558
+ })
1559
+ }
1560
+ const snapshots = writeSnapshots({
1561
+ store,
1562
+ reducer,
1563
+ sessionId: plan.sessionId,
1564
+ writes,
1565
+ })
1566
+ const measure =
1567
+ rewritesHead || cached.unmeasured + added >= STATE_CACHE.measureEvery
1568
+ let grown = 0
1569
+ if (!measure) {
1570
+ for (let position = 1; position < folded.position; position += 1) {
1571
+ grown += JSON.stringify(rows[position]!.payload)?.length ?? 0
1572
+ }
1573
+ }
1574
+ cacheState(key, {
1575
+ reducer,
1576
+ index: folded.index,
1577
+ lastId: added > 0 ? rows[folded.position - 1]!.id : cached.lastId,
1578
+ state: folded.state,
1579
+ bytes: measure
1580
+ ? measuredBytes(reducer, folded.state, folded.index, snapshots)
1581
+ : cached.bytes + grown,
1582
+ unmeasured: measure ? 0 : cached.unmeasured + added,
1583
+ persistedIndex: Math.max(
1584
+ cached.persistedIndex,
1585
+ snapshots.at(-1)?.index ?? 0,
1586
+ ),
1587
+ clearIndex: cached.clearIndex,
1588
+ })
1589
+ return true
1590
+ }
1591
+
1048
1592
  // The cache is untrusted: schema rejection refolds from the raw store.
1049
1593
  const foldStatePlan = async (
1050
1594
  store: A2Store,
@@ -1057,11 +1601,18 @@ export function createServer<
1057
1601
  let snapshotOutcome = 'miss'
1058
1602
  const snap = stateRead.snapshot
1059
1603
  let rows = stateRead.events
1604
+ let clearIndex = stateRead.clearIndex ?? 0
1060
1605
  if (snap) {
1061
- if (reducer.stateSchema) {
1606
+ let decoded: unknown
1607
+ try {
1608
+ decoded = decodeState({ reducer, state: snap.state })
1609
+ } catch {
1610
+ snapshotOutcome = 'rejected'
1611
+ }
1612
+ if (snapshotOutcome !== 'rejected' && reducer.stateSchema) {
1062
1613
  const result = validateSync(
1063
1614
  reducer.stateSchema,
1064
- snap.state,
1615
+ decoded,
1065
1616
  'the stateSchema',
1066
1617
  )
1067
1618
  if (result.issues) {
@@ -1071,8 +1622,8 @@ export function createServer<
1071
1622
  index = snap.index
1072
1623
  snapshotOutcome = 'hit'
1073
1624
  }
1074
- } else {
1075
- state = snap.state
1625
+ } else if (snapshotOutcome !== 'rejected') {
1626
+ state = decoded
1076
1627
  index = snap.index
1077
1628
  snapshotOutcome = 'hit'
1078
1629
  }
@@ -1088,116 +1639,52 @@ export function createServer<
1088
1639
  } catch (err) {
1089
1640
  throw asStoreUnavailable(err)
1090
1641
  }
1642
+ // Reads start at the latest marker, so a clear since readState leads.
1643
+ if (rows[0] && isSessionCleared(rows[0]))
1644
+ clearIndex = Math.max(clearIndex, rows[0].index)
1091
1645
  }
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)) {
1646
+ const writes: SnapshotWrites = new Map()
1647
+ const folded = foldEntries({
1648
+ reducer,
1649
+ entries: plan.entries,
1650
+ rows,
1651
+ position: 0,
1652
+ state,
1653
+ index,
1654
+ clearIndex,
1655
+ snapshot: snapshotOutcome,
1656
+ visit: (entry, entryState, entryIndex, count) => {
1657
+ if (entryIndex === 0) return
1658
+ if (count === 0 && entry.pinEventIndex === undefined) return
1116
1659
  const advancesHead =
1117
- stateRead.headIndex === null || index > stateRead.headIndex
1660
+ stateRead.headIndex === null || entryIndex > stateRead.headIndex
1118
1661
  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)
1662
+ snapshotOutcome === 'rejected' && entryIndex === stateRead.headIndex
1663
+ if (!advancesHead && !repairsHead && entry.pinEventIndex === undefined)
1664
+ return
1665
+ addSnapshotWrite(writes, entryIndex, entryState, entry.pinEventIndex)
1666
+ },
1667
+ })
1668
+ const snapshots = writeSnapshots({
1669
+ store,
1670
+ reducer,
1671
+ sessionId: plan.sessionId,
1672
+ writes,
1673
+ })
1674
+ if (folded.position > 0 && STATE_CACHE.maxEntries > 0) {
1675
+ cacheState(stateCacheKey(plan.sessionId, reducer.name), {
1676
+ reducer,
1677
+ index: folded.index,
1678
+ lastId: rows[folded.position - 1]!.id,
1679
+ state: folded.state,
1680
+ bytes: measuredBytes(reducer, folded.state, folded.index, snapshots),
1681
+ unmeasured: 0,
1682
+ persistedIndex: Math.min(
1683
+ Math.max(stateRead.headIndex ?? 0, ...writes.keys()),
1684
+ folded.index,
1685
+ ),
1686
+ clearIndex,
1687
+ })
1201
1688
  }
1202
1689
  }
1203
1690
 
@@ -1212,7 +1699,17 @@ export function createServer<
1212
1699
  reducer: Reducer<D, unknown>,
1213
1700
  batch: PendingStateRead[],
1214
1701
  ): Promise<void> => {
1215
- const plans = planStateReads(batch)
1702
+ const plans: StateReadPlan[] = []
1703
+ await Promise.all(
1704
+ planStateReads(batch).map(async (plan) => {
1705
+ try {
1706
+ if (!(await foldCachedPlan(store, reducer, plan))) plans.push(plan)
1707
+ } catch (error) {
1708
+ for (const entry of plan.entries) entry.reject(error)
1709
+ }
1710
+ }),
1711
+ )
1712
+ if (plans.length === 0) return
1216
1713
  let reads: (StoreStateRead | Promise<StoreStateRead>)[]
1217
1714
  try {
1218
1715
  reads = store.readStates
@@ -1243,7 +1740,8 @@ export function createServer<
1243
1740
  )
1244
1741
  } catch (error) {
1245
1742
  const failure = asStoreUnavailable(error)
1246
- for (const entry of batch) entry.reject(failure)
1743
+ for (const plan of plans)
1744
+ for (const entry of plan.entries) entry.reject(failure)
1247
1745
  return
1248
1746
  }
1249
1747
  await Promise.all(
@@ -1257,14 +1755,20 @@ export function createServer<
1257
1755
  )
1258
1756
  }
1259
1757
 
1758
+ type StateReadBounds = {
1759
+ throughIndex?: number
1760
+ throughEventId?: string
1761
+ pinEventIndex?: number
1762
+ triggerIndex?: number
1763
+ }
1764
+
1260
1765
  const enqueueStateRead = (
1261
1766
  store: A2Store,
1262
1767
  sessionId: string,
1263
1768
  reducer: Reducer<D, unknown>,
1264
- throughIndex?: number,
1265
- pinEventIndex?: number,
1266
- span?: A2SpanHandle,
1267
- ): Promise<{ state: unknown; index: number }> =>
1769
+ bounds: StateReadBounds,
1770
+ span: A2SpanHandle,
1771
+ ): Promise<StateResult> =>
1268
1772
  new Promise((resolve, reject) => {
1269
1773
  const reducerName = reducer.name
1270
1774
  let batch = pendingStateReads.get(reducerName)
@@ -1286,22 +1790,14 @@ export function createServer<
1286
1790
  })
1287
1791
  batch = opened
1288
1792
  }
1289
- batch.push({
1290
- sessionId,
1291
- ...(throughIndex === undefined ? {} : { throughIndex }),
1292
- ...(pinEventIndex === undefined ? {} : { pinEventIndex }),
1293
- span: span!,
1294
- resolve,
1295
- reject,
1296
- })
1793
+ batch.push({ sessionId, ...bounds, span, resolve, reject })
1297
1794
  })
1298
1795
 
1299
- const readState = async <S, V>(
1796
+ const readState = async <S>(
1300
1797
  sessionId: string,
1301
- reducer: Reducer<D, S, Record<never, never>, V>,
1302
- throughIndex?: number,
1303
- pinEventIndex?: number,
1304
- ): Promise<{ state: S; index: number }> => {
1798
+ reducer: Reducer<D, S, Record<never, never>>,
1799
+ bounds: StateReadBounds,
1800
+ ): Promise<{ state: S; index: number; clearIndex?: number }> => {
1305
1801
  const store = await resolveStore()
1306
1802
  return telemetry.span(
1307
1803
  'a2.state',
@@ -1315,20 +1811,27 @@ export function createServer<
1315
1811
  store,
1316
1812
  sessionId,
1317
1813
  reducer as Reducer<D, unknown>,
1318
- throughIndex,
1319
- pinEventIndex,
1814
+ bounds,
1320
1815
  span,
1321
- ) as Promise<{ state: S; index: number }>,
1816
+ ) as Promise<{ state: S; index: number; clearIndex?: number }>,
1322
1817
  )
1323
1818
  }
1324
1819
 
1325
1820
  const makeSchedule = (
1326
1821
  sessionId: string,
1327
- trigger: Pick<StoredEvent, 'id' | 'createdAt'> | null,
1822
+ trigger: Pick<
1823
+ StoredEvent,
1824
+ 'id' | 'createdAt' | 'index' | 'clearIndex'
1825
+ > | null,
1328
1826
  ): SessionSchedule<D> => {
1329
1827
  const triggerEventId = trigger?.id ?? null
1330
1828
  const triggerCreatedAt = trigger?.createdAt.getTime()
1331
- return async (scheduleName, timing, ...events) => {
1829
+ const clearIndex = trigger?.clearIndex ?? 0
1830
+ const schedule = async (
1831
+ scheduleName: string,
1832
+ timing: ScheduleTiming,
1833
+ ...events: AppendInput<D>[]
1834
+ ): Promise<void> => {
1332
1835
  if (!scheduler) {
1333
1836
  throw new TypeError(
1334
1837
  'session.schedule requires a configured scheduler adapter',
@@ -1357,6 +1860,7 @@ export function createServer<
1357
1860
  sessionId,
1358
1861
  triggerEventId,
1359
1862
  scheduleName,
1863
+ clearIndex,
1360
1864
  )
1361
1865
  const scheduledEvents: ScheduledEvent[] = await Promise.all(
1362
1866
  validated.map(async (event, item) => ({
@@ -1384,11 +1888,60 @@ export function createServer<
1384
1888
  sessionId,
1385
1889
  dueAt,
1386
1890
  events: scheduledEvents,
1891
+ ...(trigger ? { originIndex: trigger.index } : {}),
1892
+ })
1893
+ } catch (error) {
1894
+ throw markSchedulerSendFailure(error)
1895
+ }
1896
+ }
1897
+ const clear = async (
1898
+ scheduleName: string,
1899
+ clearOptions: ScheduleTiming & { ifLastIndex: number },
1900
+ ): Promise<void> => {
1901
+ if (!scheduler) {
1902
+ throw new TypeError(
1903
+ 'session.schedule.clear requires a configured scheduler adapter',
1904
+ )
1905
+ }
1906
+ assertScheduleName(scheduleName)
1907
+ if (clearOptions === null || typeof clearOptions !== 'object') {
1908
+ throw new TypeError('schedule.clear requires an ifLastIndex')
1909
+ }
1910
+ const { ifLastIndex, ...timing } = clearOptions
1911
+ assertClearIndex(ifLastIndex)
1912
+ const dueAt = scheduleDueAt(
1913
+ timing as ScheduleTiming,
1914
+ triggerCreatedAt ?? Date.now(),
1915
+ )
1916
+ const store = await resolveStore()
1917
+ if (!store.clear || !store.reclaim) {
1918
+ throw new TypeError(
1919
+ 'session.schedule.clear requires a store that supports clearing',
1920
+ )
1921
+ }
1922
+ const id = await deterministicScheduleClearId({
1923
+ contract: name,
1924
+ sessionId,
1925
+ triggerEventId,
1926
+ clearIndex,
1927
+ name: scheduleName,
1928
+ ifLastIndex,
1929
+ })
1930
+ try {
1931
+ await scheduler.schedule({
1932
+ version: 1,
1933
+ kind: 'clear',
1934
+ id,
1935
+ contract: name,
1936
+ sessionId,
1937
+ dueAt,
1938
+ ifLastIndex,
1387
1939
  })
1388
1940
  } catch (error) {
1389
1941
  throw markSchedulerSendFailure(error)
1390
1942
  }
1391
1943
  }
1944
+ return Object.assign(schedule, { clear })
1392
1945
  }
1393
1946
 
1394
1947
  const toSessionEvent = (id: string, event: Event): ContractEvent<D> =>
@@ -1455,6 +2008,14 @@ export function createServer<
1455
2008
  },
1456
2009
  }
1457
2010
  streamActivities.set(stream, activity)
2011
+ bufferedEvents.set(stream, () => {
2012
+ const event = closed || !inner ? undefined : takeBuffered(inner)
2013
+ if (!event) return undefined
2014
+ if (!isSerializedEvent(event)) return toSessionEvent(id, event)
2015
+ // Taken rows belong to this stream alone.
2016
+ event.sessionId = id
2017
+ return event
2018
+ })
1458
2019
  return stream
1459
2020
  },
1460
2021
  }
@@ -1582,6 +2143,8 @@ export function createServer<
1582
2143
  const ns = nsId(id)
1583
2144
  let closed = false
1584
2145
  let events: AsyncIterator<Event> | undefined
2146
+ // An event read the merge loop started and has not yet yielded.
2147
+ let pendingEvent: Promise<IteratorResult<Event>> | null = null
1585
2148
  let unsubscribe: (() => void) | undefined
1586
2149
  let sleeper: Sleeper | null = null
1587
2150
  let wakeQueue: (() => void) | null = null
@@ -1763,7 +2326,6 @@ export function createServer<
1763
2326
  }
1764
2327
  return null
1765
2328
  }
1766
- let pendingEvent: Promise<IteratorResult<Event>> | null = null
1767
2329
  try {
1768
2330
  // Subscribe before the snapshot read: a patch landing between
1769
2331
  // the two is painted twice, never lost — repaints are idempotent.
@@ -1873,36 +2435,245 @@ export function createServer<
1873
2435
  },
1874
2436
  }
1875
2437
  streamActivities.set(stream, activity)
2438
+ bufferedEvents.set(stream, () => {
2439
+ // A held event may follow only events already yielded; while a
2440
+ // read is outstanding, its event must go first.
2441
+ if (closed || !events || pendingEvent) return undefined
2442
+ const event = takeBuffered(events)
2443
+ if (!event) return undefined
2444
+ if (!isSerializedEvent(event)) return toSessionEvent(id, event)
2445
+ // Taken rows belong to this stream alone.
2446
+ event.sessionId = id
2447
+ return event
2448
+ })
1876
2449
  return stream
1877
2450
  },
1878
2451
  }
1879
2452
  }
1880
2453
 
2454
+ // ── clearing (a2-implementation.md §17) ──────────────────────────
2455
+
2456
+ const reclaimAll = async (store: A2Store, ns: string): Promise<void> => {
2457
+ for (let stalls = 0; ;) {
2458
+ let batch: StoreReclaimResult
2459
+ try {
2460
+ // oxlint-disable-next-line no-await-in-loop -- bounded batches, oldest first
2461
+ batch = await store.reclaim!(ns)
2462
+ } catch (err) {
2463
+ throw asStoreUnavailable(err)
2464
+ }
2465
+ if (batch.done) return
2466
+ if (batch.deleted > 0) {
2467
+ stalls = 0
2468
+ continue
2469
+ }
2470
+ // Only rows other work holds locked remain; they free up shortly.
2471
+ stalls += 1
2472
+ if (stalls === RECLAIM_STALL_LIMIT) {
2473
+ throw new A2Error(
2474
+ 'STORE_UNAVAILABLE',
2475
+ `deleting the cleared history of session '${stripNs(ns)}' made no progress`,
2476
+ )
2477
+ }
2478
+ // oxlint-disable-next-line no-await-in-loop -- paced retry
2479
+ await defaultSleep(RECLAIM_STALL_MS).promise
2480
+ }
2481
+ }
2482
+
2483
+ /** The handler attempt a `ctx.session.clear()` belongs to. */
2484
+ type ClearingAttempt = {
2485
+ cause: EventCause
2486
+ end: () => void
2487
+ signal: AbortSignal
2488
+ }
2489
+
2490
+ const clearSession = async (
2491
+ sessionId: string,
2492
+ clearOptions: SessionClearOptions | undefined,
2493
+ attempt: ClearingAttempt | undefined,
2494
+ ): Promise<SessionClearResult> => {
2495
+ const ifLastIndex = clearOptions?.ifLastIndex
2496
+ if (ifLastIndex !== undefined) assertClearIndex(ifLastIndex)
2497
+ const store = await resolveStore()
2498
+ if (!store.clear || !store.reclaim) {
2499
+ throw new TypeError(
2500
+ 'session.clear requires a store that supports clearing',
2501
+ )
2502
+ }
2503
+ const ns = nsId(sessionId)
2504
+ let result: StoreClearResult
2505
+ try {
2506
+ result = await store.clear({
2507
+ sessionId: ns,
2508
+ ...(ifLastIndex === undefined ? {} : { ifLastIndex }),
2509
+ ...(attempt ? { cause: attempt.cause } : {}),
2510
+ })
2511
+ } catch (err) {
2512
+ throw asStoreUnavailable(err)
2513
+ }
2514
+ if (result.cleared) {
2515
+ // This instance's own reads after the clear, including the clearing
2516
+ // handler's, go to the store and see the boundary.
2517
+ const cachePrefix = stateCacheKey(sessionId, '')
2518
+ stateCache.deleteWhere((key) => key.startsWith(cachePrefix))
2519
+ }
2520
+ const deletion = reclaimAll(store, ns)
2521
+ if (attempt) {
2522
+ if (attempt.cause.index <= result.clearIndex) attempt.end()
2523
+ // Ending the attempt releases its drain slot; the deletion must still
2524
+ // outlive the drain.
2525
+ const kept = deletion.catch(() => undefined)
2526
+ track(kept)
2527
+ platformWaitUntil(kept)
2528
+ }
2529
+ await deletion
2530
+ return { cleared: result.cleared, index: result.index }
2531
+ }
2532
+
2533
+ // ── dead letters ─────────────────────────────────────────────────
2534
+
2535
+ const redrivingStore = async (
2536
+ method: 'deadLetters' | 'redrive',
2537
+ ): Promise<A2Store & Required<Pick<A2Store, 'deadLetters' | 'redrive'>>> => {
2538
+ const store = await resolveStore()
2539
+ if (!store.deadLetters || !store.redrive) {
2540
+ throw new TypeError(
2541
+ `session.${method} requires a store that supports redriving dead letters`,
2542
+ )
2543
+ }
2544
+ return store as A2Store & Required<Pick<A2Store, 'deadLetters' | 'redrive'>>
2545
+ }
2546
+
2547
+ const listDeadLetters = async (
2548
+ sessionId: string,
2549
+ ): Promise<DeadLetter<D>[]> => {
2550
+ const store = await redrivingStore('deadLetters')
2551
+ let rows: StoredEvent[]
2552
+ try {
2553
+ rows = await store.deadLetters(nsId(sessionId))
2554
+ } catch (err) {
2555
+ throw asStoreUnavailable(err)
2556
+ }
2557
+ return rows.flatMap((row) =>
2558
+ row.failedAt === null
2559
+ ? []
2560
+ : [
2561
+ {
2562
+ ...toPublic(row),
2563
+ lane: row.lane,
2564
+ attemptCount: row.attemptCount,
2565
+ failureCount: row.failureCount,
2566
+ lastError: row.lastError,
2567
+ failedAt: row.failedAt,
2568
+ },
2569
+ ],
2570
+ )
2571
+ }
2572
+
2573
+ const redriveEvent = async (
2574
+ sessionId: string,
2575
+ index: number,
2576
+ redriveOptions: SessionRedriveOptions,
2577
+ ): Promise<SessionRedriveResult> => {
2578
+ assertRedriveIndex(index)
2579
+ const action = redriveOptions?.action
2580
+ if (action !== 'retry' && action !== 'skip') {
2581
+ throw new TypeError("redrive requires action 'retry' or 'skip'")
2582
+ }
2583
+ return telemetry.span(
2584
+ 'a2.redrive',
2585
+ {
2586
+ 'a2.contract': name,
2587
+ 'a2.session_id': sessionId,
2588
+ 'a2.redrive.index': index,
2589
+ 'a2.redrive.action': action,
2590
+ },
2591
+ async (span) => {
2592
+ const store = await redrivingStore('redrive')
2593
+ let result: StoreRedriveResult
2594
+ try {
2595
+ result = await store.redrive({
2596
+ sessionId: nsId(sessionId),
2597
+ index,
2598
+ action,
2599
+ })
2600
+ } catch (err) {
2601
+ throw asStoreUnavailable(err)
2602
+ }
2603
+ span.setAttribute('a2.redrive.redriven', result.redriven)
2604
+ // Healing like a top-level append, even when nothing was redriven, so
2605
+ // repeating a redrive whose wakeup was lost resumes the session.
2606
+ if (result.hasPending) {
2607
+ const watchdogAt = scheduler
2608
+ ? schedulerSlot(nextClaimWindow().watchdogAtMs)
2609
+ : undefined
2610
+ const arm =
2611
+ watchdogAt === undefined
2612
+ ? null
2613
+ : startSchedulerArm(sessionId, watchdogAt)
2614
+ scheduleDrain(sessionId, watchdogAt)
2615
+ if (arm) {
2616
+ try {
2617
+ await joinSchedulerArm(arm)
2618
+ } catch (err) {
2619
+ span.setAttribute('a2.redrive.armed', false)
2620
+ span.recordError(err)
2621
+ }
2622
+ }
2623
+ }
2624
+ return { redriven: result.redriven }
2625
+ },
2626
+ )
2627
+ }
2628
+
1881
2629
  const makeSession = <Append>(
1882
2630
  id: string,
1883
2631
  append: Append,
1884
- trigger: Pick<StoredEvent, 'id' | 'createdAt' | 'index'> | null,
2632
+ trigger: Pick<
2633
+ StoredEvent,
2634
+ 'id' | 'createdAt' | 'index' | 'clearIndex'
2635
+ > | null,
2636
+ attempt?: ClearingAttempt,
1885
2637
  ): Session<D, Append, P> =>
1886
2638
  ({
1887
2639
  id,
1888
2640
  append,
1889
2641
  schedule: makeSchedule(id, trigger),
2642
+ clear: (clearOptions?: SessionClearOptions) =>
2643
+ clearSession(id, clearOptions, attempt),
1890
2644
  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>,
2645
+ (await readHistory(id, bounds))
2646
+ .filter((row) => !isSessionCleared(row))
2647
+ .map(toPublic),
2648
+ state: <S>(
2649
+ reducer: Reducer<
2650
+ D,
2651
+ S,
2652
+ Record<never, never>
2653
+ > = serverContract.reducer as unknown as Reducer<
2654
+ D,
2655
+ S,
2656
+ Record<never, never>
2657
+ >,
1894
2658
  stateOptions?: StateOptions,
1895
2659
  ) => {
2660
+ if (!reducer)
2661
+ throw new A2Error('INVALID_PAYLOAD', 'contract has no public reducer')
1896
2662
  const through = stateOptions?.through ?? trigger?.index
1897
2663
  if (through !== undefined && through !== 'latest') {
1898
2664
  assertStateIndex(through)
1899
2665
  }
1900
- return readState(
1901
- id,
1902
- reducer,
1903
- through === 'latest' ? undefined : through,
1904
- trigger && through !== 'latest' ? trigger.index : undefined,
1905
- )
2666
+ const bounds: StateReadBounds = {}
2667
+ if (through !== undefined && through !== 'latest') {
2668
+ bounds.throughIndex = through
2669
+ if (trigger) bounds.pinEventIndex = trigger.index
2670
+ // Only a live attempt may be answered from memory with no store
2671
+ // request; a superseded one (including by a clear) reads the store.
2672
+ if (through === trigger?.index && !attempt?.signal.aborted)
2673
+ bounds.throughEventId = trigger.id
2674
+ }
2675
+ if (trigger) bounds.triggerIndex = trigger.index
2676
+ return readState(id, reducer, bounds)
1906
2677
  },
1907
2678
  stream: (opts?: { startAfter?: number; presence?: boolean }) => {
1908
2679
  const startAfter = opts?.startAfter ?? 0
@@ -1932,6 +2703,7 @@ export function createServer<
1932
2703
  trigger: StoredEvent,
1933
2704
  attempt: number,
1934
2705
  signal: AbortSignal,
2706
+ endAttempt: () => void,
1935
2707
  ): HandlerContext<D> => {
1936
2708
  const publicSessionId = stripNs(trigger.sessionId)
1937
2709
  const append: HandlerAppend<D> = async (appendName, ...events) => {
@@ -1955,15 +2727,22 @@ export function createServer<
1955
2727
  generatedIds,
1956
2728
  )
1957
2729
  }
1958
- return {
2730
+ const ctx: HandlerContext<D> = {
1959
2731
  event: toPublic(trigger),
1960
2732
  attempt,
1961
2733
  // The handler-scoped `setPresence` stamps `seen` with the
1962
2734
  // triggering event's index by default — the frontier the handler
1963
2735
  // provably reflects.
1964
- session: makeSession(publicSessionId, append, trigger),
2736
+ session: makeSession(publicSessionId, append, trigger, {
2737
+ cause: { index: trigger.index, attempt },
2738
+ end: endAttempt,
2739
+ signal,
2740
+ }),
1965
2741
  signal,
1966
2742
  }
2743
+ if (attempt > 1 && trigger.lastFailedAttempt === attempt - 1)
2744
+ failedPreviousAttempt.add(ctx)
2745
+ return ctx
1967
2746
  }
1968
2747
 
1969
2748
  const abortHubs = new Map<string, AbortHub>()
@@ -2267,17 +3046,10 @@ export function createServer<
2267
3046
  if (!entry) continue
2268
3047
  // Idempotent: the local lapse normally aborted this signal
2269
3048
  // 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
- ),
3049
+ endSuperseded(
3050
+ entry,
3051
+ `a newer attempt owns event ${index} in session '${sessionId}'`,
2275
3052
  )
2276
- entry.fence({
2277
- status: 'superseded',
2278
- opensLane: false,
2279
- appendsEvents: false,
2280
- })
2281
3053
  }
2282
3054
  if (window.deadlineCapped) stopRenewing = true
2283
3055
  scheduleLapse()
@@ -2299,6 +3071,7 @@ export function createServer<
2299
3071
  const runEvent = async (
2300
3072
  event: StoredEvent,
2301
3073
  supersedeSignal: AbortSignal,
3074
+ endAttempt: () => void,
2302
3075
  ): Promise<EventOutcome> => {
2303
3076
  const registration = handlers.get(event.type)
2304
3077
  return telemetry.span(
@@ -2338,7 +3111,7 @@ export function createServer<
2338
3111
  ])
2339
3112
  }
2340
3113
  const operation = registration?.handler(
2341
- makeCtx(event, event.attemptCount, handlerSignal),
3114
+ makeCtx(event, event.attemptCount, handlerSignal, endAttempt),
2342
3115
  )
2343
3116
  const result = operation
2344
3117
  ? await (subscription
@@ -2369,17 +3142,31 @@ export function createServer<
2369
3142
  // genuinely superseded attempt gets 'superseded' back with
2370
3143
  // no mutation. One adjudicator, not two.
2371
3144
  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
- })
3145
+ const failure = await telemetry.span(
3146
+ 'a2.commit',
3147
+ {
3148
+ 'a2.contract': name,
3149
+ 'a2.session_id': sessionId,
3150
+ 'a2.commit.operation': 'fail',
3151
+ 'a2.commit.index': event.index,
3152
+ 'a2.commit.attempt': event.attemptCount,
3153
+ },
3154
+ async (commitSpan) => {
3155
+ const result = await store.failAttempt({
3156
+ sessionId: ns,
3157
+ index: event.index,
3158
+ attempt: event.attemptCount,
3159
+ error: describeError(err),
3160
+ // A NonRetriableError is terminal on its first throw:
3161
+ // deterministic failures should not burn the retry
3162
+ // budget before dead-lettering.
3163
+ maxFailures:
3164
+ err instanceof NonRetriableError ? 1 : MAX_FAILURES,
3165
+ })
3166
+ commitSpan.setAttribute('a2.commit.outcome', result.outcome)
3167
+ return result
3168
+ },
3169
+ )
2383
3170
  eventSpan.setAttribute('a2.event.outcome', failure.outcome)
2384
3171
  return {
2385
3172
  status: failure.outcome,
@@ -2390,12 +3177,26 @@ export function createServer<
2390
3177
  await subscription?.close()
2391
3178
  }
2392
3179
  await renewal
2393
- const completion = await store.completeAttempt({
2394
- sessionId: ns,
2395
- index: event.index,
2396
- attempt: event.attemptCount,
2397
- events: returned,
2398
- })
3180
+ const completion = await telemetry.span(
3181
+ 'a2.commit',
3182
+ {
3183
+ 'a2.contract': name,
3184
+ 'a2.session_id': sessionId,
3185
+ 'a2.commit.operation': 'complete',
3186
+ 'a2.commit.index': event.index,
3187
+ 'a2.commit.attempt': event.attemptCount,
3188
+ },
3189
+ async (commitSpan) => {
3190
+ const result = await store.completeAttempt({
3191
+ sessionId: ns,
3192
+ index: event.index,
3193
+ attempt: event.attemptCount,
3194
+ events: returned,
3195
+ })
3196
+ commitSpan.setAttribute('a2.commit.outcome', result.outcome)
3197
+ return result
3198
+ },
3199
+ )
2399
3200
  eventSpan.setAttribute(
2400
3201
  'a2.event.outcome',
2401
3202
  completion.outcome === 'completed'
@@ -2430,29 +3231,28 @@ export function createServer<
2430
3231
  // the new one.
2431
3232
  const previous = active.get(event.index)
2432
3233
  if (previous) {
2433
- previous.supersede.abort(
2434
- new A2Error(
2435
- 'SUPERSEDED_ATTEMPT',
2436
- `a newer attempt owns event ${event.index} in session '${sessionId}'`,
2437
- ),
3234
+ endSuperseded(
3235
+ previous,
3236
+ `a newer attempt owns event ${event.index} in session '${sessionId}'`,
2438
3237
  )
2439
- previous.fence({
2440
- status: 'superseded',
2441
- opensLane: false,
2442
- appendsEvents: false,
2443
- })
2444
3238
  }
2445
3239
  const supersede = new AbortController()
2446
3240
  let fence!: (outcome: EventOutcome) => void
2447
3241
  const fenced = new Promise<EventOutcome>((resolve) => {
2448
3242
  fence = resolve
2449
3243
  })
3244
+ // A handler's own committed clear supersedes it immediately.
3245
+ const endAttempt = (): void =>
3246
+ endSuperseded(
3247
+ { supersede, fence },
3248
+ `event ${event.index} in session '${sessionId}' was cleared`,
3249
+ )
2450
3250
  // The race lets a durable supersession release this slot while the
2451
3251
  // zombie handler keeps running — its completion and failure are
2452
3252
  // fenced by the store, so nothing it does from here is counted.
2453
3253
  let entry!: ActiveEvent
2454
3254
  const execution = Promise.race([
2455
- runEvent(event, supersede.signal),
3255
+ runEvent(event, supersede.signal, endAttempt),
2456
3256
  fenced,
2457
3257
  ]).then((eventOutcome) => {
2458
3258
  // Identity-guarded: a fenced predecessor's continuation must
@@ -2493,17 +3293,29 @@ export function createServer<
2493
3293
  let claim: StoreClaimAvailableResult | undefined
2494
3294
  if (shouldClaim) {
2495
3295
  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
- })
3296
+ claim = await telemetry.span(
3297
+ 'a2.claim',
3298
+ { 'a2.contract': name, 'a2.session_id': sessionId },
3299
+ async (claimSpan) => {
3300
+ const result = await store.claimAvailable({
3301
+ sessionId: ns,
3302
+ holder,
3303
+ ttlMs: window.ttlMs,
3304
+ ...(window.deadlineCapped
3305
+ ? { expiresAtMs: window.expiresAtMs }
3306
+ : {}),
3307
+ ...(excluded.size === 0
3308
+ ? {}
3309
+ : { excludeIndexes: [...excluded] }),
3310
+ })
3311
+ claimSpan.setAttribute('a2.claim.outcome', result.outcome)
3312
+ claimSpan.setAttribute(
3313
+ 'a2.claim.count',
3314
+ result.outcome === 'claimed' ? result.events.length : 0,
3315
+ )
3316
+ return result
3317
+ },
3318
+ )
2507
3319
  if (claim.outcome === 'claimed') {
2508
3320
  requestWatchdog(window.watchdogAtMs)
2509
3321
  if (window.deadlineCapped) stopRenewing = true
@@ -2595,6 +3407,7 @@ export function createServer<
2595
3407
  mode: 'inline' | 'dispatch',
2596
3408
  events: AppendInput<D>[],
2597
3409
  requireDurableInitialArm = false,
3410
+ originIndex?: number,
2598
3411
  ): Promise<ContractEvent<D>[]> => {
2599
3412
  return appendCore(
2600
3413
  sessionId,
@@ -2605,10 +3418,13 @@ export function createServer<
2605
3418
  undefined,
2606
3419
  undefined,
2607
3420
  requireDurableInitialArm,
3421
+ originIndex,
2608
3422
  )
2609
3423
  }
2610
3424
 
2611
- const session = (id: string): Session<D, SessionAppend<D>, P> => {
3425
+ const session = (
3426
+ id: string,
3427
+ ): Session<D, SessionAppend<D>, P> & SessionDeadLetters<D> => {
2612
3428
  assertSessionId(id)
2613
3429
  const appendInline = async (...events: AppendInput<D>[]) =>
2614
3430
  appendExternal(id, 'inline', events)
@@ -2617,14 +3433,36 @@ export function createServer<
2617
3433
  const append = Object.assign(appendInline, {
2618
3434
  dispatch: appendDispatch,
2619
3435
  }) as SessionAppend<D>
2620
- return makeSession(id, append, null)
3436
+ return Object.assign(makeSession(id, append, null), {
3437
+ deadLetters: () => listDeadLetters(id),
3438
+ redrive: (index: number, redriveOptions: SessionRedriveOptions) =>
3439
+ redriveEvent(id, index, redriveOptions),
3440
+ })
3441
+ }
3442
+
3443
+ const historyPage = async (
3444
+ sessionId: string,
3445
+ bounds: { gte: number; lte: number },
3446
+ ): Promise<{ events: ContractEvent<D>[]; clearIndex?: number }> => {
3447
+ assertSessionId(sessionId)
3448
+ const rows = await readHistory(sessionId, bounds)
3449
+ // Reads start at the latest marker, so it can only lead the page.
3450
+ const marker = rows[0] && isSessionCleared(rows[0]) ? rows[0] : undefined
3451
+ return {
3452
+ events: (marker ? rows.slice(1) : rows).map(toPublic),
3453
+ ...(marker ? { clearIndex: marker.index } : {}),
3454
+ }
2621
3455
  }
2622
3456
 
2623
- const fetch = createServerFetch({ contract: serverContract, session })
2624
- const self: A2Server<D, P> = {
3457
+ const fetch = createServerFetch({
2625
3458
  contract: serverContract,
2626
- fetch,
2627
3459
  session,
3460
+ historyPage,
3461
+ })
3462
+ const self: A2Server<D, P, R> = {
3463
+ contract: serverContract,
3464
+ fetch,
3465
+ session: session as A2Server<D, P, R>['session'],
2628
3466
 
2629
3467
  async drain(sessionId) {
2630
3468
  assertSessionId(sessionId)
@@ -2643,14 +3481,38 @@ export function createServer<
2643
3481
  assertSessionId(sessionId)
2644
3482
  return drainSession(sessionId, opts?.watchdogDueAt)
2645
3483
  },
2646
- schedulerAppend: async (sessionId, events) => {
3484
+ schedulerAppend: async (sessionId, events, originIndex) => {
2647
3485
  if (!scheduler) {
2648
3486
  throw new TypeError(
2649
3487
  'scheduled append delivery requires a configured scheduler adapter',
2650
3488
  )
2651
3489
  }
2652
3490
  assertSessionId(sessionId)
2653
- await appendExternal(sessionId, 'inline', [...events], true)
3491
+ try {
3492
+ await appendExternal(
3493
+ sessionId,
3494
+ 'inline',
3495
+ [...events],
3496
+ true,
3497
+ originIndex,
3498
+ )
3499
+ } catch (error) {
3500
+ // The scheduling handler's lifetime was cleared: an acknowledged no-op.
3501
+ if (
3502
+ originIndex !== undefined &&
3503
+ error instanceof A2Error &&
3504
+ error.code === 'SESSION_CLEARED'
3505
+ )
3506
+ return
3507
+ throw error
3508
+ }
3509
+ },
3510
+ historyWithMarkers: async (sessionId, gte) =>
3511
+ (await readHistory(sessionId, { gte })).map(toPublic),
3512
+ schedulerClear: async (sessionId, ifLastIndex) => {
3513
+ assertSessionId(sessionId)
3514
+ assertClearIndex(ifLastIndex)
3515
+ await clearSession(sessionId, { ifLastIndex }, undefined)
2654
3516
  },
2655
3517
  })
2656
3518
  return self
@@ -2683,6 +3545,18 @@ function assertStateIndex(value: number): void {
2683
3545
  }
2684
3546
  }
2685
3547
 
3548
+ function assertRedriveIndex(value: number): void {
3549
+ if (!Number.isSafeInteger(value) || value < 1) {
3550
+ throw new TypeError('redrive requires a positive safe-integer index')
3551
+ }
3552
+ }
3553
+
3554
+ function assertClearIndex(value: number): void {
3555
+ if (!Number.isSafeInteger(value) || value < 0) {
3556
+ throw new TypeError('ifLastIndex must be a non-negative safe integer')
3557
+ }
3558
+ }
3559
+
2686
3560
  function assertAppendName(name: string): void {
2687
3561
  if (typeof name !== 'string' || name.length === 0) {
2688
3562
  throw new TypeError('handler append name must be a non-empty string')
@@ -2827,16 +3701,19 @@ function isJsonValue(value: unknown, ancestors: WeakSet<object>): boolean {
2827
3701
  }
2828
3702
  }
2829
3703
 
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
- }
3704
+ const measuredBytes = (
3705
+ reducer: object,
3706
+ state: unknown,
3707
+ index: number,
3708
+ snapshots: StoreSnapshotWrite[],
3709
+ ): number =>
3710
+ JSON.stringify(
3711
+ snapshots.find((snapshot) => snapshot.index === index)?.state ??
3712
+ encodeState({ reducer, state }),
3713
+ )?.length ?? 0
3714
+
3715
+ const stateCacheKey = (sessionId: string, reducerName: string): string =>
3716
+ `${sessionId}\u0000${reducerName}`
2840
3717
 
2841
3718
  // Server-side types users need when passing custom stores or building
2842
3719
  // transports — re-exported so experimental-a2/server is self-sufficient.
@@ -2857,6 +3734,7 @@ export type {
2857
3734
  export type {
2858
3735
  ScheduledEvent,
2859
3736
  SchedulerAppendTask,
3737
+ SchedulerClearTask,
2860
3738
  SchedulerDrainTask,
2861
3739
  SchedulerTask,
2862
3740
  } from './scheduler-task.ts'