experimental-a2 0.16.0 → 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 (334) hide show
  1. package/CHANGELOG.md +360 -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-CrLYwNEx.js → ai-UrWfKeuv.js} +966 -541
  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 +466 -265
  32. package/dist/ai-server.js.map +1 -1
  33. package/dist/{ai-HJ9fHfYI.d.ts → ai-vpGtcaCB.d.ts} +115 -101
  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 +680 -580
  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 +662 -195
  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 +39 -2
  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 +94 -55
  256. package/src/ai-control.ts +2 -0
  257. package/src/ai-coordinator.ts +23 -22
  258. package/src/ai-message-base.ts +209 -0
  259. package/src/ai-message-projection.ts +382 -58
  260. package/src/ai-progress-batches.ts +26 -0
  261. package/src/ai-projector.ts +95 -14
  262. package/src/ai-react.ts +146 -0
  263. package/src/ai-server.browser.ts +2 -2
  264. package/src/ai-server.ts +20 -1951
  265. package/src/ai-stored-state.ts +48 -163
  266. package/src/ai.ts +343 -276
  267. package/src/bootstrap-stream.ts +72 -0
  268. package/src/bounded-cache.ts +55 -0
  269. package/src/client-render-session.ts +49 -0
  270. package/src/client.ts +688 -151
  271. package/src/contract.ts +92 -42
  272. package/src/deterministic-id.ts +37 -0
  273. package/src/equal-json.ts +32 -0
  274. package/src/errors.ts +13 -1
  275. package/src/index.ts +5 -1
  276. package/src/initial-snapshot.ts +39 -0
  277. package/src/internal.ts +45 -0
  278. package/src/postgres-notification-scope.ts +14 -3
  279. package/src/react.ts +171 -206
  280. package/src/redis-http-reply.ts +256 -0
  281. package/src/redis-http-stream.ts +73 -0
  282. package/src/redis-http-subscriptions.ts +13 -5
  283. package/src/reducer-runtime.ts +52 -0
  284. package/src/reducer.ts +92 -10
  285. package/src/scheduler-qstash.ts +49 -20
  286. package/src/scheduler-task.ts +34 -2
  287. package/src/scheduler-vercel.ts +32 -84
  288. package/src/server-fetch.ts +139 -35
  289. package/src/server.ts +957 -237
  290. package/src/session-clear.ts +23 -0
  291. package/src/session-socket.ts +154 -49
  292. package/src/sse.ts +71 -4
  293. package/src/store-memory.ts +220 -78
  294. package/src/store-polling.ts +18 -4
  295. package/src/store-postgres.ts +461 -114
  296. package/src/store-redis-core.ts +1260 -434
  297. package/src/store-redis-http.ts +18 -2
  298. package/src/store-redis-notify.ts +260 -85
  299. package/src/store-redis.ts +19 -9
  300. package/src/store-sqlite.ts +259 -45
  301. package/src/store.ts +46 -1
  302. package/src/stream-buffer.ts +43 -0
  303. package/src/telemetry.ts +13 -1
  304. package/src/wire.ts +241 -13
  305. package/dist/actor-ohPC-81x.d.ts.map +0 -1
  306. package/dist/actor-shared-B5tJfzt-.js.map +0 -1
  307. package/dist/ai-CrLYwNEx.js.map +0 -1
  308. package/dist/ai-HJ9fHfYI.d.ts.map +0 -1
  309. package/dist/client-BYzHjkwU.js.map +0 -1
  310. package/dist/client-CzyacQpJ.d.ts.map +0 -1
  311. package/dist/contract-CKRg_E4q.js.map +0 -1
  312. package/dist/errors-DCk6ch5n.js.map +0 -1
  313. package/dist/errors-DvhSXnxk.d.ts.map +0 -1
  314. package/dist/internal-Dq2qYxou.js.map +0 -1
  315. package/dist/react.d.ts.map +0 -1
  316. package/dist/reducer-DEMjEY_O.js +0 -29
  317. package/dist/reducer-DEMjEY_O.js.map +0 -1
  318. package/dist/reducer-otzHjuJj.d.ts.map +0 -1
  319. package/dist/scheduler-task-BpzhPnRS.js.map +0 -1
  320. package/dist/server-BD5ckxZb.js.map +0 -1
  321. package/dist/server-DbD7IJVK.d.ts.map +0 -1
  322. package/dist/store-DyZM6fS5.d.ts.map +0 -1
  323. package/dist/store-polling-CmxUbV93.js.map +0 -1
  324. package/dist/store-redis-notify-BVLUfI3j.js +0 -1583
  325. package/dist/store-redis-notify-BVLUfI3j.js.map +0 -1
  326. package/dist/telemetry-B5jzpy6y.d.ts.map +0 -1
  327. package/dist/validate-XKT4FSNn.js +0 -30
  328. package/dist/validate-XKT4FSNn.js.map +0 -1
  329. package/dist/wire-BO5wWCb1.js.map +0 -1
  330. package/examples/playground/app/agent/[agentId]/compaction/route.ts +0 -14
  331. package/examples/playground/app/agent/compaction-settings.test.ts +0 -144
  332. package/examples/playground/app/agent/compaction-settings.ts +0 -49
  333. package/examples/playground/app/agent/compaction-timeline.test.ts +0 -377
  334. package/examples/playground/app/agent/compaction-timeline.ts +0 -208
