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
@@ -6,8 +6,11 @@
6
6
  *
7
7
  * The stream IS the store: each event is a stream entry whose ID is its
8
8
  * dense a2 index (`${index}-0`), so `XRANGE` is the catch-up read.
9
- * Mutable drain/failure bookkeeping lives in a side hash (stream
10
- * entries are immutable); every write path is one atomic Lua script.
9
+ * Mutable drain/failure bookkeeping lives in side hashes of fixed index
10
+ * ranges (stream entries are immutable); every write path is one atomic
11
+ * Lua script.
12
+ * Global event-id ownership is one key per id, so no key grows with
13
+ * the number of sessions.
11
14
  * The conformance suite in test/conformance is the executable
12
15
  * contract, run against a real spawned `redis-server`.
13
16
  *
@@ -17,10 +20,12 @@
17
20
  */
18
21
 
19
22
  import type { PresencePatch } from './contract.ts'
20
- import { A2Error } from './errors.ts'
23
+ import { A2Error, supersededAttemptError } from './errors.ts'
24
+ import { CLEAR_EVENT_TYPE, sessionClearedError } from './session-clear.ts'
21
25
  import { idempotentReplay } from './idempotent-replay.ts'
22
26
  import { nullProtoRecord } from './internal.ts'
23
27
  import { decodeReturnedEventIds } from './store-codec.ts'
