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
@@ -16,8 +16,9 @@
16
16
  * assignment.
17
17
  */
18
18
 
19
- import { A2Error } from './errors.ts'
19
+ import { A2Error, supersededAttemptError } from './errors.ts'
20
20
  import { idempotentReplay } from './idempotent-replay.ts'
21
+ import { CLEAR_EVENT_TYPE, sessionClearedError } from './session-clear.ts'
21
22
  import { decodeReturnedEventIds } from './store-codec.ts'
22
23
  import { pollingStream, defaultSleep, type Sleeper } from './store-polling.ts'
23
24
  import { NOTIFY_TIMINGS, nullProtoRecord } from './internal.ts'
@@ -159,6 +160,11 @@ create table if not exists a2_snapshot_pins (
159
160
  create index if not exists a2_snapshot_pins_checkpoint
160
161
  on a2_snapshot_pins (session_id, reducer_name, up_to_index);
161
162
 
163
+ create table if not exists a2_clears (
164
+ session_id text primary key,
165
+ clear_index bigint not null
166
+ );
167
+
162
168
  create table if not exists a2_presence (
163
169
  ns text not null,
164
170
  participant text not null,
@@ -173,6 +179,17 @@ create table if not exists a2_presence (
173
179
 
174
180
  const asNumber = (value: unknown): number => Number(value) // bigint columns arrive as strings
175
181
 
182
+ /**
183
+ * The session's latest clear marker, or 0, as a primary-key subquery. Read
184
+ * it in a statement that runs after the operation's lock: a statement's
185
+ * snapshot starts with the statement, so one that waited for the lock could
186
+ * miss a clear that committed meanwhile.
187
+ */
188
+ const clearIndexOf = (session: string): string =>
189
+ `coalesce((select clear_index from a2_clears where session_id = ${session}), 0)`
190
+
191
+ const RECLAIM_BATCH = 256
192
+
176
193
  const toDate = (ms: unknown): Date | null =>
177
194
  ms === null || ms === undefined ? null : new Date(asNumber(ms))
178
195
 
@@ -440,17 +457,23 @@ export function postgres(options: PostgresStoreOptions = {}): PostgresStore {
440
457
  "throughIndex" bigint,
441
458
  "snapshotThroughIndex" bigint
442
459
  )
460
+ ), bounded as materialized (
461
+ select request.*, coalesce(clears.clear_index, 0) as clear_index
462
+ from request
463
+ left join a2_clears clears on clears.session_id = request."sessionId"
443
464
  ), selected as materialized (
444
465
  select request.position,
445
466
  request."sessionId" as session_id,
446
467
  request."throughIndex" as through_index,
468
+ request.clear_index,
447
469
  head.up_to_index as head_index,
448
470
  candidate.up_to_index as snapshot_index,
449
471
  candidate.state as snapshot_state
450
- from request
472
+ from bounded request
451
473
  left join a2_snapshots head
452
474
  on head.session_id = request."sessionId"
453
475
  and head.reducer_name = $2
476
+ and head.up_to_index >= request.clear_index
454
477
  left join lateral (
455
478
  select up_to_index, state from (
456
479
  select head.up_to_index, head.state
@@ -461,6 +484,7 @@ export function postgres(options: PostgresStoreOptions = {}): PostgresStore {
461
484
  from a2_snapshot_history history
462
485
  where history.session_id = request."sessionId"
463
486
  and history.reducer_name = $2
487
+ and history.up_to_index >= request.clear_index
464
488
  and (
465
489
  request."snapshotThroughIndex" is null
466
490
  or history.up_to_index <= request."snapshotThroughIndex"
@@ -472,7 +496,7 @@ export function postgres(options: PostgresStoreOptions = {}): PostgresStore {
472
496
  )
473
497
  select selected.position, 0 as row_order, selected.session_id,
474
498
  selected.head_index, selected.snapshot_index,
475
- selected.snapshot_state,
499
+ selected.snapshot_state, selected.clear_index,
476
500
  null::bigint as idx, null::text as event_type,
477
501
  null::jsonb as payload, null::text as event_id,
478
502
  null::bigint as created_at
@@ -480,12 +504,15 @@ export function postgres(options: PostgresStoreOptions = {}): PostgresStore {
480
504
  union all
481
505
  select selected.position, 1 as row_order, event.session_id,
482
506
  selected.head_index, null::bigint as snapshot_index,
483
- null::jsonb as snapshot_state,
507
+ null::jsonb as snapshot_state, selected.clear_index,
484
508
  event.idx, event.event_type, event.payload,
485
509
  event.event_id, event.created_at
486
510
  from selected
487
511
  join a2_events event on event.session_id = selected.session_id
488
- where event.idx > coalesce(selected.snapshot_index, 0)
512
+ where event.idx > greatest(
513
+ coalesce(selected.snapshot_index, 0),
514
+ selected.clear_index - 1
515
+ )
489
516
  and (
490
517
  selected.through_index is null
491
518
  or event.idx <= selected.through_index
@@ -501,6 +528,8 @@ export function postgres(options: PostgresStoreOptions = {}): PostgresStore {
501
528
  for (const row of rows) {
502
529
  const read = reads[asNumber(row['position'])]!
503
530
  if (row['row_order'] === 0 || row['row_order'] === '0') {
531
+ const clearIndex = asNumber(row['clear_index'])
532
+ if (clearIndex > 0) read.clearIndex = clearIndex
504
533
  read.headIndex =
505
534
  row['head_index'] === null ? null : asNumber(row['head_index'])
506
535
  if (row['snapshot_index'] !== null) {
@@ -552,8 +581,126 @@ export function postgres(options: PostgresStoreOptions = {}): PostgresStore {
552
581
  })
553
582
  }
554
583
 
584
+ type Query = PostgresConnection['query']
585
+
586
+ /**
587
+ * The rows owning `ids`. This session's rows below its clear marker do
588
+ * not own their ids: they are deleted here so the ids can be reused.
589
+ */
590
+ const liveOwnersOf = async ({
591
+ query,
592
+ sessionId,
593
+ clearIndex,
594
+ ids,
595
+ }: {
596
+ query: Query
597
+ sessionId: string
598
+ clearIndex: number
599
+ ids: readonly string[]
600
+ }): Promise<Record<string, unknown>[]> => {
601
+ if (ids.length === 0) return []
602
+ const { rows } = await query(
603
+ 'select * from a2_events where event_id = any($1::text[]) order by idx',
604
+ [ids],
605
+ )
606
+ const stale = rows.filter(
607
+ (row) =>
608
+ row['session_id'] === sessionId && asNumber(row['idx']) < clearIndex,
609
+ )
610
+ if (stale.length === 0) return rows
611
+ // Never wait on a stale row: cleared work holding it may itself be
612
+ // waiting for the session lock this append holds.
613
+ const { rows: deleted } = await query(
614
+ `with stale as materialized (
615
+ select idx from a2_events
616
+ where session_id = $1 and event_id = any($2::text[])
617
+ order by idx
618
+ for update skip locked
619
+ )
620
+ delete from a2_events event using stale
621
+ where event.session_id = $1 and event.idx = stale.idx
622
+ returning event.idx`,
623
+ [sessionId, stale.map((row) => row['event_id'])],
624
+ )
625
+ if (deleted.length < stale.length) {
626
+ throw new A2Error(
627
+ 'STORE_UNAVAILABLE',
628
+ `an event id being reused is still held by cleared work in session '${sessionId}'`,
629
+ )
630
+ }
631
+ return rows.filter((row) => !stale.includes(row))
632
+ }
633
+
634
+ /** Whether `index` is at or below the boundary, read in a fresh statement. */
635
+ const clearedAt = async ({
636
+ query,
637
+ sessionId,
638
+ index,
639
+ }: {
640
+ query: Query
641
+ sessionId: string
642
+ index: number
643
+ }): Promise<boolean> => {
644
+ const { rows } = await query(
645
+ `select ${clearIndexOf('$1')} as clear_index`,
646
+ [sessionId],
647
+ )
648
+ return index <= asNumber(rows[0]?.['clear_index'])
649
+ }
650
+
651
+ /** Insert one event; its lane is ready unless an unfinished lane event of this lifetime precedes it. */
652
+ const insertEvent = async ({
653
+ query,
654
+ row,
655
+ clearIndex,
656
+ returning = false,
657
+ }: {
658
+ query: Query
659
+ row: {
660
+ sessionId: string
661
+ index: number
662
+ type: string
663
+ payload: unknown
664
+ id: string
665
+ createdAt: number
666
+ cause: EventCause | null
667
+ lane: string | null
668
+ processedAt: number | null
669
+ }
670
+ clearIndex: number
671
+ returning?: boolean
672
+ }): Promise<Record<string, unknown> | undefined> => {
673
+ const { rows } = await query(
674
+ `insert into a2_events
675
+ (session_id, idx, event_type, payload, event_id, created_at,
676
+ cause, lane, lane_ready, processed_at)
677
+ values (
678
+ $1, $2, $3, $4::jsonb, $5, $6, $7::jsonb, $8,
679
+ $8::text is null or not exists (
680
+ select 1 from a2_events
681
+ where session_id = $1 and lane = $8 and processed_at is null
682
+ and idx > $10
683
+ ),
684
+ $9
685
+ )${returning ? ' returning *' : ''}`,
686
+ [
687
+ row.sessionId,
688
+ row.index,
689
+ row.type,
690
+ jsonb(row.payload),
691
+ row.id,
692
+ row.createdAt,
693
+ row.cause ? jsonb(row.cause) : null,
694
+ row.lane,
695
+ row.processedAt,
696
+ clearIndex,
697
+ ],
698
+ )
699
+ return rows[0]
700
+ }
701
+
555
702
  const store: PostgresStore = {
556
- async append(sessionId, events) {
703
+ async append(sessionId, events, appendOptions) {
557
704
  if (events.length === 0) {
558
705
  return wrap(async () => {
559
706
  const c = await client()
@@ -561,6 +708,7 @@ export function postgres(options: PostgresStoreOptions = {}): PostgresStore {
561
708
  `select exists(
562
709
  select 1 from a2_events
563
710
  where session_id = $1 and processed_at is null
711
+ and idx > ${clearIndexOf('$1')}
564
712
  ) as has_pending`,
565
713
  [sessionId],
566
714
  )
@@ -575,6 +723,23 @@ export function postgres(options: PostgresStoreOptions = {}): PostgresStore {
575
723
  // Serialize appends per session — index assignment must not
576
724
  // race across writers. Transaction-scoped, self-releasing.
577
725
  await query('select pg_advisory_xact_lock(hashtext($1))', [sessionId])
726
+ const { rows: statusRows } = await query(
727
+ `select coalesce(max(idx), 0) as max,
728
+ ${clearIndexOf('$1')} as clear_index,
729
+ exists(
730
+ select 1 from a2_events pending
731
+ where pending.session_id = $1
732
+ and pending.processed_at is null
733
+ and pending.idx > ${clearIndexOf('$1')}
734
+ ) as session_has_pending
735
+ from a2_events
736
+ where session_id = $1`,
737
+ [sessionId],
738
+ )
739
+ const base = asNumber(statusRows[0]?.['max'])
740
+ const clearIndex = asNumber(statusRows[0]?.['clear_index'])
741
+ const sessionHasPending =
742
+ statusRows[0]?.['session_has_pending'] === true
578
743
 
579
744
  const supplied = events.filter((e) => e.id !== undefined)
580
745
  const suppliedIds = supplied.map((e) => e.id as string)
@@ -585,18 +750,12 @@ export function postgres(options: PostgresStoreOptions = {}): PostgresStore {
585
750
  )
586
751
  }
587
752
  if (suppliedIds.length > 0) {
588
- const { rows: existing } = await query(
589
- `select stored.*,
590
- exists(
591
- select 1 from a2_events pending
592
- where pending.session_id = $2
593
- and pending.processed_at is null
594
- ) as session_has_pending
595
- from a2_events stored
596
- where stored.event_id = any($1)
597
- order by stored.idx`,
598
- [suppliedIds, sessionId],
599
- )
753
+ const existing = await liveOwnersOf({
754
+ query,
755
+ sessionId,
756
+ clearIndex,
757
+ ids: suppliedIds,
758
+ })
600
759
  if (existing.length > 0) {
601
760
  const foreign = existing.find(
602
761
  (row) => row['session_id'] !== sessionId,
@@ -610,7 +769,7 @@ export function postgres(options: PostgresStoreOptions = {}): PostgresStore {
610
769
  if (existing.length === events.length) {
611
770
  return {
612
771
  events: idempotentReplay(events, existing.map(toStored)),
613
- hasPending: existing[0]?.['session_has_pending'] === true,
772
+ hasPending: sessionHasPending,
614
773
  }
615
774
  }
616
775
  throw new A2Error(
@@ -620,6 +779,16 @@ export function postgres(options: PostgresStoreOptions = {}): PostgresStore {
620
779
  }
621
780
  }
622
781
 
782
+ const originIndex = appendOptions?.originIndex
783
+ if (originIndex !== undefined && originIndex <= clearIndex) {
784
+ throw sessionClearedError(
785
+ `scheduled work from event ${originIndex} was cleared in session '${sessionId}'`,
786
+ )
787
+ }
788
+ for (const e of events) {
789
+ if (e.cause && e.cause.index <= clearIndex)
790
+ throw supersededAttemptError(sessionId, e.cause)
791
+ }
623
792
  // Attempt-currency fence: a fresh handler append commits only
624
793
  // while its causal attempt is still the parent's latest
625
794
  // (store.ts `append`). `for update` serializes this read against
@@ -654,26 +823,11 @@ export function postgres(options: PostgresStoreOptions = {}): PostgresStore {
654
823
  asNumber(parent['attempt_count']) !== e.cause.attempt ||
655
824
  parent['failed_at'] !== null
656
825
  ) {
657
- throw new A2Error(
658
- 'SUPERSEDED_ATTEMPT',
659
- `attempt ${e.cause.attempt} no longer owns event ${e.cause.index} in session '${sessionId}'`,
660
- )
826
+ throw supersededAttemptError(sessionId, e.cause)
661
827
  }
662
828
  }
663
829
  }
664
830
 
665
- const { rows: maxRows } = await query(
666
- `select coalesce(max(idx), 0) as max,
667
- exists(
668
- select 1 from a2_events pending
669
- where pending.session_id = $1
670
- and pending.processed_at is null
671
- ) as session_has_pending
672
- from a2_events
673
- where session_id = $1`,
674
- [sessionId],
675
- )
676
- const base = asNumber(maxRows[0]?.['max'])
677
831
  const now = clock.now().getTime()
678
832
  const inserted: StoredEvent[] = []
679
833
  for (let i = 0; i < events.length; i += 1) {
@@ -681,30 +835,21 @@ export function postgres(options: PostgresStoreOptions = {}): PostgresStore {
681
835
  const id = e.id ?? generateId()
682
836
  const index = base + 1 + i
683
837
  // oxlint-disable-next-line no-await-in-loop -- one tx, ordered inserts
684
- await query(
685
- `insert into a2_events
686
- (session_id, idx, event_type, payload, event_id, created_at,
687
- cause, lane, lane_ready, processed_at)
688
- values (
689
- $1, $2, $3, $4::jsonb, $5, $6, $7::jsonb, $8,
690
- $8::text is null or not exists (
691
- select 1 from a2_events
692
- where session_id = $1 and lane = $8 and processed_at is null
693
- ),
694
- $9
695
- )`,
696
- [
838
+ await insertEvent({
839
+ query,
840
+ row: {
697
841
  sessionId,
698
842
  index,
699
- e.type,
700
- jsonb(e.payload),
843
+ type: e.type,
844
+ payload: e.payload,
701
845
  id,
702
- now,
703
- e.cause ? jsonb(e.cause) : null,
704
- e.lane ?? null,
705
- e.settled ? now : null,
706
- ],
707
- )
846
+ createdAt: now,
847
+ cause: e.cause ?? null,
848
+ lane: e.lane ?? null,
849
+ processedAt: e.settled ? now : null,
850
+ },
851
+ clearIndex,
852
+ })
708
853
  inserted.push({
709
854
  id,
710
855
  type: e.type,
@@ -732,7 +877,7 @@ export function postgres(options: PostgresStoreOptions = {}): PostgresStore {
732
877
  return {
733
878
  events: inserted,
734
879
  hasPending:
735
- maxRows[0]?.['session_has_pending'] === true ||
880
+ sessionHasPending ||
736
881
  events.some((event) => event.settled !== true),
737
882
  }
738
883
  }),
@@ -752,6 +897,8 @@ export function postgres(options: PostgresStoreOptions = {}): PostgresStore {
752
897
  params.push(opts.throughIndex)
753
898
  conditions.push(`idx <= $${params.length}`)
754
899
  }
900
+ // Reads start at the latest clear marker.
901
+ conditions.push(`idx >= ${clearIndexOf('$1')}`)
755
902
  const { rows } = await c.query(
756
903
  `select * from a2_events where ${conditions.join(' and ')} order by idx`,
757
904
  params,
@@ -778,6 +925,8 @@ export function postgres(options: PostgresStoreOptions = {}): PostgresStore {
778
925
  params.push(opts.throughIndex)
779
926
  conditions.push(`idx <= $${params.length}`)
780
927
  }
928
+ // Reads start at the latest clear marker.
929
+ conditions.push(`idx >= ${clearIndexOf('$1')}`)
781
930
  const { rows } = await c.query(
782
931
  `select session_id, idx, event_id, event_type, payload, created_at
783
932
  from a2_events where ${conditions.join(' and ')} order by idx`,
@@ -806,6 +955,7 @@ export function postgres(options: PostgresStoreOptions = {}): PostgresStore {
806
955
  and event.processed_at is null
807
956
  and event.failed_at is null
808
957
  and event.lane_ready
958
+ and event.idx > ${clearIndexOf('$1')}
809
959
  and (event.claim_expires_at is null or event.claim_expires_at <= $2)
810
960
  and not (event.idx = any($5::bigint[]))
811
961
  for update of event
@@ -818,12 +968,17 @@ export function postgres(options: PostgresStoreOptions = {}): PostgresStore {
818
968
  claim_expires_at = $4
819
969
  from eligible
820
970
  where claimed.session_id = $1 and claimed.idx = eligible.idx
821
- returning claimed.*`,
971
+ returning claimed.*, ${clearIndexOf('$1')} as clear_index`,
822
972
  [sessionId, now, holder, expiresAt, [...excludeIndexes]],
823
973
  )
824
974
  if (rows.length > 0) {
825
975
  const events = rows
826
- .map(toStored)
976
+ .map((row) => {
977
+ const stored = toStored(row)
978
+ const clearIndex = asNumber(row['clear_index'])
979
+ if (clearIndex > 0) stored.clearIndex = clearIndex
980
+ return stored
981
+ })
827
982
  .toSorted((a, b) => a.index - b.index)
828
983
  return { outcome: 'claimed', events }
829
984
  }
@@ -834,6 +989,7 @@ export function postgres(options: PostgresStoreOptions = {}): PostgresStore {
834
989
  and processed_at is null
835
990
  and failed_at is null
836
991
  and lane_ready
992
+ and idx > ${clearIndexOf('$1')}
837
993
  and claim_expires_at > $2`,
838
994
  [sessionId, now],
839
995
  )
@@ -865,6 +1021,7 @@ export function postgres(options: PostgresStoreOptions = {}): PostgresStore {
865
1021
  from listed
866
1022
  where event.session_id = $2
867
1023
  and event.idx = listed.idx
1024
+ and event.idx > ${clearIndexOf('$2')}
868
1025
  and event.attempt_count = listed.attempt
869
1026
  and event.processed_at is null
870
1027
  and event.failed_at is null
@@ -872,11 +1029,13 @@ export function postgres(options: PostgresStoreOptions = {}): PostgresStore {
872
1029
  and event.claim_expires_at > $5
873
1030
  returning event.idx
874
1031
  )
875
- select event.idx, event.attempt_count,
876
- exists(select 1 from renewed where renewed.idx = event.idx)
877
- as renewed
878
- from a2_events event
879
- where event.session_id = $2 and event.idx = any($3::bigint[])`,
1032
+ select listed.idx, event.attempt_count,
1033
+ exists(select 1 from renewed where renewed.idx = listed.idx)
1034
+ as renewed,
1035
+ listed.idx <= ${clearIndexOf('$2')} as cleared
1036
+ from listed
1037
+ left join a2_events event
1038
+ on event.session_id = $2 and event.idx = listed.idx`,
880
1039
  [
881
1040
  expiresAt,
882
1041
  sessionId,
@@ -892,7 +1051,9 @@ export function postgres(options: PostgresStoreOptions = {}): PostgresStore {
892
1051
  const index = asNumber(row['idx'])
893
1052
  if (row['renewed'] === true) renewed.push(index)
894
1053
  else if (
895
- asNumber(row['attempt_count']) > (attempts.get(index) ?? 0)
1054
+ row['cleared'] === true ||
1055
+ (row['attempt_count'] !== null &&
1056
+ asNumber(row['attempt_count']) > (attempts.get(index) ?? 0))
896
1057
  ) {
897
1058
  superseded.push(index)
898
1059
  }
@@ -908,13 +1069,23 @@ export function postgres(options: PostgresStoreOptions = {}): PostgresStore {
908
1069
  return wrap(() =>
909
1070
  withTx(async (query) => {
910
1071
  const { rows: parentRows } = await query(
911
- `select * from a2_events
912
- where session_id = $1 and idx = $2
913
- for update`,
1072
+ `select boundary.clear_index, parent.*
1073
+ from (select ${clearIndexOf('$1')} as clear_index) boundary
1074
+ left join lateral (
1075
+ select * from a2_events
1076
+ where session_id = $1 and idx = $2 and idx > boundary.clear_index
1077
+ for update
1078
+ ) parent on true`,
914
1079
  [sessionId, index],
915
1080
  )
916
- const parent = parentRows[0]
1081
+ if (index <= asNumber(parentRows[0]?.['clear_index']))
1082
+ return { outcome: 'superseded' }
1083
+ const parent =
1084
+ parentRows[0]?.['idx'] == null ? undefined : parentRows[0]
917
1085
  if (!parent) {
1086
+ // Deleted while this statement waited for its lock, after a clear.
1087
+ if (await clearedAt({ query, sessionId, index }))
1088
+ return { outcome: 'superseded' }
918
1089
  throw new TypeError(
919
1090
  `no event at index ${index} in session '${sessionId}'`,
920
1091
  )
@@ -957,6 +1128,11 @@ export function postgres(options: PostgresStoreOptions = {}): PostgresStore {
957
1128
  existing.map((row) => [row['event_id'] as string, row]),
958
1129
  )
959
1130
  const ordered = ids.map((id) => byId.get(id))
1131
+ if (
1132
+ ordered.some((row) => !row) &&
1133
+ (await clearedAt({ query, sessionId, index }))
1134
+ )
1135
+ return { outcome: 'superseded' }
960
1136
  if (
961
1137
  ordered.some((row) => {
962
1138
  const cause = row ? toCause(row['cause']) : null
@@ -990,12 +1166,23 @@ export function postgres(options: PostgresStoreOptions = {}): PostgresStore {
990
1166
  return { outcome: 'superseded' }
991
1167
  }
992
1168
 
1169
+ // The parent lock came first; only a statement after the session
1170
+ // lock sees every clear that committed while this one waited.
993
1171
  await query('select pg_advisory_xact_lock(hashtext($1))', [sessionId])
1172
+ const { rows: maxRows } = await query(
1173
+ `select coalesce(max(idx), 0) as max, ${clearIndexOf('$1')} as clear_index
1174
+ from a2_events where session_id = $1`,
1175
+ [sessionId],
1176
+ )
1177
+ const clearIndex = asNumber(maxRows[0]?.['clear_index'])
1178
+ if (index <= clearIndex) return { outcome: 'superseded' }
994
1179
  if (ids.length > 0) {
995
- const { rows: existing } = await query(
996
- 'select event_id from a2_events where event_id = any($1::text[])',
997
- [ids],
998
- )
1180
+ const existing = await liveOwnersOf({
1181
+ query,
1182
+ sessionId,
1183
+ clearIndex,
1184
+ ids,
1185
+ })
999
1186
  if (existing.length > 0) {
1000
1187
  throw new A2Error(
1001
1188
  'PARTIAL_DUPLICATE_BATCH',
@@ -1004,10 +1191,6 @@ export function postgres(options: PostgresStoreOptions = {}): PostgresStore {
1004
1191
  }
1005
1192
  }
1006
1193
 
1007
- const { rows: maxRows } = await query(
1008
- 'select coalesce(max(idx), 0) as max from a2_events where session_id = $1',
1009
- [sessionId],
1010
- )
1011
1194
  const base = asNumber(maxRows[0]?.['max'])
1012
1195
  const now = clock.now().getTime()
1013
1196
  const inserted: StoredEvent[] = []
@@ -1015,32 +1198,23 @@ export function postgres(options: PostgresStoreOptions = {}): PostgresStore {
1015
1198
  const event = events[offset]!
1016
1199
  const childIndex = base + offset + 1
1017
1200
  // oxlint-disable-next-line no-await-in-loop -- one tx, ordered inserts
1018
- const { rows } = await query(
1019
- `insert into a2_events
1020
- (session_id, idx, event_type, payload, event_id, created_at,
1021
- cause, lane, lane_ready, processed_at)
1022
- values (
1023
- $1, $2, $3, $4::jsonb, $5, $6, $7::jsonb, $8,
1024
- $8::text is null or not exists (
1025
- select 1 from a2_events
1026
- where session_id = $1 and lane = $8 and processed_at is null
1027
- ),
1028
- $9
1029
- )
1030
- returning *`,
1031
- [
1201
+ const row = await insertEvent({
1202
+ query,
1203
+ row: {
1032
1204
  sessionId,
1033
- childIndex,
1034
- event.type,
1035
- jsonb(event.payload),
1036
- event.id,
1037
- now,
1038
- jsonb({ index, attempt }),
1039
- event.lane ?? null,
1040
- event.settled ? now : null,
1041
- ],
1042
- )
1043
- inserted.push(toStored(rows[0]!))
1205
+ index: childIndex,
1206
+ type: event.type,
1207
+ payload: event.payload,
1208
+ id: event.id,
1209
+ createdAt: now,
1210
+ cause: { index, attempt },
1211
+ lane: event.lane ?? null,
1212
+ processedAt: event.settled ? now : null,
1213
+ },
1214
+ clearIndex,
1215
+ returning: true,
1216
+ })
1217
+ inserted.push(toStored(row!))
1044
1218
  }
1045
1219
  await query(
1046
1220
  `update a2_events set
@@ -1077,9 +1251,10 @@ export function postgres(options: PostgresStoreOptions = {}): PostgresStore {
1077
1251
  and idx = (
1078
1252
  select idx from a2_events
1079
1253
  where session_id = $1 and lane = $2 and processed_at is null
1254
+ and idx > $3
1080
1255
  order by idx limit 1
1081
1256
  )`,
1082
- [sessionId, parentLane],
1257
+ [sessionId, parentLane, clearIndex],
1083
1258
  )
1084
1259
  }
1085
1260
  return { outcome: 'completed', events: inserted }
@@ -1094,6 +1269,7 @@ export function postgres(options: PostgresStoreOptions = {}): PostgresStore {
1094
1269
  `with current as materialized (
1095
1270
  select e.* from a2_events e
1096
1271
  where e.session_id = $1 and e.idx = $2
1272
+ and e.idx > ${clearIndexOf('$1')}
1097
1273
  for update of e
1098
1274
  ), updated as (
1099
1275
  update a2_events e set
@@ -1117,6 +1293,7 @@ export function postgres(options: PostgresStoreOptions = {}): PostgresStore {
1117
1293
  returning e.failure_count, e.failed_at
1118
1294
  )
1119
1295
  select case
1296
+ when $2 <= ${clearIndexOf('$1')} then 'cleared'
1120
1297
  when not exists (select 1 from current) then 'missing'
1121
1298
  when exists (
1122
1299
  select 1 from updated where failed_at is not null
@@ -1149,7 +1326,13 @@ export function postgres(options: PostgresStoreOptions = {}): PostgresStore {
1149
1326
  ],
1150
1327
  )
1151
1328
  const row = rows[0]
1329
+ if (row?.['outcome'] === 'cleared') {
1330
+ return { outcome: 'superseded', failureCount: 0 }
1331
+ }
1152
1332
  if (!row || row['outcome'] === 'missing') {
1333
+ // Deleted while this statement waited for its lock, after a clear.
1334
+ if (await clearedAt({ query, sessionId, index }))
1335
+ return { outcome: 'superseded', failureCount: 0 }
1153
1336
  throw new TypeError(
1154
1337
  `no event at index ${index} in session '${sessionId}'`,
1155
1338
  )
@@ -1212,15 +1395,33 @@ export function postgres(options: PostgresStoreOptions = {}): PostgresStore {
1212
1395
  async putSnapshots(sessionId, reducerName, writes) {
1213
1396
  await wrap(() =>
1214
1397
  withTx(async (query) => {
1215
- const normalized = [
1398
+ const requested = [
1216
1399
  ...new Map(writes.map((write) => [write.index, write])).values(),
1217
1400
  ].toSorted((a, b) => a.index - b.index)
1218
- if (normalized.length === 0) return
1401
+ if (requested.length === 0) return
1219
1402
 
1220
1403
  await query(
1221
1404
  'select pg_advisory_xact_lock(hashtext($1), hashtext($2))',
1222
1405
  [sessionId, reducerName],
1223
1406
  )
1407
+ const { rows: headRows } = await query(
1408
+ `select boundary.clear_index, head.up_to_index, head.state, head.updated_at
1409
+ from (select ${clearIndexOf('$1')} as clear_index) boundary
1410
+ left join lateral (
1411
+ select up_to_index, state, updated_at from a2_snapshots
1412
+ where session_id = $1 and reducer_name = $2
1413
+ for update
1414
+ ) head on true`,
1415
+ [sessionId, reducerName],
1416
+ )
1417
+ const clearIndex = asNumber(headRows[0]?.['clear_index'])
1418
+ // A fold from before the latest clear describes a lifetime that is gone.
1419
+ const normalized = requested.filter(
1420
+ (write) => write.index >= clearIndex,
1421
+ )
1422
+ if (normalized.length === 0) return
1423
+ const currentHead =
1424
+ headRows[0]?.['up_to_index'] == null ? undefined : headRows[0]
1224
1425
 
1225
1426
  const requestedPins = normalized.flatMap((write) =>
1226
1427
  (write.pinEventIndexes ?? []).map((eventIndex) => ({
@@ -1234,9 +1435,9 @@ export function postgres(options: PostgresStoreOptions = {}): PostgresStore {
1234
1435
  ]
1235
1436
  const { rows: events } = await query(
1236
1437
  `select idx, processed_at, failed_at from a2_events
1237
- where session_id = $1 and idx = any($2::bigint[])
1438
+ where session_id = $1 and idx = any($2::bigint[]) and idx > $3
1238
1439
  for update`,
1239
- [sessionId, eventIndexes],
1440
+ [sessionId, eventIndexes, clearIndex],
1240
1441
  )
1241
1442
  const unfinished = new Set(
1242
1443
  events
@@ -1264,13 +1465,6 @@ export function postgres(options: PostgresStoreOptions = {}): PostgresStore {
1264
1465
  }
1265
1466
  }
1266
1467
 
1267
- const { rows: headRows } = await query(
1268
- `select up_to_index, state, updated_at from a2_snapshots
1269
- where session_id = $1 and reducer_name = $2
1270
- for update`,
1271
- [sessionId, reducerName],
1272
- )
1273
- const currentHead = headRows[0]
1274
1468
  const latestWrite = normalized.at(-1)!
1275
1469
  const writesHead =
1276
1470
  !currentHead ||
@@ -1303,6 +1497,7 @@ export function postgres(options: PostgresStoreOptions = {}): PostgresStore {
1303
1497
  if (
1304
1498
  advancesHead &&
1305
1499
  currentHead &&
1500
+ asNumber(currentHead['up_to_index']) >= clearIndex &&
1306
1501
  pinned.has(asNumber(currentHead['up_to_index']))
1307
1502
  ) {
1308
1503
  const index = asNumber(currentHead['up_to_index'])
@@ -1467,10 +1662,12 @@ export function postgres(options: PostgresStoreOptions = {}): PostgresStore {
1467
1662
  const readAfter = (afterIndex: number): Promise<Event[]> =>
1468
1663
  wrap(async () => {
1469
1664
  const c = await client()
1665
+ // A cursor below the latest marker resumes at it.
1470
1666
  const { rows } = await c.query(
1471
1667
  `select session_id, idx, event_type, payload, event_id, created_at
1472
1668
  from a2_events
1473
1669
  where session_id = $1 and idx > $2
1670
+ and idx >= ${clearIndexOf('$1')}
1474
1671
  order by idx`,
1475
1672
  [sessionId, afterIndex],
1476
1673
  )
@@ -1488,6 +1685,156 @@ export function postgres(options: PostgresStoreOptions = {}): PostgresStore {
1488
1685
  )
1489
1686
  },
1490
1687
 
1688
+ async clear({ sessionId, ifLastIndex, cause }) {
1689
+ return wrap(() =>
1690
+ withTx(async (query) => {
1691
+ await query('select pg_advisory_xact_lock(hashtext($1))', [sessionId])
1692
+ const { rows: headRows } = await query(
1693
+ `select coalesce(max(idx), 0) as head,
1694
+ ${clearIndexOf('$1')} as clear_index
1695
+ from a2_events where session_id = $1`,
1696
+ [sessionId],
1697
+ )
1698
+ const head = asNumber(headRows[0]?.['head'])
1699
+ const clearIndex = asNumber(headRows[0]?.['clear_index'])
1700
+ if (cause) {
1701
+ if (cause.index <= clearIndex)
1702
+ throw supersededAttemptError(sessionId, cause)
1703
+ const { rows: parents } = await query(
1704
+ `select attempt_count, failed_at from a2_events
1705
+ where session_id = $1 and idx = $2
1706
+ for update`,
1707
+ [sessionId, cause.index],
1708
+ )
1709
+ const parent = parents[0]
1710
+ if (!parent) {
1711
+ throw new TypeError(
1712
+ `no event at index ${cause.index} in session '${sessionId}'`,
1713
+ )
1714
+ }
1715
+ if (
1716
+ asNumber(parent['attempt_count']) !== cause.attempt ||
1717
+ parent['failed_at'] !== null
1718
+ ) {
1719
+ throw supersededAttemptError(sessionId, cause)
1720
+ }
1721
+ }
1722
+ if (ifLastIndex !== undefined && clearIndex === ifLastIndex + 1) {
1723
+ return { cleared: true, index: head, clearIndex }
1724
+ }
1725
+ if (ifLastIndex !== undefined && ifLastIndex !== head) {
1726
+ return { cleared: false, index: head, clearIndex }
1727
+ }
1728
+ if (head === 0 || clearIndex === head) {
1729
+ return { cleared: true, index: head, clearIndex }
1730
+ }
1731
+ const marker = head + 1
1732
+ const now = clock.now().getTime()
1733
+ await insertEvent({
1734
+ query,
1735
+ row: {
1736
+ sessionId,
1737
+ index: marker,
1738
+ type: CLEAR_EVENT_TYPE,
1739
+ payload: null,
1740
+ id: generateId(),
1741
+ createdAt: now,
1742
+ cause: null,
1743
+ lane: null,
1744
+ processedAt: now,
1745
+ },
1746
+ clearIndex,
1747
+ })
1748
+ // Releasing a clearing handler's claim makes a claim waiting on its
1749
+ // row skip it once this commits, instead of re-running the handler.
1750
+ await query(
1751
+ `with released as (
1752
+ update a2_events
1753
+ set lane_ready = false, claim_holder = null, claim_expires_at = null
1754
+ where session_id = $1 and idx = $3
1755
+ )
1756
+ insert into a2_clears (session_id, clear_index) values ($1, $2)
1757
+ on conflict (session_id) do update set clear_index = excluded.clear_index`,
1758
+ [sessionId, marker, cause?.index ?? null],
1759
+ )
1760
+ return { cleared: true, index: marker, clearIndex: marker }
1761
+ }),
1762
+ )
1763
+ },
1764
+
1765
+ async reclaim(sessionId) {
1766
+ return wrap(() =>
1767
+ withTx(async (query) => {
1768
+ // Rows other work holds are skipped, never waited on, so deletion
1769
+ // cannot join a deadlock; a later batch takes them.
1770
+ const { rows } = await query(
1771
+ `with boundary as materialized (
1772
+ select ${clearIndexOf('$1')} as clear_index
1773
+ ), batch as materialized (
1774
+ select idx from a2_events
1775
+ where session_id = $1
1776
+ and idx < (select clear_index from boundary)
1777
+ order by idx
1778
+ limit $2
1779
+ for update skip locked
1780
+ ), deleted as (
1781
+ delete from a2_events event using batch
1782
+ where event.session_id = $1 and event.idx = batch.idx
1783
+ returning event.idx
1784
+ ), pins as (
1785
+ delete from a2_snapshot_pins pin using deleted
1786
+ where pin.session_id = $1 and pin.event_index = deleted.idx
1787
+ )
1788
+ select (select count(*) from deleted) as deleted,
1789
+ (select clear_index from boundary) as clear_index`,
1790
+ [sessionId, RECLAIM_BATCH],
1791
+ )
1792
+ const deleted = asNumber(rows[0]?.['deleted'])
1793
+ const clearIndex = asNumber(rows[0]?.['clear_index'])
1794
+ if (clearIndex === 0) return { deleted: 0, done: true }
1795
+ if (deleted > 0) return { deleted, done: false }
1796
+ const { rows: remaining } = await query(
1797
+ 'select 1 from a2_events where session_id = $1 and idx < $2 limit 1',
1798
+ [sessionId, clearIndex],
1799
+ )
1800
+ if (remaining.length > 0) return { deleted: 0, done: false }
1801
+ const { rows: swept } = await query(
1802
+ `with pins as materialized (
1803
+ select ctid from a2_snapshot_pins
1804
+ where session_id = $1 and up_to_index < $2
1805
+ for update skip locked
1806
+ ), history as materialized (
1807
+ select ctid from a2_snapshot_history
1808
+ where session_id = $1 and up_to_index < $2
1809
+ for update skip locked
1810
+ ), heads as materialized (
1811
+ select ctid from a2_snapshots
1812
+ where session_id = $1 and up_to_index < $2
1813
+ for update skip locked
1814
+ ), gone_pins as (
1815
+ delete from a2_snapshot_pins target using pins where target.ctid = pins.ctid
1816
+ ), gone_history as (
1817
+ delete from a2_snapshot_history target using history
1818
+ where target.ctid = history.ctid
1819
+ ), gone_heads as (
1820
+ delete from a2_snapshots target using heads where target.ctid = heads.ctid
1821
+ )
1822
+ select (select count(*) from a2_snapshot_pins
1823
+ where session_id = $1 and up_to_index < $2)
1824
+ + (select count(*) from a2_snapshot_history
1825
+ where session_id = $1 and up_to_index < $2)
1826
+ + (select count(*) from a2_snapshots
1827
+ where session_id = $1 and up_to_index < $2)
1828
+ - (select count(*) from pins)
1829
+ - (select count(*) from history)
1830
+ - (select count(*) from heads) as skipped`,
1831
+ [sessionId, clearIndex],
1832
+ )
1833
+ return { deleted: 0, done: asNumber(swept[0]?.['skipped']) === 0 }
1834
+ }),
1835
+ )
1836
+ },
1837
+
1491
1838
  async close() {
1492
1839
  if (closed) return
1493
1840
  closed = true