experimental-a2 0.16.1 → 0.18.0

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