@@ -0,0 +1,2374 @@
1
+ import { i as A2Error, r as sessionClearedError, s as supersededAttemptError, t as CLEAR_EVENT_TYPE } from "./session-clear-DGI4jTQ5.js";
2
+ import { g as nullProtoRecord, r as NOTIFY_TIMINGS, v as stateTailReaders } from "./internal-CbVvEwad.js";
3
+ import { i as streamActivities, n as pollingStream, r as createStreamActivity, t as defaultSleep } from "./store-polling-4_y8lETm.js";
4
+ import { n as isSerializedEvent, r as parseSerializedEvent, t as bufferedEvents } from "./stream-buffer-Cx7AAPGq.js";
5
+ import { t as idempotentReplay } from "./idempotent-replay-JdgMQRRm.js";
6
+ import { t as decodeReturnedEventIds } from "./store-codec-DTG0Ftek.js";
7
+ import { t as pollPresence } from "./store-presence-polling-Q1C-k2dd.js";
8
+ //#region src/presence-recovery.ts
9
+ const key = ({ participant, field }) => JSON.stringify([participant, field]);
10
+ function presenceRecovery({ read, deliver, isActive }) {
11
+ const fields = /* @__PURE__ */ new Map();
12
+ const reading = /* @__PURE__ */ new Set();
13
+ let generation = 0;
14
+ let latest;
15
+ const remember = ({ participant, field, seen, at }) => {
16
+ const id = key({
17
+ participant,
18
+ field
19
+ });
20
+ const previous = fields.get(id);
21
+ if (!previous || previous.at.getTime() < at.getTime() || previous.at.getTime() === at.getTime() && previous.seen !== seen) fields.set(id, {
22
+ participant,
23
+ field,
24
+ seen,
25
+ at
26
+ });
27
+ };
28
+ const snapshot = async ({ reconcile }) => {
29
+ const run = generation;
30
+ const before = new Map(fields);
31
+ const changed = /* @__PURE__ */ new Set();
32
+ reading.add(changed);
33
+ try {
34
+ const rows = await read();
35
+ if (!isActive()) return rows;
36
+ if (run !== generation) return latest;
37
+ const live = /* @__PURE__ */ new Set();
38
+ for (const row of rows) {
39
+ const id = key(row);
40
+ live.add(id);
41
+ if (changed.has(id)) continue;
42
+ remember(row);
43
+ if (reconcile) deliver({
44
+ participant: row.participant,
45
+ values: { [row.field]: row.value },
46
+ seen: row.seen,
47
+ at: row.at
48
+ });
49
+ }
50
+ if (reconcile) for (const [id, prior] of before) {
51
+ if (live.has(id) || changed.has(id) || fields.get(id) !== prior) continue;
52
+ fields.delete(id);
53
+ deliver({
54
+ participant: prior.participant,
55
+ values: { [prior.field]: null },
56
+ seen: prior.seen,
57
+ at: prior.at
58
+ });
59
+ }
60
+ return rows;
61
+ } finally {
62
+ reading.delete(changed);
63
+ if (reading.size === 0) latest = void 0;
64
+ }
65
+ };
66
+ return {
67
+ async read() {
68
+ let pending = latest;
69
+ while (pending) {
70
+ await pending;
71
+ if (latest === pending) break;
72
+ pending = latest;
73
+ }
74
+ return snapshot({ reconcile: false });
75
+ },
76
+ async reconnect() {
77
+ generation += 1;
78
+ latest = snapshot({ reconcile: true });
79
+ await latest;
80
+ },
81
+ update(patch) {
82
+ for (const [field, value] of Object.entries(patch.values)) {
83
+ if (value === void 0) continue;
84
+ const id = key({
85
+ participant: patch.participant,
86
+ field
87
+ });
88
+ const previous = fields.get(id);
89
+ if (previous && previous.at.getTime() > patch.at.getTime()) continue;
90
+ for (const changed of reading) changed.add(id);
91
+ if (value === null) fields.delete(id);
92
+ else remember({
93
+ participant: patch.participant,
94
+ field,
95
+ seen: patch.seen,
96
+ at: patch.at
97
+ });
98
+ }
99
+ }
100
+ };
101
+ }
102
+ const FEED_PAGE_SIZE = 1e3;
103
+ const READ_CONCURRENCY = 16;
104
+ const META_CHUNK_SIZE = 1e3;
105
+ /**
106
+ * Upstash bills every call a script makes, so scripts read the counter and
107
+ * split with one MGET, read metadata with one HMGET per hash (`read_fields`),
108
+ * and buffer hash writes (`put`) into one HSET per hash (`flush`). Buffered
109
+ * writes are invisible to reads until flushed, so a script must not read a
110
+ * field it has buffered. Per-event metadata lives in hashes of
111
+ * META_CHUNK_SIZE consecutive indexes, so no key grows with a session's
112
+ * history. Indexes at or below the split predate chunking and stay in the
113
+ * base hash, which also holds every session's lane pointers. An unset split
114
+ * means the current event count, so any script that adds events must write
115
+ * `split_write` pairs in its write phase. Including scripts bind COUNTER,
116
+ * META (the base hash), and SPLIT first.
117
+ */
118
+ const META_LUA = `
119
+ local split, split_stored = nil, false
120
+ -- The latest clear marker lives beside the counter, {prefix}:{session}:clear,
121
+ -- and is read in the same MGET, so a session never cleared pays nothing.
122
+ local CLEAR = string.sub(COUNTER, 1, -6) .. 'clear'
123
+ local boundary = 0
124
+ local function use_split(stored, count, cleared)
125
+ split_stored = stored ~= false
126
+ split = tonumber(stored or count or '0')
127
+ boundary = tonumber(cleared or '0')
128
+ end
129
+ local function mkey(field)
130
+ if split == nil then
131
+ local values = redis.call('MGET', SPLIT, COUNTER, CLEAR)
132
+ use_split(values[1], values[2], values[3])
133
+ end
134
+ local idx = tonumber(field)
135
+ if idx <= split then return META end
136
+ return META .. ':' .. tostring(math.floor((idx - 1) / ${META_CHUNK_SIZE}))
137
+ end
138
+ local function split_write(args)
139
+ if not split_stored then
140
+ args[#args + 1] = SPLIT
141
+ args[#args + 1] = tostring(split)
142
+ end
143
+ end
144
+ local function sliced(command, key, args)
145
+ local results = {}
146
+ for first = 1, #args, 1000 do
147
+ local last = math.min(first + 999, #args)
148
+ local reply
149
+ if key then
150
+ reply = redis.call(command, key, unpack(args, first, last))
151
+ else
152
+ reply = redis.call(command, unpack(args, first, last))
153
+ end
154
+ if type(reply) == 'table' then
155
+ for _, value in ipairs(reply) do results[#results + 1] = value end
156
+ end
157
+ end
158
+ return results
159
+ end
160
+ local function read_fields(fields, suffixes)
161
+ local groups, order = {}, {}
162
+ for _, field in ipairs(fields) do
163
+ local key = mkey(field)
164
+ local group = groups[key]
165
+ if not group then
166
+ group = { fields = {}, names = {} }
167
+ groups[key] = group
168
+ order[#order + 1] = key
169
+ end
170
+ group.fields[#group.fields + 1] = field
171
+ for _, suffix in ipairs(suffixes) do
172
+ group.names[#group.names + 1] = field .. ':' .. suffix
173
+ end
174
+ end
175
+ local rows = {}
176
+ for _, key in ipairs(order) do
177
+ local group = groups[key]
178
+ local values = sliced('HMGET', key, group.names)
179
+ for position, field in ipairs(group.fields) do
180
+ local row = {}
181
+ for column = 1, #suffixes do
182
+ row[column] = values[(position - 1) * #suffixes + column]
183
+ end
184
+ rows[field] = row
185
+ end
186
+ end
187
+ return rows
188
+ end
189
+ local writes, write_order = {}, {}
190
+ local function put(key, field, value)
191
+ local fields = writes[key]
192
+ if not fields then
193
+ fields = {}
194
+ writes[key] = fields
195
+ write_order[#write_order + 1] = key
196
+ end
197
+ fields[#fields + 1] = field
198
+ fields[#fields + 1] = value
199
+ end
200
+ local function flush()
201
+ for _, key in ipairs(write_order) do sliced('HSET', key, writes[key]) end
202
+ writes, write_order = {}, {}
203
+ end
204
+ `;
205
+ /**
206
+ * Lane-aware enqueueing for scripts that add events. Including scripts bind
207
+ * PENDING and READY. Lane tails are read once per lane and tracked in
208
+ * memory, so a batch in one lane links its events without re-reading. A
209
+ * lane emptied earlier in the script (`clear_lane`) is known to have no tail,
210
+ * and its pointers are deleted at `flush_queue` only if nothing re-filled it.
211
+ */
212
+ const ENQUEUE_LUA = `
213
+ local function lane_field(lane, suffix)
214
+ return '__lane:' .. tostring(string.len(lane)) .. ':' .. lane .. ':' .. suffix
215
+ end
216
+ local queued, ready_now, lane_tails, lane_order = {}, {}, {}, {}
217
+ local function enqueue(field, lane)
218
+ local score = tonumber(field)
219
+ queued[#queued + 1] = score
220
+ queued[#queued + 1] = field
221
+ if lane == '' then
222
+ ready_now[#ready_now + 1] = score
223
+ ready_now[#ready_now + 1] = field
224
+ return
225
+ end
226
+ put(mkey(field), field .. ':l', lane)
227
+ local tail = lane_tails[lane]
228
+ if tail == nil then
229
+ tail = redis.call('HGET', META, lane_field(lane, 'tail'))
230
+ if tail and tonumber(tail) <= boundary then tail = false end
231
+ lane_order[#lane_order + 1] = lane
232
+ end
233
+ if tail then
234
+ put(mkey(tail), tail .. ':next', field)
235
+ else
236
+ put(META, lane_field(lane, 'head'), field)
237
+ ready_now[#ready_now + 1] = score
238
+ ready_now[#ready_now + 1] = field
239
+ end
240
+ lane_tails[lane] = field
241
+ end
242
+ local function clear_lane(lane)
243
+ if lane_tails[lane] == nil then lane_order[#lane_order + 1] = lane end
244
+ lane_tails[lane] = false
245
+ end
246
+ local function flush_queue()
247
+ for _, lane in ipairs(lane_order) do
248
+ if lane_tails[lane] then
249
+ put(META, lane_field(lane, 'tail'), lane_tails[lane])
250
+ else
251
+ redis.call('HDEL', META, lane_field(lane, 'head'), lane_field(lane, 'tail'))
252
+ end
253
+ end
254
+ sliced('ZADD', PENDING, queued)
255
+ sliced('ZADD', READY, ready_now)
256
+ end
257
+ `;
258
+ /**
259
+ * Event-id ownership for scripts that add events. Each script reads the
260
+ * counter, split, clear boundary, and per-id keys with one MGET and applies
261
+ * the first three. Returns the owner record, or false, per event, and the
262
+ * counter. A record this session made below its latest clear is free.
263
+ */
264
+ const ID_OWNERS_LUA = `
265
+ local function read_owners(key_offset, n, session)
266
+ local keys = { COUNTER, SPLIT, CLEAR }
267
+ for i = 1, n do keys[#keys + 1] = KEYS[key_offset + i] end
268
+ local values = sliced('MGET', nil, keys)
269
+ use_split(values[2], values[1], values[3])
270
+ local owners = {}
271
+ for i = 1, n do
272
+ local owner = values[3 + i]
273
+ if owner then
274
+ local rec = cjson.decode(owner)
275
+ if rec[1] == session and tonumber(rec[2]) < boundary then owner = false end
276
+ end
277
+ owners[i] = owner
278
+ end
279
+ return owners, values[1]
280
+ end
281
+ `;
282
+ const WAKE_PAYLOAD_LIMIT = 32768;
283
+ /**
284
+ * Wake-up messages carry the events they announce, so a subscribed reader
285
+ * that is caught up applies them without reading the stream:
286
+ * `{"f":frontier,"t":createdAt,"e":[[index,id,type,payload],...]}`. Payloads
287
+ * are the stored JSON text, inlined verbatim. A batch over
288
+ * WAKE_PAYLOAD_LIMIT bytes publishes `{"f":frontier}` and readers catch up
289
+ * with XRANGE.
290
+ */
291
+ const WAKE_LUA = `
292
+ local wake_entries, wake_bytes = {}, 0
293
+ local function wake_add(field, id, typ, payload)
294
+ if wake_bytes + #payload > ${WAKE_PAYLOAD_LIMIT} then
295
+ wake_bytes = ${WAKE_PAYLOAD_LIMIT} + 1
296
+ return
297
+ end
298
+ local entry = '[' .. field .. ',' .. cjson.encode(id) .. ',' ..
299
+ cjson.encode(typ) .. ',' .. payload .. ']'
300
+ wake_bytes = wake_bytes + #entry + 1
301
+ if wake_bytes <= ${WAKE_PAYLOAD_LIMIT} then
302
+ wake_entries[#wake_entries + 1] = entry
303
+ end
304
+ end
305
+ local function wake(channel, frontier, created_at)
306
+ local message = '{"f":' .. frontier .. '}'
307
+ if wake_bytes <= ${WAKE_PAYLOAD_LIMIT} then
308
+ message = '{"f":' .. frontier .. ',"t":' .. created_at .. ',"e":[' ..
309
+ table.concat(wake_entries, ',') .. ']}'
310
+ end
311
+ redis.pcall('PUBLISH', channel, message)
312
+ end
313
+ `;
314
+ const RELEASE_PINS_LUA = `
315
+ local function release_snapshot_pins(pins, counts)
316
+ local refs = redis.call('SMEMBERS', pins)
317
+ if #refs == 0 then return end
318
+ for _, ref in ipairs(refs) do
319
+ local count = redis.call('HINCRBY', counts, ref, -1)
320
+ if count <= 0 then
321
+ redis.call('HDEL', counts, ref)
322
+ local checkpoint = cjson.decode(ref)
323
+ redis.call('ZREMRANGEBYSCORE', checkpoint[1], checkpoint[2], checkpoint[2])
324
+ end
325
+ end
326
+ redis.call('DEL', pins)
327
+ end
328
+ `;
329
+ /**
330
+ * KEYS: store stream, counter, metadata, pending, ready, metadata split,
331
+ * then one id ownership key per event.
332
+ * ARGV: sessionId, nowMs, n, then per event:
333
+ * id, type, payloadJson, causeJson, lane, settled; then notify channel and
334
+ * origin index or ''.
335
+ * Returns ['empty', hasPending] | ['ok', firstIndex, hasPending] |
336
+ * ['dup', hasPending, ...indexes] | ['partial', count] |
337
+ * ['foreign', eventId] | ['no_parent', idx] | ['superseded', idx] |
338
+ * ['cleared', originIndex].
339
+ */
340
+ const APPEND_LUA = `
341
+ local COUNTER, META, SPLIT, PENDING, READY = KEYS[2], KEYS[3], KEYS[6], KEYS[4], KEYS[5]
342
+ ${META_LUA}
343
+ ${ENQUEUE_LUA}
344
+ ${ID_OWNERS_LUA}
345
+ ${WAKE_LUA}
346
+ local function has_pending()
347
+ return redis.call('ZCARD', PENDING) > 0 and '1' or '0'
348
+ end
349
+ local n = tonumber(ARGV[3])
350
+ if n == 0 then return { 'empty', has_pending() } end
351
+ local owners, count = read_owners(6, n, ARGV[1])
352
+ local base = tonumber(count or '0')
353
+ local dups = {}
354
+ for i = 1, n do
355
+ if owners[i] then
356
+ local rec = cjson.decode(owners[i])
357
+ if rec[1] ~= ARGV[1] then return { 'foreign', ARGV[3 + (i - 1) * 6 + 1] } end
358
+ dups[#dups + 1] = tostring(rec[2])
359
+ end
360
+ end
361
+ if #dups == n then
362
+ local res = { 'dup', has_pending() }
363
+ for i = 1, #dups do res[#res + 1] = dups[i] end
364
+ return res
365
+ end
366
+ if #dups > 0 then return { 'partial', tostring(#dups) } end
367
+ local origin = ARGV[5 + n * 6]
368
+ if origin and origin ~= '' and tonumber(origin) <= boundary then
369
+ return { 'cleared', origin }
370
+ end
371
+ local causes, parents = {}, {}
372
+ for i = 1, n do
373
+ local cause = ARGV[3 + (i - 1) * 6 + 4]
374
+ if cause ~= '' then
375
+ local parsed = cjson.decode(cause)
376
+ local pidx = tonumber(parsed.index)
377
+ causes[#causes + 1] = parsed
378
+ if pidx >= 1 and pidx <= base then parents[#parents + 1] = tostring(pidx) end
379
+ end
380
+ end
381
+ local parent_rows = read_fields(parents, { 'd', 'f', 'p' })
382
+ local parent_pending = false
383
+ for _, parsed in ipairs(causes) do
384
+ local pidx = tonumber(parsed.index)
385
+ if pidx <= boundary then return { 'superseded', tostring(pidx) } end
386
+ if pidx < 1 or pidx > base then
387
+ return { 'no_parent', tostring(parsed.index) }
388
+ end
389
+ local row = parent_rows[tostring(pidx)]
390
+ if tonumber(row[1] or '0') ~= tonumber(parsed.attempt) or row[2] then
391
+ return { 'superseded', tostring(pidx) }
392
+ end
393
+ -- A claimed, unprocessed parent is still in the pending set.
394
+ if not row[3] then parent_pending = true end
395
+ end
396
+ local owner_writes = {}
397
+ for i = 1, n do
398
+ local o = 3 + (i - 1) * 6
399
+ local field = tostring(base + i)
400
+ redis.call('XADD', KEYS[1], field .. '-0',
401
+ 'id', ARGV[o + 1], 'type', ARGV[o + 2], 'payload', ARGV[o + 3],
402
+ 'created_at', ARGV[2], 'cause', ARGV[o + 4], 'lane', ARGV[o + 5])
403
+ owner_writes[#owner_writes + 1] = KEYS[6 + i]
404
+ owner_writes[#owner_writes + 1] = cjson.encode({ ARGV[1], base + i })
405
+ wake_add(field, ARGV[o + 1], ARGV[o + 2], ARGV[o + 3])
406
+ if ARGV[o + 6] == '1' then
407
+ put(mkey(field), field .. ':p', ARGV[2])
408
+ else
409
+ enqueue(field, ARGV[o + 5])
410
+ end
411
+ end
412
+ owner_writes[#owner_writes + 1] = COUNTER
413
+ owner_writes[#owner_writes + 1] = tostring(base + n)
414
+ split_write(owner_writes)
415
+ sliced('MSET', nil, owner_writes)
416
+ flush_queue()
417
+ flush()
418
+ local pending_after = (#queued > 0 or parent_pending) and '1' or has_pending()
419
+ wake(ARGV[4 + n * 6], tostring(base + n), ARGV[2])
420
+ return { 'ok', tostring(base + 1), pending_after }
421
+ `;
422
+ /**
423
+ * KEYS: counter, meta, pending, ready, event snapshot pins, snapshot pin counts,
424
+ * metadata split.
425
+ * ARGV: idx, attempt, error, maxFailures, nowMs.
426
+ */
427
+ const FAIL_ATTEMPT_LUA = `
428
+ local COUNTER, META, SPLIT = KEYS[1], KEYS[2], KEYS[7]
429
+ ${META_LUA}
430
+ ${RELEASE_PINS_LUA}
431
+ local idx = tonumber(ARGV[1])
432
+ local counts = redis.call('MGET', COUNTER, SPLIT, CLEAR)
433
+ local total = tonumber(counts[1] or '0')
434
+ use_split(counts[2], counts[1], counts[3])
435
+ if idx <= boundary then return { 'cleared', '0' } end
436
+ if idx < 1 or idx > total then return { 'missing', '0' } end
437
+ local field = ARGV[1]
438
+ local attempt = tonumber(ARGV[2])
439
+ local row = read_fields({ field }, { 'a', 'd', 'p', 'f', 'lfa', 'ch' })[field]
440
+ local failures = tonumber(row[1] or '0')
441
+ if row[3] or tonumber(row[2] or '0') ~= attempt then
442
+ return { 'superseded', tostring(failures) }
443
+ end
444
+ if row[4] then return { 'dead_lettered', tostring(failures) } end
445
+ if row[5] and tonumber(row[5]) == attempt and not row[6] then
446
+ return { 'failed', tostring(failures) }
447
+ end
448
+ if not row[6] then return { 'superseded', tostring(failures) } end
449
+ failures = failures + 1
450
+ local dead = failures >= tonumber(ARGV[4])
451
+ local meta_key = mkey(field)
452
+ put(meta_key, field .. ':a', tostring(failures))
453
+ put(meta_key, field .. ':e', ARGV[3])
454
+ put(meta_key, field .. ':lf', ARGV[5])
455
+ put(meta_key, field .. ':lfa', ARGV[2])
456
+ if dead then put(meta_key, field .. ':f', ARGV[5]) end
457
+ flush()
458
+ redis.call('HDEL', meta_key, field .. ':ch', field .. ':ce')
459
+ if dead then
460
+ redis.call('ZREM', KEYS[4], field)
461
+ release_snapshot_pins(KEYS[5], KEYS[6])
462
+ return { 'dead_lettered', tostring(failures) }
463
+ end
464
+ return { 'failed', tostring(failures) }
465
+ `;
466
+ /**
467
+ * KEYS: store, counter, meta, pending, ready, metadata split.
468
+ * ARGV: holder, nowMs, expiresAtMs, excludedCount, excluded indexes.
469
+ * The ready set is a subset of the pending set, so an empty ready set is
470
+ * enough to report 'settled'. A claimed result ends with the clear boundary.
471
+ */
472
+ const CLAIM_AVAILABLE_LUA = `
473
+ local COUNTER, META, SPLIT = KEYS[2], KEYS[3], KEYS[6]
474
+ ${META_LUA}
475
+ local ready = redis.call('ZRANGE', KEYS[5], 0, -1)
476
+ local excluded = {}
477
+ for i = 1, tonumber(ARGV[4]) do excluded[ARGV[4 + i]] = true end
478
+ local candidates = {}
479
+ for _, field in ipairs(ready) do
480
+ if not excluded[field] then candidates[#candidates + 1] = field end
481
+ end
482
+ local rows = read_fields(candidates, { 'ce', 'd', 'fc', 'a', 'e', 'lf', 'lfa' })
483
+ local claimed = {}
484
+ local retry_at = nil
485
+ for _, field in ipairs(candidates) do
486
+ local row = rows[field]
487
+ local expires = tonumber(row[1] or '0')
488
+ if expires > tonumber(ARGV[2]) then
489
+ if not retry_at or expires < retry_at then retry_at = expires end
490
+ else
491
+ local entries = redis.call('XRANGE', KEYS[1], field .. '-0', field .. '-0')
492
+ if #entries == 0 then return { 'missing', field } end
493
+ local attempt = tostring(tonumber(row[2] or '0') + 1)
494
+ local first_claimed = row[3] or ARGV[2]
495
+ local meta_key = mkey(field)
496
+ put(meta_key, field .. ':d', attempt)
497
+ put(meta_key, field .. ':fc', first_claimed)
498
+ put(meta_key, field .. ':lc', ARGV[2])
499
+ put(meta_key, field .. ':ch', ARGV[1])
500
+ put(meta_key, field .. ':ce', ARGV[3])
501
+ claimed[#claimed + 1] = {
502
+ attempt,
503
+ row[4] or '0',
504
+ row[5] or false,
505
+ first_claimed,
506
+ ARGV[2],
507
+ row[6] or false,
508
+ row[7] or false,
509
+ ARGV[1],
510
+ ARGV[3],
511
+ entries[1]
512
+ }
513
+ end
514
+ end
515
+ flush()
516
+ if #claimed > 0 then
517
+ local result = { 'claimed', tostring(#claimed) }
518
+ for _, claim in ipairs(claimed) do
519
+ for _, value in ipairs(claim) do result[#result + 1] = value end
520
+ end
521
+ result[#result + 1] = tostring(boundary)
522
+ return result
523
+ end
524
+ if retry_at then return { 'busy', tostring(retry_at) } end
525
+ return { 'settled' }
526
+ `;
527
+ /**
528
+ * KEYS: counter, meta, metadata split.
529
+ * ARGV: holder, nowMs, expiresAtMs, n, then per claim: index, attempt.
530
+ * Returns [renewedCount, ...renewed, ...superseded].
531
+ */
532
+ const RENEW_CLAIMS_LUA = `
533
+ local COUNTER, META, SPLIT = KEYS[1], KEYS[2], KEYS[3]
534
+ ${META_LUA}
535
+ local counts = redis.call('MGET', COUNTER, SPLIT, CLEAR)
536
+ local total = tonumber(counts[1] or '0')
537
+ use_split(counts[2], counts[1], counts[3])
538
+ local claims, fields, cleared = {}, {}, {}
539
+ for i = 1, tonumber(ARGV[4]) do
540
+ local field = ARGV[4 + (i - 1) * 2 + 1]
541
+ local idx = tonumber(field)
542
+ if idx <= boundary then
543
+ cleared[#cleared + 1] = field
544
+ elseif idx >= 1 and idx <= total then
545
+ claims[#claims + 1] = { field = field, attempt = tonumber(ARGV[4 + (i - 1) * 2 + 2]) }
546
+ fields[#fields + 1] = field
547
+ end
548
+ end
549
+ local rows = read_fields(fields, { 'd', 'ce', 'p', 'f', 'ch' })
550
+ local renewed = {}
551
+ local superseded = {}
552
+ for _, claim in ipairs(claims) do
553
+ local row = rows[claim.field]
554
+ if tonumber(row[1] or '0') > claim.attempt then
555
+ superseded[#superseded + 1] = claim.field
556
+ elseif tonumber(ARGV[3]) > tonumber(ARGV[2]) and
557
+ not row[3] and not row[4] and row[5] == ARGV[1] and
558
+ tonumber(row[2] or '0') > tonumber(ARGV[2]) then
559
+ put(mkey(claim.field), claim.field .. ':ce', ARGV[3])
560
+ renewed[#renewed + 1] = claim.field
561
+ end
562
+ end
563
+ flush()
564
+ for _, field in ipairs(cleared) do superseded[#superseded + 1] = field end
565
+ local result = { tostring(#renewed) }
566
+ for i = 1, #renewed do result[#result + 1] = renewed[i] end
567
+ for i = 1, #superseded do result[#result + 1] = superseded[i] end
568
+ return result
569
+ `;
570
+ /**
571
+ * KEYS: store, counter, meta, pending, ready, event snapshot pins, snapshot
572
+ * pin counts, metadata split, then one id ownership key per child.
573
+ * ARGV: sessionId, nowMs, parent index, attempt, n, then per child:
574
+ * id, type, payloadJson, lane, settled; finally notify channel.
575
+ */
576
+ const COMPLETE_ATTEMPT_LUA = `
577
+ local COUNTER, META, SPLIT, PENDING, READY = KEYS[2], KEYS[3], KEYS[8], KEYS[4], KEYS[5]
578
+ ${META_LUA}
579
+ ${ENQUEUE_LUA}
580
+ ${ID_OWNERS_LUA}
581
+ ${RELEASE_PINS_LUA}
582
+ ${WAKE_LUA}
583
+ local function settle(field, lane, next_field)
584
+ redis.call('ZREM', PENDING, field)
585
+ redis.call('ZREM', READY, field)
586
+ if lane == '' then return end
587
+ local head_key = lane_field(lane, 'head')
588
+ if redis.call('HGET', META, head_key) ~= field then return end
589
+ while next_field do
590
+ local row = read_fields({ next_field }, { 'p', 'f', 'next' })[next_field]
591
+ if not row[1] then
592
+ redis.call('HSET', META, head_key, next_field)
593
+ if not row[2] then
594
+ redis.call('ZADD', READY, tonumber(next_field), next_field)
595
+ end
596
+ return
597
+ end
598
+ next_field = row[3]
599
+ end
600
+ clear_lane(lane)
601
+ end
602
+ local parent = ARGV[3]
603
+ local attempt = tonumber(ARGV[4])
604
+ local n = tonumber(ARGV[5])
605
+ local owners, count = read_owners(8, n, ARGV[1])
606
+ local total = tonumber(count or '0')
607
+ if tonumber(parent) <= boundary then return { 'superseded' } end
608
+ if tonumber(parent) < 1 or tonumber(parent) > total then return { 'missing' } end
609
+ local row = read_fields({ parent }, { 'd', 'p', 'pa', 'ri', 'ch', 'f', 'lfa', 'l', 'next' })[parent]
610
+ if row[2] then
611
+ if tonumber(row[3] or '0') ~= attempt then return { 'superseded' } end
612
+ if not row[4] then return { 'partial' } end
613
+ local recorded_ids = cjson.decode(row[4])
614
+ if #recorded_ids ~= n then return { 'partial' } end
615
+ for i = 1, n do
616
+ if recorded_ids[i] ~= ARGV[5 + (i - 1) * 5 + 1] then return { 'partial' } end
617
+ end
618
+ local indexes = {}
619
+ for i = 1, n do
620
+ if not owners[i] then return { 'partial' } end
621
+ local rec = cjson.decode(owners[i])
622
+ if rec[1] ~= ARGV[1] then return { 'partial' } end
623
+ local entries = redis.call('XRANGE', KEYS[1], tostring(rec[2]) .. '-0', tostring(rec[2]) .. '-0')
624
+ if #entries == 0 then return { 'partial' } end
625
+ local values = entries[1][2]
626
+ local cause = nil
627
+ for j = 1, #values, 2 do
628
+ if values[j] == 'cause' then cause = values[j + 1] break end
629
+ end
630
+ if not cause or cause == '' then return { 'partial' } end
631
+ local decoded = cjson.decode(cause)
632
+ if tonumber(decoded['index']) ~= tonumber(parent) or
633
+ tonumber(decoded['attempt']) ~= attempt then return { 'partial' } end
634
+ indexes[#indexes + 1] = tostring(rec[2])
635
+ end
636
+ local result = { 'duplicate' }
637
+ for _, idx in ipairs(indexes) do result[#result + 1] = idx end
638
+ return result
639
+ end
640
+ if tonumber(row[1] or '0') ~= attempt then return { 'superseded' } end
641
+ if not row[5] or row[6] or tonumber(row[7] or '0') == attempt then
642
+ return { 'superseded' }
643
+ end
644
+ for i = 1, n do
645
+ if owners[i] then return { 'partial' } end
646
+ end
647
+ local returned_ids = {}
648
+ for i = 1, n do returned_ids[i] = ARGV[5 + (i - 1) * 5 + 1] end
649
+ local returned_json = '[]'
650
+ if n > 0 then returned_json = cjson.encode(returned_ids) end
651
+ local parent_key = mkey(parent)
652
+ put(parent_key, parent .. ':p', ARGV[2])
653
+ put(parent_key, parent .. ':pa', ARGV[4])
654
+ put(parent_key, parent .. ':ri', returned_json)
655
+ redis.call('HDEL', parent_key, parent .. ':ch', parent .. ':ce')
656
+ settle(parent, row[8] or '', row[9])
657
+ release_snapshot_pins(KEYS[6], KEYS[7])
658
+ local cause = cjson.encode({ index = tonumber(parent), attempt = attempt })
659
+ local owner_writes = {}
660
+ for i = 1, n do
661
+ local o = 5 + (i - 1) * 5
662
+ local field = tostring(total + i)
663
+ redis.call('XADD', KEYS[1], field .. '-0',
664
+ 'id', ARGV[o + 1], 'type', ARGV[o + 2], 'payload', ARGV[o + 3],
665
+ 'created_at', ARGV[2], 'cause', cause, 'lane', ARGV[o + 4])
666
+ owner_writes[#owner_writes + 1] = KEYS[8 + i]
667
+ owner_writes[#owner_writes + 1] = cjson.encode({ ARGV[1], total + i })
668
+ wake_add(field, ARGV[o + 1], ARGV[o + 2], ARGV[o + 3])
669
+ if ARGV[o + 5] == '1' then
670
+ put(mkey(field), field .. ':p', ARGV[2])
671
+ else
672
+ enqueue(field, ARGV[o + 4])
673
+ end
674
+ end
675
+ if n > 0 then
676
+ owner_writes[#owner_writes + 1] = COUNTER
677
+ owner_writes[#owner_writes + 1] = tostring(total + n)
678
+ split_write(owner_writes)
679
+ sliced('MSET', nil, owner_writes)
680
+ end
681
+ flush_queue()
682
+ flush()
683
+ if n > 0 then wake(ARGV[6 + n * 5], tostring(total + n), ARGV[2]) end
684
+ return { 'ok', tostring(total + 1) }
685
+ `;
686
+ /**
687
+ * KEYS: head, history, event counter, event meta, pin counts, metadata split.
688
+ * ARGV: nowMs, event pin key prefix, write count, then per write:
689
+ * index, snapshot JSON, pin count, pin event indexes; finally the reducer
690
+ * name. Writes from before the latest clear are dropped, and a head from
691
+ * before it is replaced as if absent.
692
+ */
693
+ const PUT_SNAPSHOTS_LUA = `
694
+ local COUNTER, META, SPLIT = KEYS[3], KEYS[4], KEYS[6]
695
+ ${META_LUA}
696
+ local counts = redis.call('MGET', COUNTER, SPLIT, CLEAR)
697
+ local total = tonumber(counts[1] or '0')
698
+ use_split(counts[2], counts[1], counts[3])
699
+ local pinned = {}
700
+ local offset = 4
701
+ for i = 1, tonumber(ARGV[3]) do
702
+ local pin_count = tonumber(ARGV[offset + 2])
703
+ for p = 1, pin_count do
704
+ local event_index = tonumber(ARGV[offset + 2 + p])
705
+ if event_index > boundary and event_index <= total then
706
+ pinned[#pinned + 1] = tostring(event_index)
707
+ end
708
+ end
709
+ offset = offset + 3 + pin_count
710
+ end
711
+ local status = read_fields(pinned, { 'p', 'f' })
712
+ local snapshots = {}
713
+ offset = 4
714
+ for i = 1, tonumber(ARGV[3]) do
715
+ local index = tonumber(ARGV[offset])
716
+ local snapshot = ARGV[offset + 1]
717
+ local pin_count = tonumber(ARGV[offset + 2])
718
+ local ref = cjson.encode({ KEYS[2], tostring(index) })
719
+ for p = 1, pin_count do
720
+ local event_index = tonumber(ARGV[offset + 2 + p])
721
+ local row = status[tostring(event_index)]
722
+ if index >= boundary and event_index > boundary and event_index <= total and
723
+ not row[1] and not row[2] then
724
+ local pin_key = ARGV[2] .. tostring(event_index)
725
+ if redis.call('SADD', pin_key, ref) == 1 then
726
+ redis.call('HINCRBY', KEYS[5], ref, 1)
727
+ end
728
+ end
729
+ end
730
+ if index >= boundary then
731
+ snapshots[#snapshots + 1] = { index = index, snapshot = snapshot, ref = ref }
732
+ end
733
+ offset = offset + 3 + pin_count
734
+ end
735
+ if #snapshots == 0 then return 1 end
736
+
737
+ local head = redis.call('GET', KEYS[1])
738
+ local head_index = nil
739
+ local registered = false
740
+ if head then
741
+ local record = cjson.decode(head)
742
+ head_index = tonumber(record['index'])
743
+ registered = record['r'] == 1
744
+ end
745
+ if head_index and head_index < boundary then
746
+ head, head_index, registered = nil, nil, false
747
+ redis.call('ZREMRANGEBYSCORE', KEYS[2], '-inf', '(' .. boundary)
748
+ end
749
+ -- Deletion finds each reducer's keys through this set instead of a scan.
750
+ -- Heads written since carry the mark, so a reducer registers once.
751
+ if not registered then
752
+ redis.call('SADD', string.sub(COUNTER, 1, -6) .. 'snap-reducers', ARGV[#ARGV])
753
+ end
754
+ local latest = snapshots[#snapshots]
755
+ local writes_head = not head_index or latest.index >= head_index
756
+ local advances = not head_index or latest.index > head_index
757
+ local final_head_index = advances and latest.index or head_index
758
+
759
+ if advances and head and tonumber(redis.call('HGET', KEYS[5], cjson.encode({ KEYS[2], tostring(head_index) })) or '0') > 0 then
760
+ redis.call('ZREMRANGEBYSCORE', KEYS[2], head_index, head_index)
761
+ redis.call('ZADD', KEYS[2], head_index, head)
762
+ end
763
+
764
+ for _, write in ipairs(snapshots) do
765
+ if write.index < final_head_index then
766
+ redis.call('ZREMRANGEBYSCORE', KEYS[2], write.index, write.index)
767
+ if tonumber(redis.call('HGET', KEYS[5], write.ref) or '0') > 0 then
768
+ redis.call('ZADD', KEYS[2], write.index, write.snapshot)
769
+ end
770
+ end
771
+ end
772
+
773
+ if writes_head then
774
+ redis.call('SET', KEYS[1], latest.snapshot)
775
+ redis.call('ZREMRANGEBYSCORE', KEYS[2], latest.index, latest.index)
776
+ end
777
+ return 1
778
+ `;
779
+ /**
780
+ * KEYS: presence hash. ARGV: atMs, ttlMs, n, then per entry:
781
+ * hashField, valueJson ('' deletes), patchFieldJson; finally channel and
782
+ * message prefix. Wire fragments stay JSON text to preserve arrays
783
+ * and numeric precision through Lua. Field-wise LWW by the sender's
784
+ * `at`: a stored entry with a strictly newer stamp survives; ties go
785
+ * to the incoming write (memory-tier parity). Returns the applied
786
+ * 1-based entry positions. The whole-key PEXPIRE is the coarse bound,
787
+ * sound because core sends one uniform ttlMs; the per-entry `e` stamp
788
+ * is the fine bound (the injected storage clock at the applied set
789
+ * plus ttlMs — never the sender stamp), pruned on read against the
790
+ * same clock.
791
+ */
792
+ const PRESENCE_SET_LUA = `
793
+ local at = tonumber(ARGV[1])
794
+ local applied = {}
795
+ local published = {}
796
+ local n = tonumber(ARGV[3])
797
+ for i = 1, n do
798
+ local field = ARGV[3 + (i - 1) * 3 + 1]
799
+ local value = ARGV[3 + (i - 1) * 3 + 2]
800
+ local existing = redis.call('HGET', KEYS[1], field)
801
+ if not existing or tonumber(cjson.decode(existing)['a']) <= at then
802
+ if value == '' then
803
+ redis.call('HDEL', KEYS[1], field)
804
+ else
805
+ redis.call('HSET', KEYS[1], field, value)
806
+ end
807
+ applied[#applied + 1] = tostring(i)
808
+ published[#published + 1] = ARGV[3 + (i - 1) * 3 + 3]
809
+ end
810
+ end
811
+ if #applied > 0 then
812
+ redis.call('PEXPIRE', KEYS[1], ARGV[2])
813
+ redis.pcall('PUBLISH', ARGV[4 + n * 3], ARGV[5 + n * 3] .. table.concat(published, ',') .. '}}')
814
+ end
815
+ return applied
816
+ `;
817
+ /**
818
+ * KEYS: presence hash. ARGV: nowMs (the injected clock, so tests can
819
+ * time-travel expiry against a real server). Returns the live
820
+ * [field, value, ...] pairs; entries at or past their fine-bound
821
+ * expiry are HDELed in the same atomic step, so a stale reader can
822
+ * never sweep a concurrently refreshed field.
823
+ */
824
+ const PRESENCE_READ_LUA = `
825
+ local now = tonumber(ARGV[1])
826
+ local rows = redis.call('HGETALL', KEYS[1])
827
+ local live = {}
828
+ for i = 1, #rows, 2 do
829
+ if tonumber(cjson.decode(rows[i + 1])['e']) <= now then
830
+ redis.call('HDEL', KEYS[1], rows[i])
831
+ else
832
+ live[#live + 1] = rows[i]
833
+ live[#live + 1] = rows[i + 1]
834
+ end
835
+ end
836
+ return live
837
+ `;
838
+ /**
839
+ * KEYS: head, history, event counter. ARGV: snapshot frontier or ''.
840
+ * Returns one atomic multiversion-cache-plus-log-frontier read, with the
841
+ * clear boundary; checkpoints from before it are ignored.
842
+ */
843
+ const READ_STATE_LUA = `
844
+ local counts = redis.call('MGET', KEYS[3], string.sub(KEYS[3], 1, -6) .. 'clear')
845
+ local boundary = tonumber(counts[2] or '0')
846
+ local head = redis.call('GET', KEYS[1])
847
+ local head_index = head and cjson.decode(head)['index'] or nil
848
+ if head and (type(head_index) ~= 'number' or head_index < 0 or head_index > 9007199254740991 or head_index ~= math.floor(head_index)) then
849
+ return redis.error_reply('invalid snapshot head index')
850
+ end
851
+ if head_index and head_index < boundary then
852
+ head = nil
853
+ head_index = nil
854
+ end
855
+ local cap = ARGV[1]
856
+ local snapshot = nil
857
+ if head_index and (cap == '' or head_index <= tonumber(cap)) then
858
+ snapshot = head
859
+ else
860
+ local upper = cap == '' and '+inf' or cap
861
+ local historical = redis.call('ZREVRANGEBYSCORE', KEYS[2], upper, boundary, 'LIMIT', 0, 1)
862
+ if #historical > 0 then snapshot = historical[1] end
863
+ end
864
+ return { head_index or '', snapshot or '', counts[1] or '0', tostring(boundary) }
865
+ `;
866
+ /**
867
+ * Removes every registered reducer's checkpoints below the latest clear, and
868
+ * the pin counts that name them. Snapshot writes register each reducer in
869
+ * {prefix}:{session}:snap-reducers, so no key scan is needed.
870
+ */
871
+ const SWEEP_CHECKPOINTS_LUA = `
872
+ local function sweep_checkpoints(session, boundary)
873
+ for _, reducer in ipairs(redis.call('SMEMBERS', session .. 'snap-reducers')) do
874
+ local head_key = session .. 'snap:' .. reducer
875
+ local head = redis.call('GET', head_key)
876
+ if head and tonumber(cjson.decode(head)['index']) < boundary then
877
+ redis.call('DEL', head_key)
878
+ end
879
+ redis.call('ZREMRANGEBYSCORE', session .. 'snap-history:' .. reducer, '-inf', '(' .. boundary)
880
+ end
881
+ local counts_key = session .. 'snapshot-pin-counts'
882
+ local counts = redis.call('HGETALL', counts_key)
883
+ local stale = {}
884
+ for i = 1, #counts, 2 do
885
+ if tonumber(cjson.decode(counts[i])[2]) < boundary then stale[#stale + 1] = counts[i] end
886
+ end
887
+ if #stale > 0 then redis.call('HDEL', counts_key, unpack(stale)) end
888
+ end
889
+ `;
890
+ /**
891
+ * KEYS: store stream, counter, metadata, metadata split, marker id ownership
892
+ * key, pending, ready.
893
+ * ARGV: sessionId, nowMs, ifLastIndex or '', cause index or '', cause
894
+ * attempt, marker id, notify channel.
895
+ * Returns ['ok', cleared, head, boundary] | ['no_parent', idx] |
896
+ * ['superseded', idx] | ['foreign']. The old lifetime's log moves aside in one
897
+ * RENAME, so every read of the log starts at the marker, and its queues are
898
+ * unlinked, so claims and pending checks see only the new lifetime; deletion
899
+ * drains the moved log.
900
+ */
901
+ const CLEAR_LUA = `
902
+ local COUNTER, META, SPLIT = KEYS[2], KEYS[3], KEYS[4]
903
+ ${META_LUA}
904
+ ${WAKE_LUA}
905
+ local counts = redis.call('MGET', COUNTER, SPLIT, CLEAR, KEYS[5])
906
+ local head = tonumber(counts[1] or '0')
907
+ use_split(counts[2], counts[1], counts[3])
908
+ if ARGV[4] ~= '' then
909
+ local field = ARGV[4]
910
+ local pidx = tonumber(field)
911
+ if pidx <= boundary then return { 'superseded', field } end
912
+ if pidx < 1 or pidx > head then return { 'no_parent', field } end
913
+ local row = read_fields({ field }, { 'd', 'f' })[field]
914
+ if tonumber(row[1] or '0') ~= tonumber(ARGV[5]) or row[2] then
915
+ return { 'superseded', field }
916
+ end
917
+ end
918
+ local expected = ARGV[3]
919
+ if expected ~= '' and boundary == tonumber(expected) + 1 then
920
+ return { 'ok', '1', tostring(head), tostring(boundary) }
921
+ end
922
+ if expected ~= '' and tonumber(expected) ~= head then
923
+ return { 'ok', '0', tostring(head), tostring(boundary) }
924
+ end
925
+ if head == 0 or boundary == head then
926
+ return { 'ok', '1', tostring(head), tostring(boundary) }
927
+ end
928
+ if counts[4] then return { 'foreign' } end
929
+ local field = tostring(head + 1)
930
+ local session = string.sub(COUNTER, 1, -6)
931
+ local moved = session .. 'cleared:' .. field
932
+ redis.call('RENAME', KEYS[1], moved)
933
+ redis.call('UNLINK', KEYS[6], KEYS[7])
934
+ redis.call('SADD', session .. 'cleared', moved)
935
+ redis.call('XADD', KEYS[1], field .. '-0',
936
+ 'id', ARGV[6], 'type', '${CLEAR_EVENT_TYPE}', 'payload', 'null',
937
+ 'created_at', ARGV[2], 'cause', '', 'lane', '')
938
+ local writes = { KEYS[5], cjson.encode({ ARGV[1], head + 1 }), COUNTER, field, CLEAR, field }
939
+ split_write(writes)
940
+ redis.call('MSET', unpack(writes))
941
+ put(mkey(field), field .. ':p', ARGV[2])
942
+ flush()
943
+ wake_add(field, ARGV[6], '${CLEAR_EVENT_TYPE}', 'null')
944
+ wake(ARGV[7], field, ARGV[2])
945
+ return { 'ok', '1', field, field }
946
+ `;
947
+ /**
948
+ * KEYS: counter. ARGV: page size.
949
+ * The next deletion batch: a moved log and up to a page of its [index, id]
950
+ * pairs, without payloads. Empty moved logs are dropped; with none left it
951
+ * sweeps the old checkpoints and returns nothing, so a retry finishes an
952
+ * interrupted deletion.
953
+ */
954
+ const RECLAIM_BATCH_LUA = `
955
+ ${SWEEP_CHECKPOINTS_LUA}
956
+ local session = string.sub(KEYS[1], 1, -6)
957
+ local boundary = tonumber(redis.call('GET', session .. 'clear') or '0')
958
+ if boundary == 0 then return {} end
959
+ for _, log in ipairs(redis.call('SMEMBERS', session .. 'cleared')) do
960
+ local entries = redis.call('XRANGE', log, '-', '+', 'COUNT', ARGV[1])
961
+ if #entries > 0 then
962
+ local batch = { log }
963
+ for _, entry in ipairs(entries) do
964
+ local fields = entry[2]
965
+ for i = 1, #fields, 2 do
966
+ if fields[i] == 'id' then
967
+ batch[#batch + 1] = string.match(entry[1], '^(%d+)')
968
+ batch[#batch + 1] = fields[i + 1]
969
+ break
970
+ end
971
+ end
972
+ end
973
+ return batch
974
+ end
975
+ redis.call('DEL', log)
976
+ redis.call('SREM', session .. 'cleared', log)
977
+ end
978
+ sweep_checkpoints(session, boundary)
979
+ return {}
980
+ `;
981
+ /**
982
+ * KEYS: moved log, counter, metadata, metadata split, then one id ownership
983
+ * key per entry.
984
+ * ARGV: sessionId, event pin key prefix, n, then per entry: index, id.
985
+ * Deletes the listed entries below the latest clear and everything that
986
+ * names them. An id record survives when it names another event, so an id
987
+ * reused in the new lifetime keeps its owner. Returns [deleted, done].
988
+ */
989
+ const RECLAIM_LUA = `
990
+ local COUNTER, META, SPLIT = KEYS[2], KEYS[3], KEYS[4]
991
+ ${META_LUA}
992
+ ${SWEEP_CHECKPOINTS_LUA}
993
+ local function lane_field(lane, suffix)
994
+ return '__lane:' .. tostring(string.len(lane)) .. ':' .. lane .. ':' .. suffix
995
+ end
996
+ local session = string.sub(COUNTER, 1, -6)
997
+ local counts = redis.call('MGET', COUNTER, SPLIT, CLEAR)
998
+ use_split(counts[2], counts[1], counts[3])
999
+ local fields, id_keys, indexes, stream_ids, pin_keys, deleted = {}, {}, {}, {}, {}, {}
1000
+ -- A concurrent reclaim may list the same batch; only XDEL says who removed it.
1001
+ local removed = 0
1002
+ for i = 1, tonumber(ARGV[3]) do
1003
+ local field = ARGV[3 + (i - 1) * 2 + 1]
1004
+ if tonumber(field) < boundary then
1005
+ fields[#fields + 1] = field
1006
+ id_keys[#fields] = KEYS[4 + i]
1007
+ indexes[#fields] = tonumber(field)
1008
+ stream_ids[#fields] = field .. '-0'
1009
+ pin_keys[#fields] = ARGV[2] .. field
1010
+ deleted[field] = true
1011
+ end
1012
+ end
1013
+ if #fields > 0 then
1014
+ local lanes, seen, pointers = read_fields(fields, { 'l' }), {}, {}
1015
+ for _, field in ipairs(fields) do
1016
+ local lane = lanes[field][1]
1017
+ if lane and lane ~= '' and not seen[lane] then
1018
+ seen[lane] = true
1019
+ pointers[#pointers + 1] = lane_field(lane, 'head')
1020
+ pointers[#pointers + 1] = lane_field(lane, 'tail')
1021
+ end
1022
+ end
1023
+ if #pointers > 0 then
1024
+ local values = redis.call('HMGET', META, unpack(pointers))
1025
+ local stale = {}
1026
+ for i = 1, #pointers do
1027
+ if values[i] and deleted[values[i]] then stale[#stale + 1] = pointers[i] end
1028
+ end
1029
+ if #stale > 0 then redis.call('HDEL', META, unpack(stale)) end
1030
+ end
1031
+ local suffixes = { 'l', 'next', 'p', 'pa', 'ri', 'ch', 'ce', 'f', 'fc', 'lc', 'lf', 'lfa', 'd', 'a', 'e' }
1032
+ local columns, order = {}, {}
1033
+ for _, field in ipairs(fields) do
1034
+ local key = mkey(field)
1035
+ if not columns[key] then
1036
+ columns[key] = {}
1037
+ order[#order + 1] = key
1038
+ end
1039
+ for _, suffix in ipairs(suffixes) do
1040
+ columns[key][#columns[key] + 1] = field .. ':' .. suffix
1041
+ end
1042
+ end
1043
+ for _, key in ipairs(order) do sliced('HDEL', key, columns[key]) end
1044
+ sliced('DEL', nil, pin_keys)
1045
+ local owners, stale_ids = sliced('MGET', nil, id_keys), {}
1046
+ for i = 1, #fields do
1047
+ if owners[i] then
1048
+ local rec = cjson.decode(owners[i])
1049
+ if rec[1] == ARGV[1] and tonumber(rec[2]) == indexes[i] then
1050
+ stale_ids[#stale_ids + 1] = id_keys[i]
1051
+ end
1052
+ end
1053
+ end
1054
+ if #stale_ids > 0 then sliced('DEL', nil, stale_ids) end
1055
+ removed = redis.call('XDEL', KEYS[1], unpack(stream_ids))
1056
+ end
1057
+ local done = 0
1058
+ if redis.call('XLEN', KEYS[1]) == 0 then
1059
+ redis.call('DEL', KEYS[1])
1060
+ redis.call('SREM', session .. 'cleared', KEYS[1])
1061
+ if redis.call('SCARD', session .. 'cleared') == 0 then
1062
+ sweep_checkpoints(session, boundary)
1063
+ done = 1
1064
+ end
1065
+ end
1066
+ return { removed, done }
1067
+ `;
1068
+ const fieldMap = (fields) => {
1069
+ const map = {};
1070
+ for (let i = 0; i + 1 < fields.length; i += 2) map[fields[i]] = fields[i + 1];
1071
+ return map;
1072
+ };
1073
+ const STORED_EVENT_META_SUFFIXES = [
1074
+ "p",
1075
+ "pa",
1076
+ "ri",
1077
+ "ch",
1078
+ "ce",
1079
+ "f",
1080
+ "fc",
1081
+ "lc",
1082
+ "lf",
1083
+ "lfa",
1084
+ "d",
1085
+ "a",
1086
+ "e"
1087
+ ];
1088
+ const entryIndex = (entryId) => Number(entryId.slice(0, entryId.indexOf("-")));
1089
+ const toEvent = (sessionId, entry) => {
1090
+ const fields = fieldMap(entry[1]);
1091
+ return {
1092
+ id: fields["id"],
1093
+ type: fields["type"],
1094
+ payload: JSON.parse(fields["payload"]),
1095
+ index: entryIndex(entry[0]),
1096
+ sessionId,
1097
+ createdAt: new Date(Number(fields["created_at"]))
1098
+ };
1099
+ };
1100
+ const fieldBytes = (name) => new TextEncoder().encode(name);
1101
+ const ID_FIELD = fieldBytes("id");
1102
+ const TYPE_FIELD = fieldBytes("type");
1103
+ const PAYLOAD_FIELD = fieldBytes("payload");
1104
+ const CREATED_AT_FIELD = fieldBytes("created_at");
1105
+ const utf8 = new TextDecoder("utf-8", { ignoreBOM: true });
1106
+ const isField = (name, field) => {
1107
+ if (name.length !== field.length) return false;
1108
+ for (let at = 0; at < field.length; at++) if (name[at] !== field[at]) return false;
1109
+ return true;
1110
+ };
1111
+ const bytesToSerializedEvent = (sessionId, entry) => {
1112
+ const values = entry[1];
1113
+ let id;
1114
+ let type;
1115
+ let payloadJson;
1116
+ let createdAt;
1117
+ for (let at = 0; at + 1 < values.length; at += 2) {
1118
+ const name = values[at];
1119
+ if (isField(name, ID_FIELD)) id = utf8.decode(values[at + 1]);
1120
+ else if (isField(name, TYPE_FIELD)) type = utf8.decode(values[at + 1]);
1121
+ else if (isField(name, PAYLOAD_FIELD)) payloadJson = utf8.decode(values[at + 1]);
1122
+ else if (isField(name, CREATED_AT_FIELD)) createdAt = utf8.decode(values[at + 1]);
1123
+ }
1124
+ return {
1125
+ id,
1126
+ type,
1127
+ payloadJson,
1128
+ index: entryIndex(utf8.decode(entry[0])),
1129
+ sessionId,
1130
+ createdAtMs: Number(createdAt)
1131
+ };
1132
+ };
1133
+ const toSerializedEvent = (sessionId, entry) => {
1134
+ const fields = fieldMap(entry[1]);
1135
+ return {
1136
+ id: fields["id"],
1137
+ type: fields["type"],
1138
+ payloadJson: fields["payload"],
1139
+ index: entryIndex(entry[0]),
1140
+ sessionId,
1141
+ createdAtMs: Number(fields["created_at"])
1142
+ };
1143
+ };
1144
+ const json = (value) => JSON.stringify(value) ?? "null";
1145
+ const toCause = (raw) => {
1146
+ if (raw === "" || raw === void 0) return null;
1147
+ const value = JSON.parse(raw);
1148
+ if (!Number.isInteger(value.index) || Number(value.index) < 1 || !Number.isInteger(value.attempt) || Number(value.attempt) < 1) throw new TypeError("stored event has an invalid cause");
1149
+ if (value.batchSize !== void 0 && (!Number.isInteger(value.batchSize) || Number(value.batchSize) < 1)) throw new TypeError("stored event has an invalid cause");
1150
+ return {
1151
+ index: Number(value.index),
1152
+ attempt: Number(value.attempt),
1153
+ ...value.batchSize === void 0 ? {} : { batchSize: Number(value.batchSize) }
1154
+ };
1155
+ };
1156
+ const toClaimAvailableResult = (sessionId, reply) => {
1157
+ const outcome = String(reply[0]);
1158
+ if (outcome === "claimed") {
1159
+ const count = Number(reply[1]);
1160
+ const events = [];
1161
+ for (let i = 0; i < count; i++) {
1162
+ const offset = 2 + i * 10;
1163
+ const entry = reply[offset + 9];
1164
+ const event = toEvent(sessionId, entry);
1165
+ const fields = fieldMap(entry[1]);
1166
+ events.push(Object.assign(event, {
1167
+ cause: toCause(fields["cause"]),
1168
+ lane: fields["lane"] === "" ? null : fields["lane"],
1169
+ processedAt: null,
1170
+ processedByAttempt: null,
1171
+ returnedEventIds: null,
1172
+ firstClaimedAt: reply[offset + 3] === null ? null : new Date(Number(reply[offset + 3])),
1173
+ lastClaimedAt: new Date(Number(reply[offset + 4])),
1174
+ attemptCount: Number(reply[offset]),
1175
+ claimHolder: String(reply[offset + 7]),
1176
+ claimExpiresAt: new Date(Number(reply[offset + 8])),
1177
+ failureCount: Number(reply[offset + 1]),
1178
+ lastFailedAt: reply[offset + 5] === null ? null : new Date(Number(reply[offset + 5])),
1179
+ lastFailedAttempt: reply[offset + 6] === null ? null : Number(reply[offset + 6]),
1180
+ lastError: reply[offset + 2] === null ? null : String(reply[offset + 2]),
1181
+ failedAt: null
1182
+ }));
1183
+ }
1184
+ const clearIndex = Number(reply[2 + count * 10] ?? 0);
1185
+ if (clearIndex > 0) for (const event of events) event.clearIndex = clearIndex;
1186
+ return {
1187
+ outcome: "claimed",
1188
+ events
1189
+ };
1190
+ }
1191
+ if (outcome === "busy") return {
1192
+ outcome,
1193
+ retryAt: new Date(Number(reply[1]))
1194
+ };
1195
+ if (outcome === "settled") return { outcome };
1196
+ throw new TypeError(`claim referenced a missing event in session '${sessionId}'`);
1197
+ };
1198
+ const toIndex = ({ raw, sessionId, name }) => {
1199
+ const index = Number(raw ?? 0);
1200
+ if (!Number.isSafeInteger(index) || index < 0) throw new A2Error("STORE_UNAVAILABLE", `redis ${name} for session '${sessionId}' is invalid`);
1201
+ return index;
1202
+ };
1203
+ /**
1204
+ * A range read that met a gap. A clear moves the old lifetime's log aside, so
1205
+ * a read whose bounds predate it finds nothing where it expected events;
1206
+ * anywhere else a gap is corruption.
1207
+ */
1208
+ const rangeGap = (sessionId, message) => Object.assign(new A2Error("STORE_UNAVAILABLE", `redis event range for session '${sessionId}' ${message}`), { rangeGap: true });
1209
+ const isRangeGap = (error) => error instanceof A2Error && error.rangeGap === true;
1210
+ /**
1211
+ * Run a whole read, bounds included. A gap means a clear raced it, so the
1212
+ * read runs once more from fresh bounds. A second gap, from another clear or
1213
+ * from corruption, surfaces as the retryable STORE_UNAVAILABLE.
1214
+ */
1215
+ const retryAfterGap = async (read) => {
1216
+ try {
1217
+ return await read();
1218
+ } catch (error) {
1219
+ if (!isRangeGap(error)) throw error;
1220
+ return read();
1221
+ }
1222
+ };
1223
+ const wrap = async (fn) => {
1224
+ try {
1225
+ return await fn();
1226
+ } catch (err) {
1227
+ if (err instanceof A2Error || err instanceof TypeError) throw err;
1228
+ throw new A2Error("STORE_UNAVAILABLE", "redis store operation failed", { cause: err });
1229
+ }
1230
+ };
1231
+ function createRedisStoreCore(options) {
1232
+ const { call, callBytes, clock } = options;
1233
+ const generateId = options.ids;
1234
+ const prefix = options.keyPrefix;
1235
+ const storeKey = (s) => `${prefix}:${s}:log`;
1236
+ const countKey = (s) => `${prefix}:${s}:count`;
1237
+ const metaKey = (s) => `${prefix}:${s}:meta`;
1238
+ const metaChunkKey = (s, index) => `${metaKey(s)}:${Math.floor((index - 1) / META_CHUNK_SIZE)}`;
1239
+ const metaSplitKey = (s) => `${prefix}:${s}:meta-split`;
1240
+ const pendingKey = (s) => `${prefix}:${s}:pending`;
1241
+ const readyKey = (s) => `${prefix}:${s}:ready`;
1242
+ const snapKey = (s, r) => `${prefix}:${s}:snap:${r}`;
1243
+ const snapHistoryKey = (s, r) => `${prefix}:${s}:snap-history:${r}`;
1244
+ const snapshotEventPinsKey = (s, index) => `${prefix}:${s}:snapshot-pins:${index}`;
1245
+ const snapshotEventPinsPrefix = (s) => `${prefix}:${s}:snapshot-pins:`;
1246
+ const snapshotPinCountsKey = (s) => `${prefix}:${s}:snapshot-pin-counts`;
1247
+ const presenceKey = (s) => `${prefix}:${s}:presence`;
1248
+ const clearKey = (s) => `${prefix}:${s}:clear`;
1249
+ const idKey = (id) => `${prefix}:ids:${JSON.stringify(id).replaceAll(":", "\\u003a")}`;
1250
+ const evalScript = (script, keys, args) => call("EVAL", script, keys.length, ...keys, ...args);
1251
+ const readRangeBounds = async (sessionId) => {
1252
+ const [count, split, cleared] = await call("MGET", countKey(sessionId), metaSplitKey(sessionId), clearKey(sessionId));
1253
+ const frontier = toIndex({
1254
+ raw: count,
1255
+ sessionId,
1256
+ name: "event count"
1257
+ });
1258
+ return {
1259
+ frontier,
1260
+ metaSplit: split === null ? frontier : toIndex({
1261
+ raw: split,
1262
+ sessionId,
1263
+ name: "metadata split"
1264
+ }),
1265
+ clearIndex: toIndex({
1266
+ raw: cleared,
1267
+ sessionId,
1268
+ name: "clear boundary"
1269
+ })
1270
+ };
1271
+ };
1272
+ const readRangePage = async ({ sessionId, afterIndex, throughIndex, metaSplit }) => {
1273
+ if (throughIndex <= afterIndex) return [];
1274
+ const entries = await call("XRANGE", storeKey(sessionId), `${afterIndex + 1}-0`, `${throughIndex}-0`, "COUNT", 256);
1275
+ if (entries.length === 0) return [];
1276
+ const fieldsByKey = /* @__PURE__ */ new Map();
1277
+ for (const entry of entries) {
1278
+ const index = entryIndex(entry[0]);
1279
+ const key = index <= metaSplit ? metaKey(sessionId) : metaChunkKey(sessionId, index);
1280
+ const fields = fieldsByKey.get(key) ?? [];
1281
+ fields.push(...STORED_EVENT_META_SUFFIXES.map((suffix) => `${index}:${suffix}`));
1282
+ fieldsByKey.set(key, fields);
1283
+ }
1284
+ const lookups = [...fieldsByKey];
1285
+ const results = await Promise.allSettled(lookups.map(([key, fields]) => call("HMGET", key, ...fields)));
1286
+ const meta = {};
1287
+ for (let position = 0; position < lookups.length; position += 1) {
1288
+ const result = results[position];
1289
+ if (result.status === "rejected") throw result.reason;
1290
+ const fields = lookups[position][1];
1291
+ const values = result.value ?? [];
1292
+ for (let i = 0; i < fields.length; i += 1) {
1293
+ const value = values[i];
1294
+ if (value !== null && value !== void 0) meta[fields[i]] = value;
1295
+ }
1296
+ }
1297
+ return entries.map((entry) => {
1298
+ const event = toEvent(sessionId, entry);
1299
+ const idx = event.index;
1300
+ const fields = fieldMap(entry[1]);
1301
+ const processedAt = meta[`${idx}:p`];
1302
+ const processedByAttempt = meta[`${idx}:pa`];
1303
+ const returnedEventIds = meta[`${idx}:ri`];
1304
+ const claimHolder = meta[`${idx}:ch`];
1305
+ const claimExpiresAt = meta[`${idx}:ce`];
1306
+ const failedAt = meta[`${idx}:f`];
1307
+ const firstClaimedAt = meta[`${idx}:fc`];
1308
+ const lastClaimedAt = meta[`${idx}:lc`];
1309
+ const lastFailedAt = meta[`${idx}:lf`];
1310
+ const lastFailedAttempt = meta[`${idx}:lfa`];
1311
+ return Object.assign(event, {
1312
+ cause: toCause(fields["cause"]),
1313
+ lane: fields["lane"] === "" ? null : fields["lane"],
1314
+ processedAt: processedAt === void 0 ? null : new Date(Number(processedAt)),
1315
+ processedByAttempt: processedByAttempt === void 0 ? null : Number(processedByAttempt),
1316
+ returnedEventIds: returnedEventIds === void 0 ? null : decodeReturnedEventIds(returnedEventIds),
1317
+ firstClaimedAt: firstClaimedAt === void 0 ? null : new Date(Number(firstClaimedAt)),
1318
+ lastClaimedAt: lastClaimedAt === void 0 ? null : new Date(Number(lastClaimedAt)),
1319
+ attemptCount: Number(meta[`${idx}:d`] ?? 0),
1320
+ claimHolder: claimHolder ?? null,
1321
+ claimExpiresAt: claimExpiresAt === void 0 ? null : new Date(Number(claimExpiresAt)),
1322
+ failureCount: Number(meta[`${idx}:a`] ?? 0),
1323
+ lastFailedAt: lastFailedAt === void 0 ? null : new Date(Number(lastFailedAt)),
1324
+ lastFailedAttempt: lastFailedAttempt === void 0 ? null : Number(lastFailedAttempt),
1325
+ lastError: meta[`${idx}:e`] ?? null,
1326
+ failedAt: failedAt === void 0 ? null : new Date(Number(failedAt))
1327
+ });
1328
+ });
1329
+ };
1330
+ const readEventPages = async (sessionId, afterIndex, throughIndex) => {
1331
+ if (throughIndex <= afterIndex) return [];
1332
+ const events = [];
1333
+ let nextIndex = afterIndex + 1;
1334
+ while (nextIndex <= throughIndex) {
1335
+ const entries = await call("XRANGE", storeKey(sessionId), `${nextIndex}-0`, `${throughIndex}-0`, "COUNT", 256);
1336
+ if (entries.length === 0) throw rangeGap(sessionId, `ended before index ${throughIndex}`);
1337
+ for (let position = 0; position < entries.length; position += 1) {
1338
+ const index = entryIndex(entries[position][0]);
1339
+ if (index !== nextIndex + position || index > throughIndex) throw rangeGap(sessionId, "is not contiguous");
1340
+ }
1341
+ events.push(...entries.map((entry) => toEvent(sessionId, entry)));
1342
+ nextIndex = events.at(-1).index + 1;
1343
+ }
1344
+ return events;
1345
+ };
1346
+ const readEventRange = async (sessionId, afterIndex, throughIndex) => {
1347
+ const events = [];
1348
+ for (let start = afterIndex; start < throughIndex; start += 256 * READ_CONCURRENCY) {
1349
+ const ranges = Array.from({ length: Math.min(READ_CONCURRENCY, Math.ceil((throughIndex - start) / 256)) }, (_, position) => {
1350
+ const after = start + position * 256;
1351
+ return readEventPages(sessionId, after, Math.min(after + 256, throughIndex));
1352
+ });
1353
+ const results = await Promise.allSettled(ranges);
1354
+ for (const result of results) {
1355
+ if (result.status === "rejected") throw result.reason;
1356
+ events.push(...result.value);
1357
+ }
1358
+ }
1359
+ return events;
1360
+ };
1361
+ /** Bounded XRANGE pages restore the full-read contract on capped providers. */
1362
+ const readRange = async (sessionId, afterIndex, throughIndex) => (await readBoundedRange(sessionId, afterIndex, throughIndex)).rows;
1363
+ /** `readRange`, with the clear boundary its rows start at. */
1364
+ const readBoundedRange = async (sessionId, afterIndex, throughIndex) => {
1365
+ if (throughIndex !== void 0 && throughIndex <= afterIndex) return {
1366
+ rows: [],
1367
+ clearIndex: 0
1368
+ };
1369
+ const { frontier: currentFrontier, metaSplit, clearIndex } = await readRangeBounds(sessionId);
1370
+ const frontier = Math.min(throughIndex ?? currentFrontier, currentFrontier);
1371
+ const after = Math.max(afterIndex, clearIndex - 1);
1372
+ if (frontier <= after) return {
1373
+ rows: [],
1374
+ clearIndex
1375
+ };
1376
+ const rows = [];
1377
+ let nextIndex = after + 1;
1378
+ while (nextIndex <= frontier) {
1379
+ const page = await readRangePage({
1380
+ sessionId,
1381
+ afterIndex: nextIndex - 1,
1382
+ throughIndex: frontier,
1383
+ metaSplit
1384
+ });
1385
+ if (page.length === 0) throw rangeGap(sessionId, `ended before index ${frontier}`);
1386
+ for (let position = 0; position < page.length; position += 1) if (page[position].index !== nextIndex + position) throw rangeGap(sessionId, "is not contiguous");
1387
+ const lastIndex = page.at(-1).index;
1388
+ if (lastIndex < nextIndex || lastIndex > frontier) throw new A2Error("STORE_UNAVAILABLE", `redis event range for session '${sessionId}' did not advance`);
1389
+ rows.push(...page);
1390
+ nextIndex = lastIndex + 1;
1391
+ }
1392
+ return {
1393
+ rows,
1394
+ clearIndex
1395
+ };
1396
+ };
1397
+ const readStream = options.readStream;
1398
+ return {
1399
+ ...readStream ? { streamEvents: async function* ({ sessionId, afterIndex, signal }) {
1400
+ try {
1401
+ for await (const entries of readStream({
1402
+ key: storeKey(sessionId),
1403
+ from: `${afterIndex + 1}-0`,
1404
+ signal
1405
+ })) {
1406
+ signal.throwIfAborted();
1407
+ yield entries.map((entry) => toSerializedEvent(sessionId, entry));
1408
+ }
1409
+ } catch (cause) {
1410
+ if (cause instanceof A2Error) throw cause;
1411
+ throw new A2Error("STORE_UNAVAILABLE", "redis stream read failed", { cause });
1412
+ }
1413
+ } } : {},
1414
+ async append(sessionId, events, appendOptions) {
1415
+ return wrap(async () => {
1416
+ const suppliedIds = events.filter((e) => e.id !== void 0).map((e) => e.id);
1417
+ if (new Set(suppliedIds).size !== suppliedIds.length) throw new A2Error("PARTIAL_DUPLICATE_BATCH", "batch contains the same event id more than once");
1418
+ const now = clock.now().getTime();
1419
+ const withIds = events.map((e) => ({
1420
+ id: e.id ?? generateId(),
1421
+ type: e.type,
1422
+ payload: e.payload,
1423
+ cause: e.cause ? { ...e.cause } : null,
1424
+ lane: e.lane ?? null,
1425
+ settled: e.settled === true
1426
+ }));
1427
+ const args = [
1428
+ sessionId,
1429
+ now,
1430
+ withIds.length,
1431
+ ...withIds.flatMap((e) => [
1432
+ e.id,
1433
+ e.type,
1434
+ json(e.payload),
1435
+ e.cause ? json(e.cause) : "",
1436
+ e.lane ?? "",
1437
+ e.settled ? "1" : "0"
1438
+ ]),
1439
+ `${prefix}:${sessionId}:notify`,
1440
+ appendOptions?.originIndex ?? ""
1441
+ ];
1442
+ const reply = await evalScript(APPEND_LUA, [
1443
+ storeKey(sessionId),
1444
+ countKey(sessionId),
1445
+ metaKey(sessionId),
1446
+ pendingKey(sessionId),
1447
+ readyKey(sessionId),
1448
+ metaSplitKey(sessionId),
1449
+ ...withIds.map((e) => idKey(e.id))
1450
+ ], args);
1451
+ const [verdict] = reply;
1452
+ if (verdict === "empty") return {
1453
+ events: [],
1454
+ hasPending: reply[1] === "1"
1455
+ };
1456
+ if (verdict === "foreign") throw new A2Error("PARTIAL_DUPLICATE_BATCH", `event id '${reply[1]}' already exists in another session`);
1457
+ if (verdict === "no_parent") throw new TypeError(`no event at index ${reply[1]} in session '${sessionId}'`);
1458
+ if (verdict === "cleared") throw sessionClearedError(`scheduled work from event ${reply[1]} was cleared in session '${sessionId}'`);
1459
+ if (verdict === "superseded") throw new A2Error("SUPERSEDED_ATTEMPT", `a newer attempt owns event ${reply[1]} in session '${sessionId}'`);
1460
+ if (verdict === "partial") throw new A2Error("PARTIAL_DUPLICATE_BATCH", `batch mixes ${reply[1]} already-appended and ${events.length - Number(reply[1])} fresh events`);
1461
+ if (verdict === "dup") {
1462
+ const hasPending = reply[1] === "1";
1463
+ const firstIndex = Math.min(...reply.slice(2).map(Number));
1464
+ const { rows: existing, clearIndex } = await retryAfterGap(() => readBoundedRange(sessionId, firstIndex - 1, firstIndex + events.length - 1));
1465
+ if (firstIndex < clearIndex) throw new A2Error("STORE_UNAVAILABLE", `session '${sessionId}' was cleared while an append retry replayed; retry it`);
1466
+ return {
1467
+ events: idempotentReplay(events, existing),
1468
+ hasPending
1469
+ };
1470
+ }
1471
+ const base = Number(reply[1]) - 1;
1472
+ return {
1473
+ events: withIds.map((e, i) => ({
1474
+ id: e.id,
1475
+ type: e.type,
1476
+ payload: structuredClone(e.payload),
1477
+ index: base + 1 + i,
1478
+ sessionId,
1479
+ createdAt: new Date(now),
1480
+ cause: e.cause,
1481
+ lane: e.lane,
1482
+ processedAt: e.settled ? new Date(now) : null,
1483
+ processedByAttempt: null,
1484
+ returnedEventIds: null,
1485
+ firstClaimedAt: null,
1486
+ lastClaimedAt: null,
1487
+ attemptCount: 0,
1488
+ claimHolder: null,
1489
+ claimExpiresAt: null,
1490
+ failureCount: 0,
1491
+ lastFailedAt: null,
1492
+ lastFailedAttempt: null,
1493
+ lastError: null,
1494
+ failedAt: null
1495
+ })),
1496
+ hasPending: reply[2] === "1"
1497
+ };
1498
+ });
1499
+ },
1500
+ async read(sessionId, opts) {
1501
+ return wrap(() => retryAfterGap(() => readRange(sessionId, opts?.afterIndex ?? 0, opts?.throughIndex)));
1502
+ },
1503
+ async readHistory(sessionId, opts) {
1504
+ const after = opts?.afterIndex ?? 0;
1505
+ if (opts?.throughIndex !== void 0 && opts.throughIndex <= after) return [];
1506
+ return wrap(() => retryAfterGap(async () => {
1507
+ const [count, cleared] = await call("MGET", countKey(sessionId), clearKey(sessionId));
1508
+ const frontier = toIndex({
1509
+ raw: count,
1510
+ sessionId,
1511
+ name: "event count"
1512
+ });
1513
+ const clearIndex = toIndex({
1514
+ raw: cleared,
1515
+ sessionId,
1516
+ name: "clear boundary"
1517
+ });
1518
+ return readEventRange(sessionId, Math.max(after, clearIndex - 1), Math.min(opts?.throughIndex ?? frontier, frontier));
1519
+ }));
1520
+ },
1521
+ async claimAvailable({ sessionId, holder, ttlMs, expiresAtMs, excludeIndexes }) {
1522
+ return wrap(async () => {
1523
+ const now = clock.now().getTime();
1524
+ const expiresAt = expiresAtMs ?? now + ttlMs;
1525
+ const excluded = excludeIndexes ?? [];
1526
+ const reply = await evalScript(CLAIM_AVAILABLE_LUA, [
1527
+ storeKey(sessionId),
1528
+ countKey(sessionId),
1529
+ metaKey(sessionId),
1530
+ pendingKey(sessionId),
1531
+ readyKey(sessionId),
1532
+ metaSplitKey(sessionId)
1533
+ ], [
1534
+ holder,
1535
+ now,
1536
+ expiresAt,
1537
+ excluded.length,
1538
+ ...excluded
1539
+ ]);
1540
+ return toClaimAvailableResult(sessionId, reply);
1541
+ });
1542
+ },
1543
+ async renewClaims({ sessionId, holder, claims, ttlMs, expiresAtMs }) {
1544
+ return wrap(async () => {
1545
+ const now = clock.now().getTime();
1546
+ const expiresAt = expiresAtMs ?? now + ttlMs;
1547
+ const reply = await evalScript(RENEW_CLAIMS_LUA, [
1548
+ countKey(sessionId),
1549
+ metaKey(sessionId),
1550
+ metaSplitKey(sessionId)
1551
+ ], [
1552
+ holder,
1553
+ now,
1554
+ expiresAt,
1555
+ claims.length,
1556
+ ...claims.flatMap((claim) => [claim.index, claim.attempt])
1557
+ ]);
1558
+ const renewedCount = Number(reply[0]);
1559
+ const values = reply.slice(1).map(Number);
1560
+ return {
1561
+ renewed: values.slice(0, renewedCount).toSorted((a, b) => a - b),
1562
+ superseded: values.slice(renewedCount).toSorted((a, b) => a - b)
1563
+ };
1564
+ });
1565
+ },
1566
+ async completeAttempt({ sessionId, index, attempt, events }) {
1567
+ return wrap(async () => {
1568
+ const ids = events.map((event) => event.id);
1569
+ if (new Set(ids).size !== ids.length) throw new A2Error("PARTIAL_DUPLICATE_BATCH", "batch contains the same event id more than once");
1570
+ const now = clock.now().getTime();
1571
+ const reply = await evalScript(COMPLETE_ATTEMPT_LUA, [
1572
+ storeKey(sessionId),
1573
+ countKey(sessionId),
1574
+ metaKey(sessionId),
1575
+ pendingKey(sessionId),
1576
+ readyKey(sessionId),
1577
+ snapshotEventPinsKey(sessionId, index),
1578
+ snapshotPinCountsKey(sessionId),
1579
+ metaSplitKey(sessionId),
1580
+ ...ids.map((id) => idKey(id))
1581
+ ], [
1582
+ sessionId,
1583
+ now,
1584
+ index,
1585
+ attempt,
1586
+ events.length,
1587
+ ...events.flatMap((event) => [
1588
+ event.id,
1589
+ event.type,
1590
+ json(event.payload),
1591
+ event.lane ?? "",
1592
+ event.settled === true ? "1" : "0"
1593
+ ]),
1594
+ `${prefix}:${sessionId}:notify`
1595
+ ]);
1596
+ const outcome = reply[0];
1597
+ if (outcome === "missing") throw new TypeError(`no event at index ${index} in session '${sessionId}'`);
1598
+ if (outcome === "partial") throw new A2Error("PARTIAL_DUPLICATE_BATCH", "returned event ids do not match the completed attempt");
1599
+ if (outcome === "superseded") return { outcome };
1600
+ const cause = {
1601
+ index,
1602
+ attempt
1603
+ };
1604
+ if (outcome === "ok") {
1605
+ const base = Number(reply[1]) - 1;
1606
+ return {
1607
+ outcome: "completed",
1608
+ events: events.map((event, i) => ({
1609
+ id: event.id,
1610
+ type: event.type,
1611
+ payload: structuredClone(event.payload),
1612
+ index: base + 1 + i,
1613
+ sessionId,
1614
+ createdAt: new Date(now),
1615
+ cause,
1616
+ lane: event.lane ?? null,
1617
+ processedAt: event.settled === true ? new Date(now) : null,
1618
+ processedByAttempt: null,
1619
+ returnedEventIds: null,
1620
+ firstClaimedAt: null,
1621
+ lastClaimedAt: null,
1622
+ attemptCount: 0,
1623
+ claimHolder: null,
1624
+ claimExpiresAt: null,
1625
+ failureCount: 0,
1626
+ lastFailedAt: null,
1627
+ lastFailedAttempt: null,
1628
+ lastError: null,
1629
+ failedAt: null
1630
+ }))
1631
+ };
1632
+ }
1633
+ if (outcome === "duplicate") {
1634
+ if (events.length === 0) return {
1635
+ outcome: "completed",
1636
+ events: []
1637
+ };
1638
+ const firstIndex = Math.min(...reply.slice(1).map(Number));
1639
+ const { rows: existing, clearIndex } = await retryAfterGap(() => readBoundedRange(sessionId, firstIndex - 1, firstIndex + events.length - 1));
1640
+ if (index <= clearIndex) return { outcome: "superseded" };
1641
+ return {
1642
+ outcome: "completed",
1643
+ events: idempotentReplay(events, existing)
1644
+ };
1645
+ }
1646
+ throw new TypeError(`unexpected completion outcome '${outcome}'`);
1647
+ });
1648
+ },
1649
+ async failAttempt({ sessionId, index, attempt, error, maxFailures }) {
1650
+ return wrap(async () => {
1651
+ const reply = await evalScript(FAIL_ATTEMPT_LUA, [
1652
+ countKey(sessionId),
1653
+ metaKey(sessionId),
1654
+ pendingKey(sessionId),
1655
+ readyKey(sessionId),
1656
+ snapshotEventPinsKey(sessionId, index),
1657
+ snapshotPinCountsKey(sessionId),
1658
+ metaSplitKey(sessionId)
1659
+ ], [
1660
+ index,
1661
+ attempt,
1662
+ error,
1663
+ maxFailures,
1664
+ clock.now().getTime()
1665
+ ]);
1666
+ const outcome = reply[0];
1667
+ if (outcome === "cleared") return {
1668
+ outcome: "superseded",
1669
+ failureCount: 0
1670
+ };
1671
+ if (outcome === "missing") throw new TypeError(`no event at index ${index} in session '${sessionId}'`);
1672
+ return {
1673
+ outcome,
1674
+ failureCount: Number(reply[1])
1675
+ };
1676
+ });
1677
+ },
1678
+ async readState(sessionId, reducerName, stateOptions) {
1679
+ return wrap(() => retryAfterGap(async () => {
1680
+ const reply = await evalScript(READ_STATE_LUA, [
1681
+ snapKey(sessionId, reducerName),
1682
+ snapHistoryKey(sessionId, reducerName),
1683
+ countKey(sessionId)
1684
+ ], [stateOptions?.snapshotThroughIndex ?? stateOptions?.throughIndex ?? ""]);
1685
+ const rawHead = reply[0];
1686
+ const raw = reply[1];
1687
+ const headIndex = rawHead === "" ? null : Number(rawHead);
1688
+ const parsed = raw === "" ? null : JSON.parse(raw);
1689
+ const currentFrontier = Number(reply[2]);
1690
+ if (!Number.isSafeInteger(currentFrontier) || currentFrontier < 0 || headIndex !== null && (!Number.isSafeInteger(headIndex) || headIndex < 0) || parsed !== null && (!Number.isSafeInteger(parsed.index) || parsed.index < 0)) throw new A2Error("STORE_UNAVAILABLE", `redis state frontier for session '${sessionId}' is invalid`);
1691
+ const frontier = Math.min(currentFrontier, stateOptions?.throughIndex ?? currentFrontier);
1692
+ const snapshot = parsed;
1693
+ const clearIndex = Number(reply[3] ?? 0);
1694
+ const events = await readEventRange(sessionId, Math.max(snapshot?.index ?? 0, clearIndex - 1), frontier);
1695
+ return {
1696
+ ...clearIndex > 0 ? { clearIndex } : {},
1697
+ headIndex,
1698
+ snapshot: snapshot ? {
1699
+ index: snapshot.index,
1700
+ state: snapshot.state
1701
+ } : null,
1702
+ events
1703
+ };
1704
+ }));
1705
+ },
1706
+ async putSnapshots(sessionId, reducerName, writes) {
1707
+ const byIndex = /* @__PURE__ */ new Map();
1708
+ for (const write of writes) {
1709
+ const existing = byIndex.get(write.index);
1710
+ if (existing) {
1711
+ existing.state = write.state;
1712
+ for (const eventIndex of write.pinEventIndexes ?? []) existing.pinEventIndexes.add(eventIndex);
1713
+ } else byIndex.set(write.index, {
1714
+ index: write.index,
1715
+ state: write.state,
1716
+ pinEventIndexes: new Set(write.pinEventIndexes ?? [])
1717
+ });
1718
+ }
1719
+ const normalized = [...byIndex.values()].toSorted((a, b) => a.index - b.index);
1720
+ if (normalized.length === 0) return;
1721
+ await wrap(async () => {
1722
+ const args = [
1723
+ clock.now().getTime(),
1724
+ snapshotEventPinsPrefix(sessionId),
1725
+ normalized.length
1726
+ ];
1727
+ for (const write of normalized) args.push(write.index, json({
1728
+ index: write.index,
1729
+ state: write.state,
1730
+ updatedAt: clock.now().getTime(),
1731
+ r: 1
1732
+ }), write.pinEventIndexes.size, ...write.pinEventIndexes);
1733
+ await evalScript(PUT_SNAPSHOTS_LUA, [
1734
+ snapKey(sessionId, reducerName),
1735
+ snapHistoryKey(sessionId, reducerName),
1736
+ countKey(sessionId),
1737
+ metaKey(sessionId),
1738
+ snapshotPinCountsKey(sessionId),
1739
+ metaSplitKey(sessionId)
1740
+ ], [...args, reducerName]);
1741
+ });
1742
+ },
1743
+ presence: {
1744
+ async set(ns, participant, values, meta) {
1745
+ return wrap(async () => {
1746
+ const entries = Object.entries(values);
1747
+ if (entries.length === 0) return null;
1748
+ const atMs = meta.at.getTime();
1749
+ const expiresAtMs = clock.now().getTime() + meta.ttlMs;
1750
+ const applied = await evalScript(PRESENCE_SET_LUA, [presenceKey(ns)], [
1751
+ atMs,
1752
+ meta.ttlMs,
1753
+ entries.length,
1754
+ ...entries.flatMap(([field, value]) => [
1755
+ JSON.stringify([participant, field]),
1756
+ value === null ? "" : json({
1757
+ v: value,
1758
+ s: meta.seen,
1759
+ a: atMs,
1760
+ e: expiresAtMs
1761
+ }),
1762
+ `${json(field)}:${json(value)}`
1763
+ ]),
1764
+ presenceKey(ns),
1765
+ `${json({
1766
+ src: options.presenceSource,
1767
+ participant,
1768
+ seen: meta.seen,
1769
+ at: atMs
1770
+ }).slice(0, -1)},"values":{`
1771
+ ]);
1772
+ if (applied.length === 0) return null;
1773
+ const appliedValues = nullProtoRecord();
1774
+ for (const position of applied) {
1775
+ const [field, value] = entries[Number(position) - 1];
1776
+ appliedValues[field] = structuredClone(value);
1777
+ }
1778
+ return {
1779
+ participant,
1780
+ values: appliedValues,
1781
+ seen: meta.seen,
1782
+ at: new Date(atMs)
1783
+ };
1784
+ });
1785
+ },
1786
+ async read(ns) {
1787
+ return wrap(async () => {
1788
+ const reply = await evalScript(PRESENCE_READ_LUA, [presenceKey(ns)], [clock.now().getTime()]);
1789
+ const rows = [];
1790
+ for (let i = 0; i + 1 < reply.length; i += 2) {
1791
+ const [participant, field] = JSON.parse(reply[i]);
1792
+ const entry = JSON.parse(reply[i + 1]);
1793
+ rows.push({
1794
+ participant,
1795
+ field,
1796
+ value: entry.v,
1797
+ seen: entry.s,
1798
+ at: new Date(entry.a),
1799
+ expiresAt: new Date(entry.e)
1800
+ });
1801
+ }
1802
+ return rows;
1803
+ });
1804
+ }
1805
+ },
1806
+ async readRange(sessionId, afterIndex, throughIndex) {
1807
+ return wrap(() => readRange(sessionId, afterIndex, throughIndex));
1808
+ },
1809
+ async readTail(sessionId, fromIndex, throughIndex) {
1810
+ return wrap(async () => {
1811
+ const page = (await call("XREVRANGE", storeKey(sessionId), throughIndex === void 0 ? "+" : `${throughIndex}-0`, `${fromIndex}-0`, "COUNT", 256)).toReversed().map((entry) => toEvent(sessionId, entry));
1812
+ for (let position = 1; position < page.length; position += 1) if (page[position].index !== page[position - 1].index + 1) throw new A2Error("STORE_UNAVAILABLE", `redis event range for session '${sessionId}' is not contiguous`);
1813
+ const oldest = page[0]?.index;
1814
+ return oldest === void 0 || oldest <= fromIndex ? page : null;
1815
+ });
1816
+ },
1817
+ async clear({ sessionId, ifLastIndex, cause }) {
1818
+ return wrap(async () => {
1819
+ const markerId = generateId();
1820
+ const reply = await evalScript(CLEAR_LUA, [
1821
+ storeKey(sessionId),
1822
+ countKey(sessionId),
1823
+ metaKey(sessionId),
1824
+ metaSplitKey(sessionId),
1825
+ idKey(markerId),
1826
+ pendingKey(sessionId),
1827
+ readyKey(sessionId)
1828
+ ], [
1829
+ sessionId,
1830
+ clock.now().getTime(),
1831
+ ifLastIndex ?? "",
1832
+ cause?.index ?? "",
1833
+ cause?.attempt ?? "",
1834
+ markerId,
1835
+ `${prefix}:${sessionId}:notify`
1836
+ ]);
1837
+ if (reply[0] === "superseded") throw supersededAttemptError(sessionId, cause);
1838
+ if (reply[0] === "no_parent") throw new TypeError(`no event at index ${reply[1]} in session '${sessionId}'`);
1839
+ if (reply[0] === "foreign") throw new A2Error("PARTIAL_DUPLICATE_BATCH", `the clear marker id '${markerId}' already exists`);
1840
+ return {
1841
+ cleared: reply[1] === "1",
1842
+ index: Number(reply[2]),
1843
+ clearIndex: Number(reply[3])
1844
+ };
1845
+ });
1846
+ },
1847
+ async reclaim(sessionId) {
1848
+ return wrap(async () => {
1849
+ const [log, ...pairs] = await evalScript(RECLAIM_BATCH_LUA, [countKey(sessionId)], [256]);
1850
+ if (log === void 0) return {
1851
+ deleted: 0,
1852
+ done: true
1853
+ };
1854
+ const ids = [];
1855
+ for (let i = 1; i < pairs.length; i += 2) ids.push(pairs[i]);
1856
+ const [deleted, done] = await evalScript(RECLAIM_LUA, [
1857
+ log,
1858
+ countKey(sessionId),
1859
+ metaKey(sessionId),
1860
+ metaSplitKey(sessionId),
1861
+ ...ids.map((id) => idKey(id))
1862
+ ], [
1863
+ sessionId,
1864
+ snapshotEventPinsPrefix(sessionId),
1865
+ ids.length,
1866
+ ...pairs
1867
+ ]);
1868
+ return {
1869
+ deleted: Number(deleted),
1870
+ done: Number(done) === 1
1871
+ };
1872
+ });
1873
+ },
1874
+ async readEvents(sessionId, afterIndex) {
1875
+ return wrap(async () => {
1876
+ return (await call("XRANGE", storeKey(sessionId), `${afterIndex + 1}-0`, "+", "COUNT", 256)).map((entry) => toEvent(sessionId, entry));
1877
+ });
1878
+ },
1879
+ async readFeedPage(sessionId, afterIndex) {
1880
+ return wrap(async () => {
1881
+ const range = [
1882
+ storeKey(sessionId),
1883
+ `${afterIndex + 1}-0`,
1884
+ "+",
1885
+ "COUNT",
1886
+ FEED_PAGE_SIZE
1887
+ ];
1888
+ if (callBytes) return (await callBytes("XRANGE", ...range)).map((entry) => bytesToSerializedEvent(sessionId, entry));
1889
+ return (await call("XRANGE", ...range)).map((entry) => toSerializedEvent(sessionId, entry));
1890
+ });
1891
+ }
1892
+ };
1893
+ }
1894
+ //#endregion
1895
+ //#region src/store-redis-notify.ts
1896
+ /** A feed that falls this far behind drops its inbox and catches up. */
1897
+ const INBOX_BYTES = 524288;
1898
+ /**
1899
+ * Parses a wake-up message: `{"f":frontier}` or a bare frontier number
1900
+ * announces events without carrying them; `{"f","t","e"}` carries them.
1901
+ * Returns null for anything else, which callers treat as a hint to catch up.
1902
+ */
1903
+ const parseWake = (message) => {
1904
+ let parsed;
1905
+ try {
1906
+ parsed = JSON.parse(message);
1907
+ } catch {
1908
+ return null;
1909
+ }
1910
+ if (typeof parsed === "number") return Number.isSafeInteger(parsed) ? {
1911
+ frontier: parsed,
1912
+ createdAt: 0,
1913
+ entries: []
1914
+ } : null;
1915
+ if (typeof parsed !== "object" || parsed === null) return null;
1916
+ const { f, t, e } = parsed;
1917
+ if (typeof f !== "number" || !Number.isSafeInteger(f)) return null;
1918
+ if (e === void 0) return {
1919
+ frontier: f,
1920
+ createdAt: 0,
1921
+ entries: []
1922
+ };
1923
+ if (!Array.isArray(e) || typeof t !== "number" || !Number.isFinite(t)) return null;
1924
+ for (const entry of e) if (!Array.isArray(entry) || entry.length !== 4 || !Number.isSafeInteger(entry[0]) || typeof entry[1] !== "string" || typeof entry[2] !== "string") return null;
1925
+ return {
1926
+ frontier: f,
1927
+ createdAt: t,
1928
+ entries: e
1929
+ };
1930
+ };
1931
+ function createRedisNotifyStore(options) {
1932
+ const { client, clock, ids, keyPrefix: prefix } = options;
1933
+ const notifyChannel = (sessionId) => `${prefix}:${sessionId}:notify`;
1934
+ const presenceChannel = (ns) => `${prefix}:${ns}:presence`;
1935
+ const instanceId = crypto.randomUUID();
1936
+ const core = createRedisStoreCore({
1937
+ call: async (command, ...args) => {
1938
+ return (await client()).call(command, ...args);
1939
+ },
1940
+ clock,
1941
+ ids,
1942
+ keyPrefix: prefix,
1943
+ presenceSource: instanceId,
1944
+ ...options.callBytes ? { callBytes: options.callBytes } : {},
1945
+ ...options.readStream ? { readStream: options.readStream } : {}
1946
+ });
1947
+ let severed = false;
1948
+ let subscriber = null;
1949
+ /** channel → live event feeds, registered before they subscribe. */
1950
+ const feeds = /* @__PURE__ */ new Map();
1951
+ /** channel → refcounted subscription held for each live iterator. */
1952
+ const subscriptions = /* @__PURE__ */ new Map();
1953
+ const presenceSubscriptions = /* @__PURE__ */ new Map();
1954
+ const pollingStreams = /* @__PURE__ */ new Set();
1955
+ options.onReconnect?.((channel) => {
1956
+ for (const feed of feeds.get(channel) ?? []) {
1957
+ feed.dirty = true;
1958
+ feed.wake?.();
1959
+ }
1960
+ presenceSubscriptions.get(channel)?.recovery?.reconnect().catch(() => {});
1961
+ });
1962
+ const deliverRemotePresence = ({ subscription, message }) => {
1963
+ let parsed;
1964
+ try {
1965
+ parsed = JSON.parse(message);
1966
+ } catch {
1967
+ return;
1968
+ }
1969
+ if (parsed.src === instanceId) return;
1970
+ const patch = {
1971
+ participant: parsed.participant,
1972
+ values: parsed.values,
1973
+ seen: parsed.seen,
1974
+ at: new Date(parsed.at)
1975
+ };
1976
+ subscription.recovery?.update(patch);
1977
+ for (const listener of subscription.listeners) listener(patch);
1978
+ };
1979
+ const getSubscriber = () => {
1980
+ subscriber ??= (async () => {
1981
+ const conn = (await client()).duplicate();
1982
+ conn.on("message", (channel, message) => {
1983
+ for (const feed of feeds.get(channel) ?? []) {
1984
+ if (feed.bytes + message.length <= INBOX_BYTES) {
1985
+ feed.inbox.push(message);
1986
+ feed.bytes += message.length;
1987
+ } else {
1988
+ feed.inbox.length = 0;
1989
+ feed.bytes = 0;
1990
+ feed.dirty = true;
1991
+ }
1992
+ feed.wake?.();
1993
+ }
1994
+ const subscription = presenceSubscriptions.get(channel);
1995
+ if (subscription) deliverRemotePresence({
1996
+ subscription,
1997
+ message
1998
+ });
1999
+ });
2000
+ return conn;
2001
+ })();
2002
+ return subscriber;
2003
+ };
2004
+ /**
2005
+ * The subscriber, gated on connection readiness — for channel
2006
+ * commands only (`close` uses the ungated promise, so it can never
2007
+ * hang on an unreachable server). SUBSCRIBE carries redis's
2008
+ * ok-loading flag, so ioredis writes it mid-handshake, ahead of the
2009
+ * ready check's INFO — which then finds the connection already in
2010
+ * subscriber mode and fails the check (a spurious error event plus
2011
+ * a reconnect). ECHO has no such flag: it resolves only once the
2012
+ * connection is ready, so every channel command chained behind it
2013
+ * lands after the check.
2014
+ */
2015
+ let subscriberGate = null;
2016
+ const gatedSubscriber = () => {
2017
+ subscriberGate ??= getSubscriber().then(async (conn) => {
2018
+ await conn.call("echo", "ready");
2019
+ return conn;
2020
+ });
2021
+ return subscriberGate;
2022
+ };
2023
+ const acquireChannel = async (channel) => {
2024
+ const existing = subscriptions.get(channel);
2025
+ if (existing) {
2026
+ existing.refs += 1;
2027
+ try {
2028
+ await existing.ready;
2029
+ } catch (err) {
2030
+ existing.refs -= 1;
2031
+ throw err;
2032
+ }
2033
+ return;
2034
+ }
2035
+ const lease = {
2036
+ refs: 1,
2037
+ ready: gatedSubscriber().then(async (conn) => {
2038
+ await conn.call("subscribe", channel);
2039
+ if (subscriptions.get(channel) !== lease || severed) return;
2040
+ const presence = presenceSubscriptions.get(channel);
2041
+ if (options.polling?.() && presence) {
2042
+ presence.recovery = void 0;
2043
+ const ns = channel.slice(prefix.length + 1, -9);
2044
+ presence.poll = pollPresence({
2045
+ read: () => core.presence.read(ns),
2046
+ deliver: (patch) => {
2047
+ for (const listener of presence.listeners) listener(patch);
2048
+ }
2049
+ });
2050
+ await presence.poll.ready;
2051
+ }
2052
+ })
2053
+ };
2054
+ subscriptions.set(channel, lease);
2055
+ try {
2056
+ await lease.ready;
2057
+ } catch (err) {
2058
+ if (subscriptions.get(channel) === lease) {
2059
+ subscriptions.delete(channel);
2060
+ const presence = presenceSubscriptions.get(channel);
2061
+ presence?.poll?.stop();
2062
+ if (presence) presence.poll = void 0;
2063
+ }
2064
+ throw err;
2065
+ }
2066
+ };
2067
+ const releaseChannel = (channel) => {
2068
+ const lease = subscriptions.get(channel);
2069
+ if (!lease) return;
2070
+ lease.refs -= 1;
2071
+ if (lease.refs > 0) return;
2072
+ subscriptions.delete(channel);
2073
+ gatedSubscriber().then((conn) => conn.call("unsubscribe", channel)).catch(() => {});
2074
+ };
2075
+ const store = {
2076
+ append: core.append,
2077
+ read: core.read,
2078
+ readHistory: core.readHistory,
2079
+ claimAvailable: core.claimAvailable,
2080
+ renewClaims: core.renewClaims,
2081
+ completeAttempt: core.completeAttempt,
2082
+ failAttempt: core.failAttempt,
2083
+ readState: core.readState,
2084
+ putSnapshots: core.putSnapshots,
2085
+ clear: core.clear,
2086
+ reclaim: core.reclaim,
2087
+ presence: {
2088
+ async set(ns, participant, values, meta) {
2089
+ const patch = await core.presence.set(ns, participant, values, meta);
2090
+ if (!patch) return;
2091
+ const channel = presenceChannel(ns);
2092
+ const subscription = presenceSubscriptions.get(channel);
2093
+ if (subscription) {
2094
+ subscription.poll?.update(patch);
2095
+ subscription.recovery?.update(patch);
2096
+ for (const listener of subscription.listeners) listener(patch);
2097
+ }
2098
+ },
2099
+ async read(ns) {
2100
+ const channel = presenceChannel(ns);
2101
+ await subscriptions.get(channel)?.ready;
2102
+ return presenceSubscriptions.get(channel)?.recovery?.read() ?? core.presence.read(ns);
2103
+ },
2104
+ subscribe(ns, onPatch) {
2105
+ const channel = presenceChannel(ns);
2106
+ let subscription = presenceSubscriptions.get(channel);
2107
+ if (!subscription) {
2108
+ subscription = { listeners: /* @__PURE__ */ new Set() };
2109
+ presenceSubscriptions.set(channel, subscription);
2110
+ if (options.onReconnect && !options.polling?.()) {
2111
+ const current = subscription;
2112
+ subscription.recovery = presenceRecovery({
2113
+ read: () => core.presence.read(ns),
2114
+ deliver: (patch) => {
2115
+ for (const listener of current.listeners) listener(patch);
2116
+ },
2117
+ isActive: () => !severed && presenceSubscriptions.get(channel) === current
2118
+ });
2119
+ }
2120
+ }
2121
+ subscription.listeners.add(onPatch);
2122
+ acquireChannel(channel).catch(() => {});
2123
+ let stopped = false;
2124
+ return () => {
2125
+ if (stopped) return;
2126
+ stopped = true;
2127
+ subscription.listeners.delete(onPatch);
2128
+ if (subscription.listeners.size === 0) {
2129
+ subscription.poll?.stop();
2130
+ presenceSubscriptions.delete(channel);
2131
+ }
2132
+ releaseChannel(channel);
2133
+ };
2134
+ }
2135
+ },
2136
+ stream(sessionId, opts) {
2137
+ const startAfter = opts?.startAfter ?? 0;
2138
+ const channel = notifyChannel(sessionId);
2139
+ return { [Symbol.asyncIterator]() {
2140
+ let last = startAfter;
2141
+ let buffer = [];
2142
+ let closed = false;
2143
+ let subscribed = false;
2144
+ let pending = null;
2145
+ let poller;
2146
+ let stopActivity;
2147
+ let catchup;
2148
+ let stoppingRead;
2149
+ const stopRead = () => {
2150
+ const reading = catchup;
2151
+ catchup = void 0;
2152
+ if (reading) {
2153
+ reading.controller.abort();
2154
+ stoppingRead = reading.iterator.return(void 0).then(() => void 0).catch(() => {});
2155
+ }
2156
+ return stoppingRead ?? Promise.resolve();
2157
+ };
2158
+ const nextRead = async (after) => {
2159
+ if (!catchup) {
2160
+ const controller = new AbortController();
2161
+ catchup = {
2162
+ controller,
2163
+ delivered: false,
2164
+ iterator: core.streamEvents({
2165
+ sessionId,
2166
+ afterIndex: after,
2167
+ signal: controller.signal
2168
+ })[Symbol.asyncIterator]()
2169
+ };
2170
+ }
2171
+ const reading = catchup;
2172
+ const result = await reading.iterator.next();
2173
+ if (result.done && catchup === reading) {
2174
+ catchup = void 0;
2175
+ if (reading.delivered) feed.dirty = true;
2176
+ } else if (!result.done) reading.delivered = true;
2177
+ return result;
2178
+ };
2179
+ const feed = {
2180
+ inbox: [],
2181
+ bytes: 0,
2182
+ dirty: true,
2183
+ wake: null,
2184
+ stopRead
2185
+ };
2186
+ const activity = createStreamActivity(() => {
2187
+ if (poller) streamActivities.get(poller)?.activate();
2188
+ });
2189
+ const release = () => {
2190
+ stopRead();
2191
+ stopActivity?.();
2192
+ if (poller) {
2193
+ poller.return?.();
2194
+ pollingStreams.delete(poller);
2195
+ }
2196
+ if (!subscribed) return;
2197
+ subscribed = false;
2198
+ const set = feeds.get(channel);
2199
+ set?.delete(feed);
2200
+ if (set?.size === 0) feeds.delete(channel);
2201
+ releaseChannel(channel);
2202
+ };
2203
+ /**
2204
+ * Moves carried events that continue this feed's sequence into
2205
+ * the buffer. A gap, a message without events, or an unreadable
2206
+ * message marks the feed dirty so it catches up with XRANGE.
2207
+ */
2208
+ const applyInbox = () => {
2209
+ feed.bytes = 0;
2210
+ for (const message of feed.inbox.splice(0)) {
2211
+ const wake = parseWake(message);
2212
+ if (!wake) {
2213
+ feed.dirty = true;
2214
+ continue;
2215
+ }
2216
+ for (const [index, id, type, payload] of wake.entries) {
2217
+ const expected = (buffer.at(-1)?.index ?? last) + 1;
2218
+ if (index < expected) continue;
2219
+ if (index > expected) {
2220
+ feed.dirty = true;
2221
+ break;
2222
+ }
2223
+ buffer.push({
2224
+ id,
2225
+ type,
2226
+ payload,
2227
+ index,
2228
+ sessionId,
2229
+ createdAt: new Date(wake.createdAt)
2230
+ });
2231
+ }
2232
+ if (wake.frontier > (buffer.at(-1)?.index ?? last)) feed.dirty = true;
2233
+ }
2234
+ };
2235
+ const iterator = {
2236
+ async next() {
2237
+ for (;;) {
2238
+ if (closed) {
2239
+ release();
2240
+ return {
2241
+ value: void 0,
2242
+ done: true
2243
+ };
2244
+ }
2245
+ const row = buffer.shift();
2246
+ if (row) {
2247
+ let event;
2248
+ try {
2249
+ event = isSerializedEvent(row) ? parseSerializedEvent(row) : row;
2250
+ } catch (cause) {
2251
+ buffer = [];
2252
+ feed.dirty = true;
2253
+ release();
2254
+ throw new A2Error("STORE_UNAVAILABLE", "redis stream read failed", { cause });
2255
+ }
2256
+ last = row.index;
2257
+ return {
2258
+ value: event,
2259
+ done: false
2260
+ };
2261
+ }
2262
+ if (severed) {
2263
+ release();
2264
+ throw new A2Error("STORE_UNAVAILABLE", "the store was closed");
2265
+ }
2266
+ try {
2267
+ if (!subscribed) {
2268
+ subscribed = true;
2269
+ let set = feeds.get(channel);
2270
+ if (!set) {
2271
+ set = /* @__PURE__ */ new Set();
2272
+ feeds.set(channel, set);
2273
+ }
2274
+ set.add(feed);
2275
+ await acquireChannel(channel);
2276
+ if (closed || severed) continue;
2277
+ }
2278
+ if (options.polling?.()) {
2279
+ if (!poller) {
2280
+ poller = pollingStream((after) => core.readEvents(sessionId, after), { startAfter: last })[Symbol.asyncIterator]();
2281
+ pollingStreams.add(poller);
2282
+ stopActivity = streamActivities.get(poller)?.subscribe(activity.setIdle);
2283
+ }
2284
+ const result = await poller.next();
2285
+ if (!result.done) presenceSubscriptions.get(presenceChannel(sessionId))?.poll?.wake();
2286
+ if (severed) throw new A2Error("STORE_UNAVAILABLE", "the store was closed");
2287
+ return result;
2288
+ }
2289
+ if (catchup) {
2290
+ const result = await nextRead(last);
2291
+ if (closed || severed) continue;
2292
+ if (!result.done) buffer = result.value;
2293
+ continue;
2294
+ }
2295
+ applyInbox();
2296
+ if (buffer.length > 0) continue;
2297
+ if (feed.dirty) {
2298
+ feed.dirty = false;
2299
+ if (core.streamEvents) {
2300
+ const result = await nextRead(last);
2301
+ if (closed || severed) continue;
2302
+ if (!result.done) buffer = result.value;
2303
+ continue;
2304
+ }
2305
+ buffer = await core.readFeedPage(sessionId, last);
2306
+ if (buffer.length >= 1e3) feed.dirty = true;
2307
+ continue;
2308
+ }
2309
+ const woken = new Promise((resolve) => {
2310
+ feed.wake = () => resolve("wake");
2311
+ });
2312
+ pending = defaultSleep(NOTIFY_TIMINGS.safetyReadMs);
2313
+ const outcome = await Promise.race([woken, pending.promise.then(() => "timer")]);
2314
+ pending.cancel();
2315
+ pending = null;
2316
+ if (outcome === "timer") feed.dirty = true;
2317
+ } catch (err) {
2318
+ release();
2319
+ if (closed) return {
2320
+ value: void 0,
2321
+ done: true
2322
+ };
2323
+ if (err instanceof A2Error) throw err;
2324
+ throw new A2Error("STORE_UNAVAILABLE", "redis store operation failed", { cause: err });
2325
+ } finally {
2326
+ feed.wake = null;
2327
+ }
2328
+ }
2329
+ },
2330
+ async return() {
2331
+ closed = true;
2332
+ pending?.cancel();
2333
+ feed.wake?.();
2334
+ release();
2335
+ await stopRead();
2336
+ return {
2337
+ value: void 0,
2338
+ done: true
2339
+ };
2340
+ }
2341
+ };
2342
+ streamActivities.set(iterator, activity);
2343
+ bufferedEvents.set(iterator, () => {
2344
+ if (closed) return void 0;
2345
+ const row = buffer.shift();
2346
+ if (row) last = row.index;
2347
+ return row;
2348
+ });
2349
+ return iterator;
2350
+ } };
2351
+ },
2352
+ async close() {
2353
+ severed = true;
2354
+ for (const subscription of presenceSubscriptions.values()) subscription.poll?.stop();
2355
+ presenceSubscriptions.clear();
2356
+ for (const poller of pollingStreams) poller.return?.();
2357
+ pollingStreams.clear();
2358
+ const reads = [];
2359
+ for (const set of feeds.values()) for (const feed of set) {
2360
+ feed.wake?.();
2361
+ reads.push(feed.stopRead());
2362
+ }
2363
+ await Promise.allSettled(reads);
2364
+ if (subscriber) (await subscriber.catch(() => null))?.disconnect();
2365
+ await options.closeClient();
2366
+ }
2367
+ };
2368
+ stateTailReaders.set(store, core.readTail);
2369
+ return store;
2370
+ }
2371
+ //#endregion
2372
+ export { createRedisNotifyStore as t };
2373
+
2374
+ //# sourceMappingURL=store-redis-notify-tqWNnMRu.js.map