28
+ import type { SerializedEvent } from './stream-buffer.ts'
24
29
  import type {
25
30
  A2Store,
26
31
  Clock,
@@ -41,12 +46,25 @@ export type RedisCall = (
41
46
 
42
47
  export type RedisStoreCoreOptions = {
43
48
  call: RedisCall
49
+ /**
50
+ * Issues a command whose bulk replies arrive as raw bytes. Live feed
51
+ * catch-up pages use it to decode only the fields a row needs.
52
+ */
53
+ callBytes?: RedisCall
44
54
  clock: Clock
45
55
  ids: IdSource
46
56
  keyPrefix: string
47
57
  presenceSource: string
58
+ readStream?: RedisStreamRead
48
59
  }
49
60
 
61
+ /** Stream entries from `from` onward, in nonempty ordered batches. */
62
+ export type RedisStreamRead = (options: {
63
+ key: string
64
+ from: string
65
+ signal: AbortSignal
66
+ }) => AsyncGenerator<StreamEntry[], void>
67
+
50
68
  export type RedisStoreCore = Omit<A2Store, 'stream' | 'presence'> & {
51
69
  /**
52
70
  * The storage half of the presence capability, shared by both
@@ -72,233 +90,464 @@ export type RedisStoreCore = Omit<A2Store, 'stream' | 'presence'> & {
72
90
  /** One bounded XRANGE page of public events for live feeds. */
73
91
  readEvents(sessionId: string, afterIndex: number): Promise<Event[]>
74
92
  /**
75
- * Declare the session watched for `ttlMs` — writes publish wake-ups
76
- * only while this presence marker lives. Live feeds refresh it
77
- * ahead of expiry; an expired marker degrades latency to the safety
78
- * cadence, never delivery.
93
+ * One bounded XRANGE page of catch-up rows for a live feed without a
94
+ * streaming reader, payloads left as stored JSON. The reply is held whole,
95
+ * so a page retains up to `FEED_PAGE_SIZE` entries.
96
+ */
97
+ readFeedPage(
98
+ sessionId: string,
99
+ afterIndex: number,
100
+ ): Promise<SerializedEvent[]>
101
+ /** Catch-up events after `afterIndex`, in nonempty ordered batches. */
102
+ streamEvents?: (options: {
103
+ sessionId: string
104
+ afterIndex: number
105
+ signal: AbortSignal
106
+ }) => AsyncGenerator<SerializedEvent[], void>
107
+ /**
108
+ * Events from `fromIndex` through `throughIndex` or the newest entry in
109
+ * one newest-first page, or null when the range doesn't fit in one.
79
110
  */
80
- markWatched(sessionId: string, ttlMs: number): Promise<void>
111
+ readTail(
112
+ sessionId: string,
113
+ fromIndex: number,
114
+ throughIndex?: number,
115
+ ): Promise<Event[] | null>
81
116
  }
82
117
 
83
- const READ_PAGE_SIZE = 256
118
+ export const READ_PAGE_SIZE = 256
119
+ export const FEED_PAGE_SIZE = 1000
84
120
  const READ_CONCURRENCY = 16
85
121
 
86
122
  // ── Lua scripts (each runs atomically) ───────────────────────────────
87
123
 
124
+ const META_CHUNK_SIZE = 1000
125
+
88
126
  /**
89
- * KEYS: store stream, counter, global id hash, metadata, pending, ready,
90
- * watched.
91
- * ARGV: sessionId, nowMs, n, then per event:
92
- * id, type, payloadJson, causeJson, lane, settled; finally notify channel.
93
- * Returns ['empty', hasPending] | ['ok', firstIndex, hasPending] |
94
- * ['dup', hasPending, ...indexes] | ['partial', count] |
95
- * ['foreign', eventId] | ['no_parent', idx] | ['superseded', idx].
127
+ * Upstash bills every call a script makes, so scripts read the counter and
128
+ * split with one MGET, read metadata with one HMGET per hash (`read_fields`),
129
+ * and buffer hash writes (`put`) into one HSET per hash (`flush`). Buffered
130
+ * writes are invisible to reads until flushed, so a script must not read a
131
+ * field it has buffered. Per-event metadata lives in hashes of
132
+ * META_CHUNK_SIZE consecutive indexes, so no key grows with a session's
133
+ * history. Indexes at or below the split predate chunking and stay in the
134
+ * base hash, which also holds every session's lane pointers. An unset split
135
+ * means the current event count, so any script that adds events must write
136
+ * `split_write` pairs in its write phase. Including scripts bind COUNTER,
137
+ * META (the base hash), and SPLIT first.
96
138
  */
97
- const APPEND_LUA = `
139
+ const META_LUA = `
140
+ local split, split_stored = nil, false
141
+ -- The latest clear marker lives beside the counter, {prefix}:{session}:clear,
142
+ -- and is read in the same MGET, so a session never cleared pays nothing.
143
+ local CLEAR = string.sub(COUNTER, 1, -6) .. 'clear'
144
+ local boundary = 0
145
+ local function use_split(stored, count, cleared)
146
+ split_stored = stored ~= false
147
+ split = tonumber(stored or count or '0')
148
+ boundary = tonumber(cleared or '0')
149
+ end
150
+ local function mkey(field)
151
+ if split == nil then
152
+ local values = redis.call('MGET', SPLIT, COUNTER, CLEAR)
153
+ use_split(values[1], values[2], values[3])
154
+ end
155
+ local idx = tonumber(field)
156
+ if idx <= split then return META end
157
+ return META .. ':' .. tostring(math.floor((idx - 1) / ${META_CHUNK_SIZE}))
158
+ end
159
+ local function split_write(args)
160
+ if not split_stored then
161
+ args[#args + 1] = SPLIT
162
+ args[#args + 1] = tostring(split)
163
+ end
164
+ end
165
+ local function sliced(command, key, args)
166
+ local results = {}
167
+ for first = 1, #args, 1000 do
168
+ local last = math.min(first + 999, #args)
169
+ local reply
170
+ if key then
171
+ reply = redis.call(command, key, unpack(args, first, last))
172
+ else
173
+ reply = redis.call(command, unpack(args, first, last))
174
+ end
175
+ if type(reply) == 'table' then
176
+ for _, value in ipairs(reply) do results[#results + 1] = value end
177
+ end
178
+ end
179
+ return results
180
+ end
181
+ local function read_fields(fields, suffixes)
182
+ local groups, order = {}, {}
183
+ for _, field in ipairs(fields) do
184
+ local key = mkey(field)
185
+ local group = groups[key]
186
+ if not group then
187
+ group = { fields = {}, names = {} }
188
+ groups[key] = group
189
+ order[#order + 1] = key
190
+ end
191
+ group.fields[#group.fields + 1] = field
192
+ for _, suffix in ipairs(suffixes) do
193
+ group.names[#group.names + 1] = field .. ':' .. suffix
194
+ end
195
+ end
196
+ local rows = {}
197
+ for _, key in ipairs(order) do
198
+ local group = groups[key]
199
+ local values = sliced('HMGET', key, group.names)
200
+ for position, field in ipairs(group.fields) do
201
+ local row = {}
202
+ for column = 1, #suffixes do
203
+ row[column] = values[(position - 1) * #suffixes + column]
204
+ end
205
+ rows[field] = row
206
+ end
207
+ end
208
+ return rows
209
+ end
210
+ local writes, write_order = {}, {}
211
+ local function put(key, field, value)
212
+ local fields = writes[key]
213
+ if not fields then
214
+ fields = {}
215
+ writes[key] = fields
216
+ write_order[#write_order + 1] = key
217
+ end
218
+ fields[#fields + 1] = field
219
+ fields[#fields + 1] = value
220
+ end
221
+ local function flush()
222
+ for _, key in ipairs(write_order) do sliced('HSET', key, writes[key]) end
223
+ writes, write_order = {}, {}
224
+ end
225
+ `
226
+
227
+ /**
228
+ * Lane-aware enqueueing for scripts that add events. Including scripts bind
229
+ * PENDING and READY. Lane tails are read once per lane and tracked in
230
+ * memory, so a batch in one lane links its events without re-reading. A
231
+ * lane emptied earlier in the script (`clear_lane`) is known to have no tail,
232
+ * and its pointers are deleted at `flush_queue` only if nothing re-filled it.
233
+ */
234
+ const ENQUEUE_LUA = `
98
235
  local function lane_field(lane, suffix)
99
236
  return '__lane:' .. tostring(string.len(lane)) .. ':' .. lane .. ':' .. suffix
100
237
  end
101
- local function enqueue(field, idx, lane)
102
- redis.call('ZADD', KEYS[5], idx, field)
238
+ local queued, ready_now, lane_tails, lane_order = {}, {}, {}, {}
239
+ local function enqueue(field, lane)
240
+ local score = tonumber(field)
241
+ queued[#queued + 1] = score
242
+ queued[#queued + 1] = field
103
243
  if lane == '' then
104
- redis.call('ZADD', KEYS[6], idx, field)
244
+ ready_now[#ready_now + 1] = score
245
+ ready_now[#ready_now + 1] = field
105
246
  return
106
247
  end
107
- local tail_key = lane_field(lane, 'tail')
108
- local tail = redis.call('HGET', KEYS[4], tail_key)
248
+ put(mkey(field), field .. ':l', lane)
249
+ local tail = lane_tails[lane]
250
+ if tail == nil then
251
+ tail = redis.call('HGET', META, lane_field(lane, 'tail'))
252
+ if tail and tonumber(tail) <= boundary then tail = false end
253
+ lane_order[#lane_order + 1] = lane
254
+ end
109
255
  if tail then
110
- redis.call('HSET', KEYS[4], tail .. ':next', field)
256
+ put(mkey(tail), tail .. ':next', field)
111
257
  else
112
- redis.call('HSET', KEYS[4], lane_field(lane, 'head'), field)
113
- redis.call('ZADD', KEYS[6], idx, field)
258
+ put(META, lane_field(lane, 'head'), field)
259
+ ready_now[#ready_now + 1] = score
260
+ ready_now[#ready_now + 1] = field
261
+ end
262
+ lane_tails[lane] = field
263
+ end
264
+ local function clear_lane(lane)
265
+ if lane_tails[lane] == nil then lane_order[#lane_order + 1] = lane end
266
+ lane_tails[lane] = false
267
+ end
268
+ local function flush_queue()
269
+ for _, lane in ipairs(lane_order) do
270
+ if lane_tails[lane] then
271
+ put(META, lane_field(lane, 'tail'), lane_tails[lane])
272
+ else
273
+ redis.call('HDEL', META, lane_field(lane, 'head'), lane_field(lane, 'tail'))
274
+ end
114
275
  end
115
- redis.call('HSET', KEYS[4], tail_key, field)
276
+ sliced('ZADD', PENDING, queued)
277
+ sliced('ZADD', READY, ready_now)
278
+ end
279
+ `
280
+
281
+ /**
282
+ * Event-id ownership for scripts that add events. Each script reads the
283
+ * counter, split, clear boundary, and per-id keys with one MGET and applies
284
+ * the first three. Returns the owner record, or false, per event, and the
285
+ * counter. A record this session made below its latest clear is free.
286
+ */
287
+ const ID_OWNERS_LUA = `
288
+ local function read_owners(key_offset, n, session)
289
+ local keys = { COUNTER, SPLIT, CLEAR }
290
+ for i = 1, n do keys[#keys + 1] = KEYS[key_offset + i] end
291
+ local values = sliced('MGET', nil, keys)
292
+ use_split(values[2], values[1], values[3])
293
+ local owners = {}
294
+ for i = 1, n do
295
+ local owner = values[3 + i]
296
+ if owner then
297
+ local rec = cjson.decode(owner)
298
+ if rec[1] == session and tonumber(rec[2]) < boundary then owner = false end
299
+ end
300
+ owners[i] = owner
301
+ end
302
+ return owners, values[1]
303
+ end
304
+ `
305
+
306
+ const WAKE_PAYLOAD_LIMIT = 32_768
307
+
308
+ /**
309
+ * Wake-up messages carry the events they announce, so a subscribed reader
310
+ * that is caught up applies them without reading the stream:
311
+ * `{"f":frontier,"t":createdAt,"e":[[index,id,type,payload],...]}`. Payloads
312
+ * are the stored JSON text, inlined verbatim. A batch over
313
+ * WAKE_PAYLOAD_LIMIT bytes publishes `{"f":frontier}` and readers catch up
314
+ * with XRANGE.
315
+ */
316
+ const WAKE_LUA = `
317
+ local wake_entries, wake_bytes = {}, 0
318
+ local function wake_add(field, id, typ, payload)
319
+ if wake_bytes + #payload > ${WAKE_PAYLOAD_LIMIT} then
320
+ wake_bytes = ${WAKE_PAYLOAD_LIMIT} + 1
321
+ return
322
+ end
323
+ local entry = '[' .. field .. ',' .. cjson.encode(id) .. ',' ..
324
+ cjson.encode(typ) .. ',' .. payload .. ']'
325
+ wake_bytes = wake_bytes + #entry + 1
326
+ if wake_bytes <= ${WAKE_PAYLOAD_LIMIT} then
327
+ wake_entries[#wake_entries + 1] = entry
328
+ end
329
+ end
330
+ local function wake(channel, frontier, created_at)
331
+ local message = '{"f":' .. frontier .. '}'
332
+ if wake_bytes <= ${WAKE_PAYLOAD_LIMIT} then
333
+ message = '{"f":' .. frontier .. ',"t":' .. created_at .. ',"e":[' ..
334
+ table.concat(wake_entries, ',') .. ']}'
335
+ end
336
+ redis.pcall('PUBLISH', channel, message)
337
+ end
338
+ `
339
+
340
+ const RELEASE_PINS_LUA = `
341
+ local function release_snapshot_pins(pins, counts)
342
+ local refs = redis.call('SMEMBERS', pins)
343
+ if #refs == 0 then return end
344
+ for _, ref in ipairs(refs) do
345
+ local count = redis.call('HINCRBY', counts, ref, -1)
346
+ if count <= 0 then
347
+ redis.call('HDEL', counts, ref)
348
+ local checkpoint = cjson.decode(ref)
349
+ redis.call('ZREMRANGEBYSCORE', checkpoint[1], checkpoint[2], checkpoint[2])
350
+ end
351
+ end
352
+ redis.call('DEL', pins)
353
+ end
354
+ `
355
+
356
+ /**
357
+ * KEYS: store stream, counter, metadata, pending, ready, metadata split,
358
+ * then one id ownership key per event.
359
+ * ARGV: sessionId, nowMs, n, then per event:
360
+ * id, type, payloadJson, causeJson, lane, settled; then notify channel and
361
+ * origin index or ''.
362
+ * Returns ['empty', hasPending] | ['ok', firstIndex, hasPending] |
363
+ * ['dup', hasPending, ...indexes] | ['partial', count] |
364
+ * ['foreign', eventId] | ['no_parent', idx] | ['superseded', idx] |
365
+ * ['cleared', originIndex].
366
+ */
367
+ const APPEND_LUA = `
368
+ local COUNTER, META, SPLIT, PENDING, READY = KEYS[2], KEYS[3], KEYS[6], KEYS[4], KEYS[5]
369
+ ${META_LUA}
370
+ ${ENQUEUE_LUA}
371
+ ${ID_OWNERS_LUA}
372
+ ${WAKE_LUA}
373
+ local function has_pending()
374
+ return redis.call('ZCARD', PENDING) > 0 and '1' or '0'
116
375
  end
117
376
  local n = tonumber(ARGV[3])
118
- local base = tonumber(redis.call('GET', KEYS[2]) or '0')
119
- local has_pending = redis.call('ZCARD', KEYS[5]) > 0 and '1' or '0'
120
- if n == 0 then return { 'empty', has_pending } end
377
+ if n == 0 then return { 'empty', has_pending() } end
378
+ local owners, count = read_owners(6, n, ARGV[1])
379
+ local base = tonumber(count or '0')
121
380
  local dups = {}
122
381
  for i = 1, n do
123
- local id = ARGV[3 + (i - 1) * 6 + 1]
124
- local existing = redis.call('HGET', KEYS[3], id)
125
- if existing then
126
- local rec = cjson.decode(existing)
127
- if rec[1] ~= ARGV[1] then return { 'foreign', id } end
382
+ if owners[i] then
383
+ local rec = cjson.decode(owners[i])
384
+ if rec[1] ~= ARGV[1] then return { 'foreign', ARGV[3 + (i - 1) * 6 + 1] } end
128
385
  dups[#dups + 1] = tostring(rec[2])
129
386
  end
130
387
  end
131
388
  if #dups == n then
132
- local res = { 'dup', has_pending }
389
+ local res = { 'dup', has_pending() }
133
390
  for i = 1, #dups do res[#res + 1] = dups[i] end
134
391
  return res
135
392
  end
136
393
  if #dups > 0 then return { 'partial', tostring(#dups) } end
394
+ local origin = ARGV[5 + n * 6]
395
+ if origin and origin ~= '' and tonumber(origin) <= boundary then
396
+ return { 'cleared', origin }
397
+ end
398
+ local causes, parents = {}, {}
137
399
  for i = 1, n do
138
400
  local cause = ARGV[3 + (i - 1) * 6 + 4]
139
401
  if cause ~= '' then
140
402
  local parsed = cjson.decode(cause)
141
403
  local pidx = tonumber(parsed.index)
142
- if pidx < 1 or pidx > base then
143
- return { 'no_parent', tostring(parsed.index) }
144
- end
145
- local field = tostring(pidx)
146
- local dispatches = tonumber(redis.call('HGET', KEYS[4], field .. ':d') or '0')
147
- if dispatches ~= tonumber(parsed.attempt) or
148
- redis.call('HEXISTS', KEYS[4], field .. ':f') == 1 then
149
- return { 'superseded', field }
150
- end
404
+ causes[#causes + 1] = parsed
405
+ if pidx >= 1 and pidx <= base then parents[#parents + 1] = tostring(pidx) end
406
+ end
407
+ end
408
+ local parent_rows = read_fields(parents, { 'd', 'f', 'p' })
409
+ local parent_pending = false
410
+ for _, parsed in ipairs(causes) do
411
+ local pidx = tonumber(parsed.index)
412
+ if pidx <= boundary then return { 'superseded', tostring(pidx) } end
413
+ if pidx < 1 or pidx > base then
414
+ return { 'no_parent', tostring(parsed.index) }
151
415
  end
416
+ local row = parent_rows[tostring(pidx)]
417
+ if tonumber(row[1] or '0') ~= tonumber(parsed.attempt) or row[2] then
418
+ return { 'superseded', tostring(pidx) }
419
+ end
420
+ -- A claimed, unprocessed parent is still in the pending set.
421
+ if not row[3] then parent_pending = true end
152
422
  end
423
+ local owner_writes = {}
153
424
  for i = 1, n do
154
- local id = ARGV[3 + (i - 1) * 6 + 1]
155
- local typ = ARGV[3 + (i - 1) * 6 + 2]
156
- local payload = ARGV[3 + (i - 1) * 6 + 3]
157
- local cause = ARGV[3 + (i - 1) * 6 + 4]
158
- local lane = ARGV[3 + (i - 1) * 6 + 5]
159
- local settled = ARGV[3 + (i - 1) * 6 + 6]
160
- local idx = base + i
161
- redis.call('XADD', KEYS[1], tostring(idx) .. '-0',
162
- 'id', id, 'type', typ, 'payload', payload, 'created_at', ARGV[2],
163
- 'cause', cause, 'lane', lane)
164
- redis.call('HSET', KEYS[3], id, cjson.encode({ ARGV[1], idx }))
165
- redis.call('HSET', KEYS[4], tostring(idx) .. ':l', lane)
166
- if settled == '1' then
167
- redis.call('HSET', KEYS[4], tostring(idx) .. ':p', ARGV[2])
425
+ local o = 3 + (i - 1) * 6
426
+ local field = tostring(base + i)
427
+ redis.call('XADD', KEYS[1], field .. '-0',
428
+ 'id', ARGV[o + 1], 'type', ARGV[o + 2], 'payload', ARGV[o + 3],
429
+ 'created_at', ARGV[2], 'cause', ARGV[o + 4], 'lane', ARGV[o + 5])
430
+ owner_writes[#owner_writes + 1] = KEYS[6 + i]
431
+ owner_writes[#owner_writes + 1] = cjson.encode({ ARGV[1], base + i })
432
+ wake_add(field, ARGV[o + 1], ARGV[o + 2], ARGV[o + 3])
433
+ if ARGV[o + 6] == '1' then
434
+ put(mkey(field), field .. ':p', ARGV[2])
168
435
  else
169
- enqueue(tostring(idx), idx, lane)
436
+ enqueue(field, ARGV[o + 5])
170
437
  end
171
438
  end
172
- redis.call('SET', KEYS[2], tostring(base + n))
173
- has_pending = redis.call('ZCARD', KEYS[5]) > 0 and '1' or '0'
174
- if redis.call('EXISTS', KEYS[7]) == 1 then
175
- redis.pcall('PUBLISH', ARGV[4 + n * 6], tostring(base + n))
176
- end
177
- return { 'ok', tostring(base + 1), has_pending }
439
+ owner_writes[#owner_writes + 1] = COUNTER
440
+ owner_writes[#owner_writes + 1] = tostring(base + n)
441
+ split_write(owner_writes)
442
+ sliced('MSET', nil, owner_writes)
443
+ flush_queue()
444
+ flush()
445
+ local pending_after = (#queued > 0 or parent_pending) and '1' or has_pending()
446
+ wake(ARGV[4 + n * 6], tostring(base + n), ARGV[2])
447
+ return { 'ok', tostring(base + 1), pending_after }
178
448
  `
179
449
 
180
450
  /**
181
- * KEYS: counter, meta, pending, ready, event snapshot pins, snapshot pin counts.
451
+ * KEYS: counter, meta, pending, ready, event snapshot pins, snapshot pin counts,
452
+ * metadata split.
182
453
  * ARGV: idx, attempt, error, maxFailures, nowMs.
183
454
  */
184
455
  const FAIL_ATTEMPT_LUA = `
185
- local function release_snapshot_pins()
186
- local refs = redis.call('SMEMBERS', KEYS[5])
187
- for _, ref in ipairs(refs) do
188
- local count = redis.call('HINCRBY', KEYS[6], ref, -1)
189
- if count <= 0 then
190
- redis.call('HDEL', KEYS[6], ref)
191
- local checkpoint = cjson.decode(ref)
192
- redis.call('ZREMRANGEBYSCORE', checkpoint[1], checkpoint[2], checkpoint[2])
193
- end
194
- end
195
- redis.call('DEL', KEYS[5])
196
- if redis.call('HLEN', KEYS[6]) == 0 then redis.call('DEL', KEYS[6]) end
197
- end
198
- local function lane_field(lane, suffix)
199
- return '__lane:' .. tostring(string.len(lane)) .. ':' .. lane .. ':' .. suffix
200
- end
201
- local function settle(field)
202
- redis.call('ZREM', KEYS[3], field)
203
- redis.call('ZREM', KEYS[4], field)
204
- local lane = redis.call('HGET', KEYS[2], field .. ':l') or ''
205
- if lane == '' then return end
206
- local head_key = lane_field(lane, 'head')
207
- if redis.call('HGET', KEYS[2], head_key) ~= field then return end
208
- local next_field = redis.call('HGET', KEYS[2], field .. ':next')
209
- while next_field and redis.call('HEXISTS', KEYS[2], next_field .. ':p') == 1 do
210
- next_field = redis.call('HGET', KEYS[2], next_field .. ':next')
211
- end
212
- if next_field then
213
- redis.call('HSET', KEYS[2], head_key, next_field)
214
- if redis.call('HEXISTS', KEYS[2], next_field .. ':f') == 0 then
215
- redis.call('ZADD', KEYS[4], tonumber(next_field), next_field)
216
- end
217
- else
218
- redis.call('HDEL', KEYS[2], head_key, lane_field(lane, 'tail'))
219
- end
220
- end
456
+ local COUNTER, META, SPLIT = KEYS[1], KEYS[2], KEYS[7]
457
+ ${META_LUA}
458
+ ${RELEASE_PINS_LUA}
221
459
  local idx = tonumber(ARGV[1])
222
- if idx < 1 or idx > tonumber(redis.call('GET', KEYS[1]) or '0') then
223
- return { 'missing', '0' }
224
- end
460
+ local counts = redis.call('MGET', COUNTER, SPLIT, CLEAR)
461
+ local total = tonumber(counts[1] or '0')
462
+ use_split(counts[2], counts[1], counts[3])
463
+ if idx <= boundary then return { 'cleared', '0' } end
464
+ if idx < 1 or idx > total then return { 'missing', '0' } end
225
465
  local field = ARGV[1]
226
- local failures = tonumber(redis.call('HGET', KEYS[2], field .. ':a') or '0')
227
- local dispatches = tonumber(redis.call('HGET', KEYS[2], field .. ':d') or '0')
228
- if redis.call('HEXISTS', KEYS[2], field .. ':p') == 1 or dispatches ~= tonumber(ARGV[2]) then
466
+ local attempt = tonumber(ARGV[2])
467
+ local row = read_fields({ field }, { 'a', 'd', 'p', 'f', 'lfa', 'ch' })[field]
468
+ local failures = tonumber(row[1] or '0')
469
+ if row[3] or tonumber(row[2] or '0') ~= attempt then
229
470
  return { 'superseded', tostring(failures) }
230
471
  end
231
- if redis.call('HEXISTS', KEYS[2], field .. ':f') == 1 then
232
- return { 'dead_lettered', tostring(failures) }
233
- end
234
- if redis.call('HEXISTS', KEYS[2], field .. ':lfa') == 1 and
235
- tonumber(redis.call('HGET', KEYS[2], field .. ':lfa')) == tonumber(ARGV[2]) and
236
- redis.call('HEXISTS', KEYS[2], field .. ':ch') == 0 then
472
+ if row[4] then return { 'dead_lettered', tostring(failures) } end
473
+ if row[5] and tonumber(row[5]) == attempt and not row[6] then
237
474
  return { 'failed', tostring(failures) }
238
475
  end
239
- if redis.call('HEXISTS', KEYS[2], field .. ':ch') == 0 then
240
- return { 'superseded', tostring(failures) }
241
- end
242
- failures = redis.call('HINCRBY', KEYS[2], field .. ':a', 1)
243
- redis.call('HSET', KEYS[2],
244
- field .. ':e', ARGV[3],
245
- field .. ':lf', ARGV[5],
246
- field .. ':lfa', ARGV[2])
247
- redis.call('HDEL', KEYS[2], field .. ':ch', field .. ':ce')
248
- if failures >= tonumber(ARGV[4]) then
249
- redis.call('HSET', KEYS[2], field .. ':f', ARGV[5])
476
+ if not row[6] then return { 'superseded', tostring(failures) } end
477
+ failures = failures + 1
478
+ local dead = failures >= tonumber(ARGV[4])
479
+ local meta_key = mkey(field)
480
+ put(meta_key, field .. ':a', tostring(failures))
481
+ put(meta_key, field .. ':e', ARGV[3])
482
+ put(meta_key, field .. ':lf', ARGV[5])
483
+ put(meta_key, field .. ':lfa', ARGV[2])
484
+ if dead then put(meta_key, field .. ':f', ARGV[5]) end
485
+ flush()
486
+ redis.call('HDEL', meta_key, field .. ':ch', field .. ':ce')
487
+ if dead then
250
488
  redis.call('ZREM', KEYS[4], field)
251
- release_snapshot_pins()
489
+ release_snapshot_pins(KEYS[5], KEYS[6])
252
490
  return { 'dead_lettered', tostring(failures) }
253
491
  end
254
492
  return { 'failed', tostring(failures) }
255
493
  `
256
494
 
257
495
  /**
258
- * KEYS: store, counter, meta, pending, ready.
496
+ * KEYS: store, counter, meta, pending, ready, metadata split.
259
497
  * ARGV: holder, nowMs, expiresAtMs, excludedCount, excluded indexes.
498
+ * The ready set is a subset of the pending set, so an empty ready set is
499
+ * enough to report 'settled'. A claimed result ends with the clear boundary.
260
500
  */
261
501
  const CLAIM_AVAILABLE_LUA = `
262
- if redis.call('ZCARD', KEYS[4]) == 0 then return { 'settled' } end
502
+ local COUNTER, META, SPLIT = KEYS[2], KEYS[3], KEYS[6]
503
+ ${META_LUA}
263
504
  local ready = redis.call('ZRANGE', KEYS[5], 0, -1)
264
505
  local excluded = {}
265
506
  for i = 1, tonumber(ARGV[4]) do excluded[ARGV[4 + i]] = true end
507
+ local candidates = {}
508
+ for _, field in ipairs(ready) do
509
+ if not excluded[field] then candidates[#candidates + 1] = field end
510
+ end
511
+ local rows = read_fields(candidates, { 'ce', 'd', 'fc', 'a', 'e', 'lf', 'lfa' })
266
512
  local claimed = {}
267
513
  local retry_at = nil
268
- for _, field in ipairs(ready) do
269
- if not excluded[field] then
270
- local expires = tonumber(redis.call('HGET', KEYS[3], field .. ':ce') or '0')
271
- if expires > tonumber(ARGV[2]) then
272
- if not retry_at or expires < retry_at then retry_at = expires end
273
- else
274
- local entries = redis.call('XRANGE', KEYS[1], field .. '-0', field .. '-0')
275
- if #entries == 0 then return { 'missing', field } end
276
- local attempt = redis.call('HINCRBY', KEYS[3], field .. ':d', 1)
277
- redis.call('HSETNX', KEYS[3], field .. ':fc', ARGV[2])
278
- redis.call('HSET', KEYS[3],
279
- field .. ':lc', ARGV[2],
280
- field .. ':ch', ARGV[1],
281
- field .. ':ce', ARGV[3])
282
- claimed[#claimed + 1] = {
283
- tostring(attempt),
284
- redis.call('HGET', KEYS[3], field .. ':a') or '0',
285
- redis.call('HGET', KEYS[3], field .. ':e') or false,
286
- redis.call('HGET', KEYS[3], field .. ':fc') or false,
287
- ARGV[2],
288
- redis.call('HGET', KEYS[3], field .. ':lf') or false,
289
- redis.call('HGET', KEYS[3], field .. ':lfa') or false,
290
- ARGV[1],
291
- ARGV[3],
292
- entries[1]
293
- }
294
- end
514
+ for _, field in ipairs(candidates) do
515
+ local row = rows[field]
516
+ local expires = tonumber(row[1] or '0')
517
+ if expires > tonumber(ARGV[2]) then
518
+ if not retry_at or expires < retry_at then retry_at = expires end
519
+ else
520
+ local entries = redis.call('XRANGE', KEYS[1], field .. '-0', field .. '-0')
521
+ if #entries == 0 then return { 'missing', field } end
522
+ local attempt = tostring(tonumber(row[2] or '0') + 1)
523
+ local first_claimed = row[3] or ARGV[2]
524
+ local meta_key = mkey(field)
525
+ put(meta_key, field .. ':d', attempt)
526
+ put(meta_key, field .. ':fc', first_claimed)
527
+ put(meta_key, field .. ':lc', ARGV[2])
528
+ put(meta_key, field .. ':ch', ARGV[1])
529
+ put(meta_key, field .. ':ce', ARGV[3])
530
+ claimed[#claimed + 1] = {
531
+ attempt,
532
+ row[4] or '0',
533
+ row[5] or false,
534
+ first_claimed,
535
+ ARGV[2],
536
+ row[6] or false,
537
+ row[7] or false,
538
+ ARGV[1],
539
+ ARGV[3],
540
+ entries[1]
541
+ }
295
542
  end
296
543
  end
544
+ flush()
297
545
  if #claimed > 0 then
298
546
  local result = { 'claimed', tostring(#claimed) }
299
547
  for _, claim in ipairs(claimed) do
300
548
  for _, value in ipairs(claim) do result[#result + 1] = value end
301
549
  end
550
+ result[#result + 1] = tostring(boundary)
302
551
  return result
303
552
  end
304
553
  if retry_at then return { 'busy', tostring(retry_at) } end
@@ -306,35 +555,43 @@ return { 'settled' }
306
555
  `
307
556
 
308
557
  /**
309
- * KEYS: counter, meta.
558
+ * KEYS: counter, meta, metadata split.
310
559
  * ARGV: holder, nowMs, expiresAtMs, n, then per claim: index, attempt.
311
560
  * Returns [renewedCount, ...renewed, ...superseded].
312
561
  */
313
562
  const RENEW_CLAIMS_LUA = `
314
- local total = tonumber(redis.call('GET', KEYS[1]) or '0')
315
- local renewed = {}
316
- local superseded = {}
563
+ local COUNTER, META, SPLIT = KEYS[1], KEYS[2], KEYS[3]
564
+ ${META_LUA}
565
+ local counts = redis.call('MGET', COUNTER, SPLIT, CLEAR)
566
+ local total = tonumber(counts[1] or '0')
567
+ use_split(counts[2], counts[1], counts[3])
568
+ local claims, fields, cleared = {}, {}, {}
317
569
  for i = 1, tonumber(ARGV[4]) do
318
570
  local field = ARGV[4 + (i - 1) * 2 + 1]
319
- local attempt = tonumber(ARGV[4 + (i - 1) * 2 + 2])
320
571
  local idx = tonumber(field)
321
- if idx >= 1 and idx <= total then
322
- local dispatches = tonumber(redis.call('HGET', KEYS[2], field .. ':d') or '0')
323
- if dispatches > attempt then
324
- superseded[#superseded + 1] = field
325
- else
326
- local expires = tonumber(redis.call('HGET', KEYS[2], field .. ':ce') or '0')
327
- if tonumber(ARGV[3]) > tonumber(ARGV[2]) and
328
- redis.call('HEXISTS', KEYS[2], field .. ':p') == 0 and
329
- redis.call('HEXISTS', KEYS[2], field .. ':f') == 0 and
330
- redis.call('HGET', KEYS[2], field .. ':ch') == ARGV[1] and
331
- expires > tonumber(ARGV[2]) then
332
- redis.call('HSET', KEYS[2], field .. ':ce', ARGV[3])
333
- renewed[#renewed + 1] = field
334
- end
335
- end
572
+ if idx <= boundary then
573
+ cleared[#cleared + 1] = field
574
+ elseif idx >= 1 and idx <= total then
575
+ claims[#claims + 1] = { field = field, attempt = tonumber(ARGV[4 + (i - 1) * 2 + 2]) }
576
+ fields[#fields + 1] = field
577
+ end
578
+ end
579
+ local rows = read_fields(fields, { 'd', 'ce', 'p', 'f', 'ch' })
580
+ local renewed = {}
581
+ local superseded = {}
582
+ for _, claim in ipairs(claims) do
583
+ local row = rows[claim.field]
584
+ if tonumber(row[1] or '0') > claim.attempt then
585
+ superseded[#superseded + 1] = claim.field
586
+ elseif tonumber(ARGV[3]) > tonumber(ARGV[2]) and
587
+ not row[3] and not row[4] and row[5] == ARGV[1] and
588
+ tonumber(row[2] or '0') > tonumber(ARGV[2]) then
589
+ put(mkey(claim.field), claim.field .. ':ce', ARGV[3])
590
+ renewed[#renewed + 1] = claim.field
336
591
  end
337
592
  end
593
+ flush()
594
+ for _, field in ipairs(cleared) do superseded[#superseded + 1] = field end
338
595
  local result = { tostring(#renewed) }
339
596
  for i = 1, #renewed do result[#result + 1] = renewed[i] end
340
597
  for i = 1, #superseded do result[#result + 1] = superseded[i] end
@@ -342,85 +599,57 @@ return result
342
599
  `
343
600
 
344
601
  /**
345
- * KEYS: store, counter, ids, meta, pending, ready, watched,
346
- * event snapshot pins, snapshot pin counts.
602
+ * KEYS: store, counter, meta, pending, ready, event snapshot pins, snapshot
603
+ * pin counts, metadata split, then one id ownership key per child.
347
604
  * ARGV: sessionId, nowMs, parent index, attempt, n, then per child:
348
605
  * id, type, payloadJson, lane, settled; finally notify channel.
349
606
  */
350
607
  const COMPLETE_ATTEMPT_LUA = `
351
- local function release_snapshot_pins()
352
- local refs = redis.call('SMEMBERS', KEYS[8])
353
- for _, ref in ipairs(refs) do
354
- local count = redis.call('HINCRBY', KEYS[9], ref, -1)
355
- if count <= 0 then
356
- redis.call('HDEL', KEYS[9], ref)
357
- local checkpoint = cjson.decode(ref)
358
- redis.call('ZREMRANGEBYSCORE', checkpoint[1], checkpoint[2], checkpoint[2])
359
- end
360
- end
361
- redis.call('DEL', KEYS[8])
362
- if redis.call('HLEN', KEYS[9]) == 0 then redis.call('DEL', KEYS[9]) end
363
- end
364
- local function lane_field(lane, suffix)
365
- return '__lane:' .. tostring(string.len(lane)) .. ':' .. lane .. ':' .. suffix
366
- end
367
- local function settle(field)
368
- redis.call('ZREM', KEYS[5], field)
369
- redis.call('ZREM', KEYS[6], field)
370
- local lane = redis.call('HGET', KEYS[4], field .. ':l') or ''
608
+ local COUNTER, META, SPLIT, PENDING, READY = KEYS[2], KEYS[3], KEYS[8], KEYS[4], KEYS[5]
609
+ ${META_LUA}
610
+ ${ENQUEUE_LUA}
611
+ ${ID_OWNERS_LUA}
612
+ ${RELEASE_PINS_LUA}
613
+ ${WAKE_LUA}
614
+ local function settle(field, lane, next_field)
615
+ redis.call('ZREM', PENDING, field)
616
+ redis.call('ZREM', READY, field)
371
617
  if lane == '' then return end
372
618
  local head_key = lane_field(lane, 'head')
373
- if redis.call('HGET', KEYS[4], head_key) ~= field then return end
374
- local next_field = redis.call('HGET', KEYS[4], field .. ':next')
375
- while next_field and redis.call('HEXISTS', KEYS[4], next_field .. ':p') == 1 do
376
- next_field = redis.call('HGET', KEYS[4], next_field .. ':next')
377
- end
378
- if next_field then
379
- redis.call('HSET', KEYS[4], head_key, next_field)
380
- if redis.call('HEXISTS', KEYS[4], next_field .. ':f') == 0 then
381
- redis.call('ZADD', KEYS[6], tonumber(next_field), next_field)
619
+ if redis.call('HGET', META, head_key) ~= field then return end
620
+ while next_field do
621
+ local row = read_fields({ next_field }, { 'p', 'f', 'next' })[next_field]
622
+ if not row[1] then
623
+ redis.call('HSET', META, head_key, next_field)
624
+ if not row[2] then
625
+ redis.call('ZADD', READY, tonumber(next_field), next_field)
626
+ end
627
+ return
382
628
  end
383
- else
384
- redis.call('HDEL', KEYS[4], head_key, lane_field(lane, 'tail'))
385
- end
386
- end
387
- local function enqueue(field, idx, lane)
388
- redis.call('ZADD', KEYS[5], idx, field)
389
- if lane == '' then
390
- redis.call('ZADD', KEYS[6], idx, field)
391
- return
392
- end
393
- local tail_key = lane_field(lane, 'tail')
394
- local tail = redis.call('HGET', KEYS[4], tail_key)
395
- if tail then
396
- redis.call('HSET', KEYS[4], tail .. ':next', field)
397
- else
398
- redis.call('HSET', KEYS[4], lane_field(lane, 'head'), field)
399
- redis.call('ZADD', KEYS[6], idx, field)
629
+ next_field = row[3]
400
630
  end
401
- redis.call('HSET', KEYS[4], tail_key, field)
631
+ clear_lane(lane)
402
632
  end
403
633
  local parent = ARGV[3]
404
634
  local attempt = tonumber(ARGV[4])
405
635
  local n = tonumber(ARGV[5])
406
- local total = tonumber(redis.call('GET', KEYS[2]) or '0')
636
+ local owners, count = read_owners(8, n, ARGV[1])
637
+ local total = tonumber(count or '0')
638
+ if tonumber(parent) <= boundary then return { 'superseded' } end
407
639
  if tonumber(parent) < 1 or tonumber(parent) > total then return { 'missing' } end
408
- local dispatches = tonumber(redis.call('HGET', KEYS[4], parent .. ':d') or '0')
409
- local processed = redis.call('HGET', KEYS[4], parent .. ':p')
410
- local processed_attempt = tonumber(redis.call('HGET', KEYS[4], parent .. ':pa') or '0')
411
- if processed then
412
- if processed_attempt ~= attempt then return { 'superseded' } end
413
- local raw_recorded_ids = redis.call('HGET', KEYS[4], parent .. ':ri')
414
- if not raw_recorded_ids then return { 'partial' } end
415
- local recorded_ids = cjson.decode(raw_recorded_ids)
640
+ local row = read_fields({ parent }, { 'd', 'p', 'pa', 'ri', 'ch', 'f', 'lfa', 'l', 'next' })[parent]
641
+ if row[2] then
642
+ if tonumber(row[3] or '0') ~= attempt then return { 'superseded' } end
643
+ if not row[4] then return { 'partial' } end
644
+ local recorded_ids = cjson.decode(row[4])
416
645
  if #recorded_ids ~= n then return { 'partial' } end
646
+ for i = 1, n do
647
+ if recorded_ids[i] ~= ARGV[5 + (i - 1) * 5 + 1] then return { 'partial' } end
648
+ end
417
649
  local indexes = {}
418
650
  for i = 1, n do
419
- local id = ARGV[5 + (i - 1) * 5 + 1]
420
- if recorded_ids[i] ~= id then return { 'partial' } end
421
- local existing = redis.call('HGET', KEYS[3], id)
422
- if not existing then return { 'partial' } end
423
- local rec = cjson.decode(existing)
651
+ if not owners[i] then return { 'partial' } end
652
+ local rec = cjson.decode(owners[i])
424
653
  if rec[1] ~= ARGV[1] then return { 'partial' } end
425
654
  local entries = redis.call('XRANGE', KEYS[1], tostring(rec[2]) .. '-0', tostring(rec[2]) .. '-0')
426
655
  if #entries == 0 then return { 'partial' } end
@@ -439,62 +668,81 @@ if processed then
439
668
  for _, idx in ipairs(indexes) do result[#result + 1] = idx end
440
669
  return result
441
670
  end
442
- if dispatches ~= attempt then return { 'superseded' } end
443
- if redis.call('HEXISTS', KEYS[4], parent .. ':ch') == 0 or
444
- redis.call('HEXISTS', KEYS[4], parent .. ':f') == 1 or
445
- tonumber(redis.call('HGET', KEYS[4], parent .. ':lfa') or '0') == attempt then
671
+ if tonumber(row[1] or '0') ~= attempt then return { 'superseded' } end
672
+ if not row[5] or row[6] or tonumber(row[7] or '0') == attempt then
446
673
  return { 'superseded' }
447
674
  end
448
675
  for i = 1, n do
449
- local id = ARGV[5 + (i - 1) * 5 + 1]
450
- if redis.call('HEXISTS', KEYS[3], id) == 1 then return { 'partial' } end
676
+ if owners[i] then return { 'partial' } end
451
677
  end
452
678
  local returned_ids = {}
453
679
  for i = 1, n do returned_ids[i] = ARGV[5 + (i - 1) * 5 + 1] end
454
680
  local returned_json = '[]'
455
681
  if n > 0 then returned_json = cjson.encode(returned_ids) end
456
- redis.call('HSET', KEYS[4],
457
- parent .. ':p', ARGV[2],
458
- parent .. ':pa', ARGV[4],
459
- parent .. ':ri', returned_json)
460
- redis.call('HDEL', KEYS[4], parent .. ':ch', parent .. ':ce')
461
- settle(parent)
462
- release_snapshot_pins()
682
+ local parent_key = mkey(parent)
683
+ put(parent_key, parent .. ':p', ARGV[2])
684
+ put(parent_key, parent .. ':pa', ARGV[4])
685
+ put(parent_key, parent .. ':ri', returned_json)
686
+ redis.call('HDEL', parent_key, parent .. ':ch', parent .. ':ce')
687
+ settle(parent, row[8] or '', row[9])
688
+ release_snapshot_pins(KEYS[6], KEYS[7])
463
689
  local cause = cjson.encode({ index = tonumber(parent), attempt = attempt })
690
+ local owner_writes = {}
464
691
  for i = 1, n do
465
- local id = ARGV[5 + (i - 1) * 5 + 1]
466
- local typ = ARGV[5 + (i - 1) * 5 + 2]
467
- local payload = ARGV[5 + (i - 1) * 5 + 3]
468
- local lane = ARGV[5 + (i - 1) * 5 + 4]
469
- local settled = ARGV[5 + (i - 1) * 5 + 5]
470
- local idx = total + i
471
- redis.call('XADD', KEYS[1], tostring(idx) .. '-0',
472
- 'id', id, 'type', typ, 'payload', payload, 'created_at', ARGV[2],
473
- 'cause', cause, 'lane', lane)
474
- redis.call('HSET', KEYS[3], id, cjson.encode({ ARGV[1], idx }))
475
- redis.call('HSET', KEYS[4], tostring(idx) .. ':l', lane)
476
- if settled == '1' then
477
- redis.call('HSET', KEYS[4], tostring(idx) .. ':p', ARGV[2])
692
+ local o = 5 + (i - 1) * 5
693
+ local field = tostring(total + i)
694
+ redis.call('XADD', KEYS[1], field .. '-0',
695
+ 'id', ARGV[o + 1], 'type', ARGV[o + 2], 'payload', ARGV[o + 3],
696
+ 'created_at', ARGV[2], 'cause', cause, 'lane', ARGV[o + 4])
697
+ owner_writes[#owner_writes + 1] = KEYS[8 + i]
698
+ owner_writes[#owner_writes + 1] = cjson.encode({ ARGV[1], total + i })
699
+ wake_add(field, ARGV[o + 1], ARGV[o + 2], ARGV[o + 3])
700
+ if ARGV[o + 5] == '1' then
701
+ put(mkey(field), field .. ':p', ARGV[2])
478
702
  else
479
- enqueue(tostring(idx), idx, lane)
703
+ enqueue(field, ARGV[o + 4])
480
704
  end
481
705
  end
482
- redis.call('SET', KEYS[2], tostring(total + n))
483
- if n > 0 and redis.call('EXISTS', KEYS[7]) == 1 then
484
- redis.pcall('PUBLISH', ARGV[6 + n * 5], tostring(total + n))
706
+ if n > 0 then
707
+ owner_writes[#owner_writes + 1] = COUNTER
708
+ owner_writes[#owner_writes + 1] = tostring(total + n)
709
+ split_write(owner_writes)
710
+ sliced('MSET', nil, owner_writes)
485
711
  end
712
+ flush_queue()
713
+ flush()
714
+ if n > 0 then wake(ARGV[6 + n * 5], tostring(total + n), ARGV[2]) end
486
715
  return { 'ok', tostring(total + 1) }
487
716
  `
488
717
 
489
718
  /**
490
- * KEYS: head, history, event counter, event meta, pin counts.
719
+ * KEYS: head, history, event counter, event meta, pin counts, metadata split.
491
720
  * ARGV: nowMs, event pin key prefix, write count, then per write:
492
- * index, snapshot JSON, pin count, pin event indexes.
721
+ * index, snapshot JSON, pin count, pin event indexes; finally the reducer
722
+ * name. Writes from before the latest clear are dropped, and a head from
723
+ * before it is replaced as if absent.
493
724
  */
494
725
  const PUT_SNAPSHOTS_LUA = `
495
- local total = tonumber(redis.call('GET', KEYS[3]) or '0')
496
- local writes = {}
726
+ local COUNTER, META, SPLIT = KEYS[3], KEYS[4], KEYS[6]
727
+ ${META_LUA}
728
+ local counts = redis.call('MGET', COUNTER, SPLIT, CLEAR)
729
+ local total = tonumber(counts[1] or '0')
730
+ use_split(counts[2], counts[1], counts[3])
731
+ local pinned = {}
497
732
  local offset = 4
733
+ for i = 1, tonumber(ARGV[3]) do
734
+ local pin_count = tonumber(ARGV[offset + 2])
735
+ for p = 1, pin_count do
736
+ local event_index = tonumber(ARGV[offset + 2 + p])
737
+ if event_index > boundary and event_index <= total then
738
+ pinned[#pinned + 1] = tostring(event_index)
739
+ end
740
+ end
741
+ offset = offset + 3 + pin_count
742
+ end
743
+ local status = read_fields(pinned, { 'p', 'f' })
744
+ local snapshots = {}
745
+ offset = 4
498
746
  for i = 1, tonumber(ARGV[3]) do
499
747
  local index = tonumber(ARGV[offset])
500
748
  local snapshot = ARGV[offset + 1]
@@ -502,23 +750,40 @@ for i = 1, tonumber(ARGV[3]) do
502
750
  local ref = cjson.encode({ KEYS[2], tostring(index) })
503
751
  for p = 1, pin_count do
504
752
  local event_index = tonumber(ARGV[offset + 2 + p])
505
- if event_index >= 1 and event_index <= total and
506
- redis.call('HEXISTS', KEYS[4], tostring(event_index) .. ':p') == 0 and
507
- redis.call('HEXISTS', KEYS[4], tostring(event_index) .. ':f') == 0 then
753
+ local row = status[tostring(event_index)]
754
+ if index >= boundary and event_index > boundary and event_index <= total and
755
+ not row[1] and not row[2] then
508
756
  local pin_key = ARGV[2] .. tostring(event_index)
509
757
  if redis.call('SADD', pin_key, ref) == 1 then
510
758
  redis.call('HINCRBY', KEYS[5], ref, 1)
511
759
  end
512
760
  end
513
761
  end
514
- writes[#writes + 1] = { index = index, snapshot = snapshot, ref = ref }
762
+ if index >= boundary then
763
+ snapshots[#snapshots + 1] = { index = index, snapshot = snapshot, ref = ref }
764
+ end
515
765
  offset = offset + 3 + pin_count
516
766
  end
767
+ if #snapshots == 0 then return 1 end
517
768
 
518
769
  local head = redis.call('GET', KEYS[1])
519
770
  local head_index = nil
520
- if head then head_index = tonumber(cjson.decode(head)['index']) end
521
- local latest = writes[#writes]
771
+ local registered = false
772
+ if head then
773
+ local record = cjson.decode(head)
774
+ head_index = tonumber(record['index'])
775
+ registered = record['r'] == 1
776
+ end
777
+ if head_index and head_index < boundary then
778
+ head, head_index, registered = nil, nil, false
779
+ redis.call('ZREMRANGEBYSCORE', KEYS[2], '-inf', '(' .. boundary)
780
+ end
781
+ -- Deletion finds each reducer's keys through this set instead of a scan.
782
+ -- Heads written since carry the mark, so a reducer registers once.
783
+ if not registered then
784
+ redis.call('SADD', string.sub(COUNTER, 1, -6) .. 'snap-reducers', ARGV[#ARGV])
785
+ end
786
+ local latest = snapshots[#snapshots]
522
787
  local writes_head = not head_index or latest.index >= head_index
523
788
  local advances = not head_index or latest.index > head_index
524
789
  local final_head_index = advances and latest.index or head_index
@@ -528,7 +793,7 @@ if advances and head and tonumber(redis.call('HGET', KEYS[5], cjson.encode({ KEY
528
793
  redis.call('ZADD', KEYS[2], head_index, head)
529
794
  end
530
795
 
531
- for _, write in ipairs(writes) do
796
+ for _, write in ipairs(snapshots) do
532
797
  if write.index < final_head_index then
533
798
  redis.call('ZREMRANGEBYSCORE', KEYS[2], write.index, write.index)
534
799
  if tonumber(redis.call('HGET', KEYS[5], write.ref) or '0') > 0 then
@@ -607,30 +872,242 @@ return live
607
872
 
608
873
  /**
609
874
  * KEYS: head, history, event counter. ARGV: snapshot frontier or ''.
610
- * Returns one atomic multiversion-cache-plus-log-frontier read.
875
+ * Returns one atomic multiversion-cache-plus-log-frontier read, with the
876
+ * clear boundary; checkpoints from before it are ignored.
611
877
  */
612
878
  const READ_STATE_LUA = `
879
+ local counts = redis.call('MGET', KEYS[3], string.sub(KEYS[3], 1, -6) .. 'clear')
880
+ local boundary = tonumber(counts[2] or '0')
613
881
  local head = redis.call('GET', KEYS[1])
614
882
  local head_index = head and cjson.decode(head)['index'] or nil
615
883
  if head and (type(head_index) ~= 'number' or head_index < 0 or head_index > 9007199254740991 or head_index ~= math.floor(head_index)) then
616
884
  return redis.error_reply('invalid snapshot head index')
617
885
  end
886
+ if head_index and head_index < boundary then
887
+ head = nil
888
+ head_index = nil
889
+ end
618
890
  local cap = ARGV[1]
619
891
  local snapshot = nil
620
892
  if head_index and (cap == '' or head_index <= tonumber(cap)) then
621
893
  snapshot = head
622
894
  else
623
895
  local upper = cap == '' and '+inf' or cap
624
- local historical = redis.call('ZREVRANGEBYSCORE', KEYS[2], upper, '-inf', 'LIMIT', 0, 1)
896
+ local historical = redis.call('ZREVRANGEBYSCORE', KEYS[2], upper, boundary, 'LIMIT', 0, 1)
625
897
  if #historical > 0 then snapshot = historical[1] end
626
898
  end
627
- local frontier = redis.call('GET', KEYS[3]) or '0'
628
- return { head_index or '', snapshot or '', frontier }
899
+ return { head_index or '', snapshot or '', counts[1] or '0', tostring(boundary) }
900
+ `
901
+
902
+ /**
903
+ * Removes every registered reducer's checkpoints below the latest clear, and
904
+ * the pin counts that name them. Snapshot writes register each reducer in
905
+ * {prefix}:{session}:snap-reducers, so no key scan is needed.
906
+ */
907
+ const SWEEP_CHECKPOINTS_LUA = `
908
+ local function sweep_checkpoints(session, boundary)
909
+ for _, reducer in ipairs(redis.call('SMEMBERS', session .. 'snap-reducers')) do
910
+ local head_key = session .. 'snap:' .. reducer
911
+ local head = redis.call('GET', head_key)
912
+ if head and tonumber(cjson.decode(head)['index']) < boundary then
913
+ redis.call('DEL', head_key)
914
+ end
915
+ redis.call('ZREMRANGEBYSCORE', session .. 'snap-history:' .. reducer, '-inf', '(' .. boundary)
916
+ end
917
+ local counts_key = session .. 'snapshot-pin-counts'
918
+ local counts = redis.call('HGETALL', counts_key)
919
+ local stale = {}
920
+ for i = 1, #counts, 2 do
921
+ if tonumber(cjson.decode(counts[i])[2]) < boundary then stale[#stale + 1] = counts[i] end
922
+ end
923
+ if #stale > 0 then redis.call('HDEL', counts_key, unpack(stale)) end
924
+ end
925
+ `
926
+
927
+ /**
928
+ * KEYS: store stream, counter, metadata, metadata split, marker id ownership
929
+ * key, pending, ready.
930
+ * ARGV: sessionId, nowMs, ifLastIndex or '', cause index or '', cause
931
+ * attempt, marker id, notify channel.
932
+ * Returns ['ok', cleared, head, boundary] | ['no_parent', idx] |
933
+ * ['superseded', idx] | ['foreign']. The old lifetime's log moves aside in one
934
+ * RENAME, so every read of the log starts at the marker, and its queues are
935
+ * unlinked, so claims and pending checks see only the new lifetime; deletion
936
+ * drains the moved log.
937
+ */
938
+ const CLEAR_LUA = `
939
+ local COUNTER, META, SPLIT = KEYS[2], KEYS[3], KEYS[4]
940
+ ${META_LUA}
941
+ ${WAKE_LUA}
942
+ local counts = redis.call('MGET', COUNTER, SPLIT, CLEAR, KEYS[5])
943
+ local head = tonumber(counts[1] or '0')
944
+ use_split(counts[2], counts[1], counts[3])
945
+ if ARGV[4] ~= '' then
946
+ local field = ARGV[4]
947
+ local pidx = tonumber(field)
948
+ if pidx <= boundary then return { 'superseded', field } end
949
+ if pidx < 1 or pidx > head then return { 'no_parent', field } end
950
+ local row = read_fields({ field }, { 'd', 'f' })[field]
951
+ if tonumber(row[1] or '0') ~= tonumber(ARGV[5]) or row[2] then
952
+ return { 'superseded', field }
953
+ end
954
+ end
955
+ local expected = ARGV[3]
956
+ if expected ~= '' and boundary == tonumber(expected) + 1 then
957
+ return { 'ok', '1', tostring(head), tostring(boundary) }
958
+ end
959
+ if expected ~= '' and tonumber(expected) ~= head then
960
+ return { 'ok', '0', tostring(head), tostring(boundary) }
961
+ end
962
+ if head == 0 or boundary == head then
963
+ return { 'ok', '1', tostring(head), tostring(boundary) }
964
+ end
965
+ if counts[4] then return { 'foreign' } end
966
+ local field = tostring(head + 1)
967
+ local session = string.sub(COUNTER, 1, -6)
968
+ local moved = session .. 'cleared:' .. field
969
+ redis.call('RENAME', KEYS[1], moved)
970
+ redis.call('UNLINK', KEYS[6], KEYS[7])
971
+ redis.call('SADD', session .. 'cleared', moved)
972
+ redis.call('XADD', KEYS[1], field .. '-0',
973
+ 'id', ARGV[6], 'type', '${CLEAR_EVENT_TYPE}', 'payload', 'null',
974
+ 'created_at', ARGV[2], 'cause', '', 'lane', '')
975
+ local writes = { KEYS[5], cjson.encode({ ARGV[1], head + 1 }), COUNTER, field, CLEAR, field }
976
+ split_write(writes)
977
+ redis.call('MSET', unpack(writes))
978
+ put(mkey(field), field .. ':p', ARGV[2])
979
+ flush()
980
+ wake_add(field, ARGV[6], '${CLEAR_EVENT_TYPE}', 'null')
981
+ wake(ARGV[7], field, ARGV[2])
982
+ return { 'ok', '1', field, field }
983
+ `
984
+
985
+ /**
986
+ * KEYS: counter. ARGV: page size.
987
+ * The next deletion batch: a moved log and up to a page of its [index, id]
988
+ * pairs, without payloads. Empty moved logs are dropped; with none left it
989
+ * sweeps the old checkpoints and returns nothing, so a retry finishes an
990
+ * interrupted deletion.
991
+ */
992
+ const RECLAIM_BATCH_LUA = `
993
+ ${SWEEP_CHECKPOINTS_LUA}
994
+ local session = string.sub(KEYS[1], 1, -6)
995
+ local boundary = tonumber(redis.call('GET', session .. 'clear') or '0')
996
+ if boundary == 0 then return {} end
997
+ for _, log in ipairs(redis.call('SMEMBERS', session .. 'cleared')) do
998
+ local entries = redis.call('XRANGE', log, '-', '+', 'COUNT', ARGV[1])
999
+ if #entries > 0 then
1000
+ local batch = { log }
1001
+ for _, entry in ipairs(entries) do
1002
+ local fields = entry[2]
1003
+ for i = 1, #fields, 2 do
1004
+ if fields[i] == 'id' then
1005
+ batch[#batch + 1] = string.match(entry[1], '^(%d+)')
1006
+ batch[#batch + 1] = fields[i + 1]
1007
+ break
1008
+ end
1009
+ end
1010
+ end
1011
+ return batch
1012
+ end
1013
+ redis.call('DEL', log)
1014
+ redis.call('SREM', session .. 'cleared', log)
1015
+ end
1016
+ sweep_checkpoints(session, boundary)
1017
+ return {}
1018
+ `
1019
+
1020
+ /**
1021
+ * KEYS: moved log, counter, metadata, metadata split, then one id ownership
1022
+ * key per entry.
1023
+ * ARGV: sessionId, event pin key prefix, n, then per entry: index, id.
1024
+ * Deletes the listed entries below the latest clear and everything that
1025
+ * names them. An id record survives when it names another event, so an id
1026
+ * reused in the new lifetime keeps its owner. Returns [deleted, done].
1027
+ */
1028
+ const RECLAIM_LUA = `
1029
+ local COUNTER, META, SPLIT = KEYS[2], KEYS[3], KEYS[4]
1030
+ ${META_LUA}
1031
+ ${SWEEP_CHECKPOINTS_LUA}
1032
+ local function lane_field(lane, suffix)
1033
+ return '__lane:' .. tostring(string.len(lane)) .. ':' .. lane .. ':' .. suffix
1034
+ end
1035
+ local session = string.sub(COUNTER, 1, -6)
1036
+ local counts = redis.call('MGET', COUNTER, SPLIT, CLEAR)
1037
+ use_split(counts[2], counts[1], counts[3])
1038
+ local fields, id_keys, indexes, stream_ids, pin_keys, deleted = {}, {}, {}, {}, {}, {}
1039
+ -- A concurrent reclaim may list the same batch; only XDEL says who removed it.
1040
+ local removed = 0
1041
+ for i = 1, tonumber(ARGV[3]) do
1042
+ local field = ARGV[3 + (i - 1) * 2 + 1]
1043
+ if tonumber(field) < boundary then
1044
+ fields[#fields + 1] = field
1045
+ id_keys[#fields] = KEYS[4 + i]
1046
+ indexes[#fields] = tonumber(field)
1047
+ stream_ids[#fields] = field .. '-0'
1048
+ pin_keys[#fields] = ARGV[2] .. field
1049
+ deleted[field] = true
1050
+ end
1051
+ end
1052
+ if #fields > 0 then
1053
+ local lanes, seen, pointers = read_fields(fields, { 'l' }), {}, {}
1054
+ for _, field in ipairs(fields) do
1055
+ local lane = lanes[field][1]
1056
+ if lane and lane ~= '' and not seen[lane] then
1057
+ seen[lane] = true
1058
+ pointers[#pointers + 1] = lane_field(lane, 'head')
1059
+ pointers[#pointers + 1] = lane_field(lane, 'tail')
1060
+ end
1061
+ end
1062
+ if #pointers > 0 then
1063
+ local values = redis.call('HMGET', META, unpack(pointers))
1064
+ local stale = {}
1065
+ for i = 1, #pointers do
1066
+ if values[i] and deleted[values[i]] then stale[#stale + 1] = pointers[i] end
1067
+ end
1068
+ if #stale > 0 then redis.call('HDEL', META, unpack(stale)) end
1069
+ end
1070
+ local suffixes = { 'l', 'next', 'p', 'pa', 'ri', 'ch', 'ce', 'f', 'fc', 'lc', 'lf', 'lfa', 'd', 'a', 'e' }
1071
+ local columns, order = {}, {}
1072
+ for _, field in ipairs(fields) do
1073
+ local key = mkey(field)
1074
+ if not columns[key] then
1075
+ columns[key] = {}
1076
+ order[#order + 1] = key
1077
+ end
1078
+ for _, suffix in ipairs(suffixes) do
1079
+ columns[key][#columns[key] + 1] = field .. ':' .. suffix
1080
+ end
1081
+ end
1082
+ for _, key in ipairs(order) do sliced('HDEL', key, columns[key]) end
1083
+ sliced('DEL', nil, pin_keys)
1084
+ local owners, stale_ids = sliced('MGET', nil, id_keys), {}
1085
+ for i = 1, #fields do
1086
+ if owners[i] then
1087
+ local rec = cjson.decode(owners[i])
1088
+ if rec[1] == ARGV[1] and tonumber(rec[2]) == indexes[i] then
1089
+ stale_ids[#stale_ids + 1] = id_keys[i]
1090
+ end
1091
+ end
1092
+ end
1093
+ if #stale_ids > 0 then sliced('DEL', nil, stale_ids) end
1094
+ removed = redis.call('XDEL', KEYS[1], unpack(stream_ids))
1095
+ end
1096
+ local done = 0
1097
+ if redis.call('XLEN', KEYS[1]) == 0 then
1098
+ redis.call('DEL', KEYS[1])
1099
+ redis.call('SREM', session .. 'cleared', KEYS[1])
1100
+ if redis.call('SCARD', session .. 'cleared') == 0 then
1101
+ sweep_checkpoints(session, boundary)
1102
+ done = 1
1103
+ end
1104
+ end
1105
+ return { removed, done }
629
1106
  `
630
1107
 
631
1108
  // ── wire helpers ─────────────────────────────────────────────────────
632
1109
 
633
- type StreamEntry = [id: string, fields: string[]]
1110
+ export type StreamEntry = [id: string, fields: string[]]
634
1111
 
635
1112
  const fieldMap = (fields: string[]): Record<string, string> => {
636
1113
  const map: Record<string, string> = {}
@@ -671,6 +1148,66 @@ const toEvent = (sessionId: string, entry: StreamEntry): Event => {
671
1148
  }
672
1149
  }
673
1150
 
1151
+ type ByteStreamEntry = [Uint8Array, Uint8Array[]]
1152
+
1153
+ const fieldBytes = (name: string): Uint8Array => new TextEncoder().encode(name)
1154
+ const ID_FIELD = fieldBytes('id')
1155
+ const TYPE_FIELD = fieldBytes('type')
1156
+ const PAYLOAD_FIELD = fieldBytes('payload')
1157
+ const CREATED_AT_FIELD = fieldBytes('created_at')
1158
+ // A leading U+FEFF is part of a stored id or type, never a byte-order mark.
1159
+ const utf8 = new TextDecoder('utf-8', { ignoreBOM: true })
1160
+
1161
+ const isField = (name: Uint8Array, field: Uint8Array): boolean => {
1162
+ if (name.length !== field.length) return false
1163
+ for (let at = 0; at < field.length; at++)
1164
+ if (name[at] !== field[at]) return false
1165
+ return true
1166
+ }
1167
+
1168
+ const bytesToSerializedEvent = (
1169
+ sessionId: string,
1170
+ entry: ByteStreamEntry,
1171
+ ): SerializedEvent => {
1172
+ const values = entry[1]
1173
+ let id: string | undefined
1174
+ let type: string | undefined
1175
+ let payloadJson: string | undefined
1176
+ let createdAt: string | undefined
1177
+ for (let at = 0; at + 1 < values.length; at += 2) {
1178
+ const name = values[at]!
1179
+ if (isField(name, ID_FIELD)) id = utf8.decode(values[at + 1])
1180
+ else if (isField(name, TYPE_FIELD)) type = utf8.decode(values[at + 1])
1181
+ else if (isField(name, PAYLOAD_FIELD))
1182
+ payloadJson = utf8.decode(values[at + 1])
1183
+ else if (isField(name, CREATED_AT_FIELD))
1184
+ createdAt = utf8.decode(values[at + 1])
1185
+ }
1186
+ return {
1187
+ id: id!,
1188
+ type: type!,
1189
+ payloadJson: payloadJson!,
1190
+ index: entryIndex(utf8.decode(entry[0])),
1191
+ sessionId,
1192
+ createdAtMs: Number(createdAt),
1193
+ }
1194
+ }
1195
+
1196
+ const toSerializedEvent = (
1197
+ sessionId: string,
1198
+ entry: StreamEntry,
1199
+ ): SerializedEvent => {
1200
+ const fields = fieldMap(entry[1])
1201
+ return {
1202
+ id: fields['id']!,
1203
+ type: fields['type']!,
1204
+ payloadJson: fields['payload']!,
1205
+ index: entryIndex(entry[0]),
1206
+ sessionId,
1207
+ createdAtMs: Number(fields['created_at']),
1208
+ }
1209
+ }
1210
+
674
1211
  const json = (value: unknown): string => JSON.stringify(value) ?? 'null'
675
1212
 
676
1213
  const toCause = (raw: string | undefined): EventCause | null => {
@@ -744,6 +1281,9 @@ const toClaimAvailableResult = (
744
1281
  }),
745
1282
  )
746
1283
  }
1284
+ const clearIndex = Number(reply[2 + count * 10] ?? 0)
1285
+ if (clearIndex > 0)
1286
+ for (const event of events) event.clearIndex = clearIndex
747
1287
  return {
748
1288
  outcome: 'claimed',
749
1289
  events,
@@ -758,6 +1298,57 @@ const toClaimAvailableResult = (
758
1298
  )
759
1299
  }
760
1300
 
1301
+ const toIndex = ({
1302
+ raw,
1303
+ sessionId,
1304
+ name,
1305
+ }: {
1306
+ raw: unknown
1307
+ sessionId: string
1308
+ name: string
1309
+ }): number => {
1310
+ const index = Number(raw ?? 0)
1311
+ if (!Number.isSafeInteger(index) || index < 0) {
1312
+ throw new A2Error(
1313
+ 'STORE_UNAVAILABLE',
1314
+ `redis ${name} for session '${sessionId}' is invalid`,
1315
+ )
1316
+ }
1317
+ return index
1318
+ }
1319
+
1320
+ /**
1321
+ * A range read that met a gap. A clear moves the old lifetime's log aside, so
1322
+ * a read whose bounds predate it finds nothing where it expected events;
1323
+ * anywhere else a gap is corruption.
1324
+ */
1325
+ const rangeGap = (sessionId: string, message: string): A2Error =>
1326
+ Object.assign(
1327
+ new A2Error(
1328
+ 'STORE_UNAVAILABLE',
1329
+ `redis event range for session '${sessionId}' ${message}`,
1330
+ ),
1331
+ { rangeGap: true },
1332
+ )
1333
+
1334
+ const isRangeGap = (error: unknown): boolean =>
1335
+ error instanceof A2Error &&
1336
+ (error as { rangeGap?: boolean }).rangeGap === true
1337
+
1338
+ /**
1339
+ * Run a whole read, bounds included. A gap means a clear raced it, so the
1340
+ * read runs once more from fresh bounds. A second gap, from another clear or
1341
+ * from corruption, surfaces as the retryable STORE_UNAVAILABLE.
1342
+ */
1343
+ const retryAfterGap = async <T>(read: () => Promise<T>): Promise<T> => {
1344
+ try {
1345
+ return await read()
1346
+ } catch (error) {
1347
+ if (!isRangeGap(error)) throw error
1348
+ return read()
1349
+ }
1350
+ }
1351
+
761
1352
  const wrap = async <T>(fn: () => Promise<T>): Promise<T> => {
762
1353
  try {
763
1354
  return await fn()
@@ -772,13 +1363,16 @@ const wrap = async <T>(fn: () => Promise<T>): Promise<T> => {
772
1363
  export function createRedisStoreCore(
773
1364
  options: RedisStoreCoreOptions,
774
1365
  ): RedisStoreCore {
775
- const { call, clock } = options
1366
+ const { call, callBytes, clock } = options
776
1367
  const generateId = options.ids
777
1368
  const prefix = options.keyPrefix
778
1369
 
779
1370
  const storeKey = (s: string): string => `${prefix}:${s}:log`
780
1371
  const countKey = (s: string): string => `${prefix}:${s}:count`
781
1372
  const metaKey = (s: string): string => `${prefix}:${s}:meta`
1373
+ const metaChunkKey = (s: string, index: number): string =>
1374
+ `${metaKey(s)}:${Math.floor((index - 1) / META_CHUNK_SIZE)}`
1375
+ const metaSplitKey = (s: string): string => `${prefix}:${s}:meta-split`
782
1376
  const pendingKey = (s: string): string => `${prefix}:${s}:pending`
783
1377
  const readyKey = (s: string): string => `${prefix}:${s}:ready`
784
1378
  const snapKey = (s: string, r: string): string => `${prefix}:${s}:snap:${r}`
@@ -790,9 +1384,15 @@ export function createRedisStoreCore(
790
1384
  `${prefix}:${s}:snapshot-pins:`
791
1385
  const snapshotPinCountsKey = (s: string): string =>
792
1386
  `${prefix}:${s}:snapshot-pin-counts`
793
- const watchedKey = (s: string): string => `${prefix}:${s}:watched`
794
1387
  const presenceKey = (s: string): string => `${prefix}:${s}:presence`
795
- const idsKey = `${prefix}:ids`
1388
+ // Scripts derive this from the counter key; keep the two in step.
1389
+ const clearKey = (s: string): string => `${prefix}:${s}:clear`
1390
+ // One ownership key per event id; reclaim deletes those naming cleared events.
1391
+ // Session ids are raw and may contain colons, so a raw id could name a
1392
+ // session key (`ids:count`). A JSON string with escaped colons starts with
1393
+ // a quote and has no colon, so it never matches a session key's suffix.
1394
+ const idKey = (id: string): string =>
1395
+ `${prefix}:ids:${JSON.stringify(id).replaceAll(':', '\\u003a')}`
796
1396
 
797
1397
  const evalScript = (
798
1398
  script: string,
@@ -800,23 +1400,37 @@ export function createRedisStoreCore(
800
1400
  args: Array<string | number>,
801
1401
  ): Promise<unknown> => call('EVAL', script, keys.length, ...keys, ...args)
802
1402
 
803
- const readFrontier = async (sessionId: string): Promise<number> => {
804
- const frontier = Number((await call('GET', countKey(sessionId))) ?? 0)
805
- if (!Number.isSafeInteger(frontier) || frontier < 0) {
806
- throw new A2Error(
807
- 'STORE_UNAVAILABLE',
808
- `redis event count for session '${sessionId}' is invalid`,
809
- )
1403
+ const readRangeBounds = async (
1404
+ sessionId: string,
1405
+ ): Promise<{ frontier: number; metaSplit: number; clearIndex: number }> => {
1406
+ const [count, split, cleared] = (await call(
1407
+ 'MGET',
1408
+ countKey(sessionId),
1409
+ metaSplitKey(sessionId),
1410
+ clearKey(sessionId),
1411
+ )) as [unknown, unknown, unknown]
1412
+ const frontier = toIndex({ raw: count, sessionId, name: 'event count' })
1413
+ return {
1414
+ frontier,
1415
+ metaSplit:
1416
+ split === null
1417
+ ? frontier
1418
+ : toIndex({ raw: split, sessionId, name: 'metadata split' }),
1419
+ clearIndex: toIndex({ raw: cleared, sessionId, name: 'clear boundary' }),
810
1420
  }
811
- return frontier
812
1421
  }
813
1422
 
814
- const readRangePage = async (
815
- sessionId: string,
816
- afterIndex: number,
817
- throughIndex: number,
818
- limit: number,
819
- ): Promise<StoredEvent[]> => {
1423
+ const readRangePage = async ({
1424
+ sessionId,
1425
+ afterIndex,
1426
+ throughIndex,
1427
+ metaSplit,
1428
+ }: {
1429
+ sessionId: string
1430
+ afterIndex: number
1431
+ throughIndex: number
1432
+ metaSplit: number
1433
+ }): Promise<StoredEvent[]> => {
820
1434
  if (throughIndex <= afterIndex) return []
821
1435
  const entries = (await call(
822
1436
  'XRANGE',
@@ -824,23 +1438,36 @@ export function createRedisStoreCore(
824
1438
  `${afterIndex + 1}-0`,
825
1439
  `${throughIndex}-0`,
826
1440
  'COUNT',
827
- limit,
1441
+ READ_PAGE_SIZE,
828
1442
  )) as StreamEntry[]
829
1443
  if (entries.length === 0) return []
830
1444
 
831
- const metaFields = entries.flatMap((entry) => {
1445
+ const fieldsByKey = new Map<string, string[]>()
1446
+ for (const entry of entries) {
832
1447
  const index = entryIndex(entry[0])
833
- return STORED_EVENT_META_SUFFIXES.map((suffix) => `${index}:${suffix}`)
834
- })
835
- const metaValues =
836
- ((await call('HMGET', metaKey(sessionId), ...metaFields)) as Array<
837
- string | null
838
- > | null) ?? []
1448
+ const key =
1449
+ index <= metaSplit ? metaKey(sessionId) : metaChunkKey(sessionId, index)
1450
+ const fields = fieldsByKey.get(key) ?? []
1451
+ fields.push(
1452
+ ...STORED_EVENT_META_SUFFIXES.map((suffix) => `${index}:${suffix}`),
1453
+ )
1454
+ fieldsByKey.set(key, fields)
1455
+ }
1456
+ const lookups = [...fieldsByKey]
1457
+ const results = await Promise.allSettled(
1458
+ lookups.map(([key, fields]) => call('HMGET', key, ...fields)),
1459
+ )
839
1460
  const meta: Record<string, string> = {}
840
- for (let i = 0; i < metaFields.length; i += 1) {
841
- const value = metaValues[i]
842
- if (value !== null && value !== undefined) {
843
- meta[metaFields[i]!] = value
1461
+ for (let position = 0; position < lookups.length; position += 1) {
1462
+ const result = results[position]!
1463
+ if (result.status === 'rejected') throw result.reason
1464
+ const fields = lookups[position]![1]
1465
+ const values = (result.value as Array<string | null> | null) ?? []
1466
+ for (let i = 0; i < fields.length; i += 1) {
1467
+ const value = values[i]
1468
+ if (value !== null && value !== undefined) {
1469
+ meta[fields[i]!] = value
1470
+ }
844
1471
  }
845
1472
  }
846
1473
  return entries.map((entry) => {
@@ -910,18 +1537,12 @@ export function createRedisStoreCore(
910
1537
  READ_PAGE_SIZE,
911
1538
  )) as StreamEntry[]
912
1539
  if (entries.length === 0) {
913
- throw new A2Error(
914
- 'STORE_UNAVAILABLE',
915
- `redis event range for session '${sessionId}' ended before index ${throughIndex}`,
916
- )
1540
+ throw rangeGap(sessionId, `ended before index ${throughIndex}`)
917
1541
  }
918
1542
  for (let position = 0; position < entries.length; position += 1) {
919
1543
  const index = entryIndex(entries[position]![0])
920
1544
  if (index !== nextIndex + position || index > throughIndex) {
921
- throw new A2Error(
922
- 'STORE_UNAVAILABLE',
923
- `redis event range for session '${sessionId}' is not contiguous`,
924
- )
1545
+ throw rangeGap(sessionId, 'is not contiguous')
925
1546
  }
926
1547
  }
927
1548
  events.push(...entries.map((entry) => toEvent(sessionId, entry)))
@@ -972,34 +1593,43 @@ export function createRedisStoreCore(
972
1593
  sessionId: string,
973
1594
  afterIndex: number,
974
1595
  throughIndex?: number,
975
- ): Promise<StoredEvent[]> => {
976
- if (throughIndex !== undefined && throughIndex <= afterIndex) return []
977
- const currentFrontier = await readFrontier(sessionId)
1596
+ ): Promise<StoredEvent[]> =>
1597
+ (await readBoundedRange(sessionId, afterIndex, throughIndex)).rows
1598
+
1599
+ /** `readRange`, with the clear boundary its rows start at. */
1600
+ const readBoundedRange = async (
1601
+ sessionId: string,
1602
+ afterIndex: number,
1603
+ throughIndex?: number,
1604
+ ): Promise<{ rows: StoredEvent[]; clearIndex: number }> => {
1605
+ if (throughIndex !== undefined && throughIndex <= afterIndex)
1606
+ return { rows: [], clearIndex: 0 }
1607
+ const {
1608
+ frontier: currentFrontier,
1609
+ metaSplit,
1610
+ clearIndex,
1611
+ } = await readRangeBounds(sessionId)
978
1612
  const frontier = Math.min(throughIndex ?? currentFrontier, currentFrontier)
979
- if (frontier <= afterIndex) return []
1613
+ // Reads start at the latest clear marker.
1614
+ const after = Math.max(afterIndex, clearIndex - 1)
1615
+ if (frontier <= after) return { rows: [], clearIndex }
980
1616
 
981
1617
  const rows: StoredEvent[] = []
982
- let nextIndex = afterIndex + 1
1618
+ let nextIndex = after + 1
983
1619
  while (nextIndex <= frontier) {
984
1620
  // oxlint-disable-next-line no-await-in-loop -- each page advances the dense frozen range
985
- const page = await readRangePage(
1621
+ const page = await readRangePage({
986
1622
  sessionId,
987
- nextIndex - 1,
988
- frontier,
989
- READ_PAGE_SIZE,
990
- )
1623
+ afterIndex: nextIndex - 1,
1624
+ throughIndex: frontier,
1625
+ metaSplit,
1626
+ })
991
1627
  if (page.length === 0) {
992
- throw new A2Error(
993
- 'STORE_UNAVAILABLE',
994
- `redis event range for session '${sessionId}' ended before index ${frontier}`,
995
- )
1628
+ throw rangeGap(sessionId, `ended before index ${frontier}`)
996
1629
  }
997
1630
  for (let position = 0; position < page.length; position += 1) {
998
1631
  if (page[position]!.index !== nextIndex + position) {
999
- throw new A2Error(
1000
- 'STORE_UNAVAILABLE',
1001
- `redis event range for session '${sessionId}' is not contiguous`,
1002
- )
1632
+ throw rangeGap(sessionId, 'is not contiguous')
1003
1633
  }
1004
1634
  }
1005
1635
  const lastIndex = page.at(-1)!.index
@@ -1012,11 +1642,45 @@ export function createRedisStoreCore(
1012
1642
  rows.push(...page)
1013
1643
  nextIndex = lastIndex + 1
1014
1644
  }
1015
- return rows
1645
+ return { rows, clearIndex }
1016
1646
  }
1017
1647
 
1648
+ const readStream = options.readStream
1018
1649
  return {
1019
- async append(sessionId, events) {
1650
+ ...(readStream
1651
+ ? {
1652
+ streamEvents: async function* ({
1653
+ sessionId,
1654
+ afterIndex,
1655
+ signal,
1656
+ }: {
1657
+ sessionId: string
1658
+ afterIndex: number
1659
+ signal: AbortSignal
1660
+ }): AsyncGenerator<SerializedEvent[], void> {
1661
+ try {
1662
+ for await (const entries of readStream({
1663
+ key: storeKey(sessionId),
1664
+ from: `${afterIndex + 1}-0`,
1665
+ signal,
1666
+ })) {
1667
+ signal.throwIfAborted()
1668
+ yield entries.map((entry) =>
1669
+ toSerializedEvent(sessionId, entry),
1670
+ )
1671
+ }
1672
+ } catch (cause) {
1673
+ if (cause instanceof A2Error) throw cause
1674
+ throw new A2Error(
1675
+ 'STORE_UNAVAILABLE',
1676
+ 'redis stream read failed',
1677
+ { cause },
1678
+ )
1679
+ }
1680
+ },
1681
+ }
1682
+ : {}),
1683
+ async append(sessionId, events, appendOptions) {
1020
1684
  return wrap(async () => {
1021
1685
  const supplied = events.filter((e) => e.id !== undefined)
1022
1686
  const suppliedIds = supplied.map((e) => e.id as string)
@@ -1048,17 +1712,18 @@ export function createRedisStoreCore(
1048
1712
  e.settled ? '1' : '0',
1049
1713
  ]),
1050
1714
  `${prefix}:${sessionId}:notify`,
1715
+ appendOptions?.originIndex ?? '',
1051
1716
  ]
1052
1717
  const reply = (await evalScript(
1053
1718
  APPEND_LUA,
1054
1719
  [
1055
1720
  storeKey(sessionId),
1056
1721
  countKey(sessionId),
1057
- idsKey,
1058
1722
  metaKey(sessionId),
1059
1723
  pendingKey(sessionId),
1060
1724
  readyKey(sessionId),
1061
- watchedKey(sessionId),
1725
+ metaSplitKey(sessionId),
1726
+ ...withIds.map((e) => idKey(e.id)),
1062
1727
  ],
1063
1728
  args,
1064
1729
  )) as string[]
@@ -1078,6 +1743,11 @@ export function createRedisStoreCore(
1078
1743
  `no event at index ${reply[1]} in session '${sessionId}'`,
1079
1744
  )
1080
1745
  }
1746
+ if (verdict === 'cleared') {
1747
+ throw sessionClearedError(
1748
+ `scheduled work from event ${reply[1]} was cleared in session '${sessionId}'`,
1749
+ )
1750
+ }
1081
1751
  if (verdict === 'superseded') {
1082
1752
  throw new A2Error(
1083
1753
  'SUPERSEDED_ATTEMPT',
@@ -1091,13 +1761,23 @@ export function createRedisStoreCore(
1091
1761
  )
1092
1762
  }
1093
1763
  if (verdict === 'dup') {
1094
- // A retry of a committed batch whose ack was lost — return
1095
- // the original rows, ordered by index like every adapter.
1096
1764
  const hasPending = reply[1] === '1'
1097
- const wanted = new Set(reply.slice(2).map(Number))
1098
- const min = Math.min(...wanted)
1099
- const rows = await readRange(sessionId, min - 1)
1100
- const existing = rows.filter((row) => wanted.has(row.index))
1765
+ const firstIndex = Math.min(...reply.slice(2).map(Number))
1766
+ // A valid retry occupies exactly events.length consecutive indexes.
1767
+ const { rows: existing, clearIndex } = await retryAfterGap(() =>
1768
+ readBoundedRange(
1769
+ sessionId,
1770
+ firstIndex - 1,
1771
+ firstIndex + events.length - 1,
1772
+ ),
1773
+ )
1774
+ if (firstIndex < clearIndex) {
1775
+ // Cleared after the script matched the retry: the ids are free.
1776
+ throw new A2Error(
1777
+ 'STORE_UNAVAILABLE',
1778
+ `session '${sessionId}' was cleared while an append retry replayed; retry it`,
1779
+ )
1780
+ }
1101
1781
  return {
1102
1782
  events: idempotentReplay(events, existing),
1103
1783
  hasPending,
@@ -1134,28 +1814,42 @@ export function createRedisStoreCore(
1134
1814
  },
1135
1815
 
1136
1816
  async read(sessionId, opts) {
1137
- return wrap(async () => {
1138
- const rows = await readRange(
1139
- sessionId,
1140
- opts?.afterIndex ?? 0,
1141
- opts?.throughIndex,
1142
- )
1143
- return rows
1144
- })
1817
+ return wrap(() =>
1818
+ retryAfterGap(() =>
1819
+ readRange(sessionId, opts?.afterIndex ?? 0, opts?.throughIndex),
1820
+ ),
1821
+ )
1145
1822
  },
1146
1823
 
1147
1824
  async readHistory(sessionId, opts) {
1148
- return wrap(async () => {
1149
- const after = opts?.afterIndex ?? 0
1150
- if (opts?.throughIndex !== undefined && opts.throughIndex <= after)
1151
- return []
1152
- const frontier = await readFrontier(sessionId)
1153
- return readEventRange(
1154
- sessionId,
1155
- after,
1156
- Math.min(opts?.throughIndex ?? frontier, frontier),
1157
- )
1158
- })
1825
+ const after = opts?.afterIndex ?? 0
1826
+ if (opts?.throughIndex !== undefined && opts.throughIndex <= after)
1827
+ return []
1828
+ return wrap(() =>
1829
+ retryAfterGap(async () => {
1830
+ const [count, cleared] = (await call(
1831
+ 'MGET',
1832
+ countKey(sessionId),
1833
+ clearKey(sessionId),
1834
+ )) as [unknown, unknown]
1835
+ const frontier = toIndex({
1836
+ raw: count,
1837
+ sessionId,
1838
+ name: 'event count',
1839
+ })
1840
+ const clearIndex = toIndex({
1841
+ raw: cleared,
1842
+ sessionId,
1843
+ name: 'clear boundary',
1844
+ })
1845
+ // Reads start at the latest clear marker.
1846
+ return readEventRange(
1847
+ sessionId,
1848
+ Math.max(after, clearIndex - 1),
1849
+ Math.min(opts?.throughIndex ?? frontier, frontier),
1850
+ )
1851
+ }),
1852
+ )
1159
1853
  },
1160
1854
 
1161
1855
  async claimAvailable({
@@ -1177,6 +1871,7 @@ export function createRedisStoreCore(
1177
1871
  metaKey(sessionId),
1178
1872
  pendingKey(sessionId),
1179
1873
  readyKey(sessionId),
1874
+ metaSplitKey(sessionId),
1180
1875
  ],
1181
1876
  [holder, now, expiresAt, excluded.length, ...excluded],
1182
1877
  )) as unknown[]
@@ -1190,7 +1885,7 @@ export function createRedisStoreCore(
1190
1885
  const expiresAt = expiresAtMs ?? now + ttlMs
1191
1886
  const reply = (await evalScript(
1192
1887
  RENEW_CLAIMS_LUA,
1193
- [countKey(sessionId), metaKey(sessionId)],
1888
+ [countKey(sessionId), metaKey(sessionId), metaSplitKey(sessionId)],
1194
1889
  [
1195
1890
  holder,
1196
1891
  now,
@@ -1223,13 +1918,13 @@ export function createRedisStoreCore(
1223
1918
  [
1224
1919
  storeKey(sessionId),
1225
1920
  countKey(sessionId),
1226
- idsKey,
1227
1921
  metaKey(sessionId),
1228
1922
  pendingKey(sessionId),
1229
1923
  readyKey(sessionId),
1230
- watchedKey(sessionId),
1231
1924
  snapshotEventPinsKey(sessionId, index),
1232
1925
  snapshotPinCountsKey(sessionId),
1926
+ metaSplitKey(sessionId),
1927
+ ...ids.map((id) => idKey(id)),
1233
1928
  ],
1234
1929
  [
1235
1930
  sessionId,
@@ -1295,13 +1990,16 @@ export function createRedisStoreCore(
1295
1990
  if (events.length === 0) {
1296
1991
  return { outcome: 'completed', events: [] }
1297
1992
  }
1298
- const wanted = new Set(reply.slice(1).map(Number))
1299
- const min = Math.min(...wanted)
1300
- const rows = await readRange(sessionId, min - 1)
1301
- const byIndex = new Map(rows.map((row) => [row.index, row]))
1302
- const existing = reply
1303
- .slice(1)
1304
- .map((value) => byIndex.get(Number(value))!)
1993
+ const firstIndex = Math.min(...reply.slice(1).map(Number))
1994
+ const { rows: existing, clearIndex } = await retryAfterGap(() =>
1995
+ readBoundedRange(
1996
+ sessionId,
1997
+ firstIndex - 1,
1998
+ firstIndex + events.length - 1,
1999
+ ),
2000
+ )
2001
+ // Cleared after the script matched the retry.
2002
+ if (index <= clearIndex) return { outcome: 'superseded' }
1305
2003
  return {
1306
2004
  outcome: 'completed',
1307
2005
  events: idempotentReplay(events, existing),
@@ -1322,10 +2020,13 @@ export function createRedisStoreCore(
1322
2020
  readyKey(sessionId),
1323
2021
  snapshotEventPinsKey(sessionId, index),
1324
2022
  snapshotPinCountsKey(sessionId),
2023
+ metaSplitKey(sessionId),
1325
2024
  ],
1326
2025
  [index, attempt, error, maxFailures, clock.now().getTime()],
1327
2026
  )) as string[]
1328
2027
  const outcome = reply[0]
2028
+ if (outcome === 'cleared')
2029
+ return { outcome: 'superseded', failureCount: 0 }
1329
2030
  if (outcome === 'missing') {
1330
2031
  throw new TypeError(
1331
2032
  `no event at index ${index} in session '${sessionId}'`,
@@ -1339,59 +2040,64 @@ export function createRedisStoreCore(
1339
2040
  },
1340
2041
 
1341
2042
  async readState(sessionId, reducerName, stateOptions) {
1342
- return wrap(async () => {
1343
- const reply = (await evalScript(
1344
- READ_STATE_LUA,
1345
- [
1346
- snapKey(sessionId, reducerName),
1347
- snapHistoryKey(sessionId, reducerName),
1348
- countKey(sessionId),
1349
- ],
1350
- [
1351
- stateOptions?.snapshotThroughIndex ??
1352
- stateOptions?.throughIndex ??
1353
- '',
1354
- ],
1355
- )) as [number | string, string, string]
1356
- const rawHead = reply[0]
1357
- const raw = reply[1]
1358
- const headIndex = rawHead === '' ? null : Number(rawHead)
1359
- const parsed =
1360
- raw === ''
1361
- ? null
1362
- : (JSON.parse(raw) as { index: number; state: unknown })
1363
- const currentFrontier = Number(reply[2])
1364
- if (
1365
- !Number.isSafeInteger(currentFrontier) ||
1366
- currentFrontier < 0 ||
1367
- (headIndex !== null &&
1368
- (!Number.isSafeInteger(headIndex) || headIndex < 0)) ||
1369
- (parsed !== null &&
1370
- (!Number.isSafeInteger(parsed.index) || parsed.index < 0))
1371
- ) {
1372
- throw new A2Error(
1373
- 'STORE_UNAVAILABLE',
1374
- `redis state frontier for session '${sessionId}' is invalid`,
2043
+ return wrap(() =>
2044
+ retryAfterGap(async () => {
2045
+ const reply = (await evalScript(
2046
+ READ_STATE_LUA,
2047
+ [
2048
+ snapKey(sessionId, reducerName),
2049
+ snapHistoryKey(sessionId, reducerName),
2050
+ countKey(sessionId),
2051
+ ],
2052
+ [
2053
+ stateOptions?.snapshotThroughIndex ??
2054
+ stateOptions?.throughIndex ??
2055
+ '',
2056
+ ],
2057
+ )) as [number | string, string, string, string]
2058
+ const rawHead = reply[0]
2059
+ const raw = reply[1]
2060
+ const headIndex = rawHead === '' ? null : Number(rawHead)
2061
+ const parsed =
2062
+ raw === ''
2063
+ ? null
2064
+ : (JSON.parse(raw) as { index: number; state: unknown })
2065
+ const currentFrontier = Number(reply[2])
2066
+ if (
2067
+ !Number.isSafeInteger(currentFrontier) ||
2068
+ currentFrontier < 0 ||
2069
+ (headIndex !== null &&
2070
+ (!Number.isSafeInteger(headIndex) || headIndex < 0)) ||
2071
+ (parsed !== null &&
2072
+ (!Number.isSafeInteger(parsed.index) || parsed.index < 0))
2073
+ ) {
2074
+ throw new A2Error(
2075
+ 'STORE_UNAVAILABLE',
2076
+ `redis state frontier for session '${sessionId}' is invalid`,
2077
+ )
2078
+ }
2079
+ const frontier = Math.min(
2080
+ currentFrontier,
2081
+ stateOptions?.throughIndex ?? currentFrontier,
1375
2082
  )
1376
- }
1377
- const frontier = Math.min(
1378
- currentFrontier,
1379
- stateOptions?.throughIndex ?? currentFrontier,
1380
- )
1381
- const snapshot = parsed
1382
- const events = await readEventRange(
1383
- sessionId,
1384
- snapshot?.index ?? 0,
1385
- frontier,
1386
- )
1387
- return {
1388
- headIndex,
1389
- snapshot: snapshot
1390
- ? { index: snapshot.index, state: snapshot.state }
1391
- : null,
1392
- events,
1393
- }
1394
- })
2083
+ const snapshot = parsed
2084
+ const clearIndex = Number(reply[3] ?? 0)
2085
+ // The fold starts at the latest clear marker when no checkpoint is newer.
2086
+ const events = await readEventRange(
2087
+ sessionId,
2088
+ Math.max(snapshot?.index ?? 0, clearIndex - 1),
2089
+ frontier,
2090
+ )
2091
+ return {
2092
+ ...(clearIndex > 0 ? { clearIndex } : {}),
2093
+ headIndex,
2094
+ snapshot: snapshot
2095
+ ? { index: snapshot.index, state: snapshot.state }
2096
+ : null,
2097
+ events,
2098
+ }
2099
+ }),
2100
+ )
1395
2101
  },
1396
2102
 
1397
2103
  async putSnapshots(sessionId, reducerName, writes) {
@@ -1431,6 +2137,8 @@ export function createRedisStoreCore(
1431
2137
  index: write.index,
1432
2138
  state: write.state,
1433
2139
  updatedAt: clock.now().getTime(),
2140
+ // This reducer is in the session's registry (PUT_SNAPSHOTS_LUA).
2141
+ r: 1,
1434
2142
  }),
1435
2143
  write.pinEventIndexes.size,
1436
2144
  ...write.pinEventIndexes,
@@ -1444,8 +2152,9 @@ export function createRedisStoreCore(
1444
2152
  countKey(sessionId),
1445
2153
  metaKey(sessionId),
1446
2154
  snapshotPinCountsKey(sessionId),
2155
+ metaSplitKey(sessionId),
1447
2156
  ],
1448
- args,
2157
+ [...args, reducerName],
1449
2158
  )
1450
2159
  })
1451
2160
  },
@@ -1540,6 +2249,105 @@ export function createRedisStoreCore(
1540
2249
  return wrap(() => readRange(sessionId, afterIndex, throughIndex))
1541
2250
  },
1542
2251
 
2252
+ async readTail(sessionId, fromIndex, throughIndex) {
2253
+ return wrap(async () => {
2254
+ const newest = (await call(
2255
+ 'XREVRANGE',
2256
+ storeKey(sessionId),
2257
+ throughIndex === undefined ? '+' : `${throughIndex}-0`,
2258
+ `${fromIndex}-0`,
2259
+ 'COUNT',
2260
+ READ_PAGE_SIZE,
2261
+ )) as StreamEntry[]
2262
+ const page = newest
2263
+ .toReversed()
2264
+ .map((entry) => toEvent(sessionId, entry))
2265
+ for (let position = 1; position < page.length; position += 1) {
2266
+ if (page[position]!.index !== page[position - 1]!.index + 1) {
2267
+ throw new A2Error(
2268
+ 'STORE_UNAVAILABLE',
2269
+ `redis event range for session '${sessionId}' is not contiguous`,
2270
+ )
2271
+ }
2272
+ }
2273
+ const oldest = page[0]?.index
2274
+ return oldest === undefined || oldest <= fromIndex ? page : null
2275
+ })
2276
+ },
2277
+
2278
+ async clear({ sessionId, ifLastIndex, cause }) {
2279
+ return wrap(async () => {
2280
+ const markerId = generateId()
2281
+ const reply = (await evalScript(
2282
+ CLEAR_LUA,
2283
+ [
2284
+ storeKey(sessionId),
2285
+ countKey(sessionId),
2286
+ metaKey(sessionId),
2287
+ metaSplitKey(sessionId),
2288
+ idKey(markerId),
2289
+ pendingKey(sessionId),
2290
+ readyKey(sessionId),
2291
+ ],
2292
+ [
2293
+ sessionId,
2294
+ clock.now().getTime(),
2295
+ ifLastIndex ?? '',
2296
+ cause?.index ?? '',
2297
+ cause?.attempt ?? '',
2298
+ markerId,
2299
+ `${prefix}:${sessionId}:notify`,
2300
+ ],
2301
+ )) as string[]
2302
+ if (reply[0] === 'superseded') {
2303
+ throw supersededAttemptError(sessionId, cause!)
2304
+ }
2305
+ if (reply[0] === 'no_parent') {
2306
+ throw new TypeError(
2307
+ `no event at index ${reply[1]} in session '${sessionId}'`,
2308
+ )
2309
+ }
2310
+ if (reply[0] === 'foreign') {
2311
+ throw new A2Error(
2312
+ 'PARTIAL_DUPLICATE_BATCH',
2313
+ `the clear marker id '${markerId}' already exists`,
2314
+ )
2315
+ }
2316
+ return {
2317
+ cleared: reply[1] === '1',
2318
+ index: Number(reply[2]),
2319
+ clearIndex: Number(reply[3]),
2320
+ }
2321
+ })
2322
+ },
2323
+
2324
+ async reclaim(sessionId) {
2325
+ return wrap(async () => {
2326
+ const [log, ...pairs] = (await evalScript(
2327
+ RECLAIM_BATCH_LUA,
2328
+ [countKey(sessionId)],
2329
+ [READ_PAGE_SIZE],
2330
+ )) as string[]
2331
+ if (log === undefined) return { deleted: 0, done: true }
2332
+ // Id keys are built here, like append's: they use JSON.stringify,
2333
+ // whose escaping cjson does not reproduce.
2334
+ const ids: string[] = []
2335
+ for (let i = 1; i < pairs.length; i += 2) ids.push(pairs[i]!)
2336
+ const [deleted, done] = (await evalScript(
2337
+ RECLAIM_LUA,
2338
+ [
2339
+ log,
2340
+ countKey(sessionId),
2341
+ metaKey(sessionId),
2342
+ metaSplitKey(sessionId),
2343
+ ...ids.map((id) => idKey(id)),
2344
+ ],
2345
+ [sessionId, snapshotEventPinsPrefix(sessionId), ids.length, ...pairs],
2346
+ )) as [number, number]
2347
+ return { deleted: Number(deleted), done: Number(done) === 1 }
2348
+ })
2349
+ },
2350
+
1543
2351
  async readEvents(sessionId, afterIndex) {
1544
2352
  return wrap(async () => {
1545
2353
  const entries = (await call(
@@ -1553,9 +2361,27 @@ export function createRedisStoreCore(
1553
2361
  return entries.map((entry) => toEvent(sessionId, entry))
1554
2362
  })
1555
2363
  },
1556
-
1557
- async markWatched(sessionId, ttlMs) {
1558
- await wrap(() => call('SET', watchedKey(sessionId), '1', 'PX', ttlMs))
2364
+ async readFeedPage(sessionId, afterIndex) {
2365
+ return wrap(async () => {
2366
+ const range = [
2367
+ storeKey(sessionId),
2368
+ `${afterIndex + 1}-0`,
2369
+ '+',
2370
+ 'COUNT',
2371
+ FEED_PAGE_SIZE,
2372
+ ] as const
2373
+ if (callBytes) {
2374
+ const entries = (await callBytes(
2375
+ 'XRANGE',
2376
+ ...range,
2377
+ )) as ByteStreamEntry[]
2378
+ return entries.map((entry) =>
2379
+ bytesToSerializedEvent(sessionId, entry),
2380
+ )
2381
+ }
2382
+ const entries = (await call('XRANGE', ...range)) as StreamEntry[]
2383
+ return entries.map((entry) => toSerializedEvent(sessionId, entry))
2384
+ })
1559
2385
  },
1560
2386
  }
1561
2387
  }