experimental-a2 0.19.0 → 0.21.1

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 (320) hide show
  1. package/CHANGELOG.md +50 -0
  2. package/README.md +1 -1
  3. package/dist/{actor-C7b-1arn.d.ts → actor-HnGmrOZZ.d.ts} +5 -5
  4. package/dist/{actor-C7b-1arn.d.ts.map → actor-HnGmrOZZ.d.ts.map} +1 -1
  5. package/dist/actor-client.d.ts +1 -1
  6. package/dist/actor-client.js +2 -2
  7. package/dist/actor-react.d.ts +3 -3
  8. package/dist/actor-react.js +5 -5
  9. package/dist/{actor-shared-t7w8xIKP.js → actor-shared-DFET5q16.js} +3 -3
  10. package/dist/{actor-shared-t7w8xIKP.js.map → actor-shared-DFET5q16.js.map} +1 -1
  11. package/dist/actor.d.ts +1 -1
  12. package/dist/actor.js +54 -43
  13. package/dist/actor.js.map +1 -1
  14. package/dist/{ai-Cz3hqVhk.d.ts → ai-CvEnqVfo.d.ts} +8 -8
  15. package/dist/{ai-Cz3hqVhk.d.ts.map → ai-CvEnqVfo.d.ts.map} +1 -1
  16. package/dist/{ai-mMiWul1T.js → ai-CzE672Q3.js} +9 -9
  17. package/dist/ai-CzE672Q3.js.map +1 -0
  18. package/dist/ai-client.d.ts +2 -2
  19. package/dist/ai-client.js +2 -2
  20. package/dist/ai-fx.d.ts +1 -1
  21. package/dist/ai-fx.d.ts.map +1 -1
  22. package/dist/ai-fx.js +36 -9
  23. package/dist/ai-fx.js.map +1 -1
  24. package/dist/{ai-harness-CV6rXbB3.d.ts → ai-harness-Bfk-Lycn.d.ts} +2 -2
  25. package/dist/ai-harness-Bfk-Lycn.d.ts.map +1 -0
  26. package/dist/{ai-harness-BuuC-gJ3.js → ai-harness-dQeJaR1t.js} +17 -4
  27. package/dist/ai-harness-dQeJaR1t.js.map +1 -0
  28. package/dist/ai-pi.d.ts +1 -1
  29. package/dist/ai-pi.js +4 -4
  30. package/dist/ai-pi.js.map +1 -1
  31. package/dist/ai-react.d.ts +5 -5
  32. package/dist/ai-react.js +2 -2
  33. package/dist/ai-server.d.ts +14 -17
  34. package/dist/ai-server.d.ts.map +1 -1
  35. package/dist/ai-server.js +84 -75
  36. package/dist/ai-server.js.map +1 -1
  37. package/dist/ai.d.ts +2 -2
  38. package/dist/ai.js +2 -2
  39. package/dist/{client-BhHLoNKF.js → client-9IeTsiAs.js} +5 -5
  40. package/dist/{client-BhHLoNKF.js.map → client-9IeTsiAs.js.map} +1 -1
  41. package/dist/{client-h_DKFAjj.d.ts → client-CUMbS_38.d.ts} +3 -3
  42. package/dist/{client-h_DKFAjj.d.ts.map → client-CUMbS_38.d.ts.map} +1 -1
  43. package/dist/client.d.ts +3 -3
  44. package/dist/client.js +1 -1
  45. package/dist/{contract-BYKASlCV.js → contract-CWukt01d.js} +2 -2
  46. package/dist/contract-CWukt01d.js.map +1 -0
  47. package/dist/index.d.ts +5 -5
  48. package/dist/index.js +3 -3
  49. package/dist/{initial-snapshot-Bx-c0pDP.d.ts → initial-snapshot-D3Lve43o.d.ts} +4 -4
  50. package/dist/initial-snapshot-D3Lve43o.d.ts.map +1 -0
  51. package/dist/{internal-CscSY7K_.js → internal-Bc4PvfHk.js} +10 -3
  52. package/dist/internal-Bc4PvfHk.js.map +1 -0
  53. package/dist/otel.d.ts +1 -1
  54. package/dist/otel.js +1 -1
  55. package/dist/otel.js.map +1 -1
  56. package/dist/{react-BBXgHWFc.d.ts → react-BoHvC3v9.d.ts} +4 -4
  57. package/dist/{react-BBXgHWFc.d.ts.map → react-BoHvC3v9.d.ts.map} +1 -1
  58. package/dist/react.d.ts +1 -1
  59. package/dist/react.js +1 -1
  60. package/dist/{reducer-CqA59Q6U.d.ts → reducer-CYPHsSmd.d.ts} +2 -2
  61. package/dist/{reducer-CqA59Q6U.d.ts.map → reducer-CYPHsSmd.d.ts.map} +1 -1
  62. package/dist/{reducer-BAXc8JyF.js → reducer-CjmvZSAb.js} +2 -2
  63. package/dist/{reducer-BAXc8JyF.js.map → reducer-CjmvZSAb.js.map} +1 -1
  64. package/dist/runtime-consumer-BWmht60z.js +12 -0
  65. package/dist/runtime-consumer-BWmht60z.js.map +1 -0
  66. package/dist/scheduler-qstash.d.ts +2 -2
  67. package/dist/scheduler-qstash.d.ts.map +1 -1
  68. package/dist/scheduler-qstash.js +7 -7
  69. package/dist/scheduler-qstash.js.map +1 -1
  70. package/dist/scheduler-task-X1SToDjP.js.map +1 -1
  71. package/dist/scheduler-vercel.d.ts +2 -2
  72. package/dist/scheduler-vercel.d.ts.map +1 -1
  73. package/dist/scheduler-vercel.js +5 -4
  74. package/dist/scheduler-vercel.js.map +1 -1
  75. package/dist/{server-DYAATFuT.d.ts → server-BshFZ0eL.d.ts} +108 -41
  76. package/dist/server-BshFZ0eL.d.ts.map +1 -0
  77. package/dist/{server-D-Y4pqwu.js → server-DvIB8kj5.js} +423 -135
  78. package/dist/server-DvIB8kj5.js.map +1 -0
  79. package/dist/server.browser.js +1 -1
  80. package/dist/server.browser.js.map +1 -1
  81. package/dist/server.d.ts +4 -4
  82. package/dist/server.js +2 -2
  83. package/dist/{session-clear-C-QwTXV1.d.ts → session-clear-Bk_29eEw.d.ts} +2 -2
  84. package/dist/session-clear-Bk_29eEw.d.ts.map +1 -0
  85. package/dist/{session-clear-DGI4jTQ5.js → session-clear-wHd5GCpZ.js} +5 -4
  86. package/dist/session-clear-wHd5GCpZ.js.map +1 -0
  87. package/dist/store-N8PXxDAS.js.map +1 -1
  88. package/dist/store-Z2qjw50n.d.ts +523 -0
  89. package/dist/store-Z2qjw50n.d.ts.map +1 -0
  90. package/dist/store-codec-DwrWlgQx.js +65 -0
  91. package/dist/store-codec-DwrWlgQx.js.map +1 -0
  92. package/dist/store-memory.d.ts +1 -1
  93. package/dist/store-memory.d.ts.map +1 -1
  94. package/dist/store-memory.js +393 -210
  95. package/dist/store-memory.js.map +1 -1
  96. package/dist/{store-polling-7WJV0YWP.js → store-polling-DYNyxCev.js} +2 -2
  97. package/dist/{store-polling-7WJV0YWP.js.map → store-polling-DYNyxCev.js.map} +1 -1
  98. package/dist/store-postgres.d.ts +1 -1
  99. package/dist/store-postgres.d.ts.map +1 -1
  100. package/dist/store-postgres.js +1595 -673
  101. package/dist/store-postgres.js.map +1 -1
  102. package/dist/{store-presence-polling-B1ixeqIS.js → store-presence-polling-CzQ0wdtM.js} +3 -3
  103. package/dist/{store-presence-polling-B1ixeqIS.js.map → store-presence-polling-CzQ0wdtM.js.map} +1 -1
  104. package/dist/store-redis-http.d.ts +1 -1
  105. package/dist/store-redis-http.js +4 -4
  106. package/dist/store-redis-notify-IiVdlYHc.js +2919 -0
  107. package/dist/store-redis-notify-IiVdlYHc.js.map +1 -0
  108. package/dist/store-redis.d.ts +1 -1
  109. package/dist/store-redis.js +2 -2
  110. package/dist/store-sqlite.d.ts +1 -1
  111. package/dist/store-sqlite.d.ts.map +1 -1
  112. package/dist/store-sqlite.js +517 -287
  113. package/dist/store-sqlite.js.map +1 -1
  114. package/dist/{telemetry-Cyfqo6Qt.d.ts → telemetry-CWwsh-Gx.d.ts} +3 -3
  115. package/dist/telemetry-CWwsh-Gx.d.ts.map +1 -0
  116. package/dist/{wire-BKxH1vbP.js → wire-CZGqNZRx.js} +5 -4
  117. package/dist/wire-CZGqNZRx.js.map +1 -0
  118. package/docs/01-quickstart.mdx +28 -19
  119. package/docs/02-examples.mdx +1 -1
  120. package/docs/actors/01-introduction.mdx +9 -1
  121. package/docs/actors/02-concurrency.mdx +1 -1
  122. package/docs/actors/meta.ts +1 -1
  123. package/docs/agents/01-introduction.mdx +1 -1
  124. package/docs/agents/02-tools.mdx +13 -12
  125. package/docs/agents/03-conversations.mdx +31 -21
  126. package/docs/agents/04-harnesses.mdx +7 -3
  127. package/docs/agents/meta.ts +1 -1
  128. package/docs/client/03-presence.mdx +18 -14
  129. package/docs/client/meta.ts +1 -1
  130. package/docs/contract/01-events.mdx +2 -2
  131. package/docs/contract/02-state.mdx +18 -14
  132. package/docs/contract/03-sessions.mdx +32 -25
  133. package/docs/index.mdx +31 -26
  134. package/docs/reference/01-core.mdx +12 -12
  135. package/docs/reference/02-server.mdx +247 -192
  136. package/docs/reference/04-ai.mdx +29 -20
  137. package/docs/reference/05-telemetry.mdx +146 -0
  138. package/docs/reference/06-errors.mdx +24 -18
  139. package/docs/reference/meta.ts +1 -1
  140. package/docs/schedulers/01-overview.mdx +112 -0
  141. package/docs/schedulers/02-vercel-queues.mdx +87 -0
  142. package/docs/schedulers/03-qstash.mdx +123 -0
  143. package/docs/schedulers/meta.ts +1 -0
  144. package/docs/server/01-consumers.mdx +415 -0
  145. package/docs/server/02-timers.mdx +33 -23
  146. package/docs/server/03-cancellation.mdx +52 -44
  147. package/docs/server/04-routes.mdx +4 -1
  148. package/docs/server/05-sql-projections.mdx +391 -0
  149. package/docs/server/06-guarantees.mdx +60 -50
  150. package/docs/stores/01-overview.mdx +37 -0
  151. package/docs/stores/02-redis.mdx +57 -0
  152. package/docs/stores/03-redis-http.mdx +56 -0
  153. package/docs/stores/04-postgres.mdx +172 -0
  154. package/docs/stores/05-sqlite.mdx +36 -0
  155. package/docs/stores/06-memory.mdx +22 -0
  156. package/docs/stores/meta.ts +1 -0
  157. package/docs/upgrades/01-overview.mdx +30 -0
  158. package/docs/upgrades/79-0-21.mdx +165 -0
  159. package/docs/upgrades/80-0-20.mdx +43 -0
  160. package/docs/upgrades/81-0-19.mdx +33 -0
  161. package/docs/upgrades/82-0-18.mdx +33 -0
  162. package/docs/upgrades/83-0-17.mdx +69 -0
  163. package/docs/upgrades/meta.ts +3 -0
  164. package/examples/playground/DEPLOY.md +48 -11
  165. package/examples/playground/app/a0/apps/[appId]/route.ts +6 -10
  166. package/examples/playground/app/a0/chat.test.tsx +421 -330
  167. package/examples/playground/app/a0/sandbox-log.ts +17 -15
  168. package/examples/playground/app/a0/server.ts +4 -4
  169. package/examples/playground/app/a0/shell.tsx +1 -1
  170. package/examples/playground/app/a0/uploads/route.test.ts +59 -0
  171. package/examples/playground/app/a2slack/a2slack.css +1067 -0
  172. package/examples/playground/app/a2slack/c/[channel]/page.tsx +20 -0
  173. package/examples/playground/app/a2slack/consumers/agent.ts +63 -0
  174. package/examples/playground/app/a2slack/consumers/feed.ts +164 -0
  175. package/examples/playground/app/a2slack/consumers/notify.ts +116 -0
  176. package/examples/playground/app/a2slack/consumers/search.ts +37 -0
  177. package/examples/playground/app/a2slack/consumers/triage.ts +31 -0
  178. package/examples/playground/app/a2slack/consumers/welcome.ts +25 -0
  179. package/examples/playground/app/a2slack/db/a2.ts +206 -0
  180. package/examples/playground/app/a2slack/db/index.ts +73 -0
  181. package/examples/playground/app/a2slack/db/migrations/0000_extensions.sql +4 -0
  182. package/examples/playground/app/a2slack/db/migrations/0001_workspace.sql +74 -0
  183. package/examples/playground/app/a2slack/db/migrations/0002_a2.sql +215 -0
  184. package/examples/playground/app/a2slack/db/migrations/0003_a2_batch.sql +67 -0
  185. package/examples/playground/app/a2slack/db/migrations/meta/0000_snapshot.json +18 -0
  186. package/examples/playground/app/a2slack/db/migrations/meta/0001_snapshot.json +500 -0
  187. package/examples/playground/app/a2slack/db/migrations/meta/0002_snapshot.json +1159 -0
  188. package/examples/playground/app/a2slack/db/migrations/meta/0003_snapshot.json +1159 -0
  189. package/examples/playground/app/a2slack/db/migrations/meta/_journal.json +34 -0
  190. package/examples/playground/app/a2slack/db/queries.ts +263 -0
  191. package/examples/playground/app/a2slack/db/schema.ts +145 -0
  192. package/examples/playground/app/a2slack/drills/a2slack.drill.ts +708 -0
  193. package/examples/playground/app/a2slack/drills/harness.ts +244 -0
  194. package/examples/playground/app/a2slack/events/route.ts +36 -0
  195. package/examples/playground/app/a2slack/identity.ts +44 -0
  196. package/examples/playground/app/a2slack/inbox/route.ts +8 -0
  197. package/examples/playground/app/a2slack/lag/data/route.ts +8 -0
  198. package/examples/playground/app/a2slack/lag/page.tsx +101 -0
  199. package/examples/playground/app/a2slack/lag.ts +73 -0
  200. package/examples/playground/app/a2slack/layout.tsx +43 -0
  201. package/examples/playground/app/a2slack/messages/route.ts +17 -0
  202. package/examples/playground/app/a2slack/model.test.ts +208 -0
  203. package/examples/playground/app/a2slack/model.ts +369 -0
  204. package/examples/playground/app/a2slack/models.test.ts +72 -0
  205. package/examples/playground/app/a2slack/page.tsx +10 -0
  206. package/examples/playground/app/a2slack/scheduler/route.ts +5 -0
  207. package/examples/playground/app/a2slack/search/route.ts +40 -0
  208. package/examples/playground/app/a2slack/search-index.ts +108 -0
  209. package/examples/playground/app/a2slack/server.test.ts +315 -0
  210. package/examples/playground/app/a2slack/server.ts +55 -0
  211. package/examples/playground/app/a2slack/sessions.ts +50 -0
  212. package/examples/playground/app/a2slack/thread/route.ts +8 -0
  213. package/examples/playground/app/a2slack/ui/ChannelView.tsx +87 -0
  214. package/examples/playground/app/a2slack/ui/Composer.tsx +117 -0
  215. package/examples/playground/app/a2slack/ui/DotsShadow.tsx +42 -0
  216. package/examples/playground/app/a2slack/ui/Feed.tsx +157 -0
  217. package/examples/playground/app/a2slack/ui/InboxContext.tsx +63 -0
  218. package/examples/playground/app/a2slack/ui/InboxView.tsx +82 -0
  219. package/examples/playground/app/a2slack/ui/Menu.tsx +128 -0
  220. package/examples/playground/app/a2slack/ui/MessageRow.tsx +314 -0
  221. package/examples/playground/app/a2slack/ui/NameGate.tsx +35 -0
  222. package/examples/playground/app/a2slack/ui/SearchContext.tsx +146 -0
  223. package/examples/playground/app/a2slack/ui/SearchResults.tsx +97 -0
  224. package/examples/playground/app/a2slack/ui/Shell.tsx +34 -0
  225. package/examples/playground/app/a2slack/ui/Sidebar.tsx +199 -0
  226. package/examples/playground/app/a2slack/ui/ThreadPanel.tsx +134 -0
  227. package/examples/playground/app/a2slack/ui/useInbox.ts +71 -0
  228. package/examples/playground/app/agent/[agentId]/agent-queue.test.tsx +116 -90
  229. package/examples/playground/app/agent/[agentId]/compaction-panel.test.tsx +107 -76
  230. package/examples/playground/app/agent/ingress.test.ts +41 -17
  231. package/examples/playground/app/agent/model-input.test.tsx +92 -43
  232. package/examples/playground/app/agent/server.ts +3 -3
  233. package/examples/playground/app/chat/[chatId]/agent-stream-drawer.test.tsx +2 -2
  234. package/examples/playground/app/chat/[chatId]/chat-client.tsx +1 -1
  235. package/examples/playground/app/chat/server.test.ts +38 -65
  236. package/examples/playground/app/chat/server.ts +45 -35
  237. package/examples/playground/app/components/activity-history.test.tsx +60 -18
  238. package/examples/playground/app/components/demo-icons.tsx +7 -0
  239. package/examples/playground/app/components/demos.tsx +7 -0
  240. package/examples/playground/app/components/fast-link.test.tsx +3 -5
  241. package/examples/playground/app/initial-state.test.tsx +86 -45
  242. package/examples/playground/app/orders/[orderId]/order-client.tsx +2 -2
  243. package/examples/playground/app/orders/page.tsx +1 -1
  244. package/examples/playground/app/orders/server.ts +49 -30
  245. package/examples/playground/app/recovery/[recoveryId]/recovery-client.tsx +14 -15
  246. package/examples/playground/app/recovery/page.tsx +4 -4
  247. package/examples/playground/app/recovery/server.ts +23 -32
  248. package/examples/playground/drizzle.config.ts +14 -0
  249. package/examples/playground/next.config.ts +6 -1
  250. package/examples/playground/package.json +13 -1
  251. package/examples/playground/scripts/migrate.mjs +31 -0
  252. package/examples/playground/test/agent.ts +35 -0
  253. package/examples/playground/test/model.ts +149 -0
  254. package/examples/playground/test/serve.ts +184 -0
  255. package/examples/playground/test/server-only.ts +1 -0
  256. package/examples/playground/vercel.json +9 -0
  257. package/examples/playground/vitest.config.ts +27 -0
  258. package/examples/playground/vitest.drills.config.ts +32 -0
  259. package/package.json +7 -2
  260. package/sql/postgres/drizzle-schema.ts +206 -0
  261. package/sql/postgres/notifications.sql +24 -0
  262. package/sql/postgres/schema.sql +276 -0
  263. package/src/actor.ts +101 -71
  264. package/src/ai-agent.ts +154 -115
  265. package/src/ai-control-server.ts +8 -8
  266. package/src/ai-fx.ts +41 -9
  267. package/src/ai-harness.ts +28 -2
  268. package/src/ai-pi.ts +2 -2
  269. package/src/ai-server.ts +2 -2
  270. package/src/ai.ts +7 -7
  271. package/src/contract.ts +1 -1
  272. package/src/deterministic-id.ts +26 -3
  273. package/src/errors.ts +9 -6
  274. package/src/idempotent-replay.ts +0 -1
  275. package/src/index.ts +1 -1
  276. package/src/internal.ts +17 -6
  277. package/src/otel.ts +1 -1
  278. package/src/postgres-notifications.ts +0 -22
  279. package/src/postgres-resources.ts +6 -9
  280. package/src/postgres-schema.ts +121 -0
  281. package/src/push-envelope.ts +20 -0
  282. package/src/runtime-consumer.ts +10 -0
  283. package/src/scheduler-qstash.ts +12 -5
  284. package/src/scheduler-task.ts +2 -2
  285. package/src/scheduler-vercel.ts +10 -4
  286. package/src/server.browser.ts +1 -1
  287. package/src/server.ts +839 -238
  288. package/src/session-socket.ts +1 -1
  289. package/src/store-codec.ts +66 -0
  290. package/src/store-memory.ts +581 -255
  291. package/src/store-postgres.ts +1984 -943
  292. package/src/store-redis-core.ts +1624 -1152
  293. package/src/store-redis-notify.ts +2 -0
  294. package/src/store-sqlite.ts +891 -486
  295. package/src/store.ts +227 -97
  296. package/src/telemetry.ts +2 -1
  297. package/src/wire.ts +1 -0
  298. package/dist/ai-harness-BuuC-gJ3.js.map +0 -1
  299. package/dist/ai-harness-CV6rXbB3.d.ts.map +0 -1
  300. package/dist/ai-mMiWul1T.js.map +0 -1
  301. package/dist/contract-BYKASlCV.js.map +0 -1
  302. package/dist/idempotent-replay-JdgMQRRm.js +0 -21
  303. package/dist/idempotent-replay-JdgMQRRm.js.map +0 -1
  304. package/dist/initial-snapshot-Bx-c0pDP.d.ts.map +0 -1
  305. package/dist/internal-CscSY7K_.js.map +0 -1
  306. package/dist/server-D-Y4pqwu.js.map +0 -1
  307. package/dist/server-DYAATFuT.d.ts.map +0 -1
  308. package/dist/session-clear-C-QwTXV1.d.ts.map +0 -1
  309. package/dist/session-clear-DGI4jTQ5.js.map +0 -1
  310. package/dist/store-CyLUy86v.d.ts +0 -404
  311. package/dist/store-CyLUy86v.d.ts.map +0 -1
  312. package/dist/store-codec-DTG0Ftek.js +0 -10
  313. package/dist/store-codec-DTG0Ftek.js.map +0 -1
  314. package/dist/store-redis-notify-CM0AfLY0.js +0 -2544
  315. package/dist/store-redis-notify-CM0AfLY0.js.map +0 -1
  316. package/dist/telemetry-Cyfqo6Qt.d.ts.map +0 -1
  317. package/dist/wire-BKxH1vbP.js.map +0 -1
  318. package/docs/reference/05-adapters.mdx +0 -351
  319. package/docs/server/01-handlers.mdx +0 -200
  320. package/docs/server/05-production.mdx +0 -323
@@ -0,0 +1,2919 @@
1
+ import { i as A2Error, r as sessionClearedError, s as supersededAttemptError, t as CLEAR_EVENT_TYPE } from "./session-clear-wHd5GCpZ.js";
2
+ import { _ as nullProtoRecord, r as NOTIFY_TIMINGS, x as stateTailReaders } from "./internal-Bc4PvfHk.js";
3
+ import { i as streamActivities, n as pollingStream, r as createStreamActivity, t as defaultSleep } from "./store-polling-DYNyxCev.js";
4
+ import { n as isSerializedEvent, r as parseSerializedEvent, t as bufferedEvents } from "./stream-buffer-Cx7AAPGq.js";
5
+ import { a as idempotentReplay, n as decodeCause } from "./store-codec-DwrWlgQx.js";
6
+ import { t as pollPresence } from "./store-presence-polling-CzQ0wdtM.js";
7
+ //#region src/presence-recovery.ts
8
+ const key = ({ participant, field }) => JSON.stringify([participant, field]);
9
+ function presenceRecovery({ read, deliver, isActive }) {
10
+ const fields = /* @__PURE__ */ new Map();
11
+ const reading = /* @__PURE__ */ new Set();
12
+ let generation = 0;
13
+ let latest;
14
+ const remember = ({ participant, field, seen, at }) => {
15
+ const id = key({
16
+ participant,
17
+ field
18
+ });
19
+ const previous = fields.get(id);
20
+ if (!previous || previous.at.getTime() < at.getTime() || previous.at.getTime() === at.getTime() && previous.seen !== seen) fields.set(id, {
21
+ participant,
22
+ field,
23
+ seen,
24
+ at
25
+ });
26
+ };
27
+ const snapshot = async ({ reconcile }) => {
28
+ const run = generation;
29
+ const before = new Map(fields);
30
+ const changed = /* @__PURE__ */ new Set();
31
+ reading.add(changed);
32
+ try {
33
+ const rows = await read();
34
+ if (!isActive()) return rows;
35
+ if (run !== generation) return latest;
36
+ const live = /* @__PURE__ */ new Set();
37
+ for (const row of rows) {
38
+ const id = key(row);
39
+ live.add(id);
40
+ if (changed.has(id)) continue;
41
+ remember(row);
42
+ if (reconcile) deliver({
43
+ participant: row.participant,
44
+ values: { [row.field]: row.value },
45
+ seen: row.seen,
46
+ at: row.at
47
+ });
48
+ }
49
+ if (reconcile) for (const [id, prior] of before) {
50
+ if (live.has(id) || changed.has(id) || fields.get(id) !== prior) continue;
51
+ fields.delete(id);
52
+ deliver({
53
+ participant: prior.participant,
54
+ values: { [prior.field]: null },
55
+ seen: prior.seen,
56
+ at: prior.at
57
+ });
58
+ }
59
+ return rows;
60
+ } finally {
61
+ reading.delete(changed);
62
+ if (reading.size === 0) latest = void 0;
63
+ }
64
+ };
65
+ return {
66
+ async read() {
67
+ let pending = latest;
68
+ while (pending) {
69
+ await pending;
70
+ if (latest === pending) break;
71
+ pending = latest;
72
+ }
73
+ return snapshot({ reconcile: false });
74
+ },
75
+ async reconnect() {
76
+ generation += 1;
77
+ latest = snapshot({ reconcile: true });
78
+ await latest;
79
+ },
80
+ update(patch) {
81
+ for (const [field, value] of Object.entries(patch.values)) {
82
+ if (value === void 0) continue;
83
+ const id = key({
84
+ participant: patch.participant,
85
+ field
86
+ });
87
+ const previous = fields.get(id);
88
+ if (previous && previous.at.getTime() > patch.at.getTime()) continue;
89
+ for (const changed of reading) changed.add(id);
90
+ if (value === null) fields.delete(id);
91
+ else remember({
92
+ participant: patch.participant,
93
+ field,
94
+ seen: patch.seen,
95
+ at: patch.at
96
+ });
97
+ }
98
+ }
99
+ };
100
+ }
101
+ const FEED_PAGE_SIZE = 1e3;
102
+ const READ_CONCURRENCY = 16;
103
+ /** The key layout this core writes; any other is rejected. */
104
+ const REDIS_LAYOUT = "cursors-2";
105
+ const LAYOUT_ERROR = "A2_LAYOUT";
106
+ const META_CHUNK_SIZE = 1e3;
107
+ /** A head row's dispatch columns, in the order scripts return them. */
108
+ const HEAD_COLUMNS = `{ 'd', 'fc', 'lc', 'ch', 'ce', 'a', 'lf', 'lfa', 'e', 'f' }`;
109
+ /**
110
+ * Upstash bills every call a script makes, so scripts read the counter,
111
+ * clear marker, layout, typed-type index, and id keys with one MGET
112
+ * (`read_bounds`), read rows with one HMGET per chunk (`read_rows`), and
113
+ * buffer hash writes (`put`, `drop`) into one HSET and one HDEL per hash
114
+ * (`flush`). Buffered writes are invisible to reads until flushed, so a
115
+ * script must not read a field it has buffered. Every script binds KEYS[1]
116
+ * to the session's counter and KEYS[2] to the prefix's layout key, and
117
+ * derives the session's other keys.
118
+ */
119
+ const BASE_LUA = `
120
+ local function sliced(command, key, args)
121
+ local results = {}
122
+ for first = 1, #args, 1000 do
123
+ local last = math.min(first + 999, #args)
124
+ local reply
125
+ if key then
126
+ reply = redis.call(command, key, unpack(args, first, last))
127
+ else
128
+ reply = redis.call(command, unpack(args, first, last))
129
+ end
130
+ if type(reply) == 'table' then
131
+ for _, value in ipairs(reply) do results[#results + 1] = value end
132
+ end
133
+ end
134
+ return results
135
+ end
136
+ local COUNTER, SCHEMA = KEYS[1], KEYS[2]
137
+ local SESSION = string.sub(COUNTER, 1, -6)
138
+ local LOG, CLEAR = SESSION .. 'log', SESSION .. 'clear'
139
+ local META, READY, CURSORS = SESSION .. 'heads', SESSION .. 'runnable', SESSION .. 'cursors'
140
+ local PIN_COUNTS, TYPED = SESSION .. 'pins', SESSION .. 'typed'
141
+ local CLEAR_TYPE = '${CLEAR_EVENT_TYPE}'
142
+ local total, boundary, schema_missing = 0, 0, false
143
+ -- The event types typed log cursors read: type → { from, last }. The type's
144
+ -- index holds every event of it above \`from\`, and no event of it lies above
145
+ -- \`last\`.
146
+ local typed, typed_dirty = {}, false
147
+ -- Reads the counter, the latest clear marker, the layout, the typed types,
148
+ -- and the extra keys in one MGET. Returns the extra keys' values, or nil when
149
+ -- the prefix holds another layout.
150
+ local function read_bounds(extra)
151
+ local keys = { COUNTER, CLEAR, SCHEMA, TYPED }
152
+ for _, key in ipairs(extra or {}) do keys[#keys + 1] = key end
153
+ local values = sliced('MGET', nil, keys)
154
+ if values[3] and values[3] ~= '${REDIS_LAYOUT}' then return nil end
155
+ total = tonumber(values[1] or '0')
156
+ boundary = tonumber(values[2] or '0')
157
+ schema_missing = not values[3]
158
+ if values[4] then typed = cjson.decode(values[4]) end
159
+ local rest = {}
160
+ for i = 5, #keys do rest[i - 4] = values[i] end
161
+ return rest
162
+ end
163
+ local function entry_index(id) return tonumber(string.match(id, '^(%d+)')) end
164
+ local function cid_of(consumer, tag)
165
+ return tostring(string.len(consumer)) .. ':' .. consumer .. tag
166
+ end
167
+ local function parse_cid(cid)
168
+ local colon = string.find(cid, ':', 1, true)
169
+ local n = tonumber(string.sub(cid, 1, colon - 1))
170
+ return string.sub(cid, colon + 1, colon + n), string.sub(cid, colon + n + 1)
171
+ end
172
+ -- A cursor's rows: its consumer, then '#' for an event cursor or 'L' .. lane
173
+ -- for a lane or log cursor, as requests name it.
174
+ local function row_of(consumer, tag)
175
+ local kind = string.sub(tag, 1, 1)
176
+ if kind == '#' then return cid_of(consumer, '#') end
177
+ if kind == 'G' then return cid_of(consumer, 'L' .. string.sub(tag, 2)) end
178
+ return cid_of(consumer, tag)
179
+ end
180
+ local function chunk_key(idx)
181
+ return META .. ':' .. tostring(math.floor((idx - 1) / ${META_CHUNK_SIZE}))
182
+ end
183
+ local function row_name(idx, row, suffix)
184
+ return tostring(idx) .. ':' .. row .. ':' .. suffix
185
+ end
186
+ -- Rows of (index, row) refs, aligned with refs; one HMGET per chunk.
187
+ local function read_rows(refs, suffixes)
188
+ local groups, order = {}, {}
189
+ for position, ref in ipairs(refs) do
190
+ local key = chunk_key(ref[1])
191
+ local group = groups[key]
192
+ if not group then
193
+ group = { positions = {}, names = {} }
194
+ groups[key] = group
195
+ order[#order + 1] = key
196
+ end
197
+ group.positions[#group.positions + 1] = position
198
+ for _, suffix in ipairs(suffixes) do
199
+ group.names[#group.names + 1] = row_name(ref[1], ref[2], suffix)
200
+ end
201
+ end
202
+ local rows = {}
203
+ for _, key in ipairs(order) do
204
+ local group = groups[key]
205
+ local values = sliced('HMGET', key, group.names)
206
+ for n, position in ipairs(group.positions) do
207
+ local row = {}
208
+ for column = 1, #suffixes do row[column] = values[(n - 1) * #suffixes + column] end
209
+ rows[position] = row
210
+ end
211
+ end
212
+ return rows
213
+ end
214
+ local writes, write_order, deletes, delete_order = {}, {}, {}, {}
215
+ local function put(key, field, value)
216
+ local fields = writes[key]
217
+ if not fields then
218
+ fields = {}
219
+ writes[key] = fields
220
+ write_order[#write_order + 1] = key
221
+ end
222
+ fields[#fields + 1] = field
223
+ fields[#fields + 1] = value
224
+ end
225
+ local function drop(key, field)
226
+ local fields = deletes[key]
227
+ if not fields then
228
+ fields = {}
229
+ deletes[key] = fields
230
+ delete_order[#delete_order + 1] = key
231
+ end
232
+ fields[#fields + 1] = field
233
+ end
234
+ local function flush()
235
+ for _, key in ipairs(write_order) do sliced('HSET', key, writes[key]) end
236
+ for _, key in ipairs(delete_order) do sliced('HDEL', key, deletes[key]) end
237
+ writes, write_order, deletes, delete_order = {}, {}, {}, {}
238
+ end
239
+ local function put_row(idx, row, suffix, value)
240
+ put(chunk_key(idx), row_name(idx, row, suffix), value)
241
+ end
242
+ local function drop_row(idx, row, suffix)
243
+ drop(chunk_key(idx), row_name(idx, row, suffix))
244
+ end
245
+ -- A request names a cursor by tag: '#' for an event cursor, 'L' .. lane for a
246
+ -- lane or log cursor. A claimed row records its kind: '#', or the cursor's tag.
247
+ local function owns(kind, tag)
248
+ if not kind then return false end
249
+ if tag == '#' then return kind == '#' end
250
+ return kind == tag or kind == 'G' .. string.sub(tag, 2)
251
+ end
252
+ local function cid_for(consumer, kind, idx)
253
+ if kind == '#' then return cid_of(consumer, '#' .. tostring(idx)) end
254
+ return cid_of(consumer, kind)
255
+ end
256
+ -- One type's index, named by the type's bytes in hex so no type can spell
257
+ -- another key of the session.
258
+ local function type_key(name)
259
+ return SESSION .. 'type:' .. (string.gsub(name, '.', function(c)
260
+ return string.format('%02x', string.byte(c))
261
+ end))
262
+ end
263
+ local ready_changes, ready_order = {}, {}
264
+ local function ready_set(cid, score)
265
+ if ready_changes[cid] == nil then ready_order[#ready_order + 1] = cid end
266
+ ready_changes[cid] = score
267
+ end
268
+ local function flush_ready()
269
+ local adds, removes = {}, {}
270
+ for _, cid in ipairs(ready_order) do
271
+ local score = ready_changes[cid]
272
+ if score then
273
+ adds[#adds + 1] = score
274
+ adds[#adds + 1] = cid
275
+ else
276
+ removes[#removes + 1] = cid
277
+ end
278
+ end
279
+ sliced('ZADD', READY, adds)
280
+ sliced('ZREM', READY, removes)
281
+ ready_changes, ready_order = {}, {}
282
+ end
283
+ local function release_pins(idx, row, pins)
284
+ if not pins or pins == '' then return end
285
+ for _, ref in ipairs(cjson.decode(pins)) do
286
+ local count = redis.call('HINCRBY', PIN_COUNTS, ref, -1)
287
+ if count <= 0 then
288
+ redis.call('HDEL', PIN_COUNTS, ref)
289
+ local checkpoint = cjson.decode(ref)
290
+ redis.call('ZREMRANGEBYSCORE', checkpoint[1], checkpoint[2], checkpoint[2])
291
+ end
292
+ end
293
+ put_row(idx, row, 'pins', '')
294
+ end
295
+ `;
296
+ /**
297
+ * Cursor movement for scripts that add events or move cursors. Lane and log
298
+ * cursor records are read once per script (`load_states`) and written back
299
+ * once (`flush_states`). A record whose head lies at or below the latest clear
300
+ * marker is idle, and a log cursor counts from the marker.
301
+ *
302
+ * A typed log cursor finds its next head in its types' indexes, one bounded
303
+ * lookup per type, skipped when `typed` shows no newer event of it. When an
304
+ * index does not reach back far enough yet (a typed backfill, or a type a
305
+ * cursor newly reads), the lookup returns the type, and the script returns
306
+ * `{ 'index', type, after }` before writing anything: the caller indexes
307
+ * that type down to `after` (INDEX_TYPE_LUA, one page per call) and runs the
308
+ * operation again.
309
+ */
310
+ const CURSOR_LUA = `
311
+ local served_all, served_set, served_logs = true, {}, {}
312
+ local pending_fast = false
313
+ local function types_of(raw)
314
+ if not raw or raw == '*' then return nil end
315
+ local set = {}
316
+ for _, name in ipairs(cjson.decode(raw)) do set[name] = true end
317
+ return set
318
+ end
319
+ local function takes(types, kind)
320
+ return kind ~= CLEAR_TYPE and (types == nil or types[kind] == true)
321
+ end
322
+ -- The served consumers: '' for every consumer, else [[name] or
323
+ -- [name, lane, typesJson or '*', backfill]].
324
+ local function use_served(raw)
325
+ if raw == '' then return end
326
+ served_all = false
327
+ for _, entry in ipairs(cjson.decode(raw)) do
328
+ served_set[entry[1]] = true
329
+ if entry[2] then
330
+ served_logs[#served_logs + 1] = {
331
+ name = entry[1], lane = entry[2], ty = entry[3],
332
+ types = types_of(entry[3]), backfill = entry[4] == 1,
333
+ }
334
+ end
335
+ end
336
+ end
337
+ local function served_has(name) return served_all or served_set[name] == true end
338
+ -- Whether a served consumer has a live head. Newest heads come first: a
339
+ -- removed consumer's backlog is older than the work a deployment serves.
340
+ local function has_pending()
341
+ if pending_fast then return '1' end
342
+ if served_all then return redis.call('ZCOUNT', READY, 1, '+inf') > 0 and '1' or '0' end
343
+ if next(served_set) == nil then return '0' end
344
+ local page = redis.call('ZREVRANGEBYSCORE', READY, '+inf', 1, 'LIMIT', 0, 32)
345
+ for _, cid in ipairs(page) do
346
+ if served_set[(parse_cid(cid))] then return '1' end
347
+ end
348
+ if #page < 32 then return '0' end
349
+ for _, cid in ipairs(redis.call('ZREVRANGEBYSCORE', READY, '+inf', 1, 'LIMIT', 32, -1)) do
350
+ if served_set[(parse_cid(cid))] then return '1' end
351
+ end
352
+ return '0'
353
+ end
354
+ local function targets_of(raw)
355
+ if raw == '' then return {} end
356
+ return cjson.decode(raw)
357
+ end
358
+ -- Starts indexing a type: every later event of it.
359
+ local function register(name, from)
360
+ typed[name] = { from, from }
361
+ typed_dirty = true
362
+ end
363
+ -- The first stored event in (after, total] of a type in \`types\` (nil: any
364
+ -- type), or false. Nil and a type when that type's index can't answer yet.
365
+ local function stored_head(after, types)
366
+ if types == nil then
367
+ if after + 1 <= total then return after + 1 end
368
+ return false
369
+ end
370
+ local best = false
371
+ for name in pairs(types) do
372
+ local entry = typed[name]
373
+ if entry == nil then
374
+ if after < total then return nil, name end
375
+ register(name, total)
376
+ elseif entry[2] > after then
377
+ if entry[1] > after then return nil, name end
378
+ local found = redis.call('ZRANGEBYSCORE', type_key(name),
379
+ '(' .. tostring(after), tostring(total), 'LIMIT', 0, 1)
380
+ if #found > 0 then
381
+ local idx = tonumber(found[1])
382
+ if not best or idx < best then best = idx end
383
+ end
384
+ end
385
+ end
386
+ return best
387
+ end
388
+ -- The first event after \`after\` a log cursor takes: stored, then in the
389
+ -- batch being appended.
390
+ local function next_log_head(after, types, batch)
391
+ local head, missing = stored_head(after, types)
392
+ if head == nil then return nil, missing end
393
+ if head then return head end
394
+ for _, event in ipairs(batch) do
395
+ if event.idx > after and takes(types, event.type) then return event.idx end
396
+ end
397
+ return false
398
+ end
399
+ -- Indexes a batch's events of the types typed log cursors read.
400
+ local function index_batch(batch)
401
+ local adds, order = {}, {}
402
+ for _, event in ipairs(batch) do
403
+ local entry = typed[event.type]
404
+ if entry then
405
+ local list = adds[event.type]
406
+ if not list then
407
+ list = {}
408
+ adds[event.type] = list
409
+ order[#order + 1] = event.type
410
+ end
411
+ list[#list + 1] = event.idx
412
+ list[#list + 1] = tostring(event.idx)
413
+ entry[2] = event.idx
414
+ typed_dirty = true
415
+ end
416
+ end
417
+ for _, name in ipairs(order) do sliced('ZADD', type_key(name), adds[name]) end
418
+ end
419
+ local states, state_order = {}, {}
420
+ local function load_states(cids)
421
+ local need, names = {}, {}
422
+ for _, cid in ipairs(cids) do
423
+ local state = states[cid]
424
+ if (state == nil or state.partial) and not need[cid] then
425
+ need[cid] = true
426
+ need[#need + 1] = cid
427
+ names[#names + 1] = cid .. ':h'
428
+ names[#names + 1] = cid .. ':p'
429
+ names[#names + 1] = cid .. ':t'
430
+ names[#names + 1] = cid .. ':ty'
431
+ end
432
+ end
433
+ if #need == 0 then return end
434
+ local values = sliced('HMGET', CURSORS, names)
435
+ for n, cid in ipairs(need) do
436
+ local h, p, t, ty = values[n * 4 - 3], values[n * 4 - 2], values[n * 4 - 1], values[n * 4]
437
+ local tail = (t and t ~= '') and tonumber(t) or false
438
+ local state = states[cid]
439
+ if state then
440
+ state.t = tail
441
+ state.partial = nil
442
+ else
443
+ local _, tag = parse_cid(cid)
444
+ state = {
445
+ kind = string.sub(tag, 1, 1), exists = p ~= false,
446
+ h = (h and h ~= '') and tonumber(h) or false,
447
+ p = tonumber(p or '0'), t = tail, ty = ty or false,
448
+ }
449
+ if state.h and state.h <= boundary then state.h = false end
450
+ if state.kind == 'G' and state.exists and state.p < boundary then state.p = boundary end
451
+ states[cid] = state
452
+ state_order[#state_order + 1] = cid
453
+ end
454
+ end
455
+ end
456
+ local function flush_states()
457
+ for _, cid in ipairs(state_order) do
458
+ local state = states[cid]
459
+ if state.dirty then
460
+ put(CURSORS, cid .. ':h', state.h and tostring(state.h) or '')
461
+ put(CURSORS, cid .. ':p', tostring(state.p))
462
+ if state.kind == 'L' then
463
+ if not state.partial then put(CURSORS, cid .. ':t', state.t and tostring(state.t) or '') end
464
+ elseif state.ty then
465
+ put(CURSORS, cid .. ':ty', state.ty)
466
+ end
467
+ end
468
+ end
469
+ end
470
+ -- Moves a cursor past its head \`idx\`. \`kind\` is the head row's kind;
471
+ -- \`batch\` is the returned events, appended after the stored log. Writes
472
+ -- nothing; returns nil and a type when that type's index must reach back
473
+ -- to \`idx\` first.
474
+ local function advance(consumer, kind, idx, row_next, batch)
475
+ if kind == '#' then
476
+ ready_set(cid_of(consumer, '#' .. tostring(idx)), false)
477
+ return true
478
+ end
479
+ local cid = cid_of(consumer, kind)
480
+ local state = states[cid]
481
+ local next_head
482
+ if string.sub(kind, 1, 1) == 'L' then
483
+ next_head = row_next and tonumber(row_next) or false
484
+ if not state then
485
+ state = { kind = 'L', exists = true, p = idx, h = false, t = false, ty = false, partial = true }
486
+ states[cid] = state
487
+ state_order[#state_order + 1] = cid
488
+ end
489
+ if not next_head then
490
+ state.t = false
491
+ state.partial = nil
492
+ end
493
+ else
494
+ local lane, raw = string.sub(kind, 2), nil
495
+ for _, spec in ipairs(served_logs) do
496
+ if spec.name == consumer and spec.lane == lane then raw = spec.ty end
497
+ end
498
+ if not raw then
499
+ load_states({ cid })
500
+ raw = states[cid].ty or '*'
501
+ end
502
+ local head, missing = next_log_head(idx, types_of(raw), batch)
503
+ if head == nil then return nil, missing end
504
+ next_head = head
505
+ state = states[cid]
506
+ if not state then
507
+ state = { kind = 'G', exists = true, p = idx, h = false, t = false, ty = raw }
508
+ states[cid] = state
509
+ state_order[#state_order + 1] = cid
510
+ end
511
+ state.ty = raw
512
+ end
513
+ state.h = next_head
514
+ state.p = idx
515
+ state.exists = true
516
+ state.dirty = true
517
+ ready_set(cid, next_head)
518
+ return true
519
+ end
520
+ -- Reads what an appended batch's cursor work needs: its lane targets'
521
+ -- records, and the head of each served log consumer whose types it carries.
522
+ -- Writes nothing; returns nil, a type, and an index when that type's index
523
+ -- must reach back to the index first.
524
+ local log_plan = {}
525
+ local function plan(batch, base)
526
+ local need = {}
527
+ for _, event in ipairs(batch) do
528
+ for _, target in ipairs(event.targets) do
529
+ if target[2] ~= '#' then need[#need + 1] = cid_of(target[1], target[2]) end
530
+ end
531
+ end
532
+ for _, spec in ipairs(served_logs) do
533
+ for _, event in ipairs(batch) do
534
+ if takes(spec.types, event.type) then
535
+ local cid = cid_of(spec.name, 'G' .. spec.lane)
536
+ log_plan[#log_plan + 1] = { spec = spec, first = event.idx, cid = cid }
537
+ need[#need + 1] = cid
538
+ break
539
+ end
540
+ end
541
+ end
542
+ load_states(need)
543
+ for _, log in ipairs(log_plan) do
544
+ local state, spec = states[log.cid], log.spec
545
+ if not state.exists then
546
+ if spec.backfill then
547
+ local head, missing = stored_head(boundary, spec.types)
548
+ if head == nil then return nil, missing, boundary end
549
+ log.p, log.h = boundary, head or log.first
550
+ else
551
+ -- Nothing it takes is stored after its start: index its types from there.
552
+ for name in pairs(spec.types or {}) do
553
+ if typed[name] == nil then register(name, base) end
554
+ end
555
+ log.p, log.h = base, log.first
556
+ end
557
+ elseif not state.h then
558
+ -- Not the batch's first: a deployment that doesn't serve this consumer
559
+ -- may have appended events it takes since it went idle.
560
+ local head, missing = stored_head(state.p, spec.types)
561
+ if head == nil then return nil, missing, state.p end
562
+ log.h = head or log.first
563
+ end
564
+ end
565
+ return true
566
+ end
567
+ -- Starts the cursor work \`plan\` read: each event's targets, and each
568
+ -- planned log cursor.
569
+ local function enqueue(batch)
570
+ for _, event in ipairs(batch) do
571
+ for _, target in ipairs(event.targets) do
572
+ local consumer, tag = target[1], target[2]
573
+ if served_has(consumer) then pending_fast = true end
574
+ if tag == '#' then
575
+ ready_set(cid_of(consumer, '#' .. tostring(event.idx)), event.idx)
576
+ else
577
+ local cid = cid_of(consumer, tag)
578
+ local state = states[cid]
579
+ if state.h and state.t then
580
+ put_row(state.t, row_of(consumer, tag), 'next', tostring(event.idx))
581
+ else
582
+ state.h = event.idx
583
+ if state.p < boundary then state.p = boundary end
584
+ ready_set(cid, event.idx)
585
+ end
586
+ state.t = event.idx
587
+ state.exists = true
588
+ state.dirty = true
589
+ end
590
+ end
591
+ end
592
+ for _, log in ipairs(log_plan) do
593
+ pending_fast = true
594
+ local state = states[log.cid]
595
+ if not state.exists then
596
+ state.exists, state.p, state.h = true, log.p, log.h
597
+ ready_set(log.cid, log.h)
598
+ elseif not state.h then
599
+ state.h = log.h
600
+ ready_set(log.cid, log.h)
601
+ end
602
+ state.ty = log.spec.ty
603
+ state.dirty = true
604
+ end
605
+ end
606
+ -- Event-id owners for the extra keys read_bounds returned. A record this
607
+ -- session made below its latest clear is free.
608
+ local function owners_of(values, session)
609
+ local owners = {}
610
+ for i = 1, #values do
611
+ local owner = values[i]
612
+ if owner then
613
+ local rec = cjson.decode(owner)
614
+ if rec[1] == session and tonumber(rec[2]) < boundary then owner = false end
615
+ end
616
+ owners[i] = owner
617
+ end
618
+ return owners
619
+ end
620
+ `;
621
+ const WAKE_PAYLOAD_LIMIT = 32768;
622
+ /**
623
+ * Wake-up messages carry the events they announce, so a subscribed reader
624
+ * that is caught up applies them without reading the stream:
625
+ * `{"f":frontier,"t":createdAt,"e":[[index,id,type,payload],...]}`. Payloads
626
+ * are the stored JSON text, inlined verbatim. A batch over
627
+ * WAKE_PAYLOAD_LIMIT bytes publishes `{"f":frontier}` and readers catch up
628
+ * with XRANGE.
629
+ */
630
+ const WAKE_LUA = `
631
+ local wake_entries, wake_bytes = {}, 0
632
+ local function wake_add(field, id, typ, payload)
633
+ if wake_bytes + #payload > ${WAKE_PAYLOAD_LIMIT} then
634
+ wake_bytes = ${WAKE_PAYLOAD_LIMIT} + 1
635
+ return
636
+ end
637
+ local entry = '[' .. field .. ',' .. cjson.encode(id) .. ',' ..
638
+ cjson.encode(typ) .. ',' .. payload .. ']'
639
+ wake_bytes = wake_bytes + #entry + 1
640
+ if wake_bytes <= ${WAKE_PAYLOAD_LIMIT} then
641
+ wake_entries[#wake_entries + 1] = entry
642
+ end
643
+ end
644
+ local function wake(channel, frontier, created_at)
645
+ local message = '{"f":' .. frontier .. '}'
646
+ if wake_bytes <= ${WAKE_PAYLOAD_LIMIT} then
647
+ message = '{"f":' .. frontier .. ',"t":' .. created_at .. ',"e":[' ..
648
+ table.concat(wake_entries, ',') .. ']}'
649
+ end
650
+ redis.pcall('PUBLISH', channel, message)
651
+ end
652
+ `;
653
+ /**
654
+ * KEYS: counter, layout, then one id ownership key per event.
655
+ * ARGV: sessionId, nowMs, n, served consumers, notify channel, origin index
656
+ * or '', then per event: id, type, payloadJson, causeJson or '', targets.
657
+ * Returns ['empty', hasPending] | ['ok', firstIndex, hasPending] |
658
+ * ['dup', hasPending, ...indexes] | ['partial', count] |
659
+ * ['foreign', eventId] | ['superseded', position] | ['cleared', originIndex] |
660
+ * ['index', type, after].
661
+ */
662
+ const APPEND_LUA = `${BASE_LUA}${CURSOR_LUA}${WAKE_LUA}
663
+ local session_id, now, n = ARGV[1], ARGV[2], tonumber(ARGV[3])
664
+ use_served(ARGV[4])
665
+ if n == 0 then return { 'empty', has_pending() } end
666
+ local id_keys = {}
667
+ for i = 1, n do id_keys[i] = KEYS[2 + i] end
668
+ local values = read_bounds(id_keys)
669
+ if not values then return redis.error_reply('${LAYOUT_ERROR}') end
670
+ local owners = owners_of(values, session_id)
671
+ local base = total
672
+ local dups = {}
673
+ for i = 1, n do
674
+ if owners[i] then
675
+ local rec = cjson.decode(owners[i])
676
+ if rec[1] ~= session_id then return { 'foreign', ARGV[6 + (i - 1) * 5 + 1] } end
677
+ dups[#dups + 1] = tostring(rec[2])
678
+ end
679
+ end
680
+ if #dups == n then
681
+ local res = { 'dup', has_pending() }
682
+ for i = 1, #dups do res[#res + 1] = dups[i] end
683
+ return res
684
+ end
685
+ if #dups > 0 then return { 'partial', tostring(#dups) } end
686
+ local origin = ARGV[6]
687
+ if origin ~= '' and tonumber(origin) <= boundary then return { 'cleared', origin } end
688
+ -- The attempt-currency fence: a cause's head row must still be its cursor's
689
+ -- head, at that attempt, not dead-lettered.
690
+ local refs, fenced = {}, {}
691
+ for i = 1, n do
692
+ local raw = ARGV[6 + (i - 1) * 5 + 4]
693
+ if raw ~= '' then
694
+ local cause = cjson.decode(raw)
695
+ local index = tonumber(cause.index)
696
+ if index <= boundary then return { 'superseded', tostring(i) } end
697
+ local tag = type(cause.lane) == 'string' and ('L' .. cause.lane) or '#'
698
+ refs[#refs + 1] = { index, row_of(cause.consumer, tag) }
699
+ fenced[#fenced + 1] = {
700
+ position = i, consumer = cause.consumer, attempt = tonumber(cause.attempt), tag = tag,
701
+ }
702
+ end
703
+ end
704
+ local rows = read_rows(refs, { 'd', 'f', 'p', 'l' })
705
+ for k, cause in ipairs(fenced) do
706
+ local row = rows[k]
707
+ if tonumber(row[1] or '0') ~= cause.attempt or row[2] or row[3] or not owns(row[4], cause.tag) then
708
+ return { 'superseded', tostring(cause.position) }
709
+ end
710
+ if served_has(cause.consumer) then pending_fast = true end
711
+ end
712
+ local batch = {}
713
+ for i = 1, n do
714
+ local o = 6 + (i - 1) * 5
715
+ batch[i] = { idx = base + i, type = ARGV[o + 2], targets = targets_of(ARGV[o + 5]) }
716
+ end
717
+ local planned, missing, after = plan(batch, base)
718
+ if not planned then return { 'index', missing, tostring(after) } end
719
+ local owner_writes = {}
720
+ for i = 1, n do
721
+ local o = 6 + (i - 1) * 5
722
+ local field = tostring(base + i)
723
+ redis.call('XADD', LOG, field .. '-0',
724
+ 'id', ARGV[o + 1], 'type', ARGV[o + 2], 'payload', ARGV[o + 3],
725
+ 'created_at', now, 'cause', ARGV[o + 4])
726
+ owner_writes[#owner_writes + 1] = KEYS[2 + i]
727
+ owner_writes[#owner_writes + 1] = cjson.encode({ session_id, base + i })
728
+ wake_add(field, ARGV[o + 1], ARGV[o + 2], ARGV[o + 3])
729
+ end
730
+ index_batch(batch)
731
+ enqueue(batch)
732
+ owner_writes[#owner_writes + 1] = COUNTER
733
+ owner_writes[#owner_writes + 1] = tostring(base + n)
734
+ if schema_missing then
735
+ owner_writes[#owner_writes + 1] = SCHEMA
736
+ owner_writes[#owner_writes + 1] = '${REDIS_LAYOUT}'
737
+ end
738
+ if typed_dirty then
739
+ owner_writes[#owner_writes + 1] = TYPED
740
+ owner_writes[#owner_writes + 1] = cjson.encode(typed)
741
+ end
742
+ sliced('MSET', nil, owner_writes)
743
+ flush_states()
744
+ flush()
745
+ flush_ready()
746
+ local pending = has_pending()
747
+ wake(ARGV[5], tostring(base + n), now)
748
+ return { 'ok', tostring(base + 1), pending }
749
+ `;
750
+ /**
751
+ * KEYS: counter, layout.
752
+ * ARGV: type, after. Indexes one page of the stored log below the type's
753
+ * index, toward `after`, registering the type first if no cursor read it
754
+ * yet. Returns ['1'] once the index reaches `after`, else ['0'].
755
+ */
756
+ const INDEX_TYPE_LUA = `${BASE_LUA}
757
+ if not read_bounds() then return redis.error_reply('${LAYOUT_ERROR}') end
758
+ local name, after = ARGV[1], tonumber(ARGV[2])
759
+ local entry = typed[name]
760
+ if not entry then
761
+ entry = { total, total }
762
+ typed[name] = entry
763
+ end
764
+ if entry[1] > after then
765
+ local entries = redis.call('XREVRANGE', LOG, tostring(entry[1]) .. '-0',
766
+ tostring(after + 1) .. '-0', 'COUNT', 256)
767
+ local adds = {}
768
+ for _, stored in ipairs(entries) do
769
+ local fields = stored[2]
770
+ for j = 1, #fields, 2 do
771
+ if fields[j] == 'type' then
772
+ if fields[j + 1] == name then
773
+ local idx = entry_index(stored[1])
774
+ adds[#adds + 1] = idx
775
+ adds[#adds + 1] = tostring(idx)
776
+ end
777
+ break
778
+ end
779
+ end
780
+ end
781
+ sliced('ZADD', type_key(name), adds)
782
+ if #entries < 256 then
783
+ entry[1] = after
784
+ else
785
+ entry[1] = entry_index(entries[#entries][1]) - 1
786
+ end
787
+ end
788
+ redis.call('SET', TYPED, cjson.encode(typed))
789
+ return { entry[1] <= after and '1' or '0' }
790
+ `;
791
+ /**
792
+ * KEYS: counter, layout.
793
+ * ARGV: holder, nowMs, expiresAtMs, maxFailures or '', served consumers,
794
+ * the highest head to claim or '', excluded count, then per excluded head:
795
+ * consumer, tag.
796
+ * Live heads are the ready set's positive scores, so an empty one is enough
797
+ * to report 'settled'. A claimed result ends with the clear marker.
798
+ */
799
+ const CLAIM_AVAILABLE_LUA = `${BASE_LUA}${CURSOR_LUA}
800
+ local ready = redis.call('ZRANGEBYSCORE', READY, 1, ARGV[6] == '' and '+inf' or ARGV[6], 'WITHSCORES')
801
+ if #ready == 0 then return { 'settled' } end
802
+ if not read_bounds() then return redis.error_reply('${LAYOUT_ERROR}') end
803
+ local holder, now, expires_at = ARGV[1], tonumber(ARGV[2]), ARGV[3]
804
+ local max_failures = tonumber(ARGV[4])
805
+ use_served(ARGV[5])
806
+ local excluded = {}
807
+ for i = 1, tonumber(ARGV[7]) do
808
+ excluded[ARGV[7 + (i - 1) * 2 + 1] .. '\\0' .. ARGV[7 + (i - 1) * 2 + 2]] = true
809
+ end
810
+ local candidates = {}
811
+ for i = 1, #ready, 2 do
812
+ local cid, idx = ready[i], tonumber(ready[i + 1])
813
+ local consumer, tag = parse_cid(cid)
814
+ local kind = string.sub(tag, 1, 1)
815
+ local key = consumer .. '\\0' .. (kind == 'G' and ('L' .. string.sub(tag, 2)) or tag)
816
+ if served_has(consumer) and not excluded[key] then
817
+ candidates[#candidates + 1] = {
818
+ idx, row_of(consumer, tag), consumer = consumer, cid = cid,
819
+ kind = kind == '#' and '#' or tag,
820
+ }
821
+ end
822
+ end
823
+ local rows = read_rows(candidates, { 'd', 'fc', 'lc', 'ch', 'ce', 'a', 'lf', 'lfa', 'e', 'f', 'pins' })
824
+ local takes_over, retry_at, wanted = {}, nil, {}
825
+ for position, candidate in ipairs(candidates) do
826
+ local row = rows[position]
827
+ local expires = tonumber(row[5] or '0')
828
+ if expires > now then
829
+ if not retry_at or expires < retry_at then retry_at = expires end
830
+ else
831
+ takes_over[#takes_over + 1] = { candidate = candidate, row = row }
832
+ wanted[candidate[1]] = true
833
+ end
834
+ end
835
+ if #takes_over == 0 then
836
+ if retry_at then return { 'busy', tostring(retry_at) } end
837
+ return { 'settled' }
838
+ end
839
+ -- One XRANGE per run of consecutive claimed indexes.
840
+ local indexes, entries = {}, {}
841
+ for idx in pairs(wanted) do indexes[#indexes + 1] = idx end
842
+ table.sort(indexes)
843
+ local first = 1
844
+ while first <= #indexes do
845
+ local last = first
846
+ while last < #indexes and indexes[last + 1] == indexes[last] + 1 do last = last + 1 end
847
+ local range = redis.call('XRANGE', LOG, tostring(indexes[first]) .. '-0', tostring(indexes[last]) .. '-0')
848
+ for _, entry in ipairs(range) do entries[entry_index(entry[1])] = entry end
849
+ first = last + 1
850
+ end
851
+ for _, idx in ipairs(indexes) do
852
+ if not entries[idx] then return { 'missing', tostring(idx) } end
853
+ end
854
+ local result = { 'claimed', tostring(#takes_over) }
855
+ for _, take in ipairs(takes_over) do
856
+ local candidate, row = take.candidate, take.row
857
+ local idx, at = candidate[1], candidate[2]
858
+ local attempt = tostring(tonumber(row[1] or '0') + 1)
859
+ -- A claim still held here lapsed: its attempt never finished.
860
+ local lapsed = row[4] ~= false
861
+ local failures = tonumber(row[6] or '0')
862
+ if lapsed then
863
+ failures = failures + 1
864
+ put_row(idx, at, 'a', tostring(failures))
865
+ end
866
+ put_row(idx, at, 'd', attempt)
867
+ local columns
868
+ if lapsed and max_failures and failures >= max_failures then
869
+ -- That takeover spent the last failed attempt: dead-letter, unclaimed.
870
+ -- Advancing the attempt fences the lapsed one; nothing runs.
871
+ put_row(idx, at, 'f', ARGV[2])
872
+ drop_row(idx, at, 'ch')
873
+ drop_row(idx, at, 'ce')
874
+ ready_set(candidate.cid, -idx)
875
+ release_pins(idx, at, row[11])
876
+ columns = { attempt, row[2], row[3], false, false, tostring(failures), row[7], row[8], row[9], ARGV[2] }
877
+ else
878
+ local first_claimed = row[2] or ARGV[2]
879
+ put_row(idx, at, 'fc', first_claimed)
880
+ put_row(idx, at, 'lc', ARGV[2])
881
+ put_row(idx, at, 'ch', holder)
882
+ put_row(idx, at, 'ce', expires_at)
883
+ put_row(idx, at, 'l', candidate.kind)
884
+ columns = { attempt, first_claimed, ARGV[2], holder, expires_at, tostring(failures), row[7], row[8], row[9], false }
885
+ end
886
+ result[#result + 1] = { candidate.consumer, candidate.kind, tostring(idx), columns, entries[idx] }
887
+ end
888
+ flush()
889
+ flush_ready()
890
+ result[#result + 1] = tostring(boundary)
891
+ result[#result + 1] = tostring(total)
892
+ return result
893
+ `;
894
+ /**
895
+ * KEYS: counter, layout.
896
+ * ARGV: holder, nowMs, expiresAtMs, n, then per claim: consumer, tag, index,
897
+ * attempt. Returns [renewedCount, ...renewed, ...superseded] as 1-based
898
+ * positions in the request.
899
+ */
900
+ const RENEW_CLAIMS_LUA = `${BASE_LUA}
901
+ if not read_bounds() then return redis.error_reply('${LAYOUT_ERROR}') end
902
+ local holder, now, expires_at = ARGV[1], tonumber(ARGV[2]), ARGV[3]
903
+ local refs, items, superseded = {}, {}, {}
904
+ for i = 1, tonumber(ARGV[4]) do
905
+ local o = 4 + (i - 1) * 4
906
+ local idx = tonumber(ARGV[o + 3])
907
+ if idx <= boundary then
908
+ superseded[#superseded + 1] = tostring(i)
909
+ else
910
+ local row = row_of(ARGV[o + 1], ARGV[o + 2])
911
+ refs[#refs + 1] = { idx, row }
912
+ items[#items + 1] = { position = i, idx = idx, row = row, tag = ARGV[o + 2], attempt = tonumber(ARGV[o + 4]) }
913
+ end
914
+ end
915
+ local rows = read_rows(refs, { 'd', 'ce', 'p', 'f', 'ch', 'l' })
916
+ local renewed = {}
917
+ for k, item in ipairs(items) do
918
+ local row = rows[k]
919
+ local attempt = tonumber(row[1] or '0')
920
+ if row[6] and not owns(row[6], item.tag) then
921
+ -- Another cursor's head: this one never claimed it.
922
+ elseif row[3] or attempt > item.attempt then
923
+ superseded[#superseded + 1] = tostring(item.position)
924
+ elseif attempt == item.attempt and tonumber(expires_at) > now and
925
+ not row[4] and row[5] == holder and tonumber(row[2] or '0') > now then
926
+ put_row(item.idx, item.row, 'ce', expires_at)
927
+ renewed[#renewed + 1] = tostring(item.position)
928
+ end
929
+ end
930
+ flush()
931
+ local result = { tostring(#renewed) }
932
+ for i = 1, #renewed do result[#result + 1] = renewed[i] end
933
+ for i = 1, #superseded do result[#result + 1] = superseded[i] end
934
+ return result
935
+ `;
936
+ /**
937
+ * KEYS: counter, layout, then one id ownership key per child.
938
+ * ARGV: sessionId, nowMs, consumer, tag, index, attempt, child causeJson,
939
+ * served consumers, notify channel, returned idsJson, n, then per child: id,
940
+ * type, payloadJson, targets.
941
+ */
942
+ const COMPLETE_ATTEMPT_LUA = `${BASE_LUA}${CURSOR_LUA}${WAKE_LUA}
943
+ local session_id, now = ARGV[1], ARGV[2]
944
+ local consumer, tag, idx, attempt = ARGV[3], ARGV[4], tonumber(ARGV[5]), tonumber(ARGV[6])
945
+ local at = row_of(consumer, tag)
946
+ local n = tonumber(ARGV[11])
947
+ local id_keys = {}
948
+ for i = 1, n do id_keys[i] = KEYS[2 + i] end
949
+ local values = read_bounds(id_keys)
950
+ if not values then return redis.error_reply('${LAYOUT_ERROR}') end
951
+ local owners = owners_of(values, session_id)
952
+ if idx <= boundary then return { 'superseded' } end
953
+ if idx < 1 or idx > total then return { 'missing' } end
954
+ local row = read_rows({ { idx, at } }, { 'd', 'p', 'pa', 'ri', 'ch', 'f', 'l', 'next', 'pins' })[1]
955
+ if row[2] then
956
+ -- The cursor moved past this head: a retry of its completion replays.
957
+ if not owns(row[7], tag) then return { 'superseded' } end
958
+ if not row[3] or tonumber(row[3]) ~= attempt then return { 'superseded' } end
959
+ local recorded = cjson.decode(row[4] or '[]')
960
+ if #recorded ~= n then return { 'partial' } end
961
+ for i = 1, n do
962
+ if recorded[i] ~= ARGV[11 + (i - 1) * 4 + 1] then return { 'partial' } end
963
+ end
964
+ local result = { 'duplicate' }
965
+ for i = 1, n do
966
+ if not owners[i] then return { 'partial' } end
967
+ local rec = cjson.decode(owners[i])
968
+ if rec[1] ~= session_id then return { 'partial' } end
969
+ result[#result + 1] = tostring(rec[2])
970
+ end
971
+ return result
972
+ end
973
+ if tonumber(row[1] or '0') ~= attempt or not row[5] or row[6] or not owns(row[7], tag) then
974
+ return { 'superseded' }
975
+ end
976
+ for i = 1, n do
977
+ if owners[i] then return { 'partial' } end
978
+ end
979
+ use_served(ARGV[8])
980
+ local batch = {}
981
+ for i = 1, n do
982
+ local o = 11 + (i - 1) * 4
983
+ batch[i] = { idx = total + i, type = ARGV[o + 2], targets = targets_of(ARGV[o + 4]) }
984
+ end
985
+ local moved, missing = advance(consumer, row[7], idx, row[8], batch)
986
+ if not moved then return { 'index', missing, tostring(idx) } end
987
+ local planned, unindexed, after = plan(batch, total)
988
+ if not planned then return { 'index', unindexed, tostring(after) } end
989
+ put_row(idx, at, 'p', now)
990
+ put_row(idx, at, 'pa', ARGV[6])
991
+ put_row(idx, at, 'ri', ARGV[10])
992
+ release_pins(idx, at, row[9])
993
+ local owner_writes = {}
994
+ for i = 1, n do
995
+ local o = 11 + (i - 1) * 4
996
+ local field = tostring(total + i)
997
+ redis.call('XADD', LOG, field .. '-0',
998
+ 'id', ARGV[o + 1], 'type', ARGV[o + 2], 'payload', ARGV[o + 3],
999
+ 'created_at', now, 'cause', ARGV[7])
1000
+ owner_writes[#owner_writes + 1] = KEYS[2 + i]
1001
+ owner_writes[#owner_writes + 1] = cjson.encode({ session_id, total + i })
1002
+ wake_add(field, ARGV[o + 1], ARGV[o + 2], ARGV[o + 3])
1003
+ end
1004
+ index_batch(batch)
1005
+ enqueue(batch)
1006
+ if n > 0 then
1007
+ owner_writes[#owner_writes + 1] = COUNTER
1008
+ owner_writes[#owner_writes + 1] = tostring(total + n)
1009
+ if schema_missing then
1010
+ owner_writes[#owner_writes + 1] = SCHEMA
1011
+ owner_writes[#owner_writes + 1] = '${REDIS_LAYOUT}'
1012
+ end
1013
+ end
1014
+ if typed_dirty then
1015
+ owner_writes[#owner_writes + 1] = TYPED
1016
+ owner_writes[#owner_writes + 1] = cjson.encode(typed)
1017
+ end
1018
+ sliced('MSET', nil, owner_writes)
1019
+ flush_states()
1020
+ flush()
1021
+ flush_ready()
1022
+ if n > 0 then wake(ARGV[9], tostring(total + n), now) end
1023
+ return { 'ok', tostring(total + 1) }
1024
+ `;
1025
+ /**
1026
+ * KEYS: counter, layout.
1027
+ * ARGV: consumer, tag, index, attempt, error, maxFailures, nowMs, retryAtMs
1028
+ * or ''.
1029
+ */
1030
+ const FAIL_ATTEMPT_LUA = `${BASE_LUA}
1031
+ if not read_bounds() then return redis.error_reply('${LAYOUT_ERROR}') end
1032
+ local consumer, tag, idx, attempt = ARGV[1], ARGV[2], tonumber(ARGV[3]), tonumber(ARGV[4])
1033
+ local at = row_of(consumer, tag)
1034
+ if idx <= boundary then return { 'superseded', '0' } end
1035
+ if idx < 1 or idx > total then return { 'missing', '0' } end
1036
+ local row = read_rows({ { idx, at } }, { 'a', 'd', 'p', 'f', 'lfa', 'ch', 'l', 'pins' })[1]
1037
+ if row[3] or (row[7] and not owns(row[7], tag)) then return { 'superseded', '0' } end
1038
+ local failures = tonumber(row[1] or '0')
1039
+ if tonumber(row[2] or '0') ~= attempt then return { 'superseded', tostring(failures) } end
1040
+ if not row[6] and row[5] and tonumber(row[5]) == attempt then
1041
+ return { row[4] and 'dead_lettered' or 'failed', tostring(failures) }
1042
+ end
1043
+ if row[4] then return { 'dead_lettered', tostring(failures) } end
1044
+ if not row[6] then return { 'superseded', tostring(failures) } end
1045
+ failures = failures + 1
1046
+ local dead = failures >= tonumber(ARGV[6])
1047
+ put_row(idx, at, 'a', tostring(failures))
1048
+ put_row(idx, at, 'e', ARGV[5])
1049
+ put_row(idx, at, 'lf', ARGV[7])
1050
+ put_row(idx, at, 'lfa', ARGV[4])
1051
+ drop_row(idx, at, 'ch')
1052
+ if dead then
1053
+ put_row(idx, at, 'f', ARGV[7])
1054
+ drop_row(idx, at, 'ce')
1055
+ ready_set(cid_for(consumer, row[7], idx), -idx)
1056
+ release_pins(idx, at, row[8])
1057
+ elseif ARGV[8] ~= '' then
1058
+ put_row(idx, at, 'ce', ARGV[8])
1059
+ else
1060
+ drop_row(idx, at, 'ce')
1061
+ end
1062
+ flush()
1063
+ flush_ready()
1064
+ return { dead and 'dead_lettered' or 'failed', tostring(failures) }
1065
+ `;
1066
+ /**
1067
+ * KEYS: counter, layout. ARGV: consumer, index, action, nowMs, served
1068
+ * consumers. Returns [redriven, hasPending] | ['index', type, after]. Acts
1069
+ * only on the consumer's dead-lettered head at the index, above the clear
1070
+ * marker.
1071
+ */
1072
+ const REDRIVE_LUA = `${BASE_LUA}${CURSOR_LUA}
1073
+ if not read_bounds() then return redis.error_reply('${LAYOUT_ERROR}') end
1074
+ use_served(ARGV[5])
1075
+ local consumer, idx = ARGV[1], tonumber(ARGV[2])
1076
+ local function result(redriven) return { redriven, has_pending() } end
1077
+ if idx <= boundary or idx < 1 or idx > total then return result('0') end
1078
+ local cid, tag = nil, nil
1079
+ for _, member in ipairs(redis.call('ZRANGEBYSCORE', READY, -idx, -idx)) do
1080
+ local owner, member_tag = parse_cid(member)
1081
+ if owner == consumer then
1082
+ cid, tag = member, member_tag
1083
+ break
1084
+ end
1085
+ end
1086
+ if not cid then return result('0') end
1087
+ local kind = string.sub(tag, 1, 1) == '#' and '#' or tag
1088
+ local at = row_of(consumer, tag)
1089
+ if ARGV[3] == 'skip' then
1090
+ local row = read_rows({ { idx, at } }, { 'next' })[1]
1091
+ local moved, missing = advance(consumer, kind, idx, row[1], {})
1092
+ if not moved then return { 'index', missing, tostring(idx) } end
1093
+ put_row(idx, at, 'p', ARGV[4])
1094
+ flush_states()
1095
+ if typed_dirty then redis.call('SET', TYPED, cjson.encode(typed)) end
1096
+ else
1097
+ drop_row(idx, at, 'f')
1098
+ drop_row(idx, at, 'a')
1099
+ ready_set(cid, idx)
1100
+ end
1101
+ flush()
1102
+ flush_ready()
1103
+ return result('1')
1104
+ `;
1105
+ /**
1106
+ * KEYS: counter, layout. ARGV: consumer, lane, types JSON or '*', backfill
1107
+ * ('1' or '0'). Brings one log cursor up to date without an append, as
1108
+ * `plan` and `enqueue` would for an append carrying its types: a missing
1109
+ * cursor starts at the clear marker with backfill, else at the head with
1110
+ * nothing to run; an idle one takes the next stored event of its types; one
1111
+ * with a head, claimed or not, keeps it. Records the types, and writes
1112
+ * nothing when nothing changed. Returns ['ok', position, head or nil, head
1113
+ * columns, session head] | ['index', type, after].
1114
+ */
1115
+ const WAKE_LOG_LUA = `${BASE_LUA}${CURSOR_LUA}
1116
+ if not read_bounds() then return redis.error_reply('${LAYOUT_ERROR}') end
1117
+ local consumer, lane, ty = ARGV[1], ARGV[2], ARGV[3]
1118
+ local types = types_of(ty)
1119
+ local cid = cid_of(consumer, 'G' .. lane)
1120
+ load_states({ cid })
1121
+ local state = states[cid]
1122
+ local had_head = state.h
1123
+ -- Nothing after the latest clear: a missing cursor stays missing.
1124
+ if not state.exists and total == boundary then
1125
+ return { 'ok', tostring(boundary), false, {}, tostring(total) }
1126
+ end
1127
+ if not state.exists then
1128
+ state.exists, state.p = true, total
1129
+ if ARGV[4] == '1' then
1130
+ local head, missing = stored_head(boundary, types)
1131
+ if head == nil then return { 'index', missing, tostring(boundary) } end
1132
+ state.p, state.h = boundary, head
1133
+ else
1134
+ -- Nothing it takes is stored after its start: index its types from there.
1135
+ for name in pairs(types or {}) do
1136
+ if typed[name] == nil then register(name, total) end
1137
+ end
1138
+ end
1139
+ state.dirty = true
1140
+ elseif not state.h then
1141
+ -- A deployment that doesn't serve this consumer may have appended events
1142
+ -- it takes since it went idle.
1143
+ local head, missing = stored_head(state.p, types)
1144
+ if head == nil then return { 'index', missing, tostring(state.p) } end
1145
+ if head then state.h, state.dirty = head, true end
1146
+ end
1147
+ if state.ty ~= ty then state.ty, state.dirty = ty, true end
1148
+ local columns = {}
1149
+ if had_head then
1150
+ columns = read_rows({ { state.h, row_of(consumer, 'G' .. lane) } }, ${HEAD_COLUMNS})[1]
1151
+ elseif state.h then
1152
+ ready_set(cid, state.h)
1153
+ end
1154
+ flush_states()
1155
+ if typed_dirty then redis.call('SET', TYPED, cjson.encode(typed)) end
1156
+ flush()
1157
+ flush_ready()
1158
+ return { 'ok', tostring(state.p), state.h and tostring(state.h) or false, columns, tostring(total) }
1159
+ `;
1160
+ /**
1161
+ * KEYS: counter, layout. Returns the dead-lettered heads above the clear
1162
+ * marker as [consumer, tag, index, columns, entry] tuples.
1163
+ */
1164
+ const DEAD_LETTERS_LUA = `${BASE_LUA}
1165
+ local dead = redis.call('ZRANGEBYSCORE', READY, '-inf', '(0', 'WITHSCORES')
1166
+ if #dead == 0 then return {} end
1167
+ if not read_bounds() then return redis.error_reply('${LAYOUT_ERROR}') end
1168
+ local refs, items = {}, {}
1169
+ for i = 1, #dead, 2 do
1170
+ local consumer, tag = parse_cid(dead[i])
1171
+ local idx = -tonumber(dead[i + 1])
1172
+ if idx > boundary then
1173
+ refs[#refs + 1] = { idx, row_of(consumer, tag) }
1174
+ items[#items + 1] = { consumer, tag, idx }
1175
+ end
1176
+ end
1177
+ local rows = read_rows(refs, ${HEAD_COLUMNS})
1178
+ local result = {}
1179
+ for k, item in ipairs(items) do
1180
+ local entries = redis.call('XRANGE', LOG, tostring(item[3]) .. '-0', tostring(item[3]) .. '-0')
1181
+ if #entries > 0 then
1182
+ result[#result + 1] = { item[1], item[2], tostring(item[3]), rows[k], entries[1] }
1183
+ end
1184
+ end
1185
+ return result
1186
+ `;
1187
+ /**
1188
+ * KEYS: counter, layout. Returns every listed cursor as [consumer, tag,
1189
+ * position, head or nil, columns]: lane and log cursors from the cursor
1190
+ * hash, read against the clear marker, then the event cursors with a head.
1191
+ */
1192
+ const CURSORS_LUA = `${BASE_LUA}
1193
+ if not read_bounds() then return redis.error_reply('${LAYOUT_ERROR}') end
1194
+ local listed = {}
1195
+ local fields = redis.call('HGETALL', CURSORS)
1196
+ local records, order = {}, {}
1197
+ for i = 1, #fields, 2 do
1198
+ local at = string.find(fields[i], ':[^:]*$')
1199
+ local cid, suffix = string.sub(fields[i], 1, at - 1), string.sub(fields[i], at + 1)
1200
+ if not records[cid] then
1201
+ records[cid] = {}
1202
+ order[#order + 1] = cid
1203
+ end
1204
+ records[cid][suffix] = fields[i + 1]
1205
+ end
1206
+ for _, cid in ipairs(order) do
1207
+ local record = records[cid]
1208
+ local consumer, tag = parse_cid(cid)
1209
+ local head = (record.h and record.h ~= '') and tonumber(record.h) or false
1210
+ local position = tonumber(record.p or '0')
1211
+ if head and head <= boundary then head = false end
1212
+ local kind = string.sub(tag, 1, 1)
1213
+ if kind == 'G' and position < boundary then position = boundary end
1214
+ if kind == 'G' or head or position > boundary then
1215
+ listed[#listed + 1] = { consumer, tag, position, head }
1216
+ end
1217
+ end
1218
+ local ready = redis.call('ZRANGE', READY, 0, -1, 'WITHSCORES')
1219
+ for i = 1, #ready, 2 do
1220
+ local consumer, tag = parse_cid(ready[i])
1221
+ if string.sub(tag, 1, 1) == '#' then
1222
+ local head = math.abs(tonumber(ready[i + 1]))
1223
+ listed[#listed + 1] = { consumer, '#', head - 1, head }
1224
+ end
1225
+ end
1226
+ local refs, positions = {}, {}
1227
+ for position, cursor in ipairs(listed) do
1228
+ if cursor[4] then
1229
+ refs[#refs + 1] = { cursor[4], row_of(cursor[1], cursor[2]) }
1230
+ positions[#refs] = position
1231
+ end
1232
+ end
1233
+ local rows = read_rows(refs, ${HEAD_COLUMNS})
1234
+ local columns = {}
1235
+ for k, position in ipairs(positions) do columns[position] = rows[k] end
1236
+ local result = {}
1237
+ for position, cursor in ipairs(listed) do
1238
+ result[#result + 1] = {
1239
+ cursor[1], cursor[2], tostring(cursor[3]),
1240
+ cursor[4] and tostring(cursor[4]) or false, columns[position] or {},
1241
+ }
1242
+ end
1243
+ return result
1244
+ `;
1245
+ /**
1246
+ * KEYS: counter, layout, head, history.
1247
+ * ARGV: write count, then per write: index, snapshot JSON, pin count, then
1248
+ * per pin: consumer, tag, index; finally the reducer name. Writes from
1249
+ * before the latest clear are dropped, and a head from before it is
1250
+ * replaced as if absent. A pin holds only on an unfinished, live head: a
1251
+ * claimed row says so itself, an unclaimed head by its cursor's ready score.
1252
+ */
1253
+ const PUT_SNAPSHOTS_LUA = `${BASE_LUA}
1254
+ if not read_bounds() then return redis.error_reply('${LAYOUT_ERROR}') end
1255
+ local HEAD, HISTORY = KEYS[3], KEYS[4]
1256
+ local entries, refs, pins = {}, {}, {}
1257
+ local offset = 2
1258
+ for w = 1, tonumber(ARGV[1]) do
1259
+ local index = tonumber(ARGV[offset])
1260
+ local pin_count = tonumber(ARGV[offset + 2])
1261
+ local entry = { index = index, snapshot = ARGV[offset + 1], pins = {} }
1262
+ for p = 1, pin_count do
1263
+ local o = offset + 2 + (p - 1) * 3
1264
+ local pin = { consumer = ARGV[o + 1], tag = ARGV[o + 2], idx = tonumber(ARGV[o + 3]) }
1265
+ if index >= boundary and pin.idx > boundary and pin.idx <= total then
1266
+ pin.row = row_of(pin.consumer, pin.tag)
1267
+ refs[#refs + 1] = { pin.idx, pin.row }
1268
+ pins[#pins + 1] = pin
1269
+ entry.pins[#entry.pins + 1] = pin
1270
+ end
1271
+ end
1272
+ entries[w] = entry
1273
+ offset = offset + 3 + pin_count * 3
1274
+ end
1275
+ local function live(consumer, tag, idx)
1276
+ return tonumber(redis.call('ZSCORE', READY, cid_of(consumer, tag)) or '0') == idx
1277
+ end
1278
+ local rows = read_rows(refs, { 'pins', 'p', 'f', 'l' })
1279
+ local held, held_order = {}, {}
1280
+ for k, pin in ipairs(pins) do
1281
+ local row = rows[k]
1282
+ local valid
1283
+ if row[2] or row[3] then
1284
+ valid = false
1285
+ elseif row[4] then
1286
+ valid = owns(row[4], pin.tag)
1287
+ elseif pin.tag == '#' then
1288
+ valid = live(pin.consumer, '#' .. tostring(pin.idx), pin.idx)
1289
+ else
1290
+ valid = live(pin.consumer, pin.tag, pin.idx) or
1291
+ live(pin.consumer, 'G' .. string.sub(pin.tag, 2), pin.idx)
1292
+ end
1293
+ if valid then
1294
+ local key = tostring(pin.idx) .. ':' .. pin.row
1295
+ if not held[key] then
1296
+ local list = (row[1] and row[1] ~= '') and cjson.decode(row[1]) or {}
1297
+ local set = {}
1298
+ for _, ref in ipairs(list) do set[ref] = true end
1299
+ held[key] = { list = list, set = set, idx = pin.idx, row = pin.row }
1300
+ held_order[#held_order + 1] = key
1301
+ end
1302
+ pin.held = held[key]
1303
+ end
1304
+ end
1305
+ local snapshots = {}
1306
+ for _, entry in ipairs(entries) do
1307
+ local ref = cjson.encode({ HISTORY, tostring(entry.index) })
1308
+ for _, pin in ipairs(entry.pins) do
1309
+ if pin.held and not pin.held.set[ref] then
1310
+ pin.held.set[ref] = true
1311
+ pin.held.list[#pin.held.list + 1] = ref
1312
+ pin.held.dirty = true
1313
+ redis.call('HINCRBY', PIN_COUNTS, ref, 1)
1314
+ end
1315
+ end
1316
+ if entry.index >= boundary then
1317
+ snapshots[#snapshots + 1] = { index = entry.index, snapshot = entry.snapshot, ref = ref }
1318
+ end
1319
+ end
1320
+ for _, key in ipairs(held_order) do
1321
+ local row = held[key]
1322
+ if row.dirty then put_row(row.idx, row.row, 'pins', cjson.encode(row.list)) end
1323
+ end
1324
+ flush()
1325
+ if #snapshots == 0 then return 1 end
1326
+
1327
+ local head = redis.call('GET', HEAD)
1328
+ local head_index = nil
1329
+ local registered = false
1330
+ if head then
1331
+ local record = cjson.decode(head)
1332
+ head_index = tonumber(record['index'])
1333
+ registered = record['r'] == 1
1334
+ end
1335
+ if head_index and head_index < boundary then
1336
+ head, head_index, registered = nil, nil, false
1337
+ redis.call('ZREMRANGEBYSCORE', HISTORY, '-inf', '(' .. boundary)
1338
+ end
1339
+ -- Deletion finds each reducer's keys through this set instead of a scan.
1340
+ -- Heads written since carry the mark, so a reducer registers once.
1341
+ if not registered then
1342
+ redis.call('SADD', SESSION .. 'snap-reducers', ARGV[#ARGV])
1343
+ end
1344
+ local latest = snapshots[#snapshots]
1345
+ local writes_head = not head_index or latest.index >= head_index
1346
+ local advances = not head_index or latest.index > head_index
1347
+ local final_head_index = advances and latest.index or head_index
1348
+
1349
+ if advances and head and tonumber(redis.call('HGET', PIN_COUNTS, cjson.encode({ HISTORY, tostring(head_index) })) or '0') > 0 then
1350
+ redis.call('ZREMRANGEBYSCORE', HISTORY, head_index, head_index)
1351
+ redis.call('ZADD', HISTORY, head_index, head)
1352
+ end
1353
+
1354
+ for _, write in ipairs(snapshots) do
1355
+ if write.index < final_head_index then
1356
+ redis.call('ZREMRANGEBYSCORE', HISTORY, write.index, write.index)
1357
+ if tonumber(redis.call('HGET', PIN_COUNTS, write.ref) or '0') > 0 then
1358
+ redis.call('ZADD', HISTORY, write.index, write.snapshot)
1359
+ end
1360
+ end
1361
+ end
1362
+
1363
+ if writes_head then
1364
+ redis.call('SET', HEAD, latest.snapshot)
1365
+ redis.call('ZREMRANGEBYSCORE', HISTORY, latest.index, latest.index)
1366
+ end
1367
+ return 1
1368
+ `;
1369
+ /**
1370
+ * KEYS: presence hash. ARGV: atMs, ttlMs, n, then per entry:
1371
+ * hashField, valueJson ('' deletes), patchFieldJson; finally channel and
1372
+ * message prefix. Wire fragments stay JSON text to preserve arrays
1373
+ * and numeric precision through Lua. Field-wise LWW by the sender's
1374
+ * `at`: a stored entry with a strictly newer stamp survives; ties go
1375
+ * to the incoming write (memory-tier parity). Returns the applied
1376
+ * 1-based entry positions. The whole-key PEXPIRE is the coarse bound,
1377
+ * sound because core sends one uniform ttlMs; the per-entry `e` stamp
1378
+ * is the fine bound (the injected storage clock at the applied set
1379
+ * plus ttlMs — never the sender stamp), pruned on read against the
1380
+ * same clock.
1381
+ */
1382
+ const PRESENCE_SET_LUA = `
1383
+ local at = tonumber(ARGV[1])
1384
+ local applied = {}
1385
+ local published = {}
1386
+ local n = tonumber(ARGV[3])
1387
+ for i = 1, n do
1388
+ local field = ARGV[3 + (i - 1) * 3 + 1]
1389
+ local value = ARGV[3 + (i - 1) * 3 + 2]
1390
+ local existing = redis.call('HGET', KEYS[1], field)
1391
+ if not existing or tonumber(cjson.decode(existing)['a']) <= at then
1392
+ if value == '' then
1393
+ redis.call('HDEL', KEYS[1], field)
1394
+ else
1395
+ redis.call('HSET', KEYS[1], field, value)
1396
+ end
1397
+ applied[#applied + 1] = tostring(i)
1398
+ published[#published + 1] = ARGV[3 + (i - 1) * 3 + 3]
1399
+ end
1400
+ end
1401
+ if #applied > 0 then
1402
+ redis.call('PEXPIRE', KEYS[1], ARGV[2])
1403
+ redis.pcall('PUBLISH', ARGV[4 + n * 3], ARGV[5 + n * 3] .. table.concat(published, ',') .. '}}')
1404
+ end
1405
+ return applied
1406
+ `;
1407
+ /**
1408
+ * KEYS: presence hash. ARGV: nowMs (the injected clock, so tests can
1409
+ * time-travel expiry against a real server). Returns the live
1410
+ * [field, value, ...] pairs; entries at or past their fine-bound
1411
+ * expiry are HDELed in the same atomic step, so a stale reader can
1412
+ * never sweep a concurrently refreshed field.
1413
+ */
1414
+ const PRESENCE_READ_LUA = `
1415
+ local now = tonumber(ARGV[1])
1416
+ local rows = redis.call('HGETALL', KEYS[1])
1417
+ local live = {}
1418
+ for i = 1, #rows, 2 do
1419
+ if tonumber(cjson.decode(rows[i + 1])['e']) <= now then
1420
+ redis.call('HDEL', KEYS[1], rows[i])
1421
+ else
1422
+ live[#live + 1] = rows[i]
1423
+ live[#live + 1] = rows[i + 1]
1424
+ end
1425
+ end
1426
+ return live
1427
+ `;
1428
+ /**
1429
+ * KEYS: counter, layout, head, history. ARGV: snapshot frontier or ''.
1430
+ * Returns one atomic multiversion-cache-plus-log-frontier read, with the
1431
+ * clear boundary; checkpoints from before it are ignored.
1432
+ */
1433
+ const READ_STATE_LUA = `
1434
+ local session = string.sub(KEYS[1], 1, -6)
1435
+ local counts = redis.call('MGET', KEYS[1], session .. 'clear', KEYS[2])
1436
+ if counts[3] and counts[3] ~= '${REDIS_LAYOUT}' then
1437
+ return redis.error_reply('${LAYOUT_ERROR}')
1438
+ end
1439
+ local boundary = tonumber(counts[2] or '0')
1440
+ local head = redis.call('GET', KEYS[3])
1441
+ local head_index = head and cjson.decode(head)['index'] or nil
1442
+ if head and (type(head_index) ~= 'number' or head_index < 0 or head_index > 9007199254740991 or head_index ~= math.floor(head_index)) then
1443
+ return redis.error_reply('invalid snapshot head index')
1444
+ end
1445
+ if head_index and head_index < boundary then
1446
+ head = nil
1447
+ head_index = nil
1448
+ end
1449
+ local cap = ARGV[1]
1450
+ local snapshot = nil
1451
+ if head_index and (cap == '' or head_index <= tonumber(cap)) then
1452
+ snapshot = head
1453
+ else
1454
+ local upper = cap == '' and '+inf' or cap
1455
+ local historical = redis.call('ZREVRANGEBYSCORE', KEYS[4], upper, boundary, 'LIMIT', 0, 1)
1456
+ if #historical > 0 then snapshot = historical[1] end
1457
+ end
1458
+ return { head_index or '', snapshot or '', counts[1] or '0', tostring(boundary) }
1459
+ `;
1460
+ /**
1461
+ * What reclaim removes once a session has no moved log left: every
1462
+ * registered reducer's checkpoints below the latest clear and the pin counts
1463
+ * naming them (snapshot writes register each reducer in
1464
+ * {prefix}:{session}:snap-reducers, so no key scan is needed), every head
1465
+ * row below it (whole chunks, then the boundary chunk's older rows), the
1466
+ * type indexes' older entries, and the lane cursors it left idle.
1467
+ */
1468
+ const SWEEP_LUA = `
1469
+ local function sweep(boundary)
1470
+ for _, reducer in ipairs(redis.call('SMEMBERS', SESSION .. 'snap-reducers')) do
1471
+ local head_key = SESSION .. 'snap:' .. reducer
1472
+ local head = redis.call('GET', head_key)
1473
+ if head and tonumber(cjson.decode(head)['index']) < boundary then
1474
+ redis.call('DEL', head_key)
1475
+ end
1476
+ redis.call('ZREMRANGEBYSCORE', SESSION .. 'snap-history:' .. reducer, '-inf', '(' .. boundary)
1477
+ end
1478
+ local counts = redis.call('HGETALL', PIN_COUNTS)
1479
+ local stale = {}
1480
+ for i = 1, #counts, 2 do
1481
+ if tonumber(cjson.decode(counts[i])[2]) < boundary then stale[#stale + 1] = counts[i] end
1482
+ end
1483
+ sliced('HDEL', PIN_COUNTS, stale)
1484
+ local raw = redis.call('GET', TYPED)
1485
+ if raw then
1486
+ for name in pairs(cjson.decode(raw)) do
1487
+ redis.call('ZREMRANGEBYSCORE', type_key(name), '-inf', '(' .. boundary)
1488
+ end
1489
+ end
1490
+ local last = math.floor((boundary - 1) / ${META_CHUNK_SIZE})
1491
+ local chunks = {}
1492
+ for chunk = 0, last - 1 do chunks[#chunks + 1] = META .. ':' .. tostring(chunk) end
1493
+ sliced('UNLINK', nil, chunks)
1494
+ local older = {}
1495
+ for _, field in ipairs(redis.call('HKEYS', META .. ':' .. tostring(last))) do
1496
+ local idx = tonumber(string.match(field, '^(%d+):'))
1497
+ if idx and idx < boundary then older[#older + 1] = field end
1498
+ end
1499
+ sliced('HDEL', META .. ':' .. tostring(last), older)
1500
+ local fields = redis.call('HGETALL', CURSORS)
1501
+ local records, order = {}, {}
1502
+ for i = 1, #fields, 2 do
1503
+ local at = string.find(fields[i], ':[^:]*$')
1504
+ local cid, suffix = string.sub(fields[i], 1, at - 1), string.sub(fields[i], at + 1)
1505
+ if not records[cid] then
1506
+ records[cid] = {}
1507
+ order[#order + 1] = cid
1508
+ end
1509
+ records[cid][suffix] = fields[i + 1]
1510
+ end
1511
+ local idle, moved = {}, {}
1512
+ for _, cid in ipairs(order) do
1513
+ local record = records[cid]
1514
+ local _, tag = parse_cid(cid)
1515
+ local head = (record.h and record.h ~= '') and tonumber(record.h) or false
1516
+ local position = tonumber(record.p or '0')
1517
+ if head and head <= boundary then head = false end
1518
+ if string.sub(tag, 1, 1) == 'G' then
1519
+ if position < boundary then position = boundary end
1520
+ moved[#moved + 1] = cid .. ':h'
1521
+ moved[#moved + 1] = head and tostring(head) or ''
1522
+ moved[#moved + 1] = cid .. ':p'
1523
+ moved[#moved + 1] = tostring(position)
1524
+ elseif not head and position <= boundary then
1525
+ for suffix in pairs(record) do idle[#idle + 1] = cid .. ':' .. suffix end
1526
+ elseif not head and record.h ~= '' then
1527
+ moved[#moved + 1] = cid .. ':h'
1528
+ moved[#moved + 1] = ''
1529
+ end
1530
+ end
1531
+ sliced('HDEL', CURSORS, idle)
1532
+ sliced('HSET', CURSORS, moved)
1533
+ end
1534
+ `;
1535
+ /**
1536
+ * KEYS: counter, layout, marker id ownership key.
1537
+ * ARGV: sessionId, nowMs, ifLastIndex or '', cause consumer or '', cause tag,
1538
+ * cause index, cause attempt, marker id, notify channel.
1539
+ * Returns ['ok', cleared, head, boundary] | ['superseded'] | ['foreign'].
1540
+ * The old lifetime's log moves aside in one RENAME, so every read of the log
1541
+ * starts at the marker, and its ready set is unlinked, so claims and pending
1542
+ * checks see only the new lifetime; deletion drains the moved log. Type
1543
+ * indexes keep their older entries until then: lookups start above the
1544
+ * marker.
1545
+ */
1546
+ const CLEAR_LUA = `${BASE_LUA}${WAKE_LUA}
1547
+ local values = read_bounds({ KEYS[3] })
1548
+ if not values then return redis.error_reply('${LAYOUT_ERROR}') end
1549
+ local head = total
1550
+ if ARGV[4] ~= '' then
1551
+ local idx = tonumber(ARGV[6])
1552
+ if idx <= boundary then return { 'superseded' } end
1553
+ local row = read_rows({ { idx, row_of(ARGV[4], ARGV[5]) } }, { 'd', 'f', 'p', 'l' })[1]
1554
+ if tonumber(row[1] or '0') ~= tonumber(ARGV[7]) or row[2] or row[3] or not owns(row[4], ARGV[5]) then
1555
+ return { 'superseded' }
1556
+ end
1557
+ end
1558
+ local expected = ARGV[3]
1559
+ if expected ~= '' and boundary == tonumber(expected) + 1 then
1560
+ return { 'ok', '1', tostring(head), tostring(boundary) }
1561
+ end
1562
+ if expected ~= '' and tonumber(expected) ~= head then
1563
+ return { 'ok', '0', tostring(head), tostring(boundary) }
1564
+ end
1565
+ if head == 0 or boundary == head then
1566
+ return { 'ok', '1', tostring(head), tostring(boundary) }
1567
+ end
1568
+ if values[1] then return { 'foreign' } end
1569
+ local field = tostring(head + 1)
1570
+ local moved = SESSION .. 'cleared:' .. field
1571
+ redis.call('RENAME', LOG, moved)
1572
+ redis.call('UNLINK', READY)
1573
+ redis.call('SADD', SESSION .. 'cleared', moved)
1574
+ redis.call('XADD', LOG, field .. '-0',
1575
+ 'id', ARGV[8], 'type', CLEAR_TYPE, 'payload', 'null',
1576
+ 'created_at', ARGV[2], 'cause', '')
1577
+ local marker = { KEYS[3], cjson.encode({ ARGV[1], head + 1 }), COUNTER, field, CLEAR, field }
1578
+ if schema_missing then
1579
+ marker[#marker + 1] = SCHEMA
1580
+ marker[#marker + 1] = '${REDIS_LAYOUT}'
1581
+ end
1582
+ redis.call('MSET', unpack(marker))
1583
+ wake_add(field, ARGV[8], CLEAR_TYPE, 'null')
1584
+ wake(ARGV[9], field, ARGV[2])
1585
+ return { 'ok', '1', field, field }
1586
+ `;
1587
+ /**
1588
+ * KEYS: counter, layout. ARGV: page size.
1589
+ * The next deletion batch: a moved log and up to a page of its [index, id]
1590
+ * pairs, without payloads. Empty moved logs are dropped; with none left it
1591
+ * sweeps what the old lifetime left and returns nothing, so a retry
1592
+ * finishes an interrupted deletion.
1593
+ */
1594
+ const RECLAIM_BATCH_LUA = `${BASE_LUA}${SWEEP_LUA}
1595
+ local boundary = tonumber(redis.call('GET', CLEAR) or '0')
1596
+ if boundary == 0 then return {} end
1597
+ for _, log in ipairs(redis.call('SMEMBERS', SESSION .. 'cleared')) do
1598
+ local entries = redis.call('XRANGE', log, '-', '+', 'COUNT', ARGV[1])
1599
+ if #entries > 0 then
1600
+ local batch = { log }
1601
+ for _, entry in ipairs(entries) do
1602
+ local fields = entry[2]
1603
+ for i = 1, #fields, 2 do
1604
+ if fields[i] == 'id' then
1605
+ batch[#batch + 1] = string.match(entry[1], '^(%d+)')
1606
+ batch[#batch + 1] = fields[i + 1]
1607
+ break
1608
+ end
1609
+ end
1610
+ end
1611
+ return batch
1612
+ end
1613
+ redis.call('DEL', log)
1614
+ redis.call('SREM', SESSION .. 'cleared', log)
1615
+ end
1616
+ sweep(boundary)
1617
+ return {}
1618
+ `;
1619
+ /**
1620
+ * KEYS: counter, layout, moved log, then one id ownership key per entry.
1621
+ * ARGV: sessionId, n, then per entry: index, id.
1622
+ * Deletes the listed entries below the latest clear and their id records.
1623
+ * An id record survives when it names another event, so an id reused in the
1624
+ * new lifetime keeps its owner. Returns [deleted, done].
1625
+ */
1626
+ const RECLAIM_LUA = `${BASE_LUA}${SWEEP_LUA}
1627
+ local boundary = tonumber(redis.call('GET', CLEAR) or '0')
1628
+ local MOVED = KEYS[3]
1629
+ local id_keys, indexes, stream_ids = {}, {}, {}
1630
+ -- A concurrent reclaim may list the same batch; only XDEL says who removed it.
1631
+ local removed = 0
1632
+ for i = 1, tonumber(ARGV[2]) do
1633
+ local field = ARGV[2 + (i - 1) * 2 + 1]
1634
+ if tonumber(field) < boundary then
1635
+ id_keys[#id_keys + 1] = KEYS[3 + i]
1636
+ indexes[#id_keys] = tonumber(field)
1637
+ stream_ids[#id_keys] = field .. '-0'
1638
+ end
1639
+ end
1640
+ if #id_keys > 0 then
1641
+ local owners, stale_ids = sliced('MGET', nil, id_keys), {}
1642
+ for i = 1, #id_keys do
1643
+ if owners[i] then
1644
+ local rec = cjson.decode(owners[i])
1645
+ if rec[1] == ARGV[1] and tonumber(rec[2]) == indexes[i] then
1646
+ stale_ids[#stale_ids + 1] = id_keys[i]
1647
+ end
1648
+ end
1649
+ end
1650
+ sliced('DEL', nil, stale_ids)
1651
+ removed = redis.call('XDEL', MOVED, unpack(stream_ids))
1652
+ end
1653
+ local done = 0
1654
+ if redis.call('XLEN', MOVED) == 0 then
1655
+ redis.call('DEL', MOVED)
1656
+ redis.call('SREM', SESSION .. 'cleared', MOVED)
1657
+ if redis.call('SCARD', SESSION .. 'cleared') == 0 then
1658
+ sweep(boundary)
1659
+ done = 1
1660
+ end
1661
+ end
1662
+ return { removed, done }
1663
+ `;
1664
+ const fieldMap = (fields) => {
1665
+ const map = {};
1666
+ for (let i = 0; i + 1 < fields.length; i += 2) map[fields[i]] = fields[i + 1];
1667
+ return map;
1668
+ };
1669
+ const entryIndex = (entryId) => Number(entryId.slice(0, entryId.indexOf("-")));
1670
+ const toEvent = (sessionId, entry) => {
1671
+ const fields = fieldMap(entry[1]);
1672
+ return {
1673
+ id: fields["id"],
1674
+ type: fields["type"],
1675
+ payload: JSON.parse(fields["payload"]),
1676
+ index: entryIndex(entry[0]),
1677
+ sessionId,
1678
+ createdAt: new Date(Number(fields["created_at"]))
1679
+ };
1680
+ };
1681
+ /**
1682
+ * A stream entry's causal edge. Releases before consumers (0.17 to 0.20)
1683
+ * recorded only the parent's index and attempt: their edge reads as the
1684
+ * handlers table's, `a2.handlers`, as Postgres's upgrade names it, with no
1685
+ * lane, since nothing reads an earlier release's lane.
1686
+ */
1687
+ const decodeStreamCause = (raw) => {
1688
+ const parsed = JSON.parse(raw);
1689
+ return decodeCause(parsed.consumer === void 0 ? {
1690
+ consumer: "a2.handlers",
1691
+ lane: null,
1692
+ ...parsed
1693
+ } : parsed);
1694
+ };
1695
+ /** A stream entry with its causal edge. */
1696
+ const toStoredEvent = (sessionId, entry) => {
1697
+ const cause = fieldMap(entry[1])["cause"];
1698
+ return Object.assign(toEvent(sessionId, entry), { cause: cause === void 0 || cause === "" ? null : decodeStreamCause(cause) });
1699
+ };
1700
+ const fieldBytes = (name) => new TextEncoder().encode(name);
1701
+ const ID_FIELD = fieldBytes("id");
1702
+ const TYPE_FIELD = fieldBytes("type");
1703
+ const PAYLOAD_FIELD = fieldBytes("payload");
1704
+ const CREATED_AT_FIELD = fieldBytes("created_at");
1705
+ const utf8 = new TextDecoder("utf-8", { ignoreBOM: true });
1706
+ const isField = (name, field) => {
1707
+ if (name.length !== field.length) return false;
1708
+ for (let at = 0; at < field.length; at++) if (name[at] !== field[at]) return false;
1709
+ return true;
1710
+ };
1711
+ const bytesToSerializedEvent = (sessionId, entry) => {
1712
+ const values = entry[1];
1713
+ let id;
1714
+ let type;
1715
+ let payloadJson;
1716
+ let createdAt;
1717
+ for (let at = 0; at + 1 < values.length; at += 2) {
1718
+ const name = values[at];
1719
+ if (isField(name, ID_FIELD)) id = utf8.decode(values[at + 1]);
1720
+ else if (isField(name, TYPE_FIELD)) type = utf8.decode(values[at + 1]);
1721
+ else if (isField(name, PAYLOAD_FIELD)) payloadJson = utf8.decode(values[at + 1]);
1722
+ else if (isField(name, CREATED_AT_FIELD)) createdAt = utf8.decode(values[at + 1]);
1723
+ }
1724
+ return {
1725
+ id,
1726
+ type,
1727
+ payloadJson,
1728
+ index: entryIndex(utf8.decode(entry[0])),
1729
+ sessionId,
1730
+ createdAtMs: Number(createdAt)
1731
+ };
1732
+ };
1733
+ const toSerializedEvent = (sessionId, entry) => {
1734
+ const fields = fieldMap(entry[1]);
1735
+ return {
1736
+ id: fields["id"],
1737
+ type: fields["type"],
1738
+ payloadJson: fields["payload"],
1739
+ index: entryIndex(entry[0]),
1740
+ sessionId,
1741
+ createdAtMs: Number(fields["created_at"])
1742
+ };
1743
+ };
1744
+ const json = (value) => JSON.stringify(value) ?? "null";
1745
+ /** How scripts name a cursor within its consumer: `#`, or `L` and its lane. */
1746
+ const tagOf = (lane) => lane === null ? "#" : `L${lane}`;
1747
+ /** The lane a cursor tag names: null for an event cursor. */
1748
+ const laneOf = (tag) => tag.startsWith("#") ? null : tag.slice(1);
1749
+ const targetsArg = (targets) => targets === void 0 || targets.length === 0 ? "" : JSON.stringify(targets.map((target) => [target.consumer, tagOf(target.lane)]));
1750
+ /** A log consumer's types, as scripts read them; '*' takes every type. */
1751
+ const logTypesArg = (types) => types === null ? "*" : JSON.stringify(types);
1752
+ /** The served consumers, as scripts read them; '' serves every consumer. */
1753
+ const servedArg = (consumers) => consumers === void 0 ? "" : JSON.stringify(consumers.map((consumer) => consumer.log ? [
1754
+ consumer.name,
1755
+ consumer.log.lane,
1756
+ logTypesArg(consumer.log.types),
1757
+ consumer.log.backfill ? 1 : 0
1758
+ ] : [consumer.name]));
1759
+ /** A head's dispatch state from a row's HEAD_COLUMNS. */
1760
+ const dispatchOf = (consumer, lane, columns) => {
1761
+ const column = (at) => {
1762
+ const value = columns?.[at];
1763
+ return value === null || value === void 0 ? null : String(value);
1764
+ };
1765
+ const date = (at) => {
1766
+ const value = column(at);
1767
+ return value === null ? null : new Date(Number(value));
1768
+ };
1769
+ const count = (at) => {
1770
+ const value = column(at);
1771
+ return value === null ? null : Number(value);
1772
+ };
1773
+ return {
1774
+ consumer,
1775
+ lane,
1776
+ firstClaimedAt: date(1),
1777
+ lastClaimedAt: date(2),
1778
+ attemptCount: count(0) ?? 0,
1779
+ claimHolder: column(3),
1780
+ claimExpiresAt: date(4),
1781
+ failureCount: count(5) ?? 0,
1782
+ lastFailedAt: date(6),
1783
+ lastFailedAttempt: count(7),
1784
+ lastError: column(8),
1785
+ failedAt: date(9)
1786
+ };
1787
+ };
1788
+ const toHeadEvent = (sessionId, head) => ({
1789
+ ...toStoredEvent(sessionId, head[4]),
1790
+ ...dispatchOf(head[0], laneOf(head[1]), head[3])
1791
+ });
1792
+ const compareText = (a, b) => a < b ? -1 : a > b ? 1 : 0;
1793
+ const byHead = (a, b) => a.index - b.index || compareText(a.consumer, b.consumer);
1794
+ /** Consumer, then lane, with concurrent cursors last by head. */
1795
+ const byCursor = (a, b) => compareText(a.consumer, b.consumer) || (a.lane === null ? b.lane === null ? (a.head ?? 0) - (b.head ?? 0) : 1 : b.lane === null ? -1 : compareText(a.lane, b.lane));
1796
+ const toClaimAvailableResult = (sessionId, reply) => {
1797
+ const outcome = String(reply[0]);
1798
+ if (outcome === "claimed") {
1799
+ const count = Number(reply[1]);
1800
+ const events = reply.slice(2, 2 + count).map((head) => toHeadEvent(sessionId, head)).toSorted(byHead);
1801
+ const clearIndex = Number(reply[2 + count] ?? 0);
1802
+ if (clearIndex > 0) for (const event of events) event.clearIndex = clearIndex;
1803
+ return {
1804
+ outcome: "claimed",
1805
+ events,
1806
+ head: Number(reply[3 + count])
1807
+ };
1808
+ }
1809
+ if (outcome === "busy") return {
1810
+ outcome,
1811
+ retryAt: new Date(Number(reply[1]))
1812
+ };
1813
+ if (outcome === "settled") return { outcome };
1814
+ throw new TypeError(`claim referenced a missing event in session '${sessionId}'`);
1815
+ };
1816
+ const toIndex = ({ raw, sessionId, name }) => {
1817
+ const index = Number(raw ?? 0);
1818
+ if (!Number.isSafeInteger(index) || index < 0) throw new A2Error("STORE_UNAVAILABLE", `redis ${name} for session '${sessionId}' is invalid`);
1819
+ return index;
1820
+ };
1821
+ /**
1822
+ * A range read that met a gap. A clear moves the old lifetime's log aside, so
1823
+ * a read whose bounds predate it finds nothing where it expected events;
1824
+ * anywhere else a gap is corruption.
1825
+ */
1826
+ const rangeGap = (sessionId, message) => Object.assign(new A2Error("STORE_UNAVAILABLE", `redis event range for session '${sessionId}' ${message}`), { rangeGap: true });
1827
+ const isRangeGap = (error) => error instanceof A2Error && error.rangeGap === true;
1828
+ /**
1829
+ * Run a whole read, bounds included. A gap means a clear raced it, so the
1830
+ * read runs once more from fresh bounds. A second gap, from another clear or
1831
+ * from corruption, surfaces as the retryable STORE_UNAVAILABLE.
1832
+ */
1833
+ const retryAfterGap = async (read) => {
1834
+ try {
1835
+ return await read();
1836
+ } catch (error) {
1837
+ if (!isRangeGap(error)) throw error;
1838
+ return read();
1839
+ }
1840
+ };
1841
+ const wrap = async (fn) => {
1842
+ try {
1843
+ return await fn();
1844
+ } catch (err) {
1845
+ if (err instanceof A2Error || err instanceof TypeError) throw err;
1846
+ throw new A2Error("STORE_UNAVAILABLE", "redis store operation failed", { cause: err });
1847
+ }
1848
+ };
1849
+ function createRedisStoreCore(options) {
1850
+ const { call, callBytes, clock } = options;
1851
+ const generateId = options.ids;
1852
+ const prefix = options.keyPrefix;
1853
+ const storeKey = (s) => `${prefix}:${s}:log`;
1854
+ const countKey = (s) => `${prefix}:${s}:count`;
1855
+ const clearKey = (s) => `${prefix}:${s}:clear`;
1856
+ const schemaKey = `${prefix}:schema`;
1857
+ const snapKey = (s, r) => `${prefix}:${s}:snap:${r}`;
1858
+ const snapHistoryKey = (s, r) => `${prefix}:${s}:snap-history:${r}`;
1859
+ const presenceKey = (s) => `${prefix}:${s}:presence`;
1860
+ const notifyChannel = (s) => `${prefix}:${s}:notify`;
1861
+ const idKey = (id) => `${prefix}:ids:${JSON.stringify(id).replaceAll(":", "\\u003a")}`;
1862
+ const layoutError = () => new A2Error("STORE_NOT_CONFIGURED", `the redis keys under prefix '${prefix}' hold an A2 layout other than '${REDIS_LAYOUT}', from another release; point the store at a fresh keyPrefix, or give the contract a new name so it starts new sessions`);
1863
+ /** Runs a script over a session: KEYS start with its counter and the layout key. */
1864
+ const evalScript = async (script, sessionId, keys, args) => {
1865
+ const allKeys = [
1866
+ countKey(sessionId),
1867
+ schemaKey,
1868
+ ...keys
1869
+ ];
1870
+ try {
1871
+ return await call("EVAL", script, allKeys.length, ...allKeys, ...args);
1872
+ } catch (error) {
1873
+ if (String(error?.message).includes(LAYOUT_ERROR)) throw layoutError();
1874
+ throw error;
1875
+ }
1876
+ };
1877
+ /**
1878
+ * Runs a write that may need a type's index to reach further back first.
1879
+ * Such a write returns ['index', type, after] having written nothing, and
1880
+ * runs again once INDEX_TYPE_LUA has indexed that history, a page per call.
1881
+ */
1882
+ const indexing = async (sessionId, run) => {
1883
+ for (;;) {
1884
+ const reply = await run();
1885
+ if (reply[0] !== "index") return reply;
1886
+ for (;;) {
1887
+ const [done] = await evalScript(INDEX_TYPE_LUA, sessionId, [], [String(reply[1]), Number(reply[2])]);
1888
+ if (done === "1") break;
1889
+ }
1890
+ }
1891
+ };
1892
+ /** The frontier and clear marker, checking the layout in the same MGET. */
1893
+ const readBounds = async (sessionId) => {
1894
+ const [count, cleared, schema] = await call("MGET", countKey(sessionId), clearKey(sessionId), schemaKey);
1895
+ if (schema !== null && schema !== void 0 && schema !== "cursors-2") throw layoutError();
1896
+ return {
1897
+ frontier: toIndex({
1898
+ raw: count,
1899
+ sessionId,
1900
+ name: "event count"
1901
+ }),
1902
+ clearIndex: toIndex({
1903
+ raw: cleared,
1904
+ sessionId,
1905
+ name: "clear boundary"
1906
+ })
1907
+ };
1908
+ };
1909
+ const readEventPages = async (sessionId, afterIndex, throughIndex) => {
1910
+ if (throughIndex <= afterIndex) return [];
1911
+ const events = [];
1912
+ let nextIndex = afterIndex + 1;
1913
+ while (nextIndex <= throughIndex) {
1914
+ const entries = await call("XRANGE", storeKey(sessionId), `${nextIndex}-0`, `${throughIndex}-0`, "COUNT", 256);
1915
+ if (entries.length === 0) throw rangeGap(sessionId, `ended before index ${throughIndex}`);
1916
+ for (let position = 0; position < entries.length; position += 1) {
1917
+ const index = entryIndex(entries[position][0]);
1918
+ if (index !== nextIndex + position || index > throughIndex) throw rangeGap(sessionId, "is not contiguous");
1919
+ }
1920
+ events.push(...entries.map((entry) => toEvent(sessionId, entry)));
1921
+ nextIndex = events.at(-1).index + 1;
1922
+ }
1923
+ return events;
1924
+ };
1925
+ const readEventRange = async (sessionId, afterIndex, throughIndex) => {
1926
+ const events = [];
1927
+ for (let start = afterIndex; start < throughIndex; start += 256 * READ_CONCURRENCY) {
1928
+ const ranges = Array.from({ length: Math.min(READ_CONCURRENCY, Math.ceil((throughIndex - start) / 256)) }, (_, position) => {
1929
+ const after = start + position * 256;
1930
+ return readEventPages(sessionId, after, Math.min(after + 256, throughIndex));
1931
+ });
1932
+ const results = await Promise.allSettled(ranges);
1933
+ for (const result of results) {
1934
+ if (result.status === "rejected") throw result.reason;
1935
+ events.push(...result.value);
1936
+ }
1937
+ }
1938
+ return events;
1939
+ };
1940
+ /** Bounded XRANGE pages restore the full-read contract on capped providers. */
1941
+ const readRange = async (sessionId, afterIndex, throughIndex) => (await readBoundedRange(sessionId, afterIndex, throughIndex)).rows;
1942
+ /** `readRange`, with the clear boundary its rows start at. */
1943
+ const readBoundedRange = async (sessionId, afterIndex, throughIndex) => {
1944
+ if (throughIndex !== void 0 && throughIndex <= afterIndex) return {
1945
+ rows: [],
1946
+ clearIndex: 0
1947
+ };
1948
+ const { frontier: currentFrontier, clearIndex } = await readBounds(sessionId);
1949
+ const frontier = Math.min(throughIndex ?? currentFrontier, currentFrontier);
1950
+ const after = Math.max(afterIndex, clearIndex - 1);
1951
+ if (frontier <= after) return {
1952
+ rows: [],
1953
+ clearIndex
1954
+ };
1955
+ const rows = [];
1956
+ let nextIndex = after + 1;
1957
+ while (nextIndex <= frontier) {
1958
+ const entries = await call("XRANGE", storeKey(sessionId), `${nextIndex}-0`, `${frontier}-0`, "COUNT", 256);
1959
+ if (entries.length === 0) throw rangeGap(sessionId, `ended before index ${frontier}`);
1960
+ const page = entries.map((entry) => toStoredEvent(sessionId, entry));
1961
+ for (let position = 0; position < page.length; position += 1) if (page[position].index !== nextIndex + position) throw rangeGap(sessionId, "is not contiguous");
1962
+ const lastIndex = page.at(-1).index;
1963
+ if (lastIndex < nextIndex || lastIndex > frontier) throw new A2Error("STORE_UNAVAILABLE", `redis event range for session '${sessionId}' did not advance`);
1964
+ rows.push(...page);
1965
+ nextIndex = lastIndex + 1;
1966
+ }
1967
+ return {
1968
+ rows,
1969
+ clearIndex
1970
+ };
1971
+ };
1972
+ const readStream = options.readStream;
1973
+ return {
1974
+ ...readStream ? { streamEvents: async function* ({ sessionId, afterIndex, signal }) {
1975
+ try {
1976
+ for await (const entries of readStream({
1977
+ key: storeKey(sessionId),
1978
+ from: `${afterIndex + 1}-0`,
1979
+ signal
1980
+ })) {
1981
+ signal.throwIfAborted();
1982
+ yield entries.map((entry) => toSerializedEvent(sessionId, entry));
1983
+ }
1984
+ } catch (cause) {
1985
+ if (cause instanceof A2Error) throw cause;
1986
+ throw new A2Error("STORE_UNAVAILABLE", "redis stream read failed", { cause });
1987
+ }
1988
+ } } : {},
1989
+ async append(sessionId, events, appendOptions) {
1990
+ return wrap(async () => {
1991
+ const suppliedIds = events.filter((e) => e.id !== void 0).map((e) => e.id);
1992
+ if (new Set(suppliedIds).size !== suppliedIds.length) throw new A2Error("PARTIAL_DUPLICATE_BATCH", "batch contains the same event id more than once");
1993
+ const now = clock.now().getTime();
1994
+ const withIds = events.map((e) => ({
1995
+ id: e.id ?? generateId(),
1996
+ type: e.type,
1997
+ payload: e.payload,
1998
+ cause: e.cause ? { ...e.cause } : null,
1999
+ targets: e.targets
2000
+ }));
2001
+ const reply = await indexing(sessionId, () => evalScript(APPEND_LUA, sessionId, withIds.map((e) => idKey(e.id)), [
2002
+ sessionId,
2003
+ now,
2004
+ withIds.length,
2005
+ servedArg(appendOptions?.consumers),
2006
+ notifyChannel(sessionId),
2007
+ appendOptions?.originIndex ?? "",
2008
+ ...withIds.flatMap((e) => [
2009
+ e.id,
2010
+ e.type,
2011
+ json(e.payload),
2012
+ e.cause ? json(e.cause) : "",
2013
+ targetsArg(e.targets)
2014
+ ])
2015
+ ]));
2016
+ const [verdict] = reply;
2017
+ if (verdict === "empty") return {
2018
+ events: [],
2019
+ hasPending: reply[1] === "1"
2020
+ };
2021
+ if (verdict === "foreign") throw new A2Error("PARTIAL_DUPLICATE_BATCH", `event id '${reply[1]}' already exists in another session`);
2022
+ if (verdict === "cleared") throw sessionClearedError(`scheduled work from event ${reply[1]} was cleared in session '${sessionId}'`);
2023
+ if (verdict === "superseded") throw supersededAttemptError(sessionId, withIds[Number(reply[1]) - 1].cause);
2024
+ if (verdict === "partial") throw new A2Error("PARTIAL_DUPLICATE_BATCH", `batch mixes ${reply[1]} already-appended and ${events.length - Number(reply[1])} fresh events`);
2025
+ if (verdict === "dup") {
2026
+ const hasPending = reply[1] === "1";
2027
+ const firstIndex = Math.min(...reply.slice(2).map(Number));
2028
+ const { rows: existing, clearIndex } = await retryAfterGap(() => readBoundedRange(sessionId, firstIndex - 1, firstIndex + events.length - 1));
2029
+ if (firstIndex < clearIndex) throw new A2Error("STORE_UNAVAILABLE", `session '${sessionId}' was cleared while an append retry replayed; retry it`);
2030
+ return {
2031
+ events: idempotentReplay(events, existing),
2032
+ hasPending
2033
+ };
2034
+ }
2035
+ const base = Number(reply[1]) - 1;
2036
+ return {
2037
+ events: withIds.map((e, i) => ({
2038
+ id: e.id,
2039
+ type: e.type,
2040
+ payload: structuredClone(e.payload),
2041
+ index: base + 1 + i,
2042
+ sessionId,
2043
+ createdAt: new Date(now),
2044
+ cause: e.cause
2045
+ })),
2046
+ hasPending: reply[2] === "1"
2047
+ };
2048
+ });
2049
+ },
2050
+ async cursors(sessionId) {
2051
+ return wrap(async () => {
2052
+ return (await evalScript(CURSORS_LUA, sessionId, [], [])).map(([consumer, tag, position, head, columns]) => Object.assign(dispatchOf(consumer, laneOf(tag), columns), {
2053
+ position: Number(position),
2054
+ head: head === null ? null : Number(head)
2055
+ })).toSorted(byCursor);
2056
+ });
2057
+ },
2058
+ async read(sessionId, opts) {
2059
+ return wrap(() => retryAfterGap(() => readRange(sessionId, opts?.afterIndex ?? 0, opts?.throughIndex)));
2060
+ },
2061
+ async readHistory(sessionId, opts) {
2062
+ const after = opts?.afterIndex ?? 0;
2063
+ if (opts?.throughIndex !== void 0 && opts.throughIndex <= after) return [];
2064
+ return wrap(() => retryAfterGap(async () => {
2065
+ const { frontier, clearIndex } = await readBounds(sessionId);
2066
+ return readEventRange(sessionId, Math.max(after, clearIndex - 1), Math.min(opts?.throughIndex ?? frontier, frontier));
2067
+ }));
2068
+ },
2069
+ async claimAvailable({ sessionId, holder, ttlMs, expiresAtMs, exclude, consumers, maxFailures, through }) {
2070
+ return wrap(async () => {
2071
+ const now = clock.now().getTime();
2072
+ const expiresAt = expiresAtMs ?? now + ttlMs;
2073
+ const excluded = exclude ?? [];
2074
+ const reply = await evalScript(CLAIM_AVAILABLE_LUA, sessionId, [], [
2075
+ holder,
2076
+ now,
2077
+ expiresAt,
2078
+ maxFailures ?? "",
2079
+ servedArg(consumers),
2080
+ through ?? "",
2081
+ excluded.length,
2082
+ ...excluded.flatMap((ref) => [ref.consumer, ref.lane === null ? `#${ref.index}` : `L${ref.lane}`])
2083
+ ]);
2084
+ return toClaimAvailableResult(sessionId, reply);
2085
+ });
2086
+ },
2087
+ async renewClaims({ sessionId, holder, claims, ttlMs, expiresAtMs }) {
2088
+ if (claims.length === 0) return {
2089
+ renewed: [],
2090
+ superseded: []
2091
+ };
2092
+ return wrap(async () => {
2093
+ const now = clock.now().getTime();
2094
+ const expiresAt = expiresAtMs ?? now + ttlMs;
2095
+ const reply = await evalScript(RENEW_CLAIMS_LUA, sessionId, [], [
2096
+ holder,
2097
+ now,
2098
+ expiresAt,
2099
+ claims.length,
2100
+ ...claims.flatMap((claim) => [
2101
+ claim.consumer,
2102
+ tagOf(claim.lane),
2103
+ claim.index,
2104
+ claim.attempt
2105
+ ])
2106
+ ]);
2107
+ const renewedCount = Number(reply[0]);
2108
+ const refs = reply.slice(1).map((position) => {
2109
+ const { consumer, lane, index } = claims[Number(position) - 1];
2110
+ return {
2111
+ consumer,
2112
+ lane,
2113
+ index
2114
+ };
2115
+ });
2116
+ return {
2117
+ renewed: refs.slice(0, renewedCount).toSorted(byHead),
2118
+ superseded: refs.slice(renewedCount).toSorted(byHead)
2119
+ };
2120
+ });
2121
+ },
2122
+ async completeAttempt({ sessionId, consumer, lane, index, attempt, events, consumers }) {
2123
+ return wrap(async () => {
2124
+ const ids = events.map((event) => event.id);
2125
+ if (new Set(ids).size !== ids.length) throw new A2Error("PARTIAL_DUPLICATE_BATCH", "batch contains the same event id more than once");
2126
+ const now = clock.now().getTime();
2127
+ const cause = {
2128
+ consumer,
2129
+ lane,
2130
+ index,
2131
+ attempt
2132
+ };
2133
+ const reply = await indexing(sessionId, () => evalScript(COMPLETE_ATTEMPT_LUA, sessionId, ids.map((id) => idKey(id)), [
2134
+ sessionId,
2135
+ now,
2136
+ consumer,
2137
+ tagOf(lane),
2138
+ index,
2139
+ attempt,
2140
+ json(cause),
2141
+ servedArg(consumers),
2142
+ notifyChannel(sessionId),
2143
+ json(ids),
2144
+ events.length,
2145
+ ...events.flatMap((event) => [
2146
+ event.id,
2147
+ event.type,
2148
+ json(event.payload),
2149
+ targetsArg(event.targets)
2150
+ ])
2151
+ ]));
2152
+ const outcome = reply[0];
2153
+ if (outcome === "missing") throw new TypeError(`no event at index ${index} in session '${sessionId}'`);
2154
+ if (outcome === "partial") throw new A2Error("PARTIAL_DUPLICATE_BATCH", "returned event ids do not match the completed attempt");
2155
+ if (outcome === "superseded") return { outcome };
2156
+ if (outcome === "ok") {
2157
+ const base = Number(reply[1]) - 1;
2158
+ return {
2159
+ outcome: "completed",
2160
+ events: events.map((event, i) => ({
2161
+ id: event.id,
2162
+ type: event.type,
2163
+ payload: structuredClone(event.payload),
2164
+ index: base + 1 + i,
2165
+ sessionId,
2166
+ createdAt: new Date(now),
2167
+ cause: { ...cause }
2168
+ }))
2169
+ };
2170
+ }
2171
+ if (outcome === "duplicate") {
2172
+ if (events.length === 0) return {
2173
+ outcome: "completed",
2174
+ events: []
2175
+ };
2176
+ const firstIndex = Math.min(...reply.slice(1).map(Number));
2177
+ const { rows: existing, clearIndex } = await retryAfterGap(() => readBoundedRange(sessionId, firstIndex - 1, firstIndex + events.length - 1));
2178
+ if (index <= clearIndex) return { outcome: "superseded" };
2179
+ if (existing.some((row) => row.cause?.consumer !== consumer || row.cause.lane !== lane || row.cause.index !== index || row.cause.attempt !== attempt)) throw new A2Error("PARTIAL_DUPLICATE_BATCH", "returned event retry does not match the committed batch");
2180
+ return {
2181
+ outcome: "completed",
2182
+ events: idempotentReplay(events, existing)
2183
+ };
2184
+ }
2185
+ throw new TypeError(`unexpected completion outcome '${outcome}'`);
2186
+ });
2187
+ },
2188
+ async failAttempt({ sessionId, consumer, lane, index, attempt, error, maxFailures, retryAfterMs }) {
2189
+ return wrap(async () => {
2190
+ const now = clock.now().getTime();
2191
+ const reply = await evalScript(FAIL_ATTEMPT_LUA, sessionId, [], [
2192
+ consumer,
2193
+ tagOf(lane),
2194
+ index,
2195
+ attempt,
2196
+ error,
2197
+ maxFailures,
2198
+ now,
2199
+ retryAfterMs === void 0 ? "" : now + retryAfterMs
2200
+ ]);
2201
+ const outcome = reply[0];
2202
+ if (outcome === "missing") throw new TypeError(`no event at index ${index} in session '${sessionId}'`);
2203
+ return {
2204
+ outcome,
2205
+ failureCount: Number(reply[1])
2206
+ };
2207
+ });
2208
+ },
2209
+ async deadLetters(sessionId) {
2210
+ return wrap(async () => {
2211
+ return (await evalScript(DEAD_LETTERS_LUA, sessionId, [], [])).map((head) => toHeadEvent(sessionId, head)).toSorted(byHead);
2212
+ });
2213
+ },
2214
+ async redrive({ sessionId, consumer, index, action, consumers }) {
2215
+ return wrap(async () => {
2216
+ const [redriven, hasPending] = await indexing(sessionId, () => evalScript(REDRIVE_LUA, sessionId, [], [
2217
+ consumer,
2218
+ index,
2219
+ action,
2220
+ clock.now().getTime(),
2221
+ servedArg(consumers)
2222
+ ]));
2223
+ return {
2224
+ redriven: redriven === "1",
2225
+ hasPending: hasPending === "1"
2226
+ };
2227
+ });
2228
+ },
2229
+ async wakeLog({ sessionId, consumer }) {
2230
+ return wrap(async () => {
2231
+ const { name, log } = consumer;
2232
+ const [, position, head, columns, sessionHead] = await indexing(sessionId, () => evalScript(WAKE_LOG_LUA, sessionId, [], [
2233
+ name,
2234
+ log.lane,
2235
+ logTypesArg(log.types),
2236
+ log.backfill ? 1 : 0
2237
+ ]));
2238
+ return {
2239
+ cursor: Object.assign(dispatchOf(name, log.lane, columns), {
2240
+ position: Number(position),
2241
+ head: head === null ? null : Number(head)
2242
+ }),
2243
+ head: Number(sessionHead)
2244
+ };
2245
+ });
2246
+ },
2247
+ async readState(sessionId, reducerName, stateOptions) {
2248
+ return wrap(() => retryAfterGap(async () => {
2249
+ const reply = await evalScript(READ_STATE_LUA, sessionId, [snapKey(sessionId, reducerName), snapHistoryKey(sessionId, reducerName)], [stateOptions?.snapshotThroughIndex ?? stateOptions?.throughIndex ?? ""]);
2250
+ const rawHead = reply[0];
2251
+ const raw = reply[1];
2252
+ const headIndex = rawHead === "" ? null : Number(rawHead);
2253
+ const parsed = raw === "" ? null : JSON.parse(raw);
2254
+ const currentFrontier = Number(reply[2]);
2255
+ 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`);
2256
+ const frontier = Math.min(currentFrontier, stateOptions?.throughIndex ?? currentFrontier);
2257
+ const snapshot = parsed;
2258
+ const clearIndex = Number(reply[3] ?? 0);
2259
+ const events = await readEventRange(sessionId, Math.max(snapshot?.index ?? 0, clearIndex - 1), frontier);
2260
+ return {
2261
+ ...clearIndex > 0 ? { clearIndex } : {},
2262
+ headIndex,
2263
+ snapshot: snapshot ? {
2264
+ index: snapshot.index,
2265
+ state: snapshot.state
2266
+ } : null,
2267
+ events
2268
+ };
2269
+ }));
2270
+ },
2271
+ async putSnapshots(sessionId, reducerName, writes) {
2272
+ const byIndex = /* @__PURE__ */ new Map();
2273
+ for (const write of writes) {
2274
+ const existing = byIndex.get(write.index);
2275
+ const pins = existing?.pins ?? /* @__PURE__ */ new Map();
2276
+ for (const pin of write.pins ?? []) pins.set(JSON.stringify([
2277
+ pin.consumer,
2278
+ pin.lane,
2279
+ pin.index
2280
+ ]), pin);
2281
+ if (existing) existing.state = write.state;
2282
+ else byIndex.set(write.index, {
2283
+ index: write.index,
2284
+ state: write.state,
2285
+ pins
2286
+ });
2287
+ }
2288
+ const normalized = [...byIndex.values()].toSorted((a, b) => a.index - b.index);
2289
+ if (normalized.length === 0) return;
2290
+ await wrap(async () => {
2291
+ const args = [normalized.length];
2292
+ for (const write of normalized) args.push(write.index, json({
2293
+ index: write.index,
2294
+ state: write.state,
2295
+ updatedAt: clock.now().getTime(),
2296
+ r: 1
2297
+ }), write.pins.size, ...[...write.pins.values()].flatMap((pin) => [
2298
+ pin.consumer,
2299
+ tagOf(pin.lane),
2300
+ pin.index
2301
+ ]));
2302
+ await evalScript(PUT_SNAPSHOTS_LUA, sessionId, [snapKey(sessionId, reducerName), snapHistoryKey(sessionId, reducerName)], [...args, reducerName]);
2303
+ });
2304
+ },
2305
+ presence: {
2306
+ async set(ns, participant, values, meta) {
2307
+ return wrap(async () => {
2308
+ const entries = Object.entries(values);
2309
+ if (entries.length === 0) return null;
2310
+ const atMs = meta.at.getTime();
2311
+ const expiresAtMs = clock.now().getTime() + meta.ttlMs;
2312
+ const applied = await call("EVAL", PRESENCE_SET_LUA, 1, presenceKey(ns), atMs, meta.ttlMs, entries.length, ...entries.flatMap(([field, value]) => [
2313
+ JSON.stringify([participant, field]),
2314
+ value === null ? "" : json({
2315
+ v: value,
2316
+ s: meta.seen,
2317
+ a: atMs,
2318
+ e: expiresAtMs
2319
+ }),
2320
+ `${json(field)}:${json(value)}`
2321
+ ]), presenceKey(ns), `${json({
2322
+ src: options.presenceSource,
2323
+ participant,
2324
+ seen: meta.seen,
2325
+ at: atMs
2326
+ }).slice(0, -1)},"values":{`);
2327
+ if (applied.length === 0) return null;
2328
+ const appliedValues = nullProtoRecord();
2329
+ for (const position of applied) {
2330
+ const [field, value] = entries[Number(position) - 1];
2331
+ appliedValues[field] = structuredClone(value);
2332
+ }
2333
+ return {
2334
+ participant,
2335
+ values: appliedValues,
2336
+ seen: meta.seen,
2337
+ at: new Date(atMs)
2338
+ };
2339
+ });
2340
+ },
2341
+ async read(ns) {
2342
+ return wrap(async () => {
2343
+ const reply = await call("EVAL", PRESENCE_READ_LUA, 1, presenceKey(ns), clock.now().getTime());
2344
+ const rows = [];
2345
+ for (let i = 0; i + 1 < reply.length; i += 2) {
2346
+ const [participant, field] = JSON.parse(reply[i]);
2347
+ const entry = JSON.parse(reply[i + 1]);
2348
+ rows.push({
2349
+ participant,
2350
+ field,
2351
+ value: entry.v,
2352
+ seen: entry.s,
2353
+ at: new Date(entry.a),
2354
+ expiresAt: new Date(entry.e)
2355
+ });
2356
+ }
2357
+ return rows;
2358
+ });
2359
+ }
2360
+ },
2361
+ async readRange(sessionId, afterIndex, throughIndex) {
2362
+ return wrap(() => readRange(sessionId, afterIndex, throughIndex));
2363
+ },
2364
+ async readTail(sessionId, fromIndex, throughIndex) {
2365
+ return wrap(async () => {
2366
+ const page = (await call("XREVRANGE", storeKey(sessionId), throughIndex === void 0 ? "+" : `${throughIndex}-0`, `${fromIndex}-0`, "COUNT", 256)).toReversed().map((entry) => toEvent(sessionId, entry));
2367
+ 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`);
2368
+ const oldest = page[0]?.index;
2369
+ return oldest === void 0 || oldest <= fromIndex ? page : null;
2370
+ });
2371
+ },
2372
+ async clear({ sessionId, ifLastIndex, cause }) {
2373
+ return wrap(async () => {
2374
+ const markerId = generateId();
2375
+ const reply = await evalScript(CLEAR_LUA, sessionId, [idKey(markerId)], [
2376
+ sessionId,
2377
+ clock.now().getTime(),
2378
+ ifLastIndex ?? "",
2379
+ cause?.consumer ?? "",
2380
+ cause ? tagOf(cause.lane) : "",
2381
+ cause?.index ?? "",
2382
+ cause?.attempt ?? "",
2383
+ markerId,
2384
+ notifyChannel(sessionId)
2385
+ ]);
2386
+ if (reply[0] === "superseded") throw supersededAttemptError(sessionId, cause);
2387
+ if (reply[0] === "foreign") throw new A2Error("PARTIAL_DUPLICATE_BATCH", `the clear marker id '${markerId}' already exists`);
2388
+ return {
2389
+ cleared: reply[1] === "1",
2390
+ index: Number(reply[2]),
2391
+ clearIndex: Number(reply[3])
2392
+ };
2393
+ });
2394
+ },
2395
+ async reclaim(sessionId) {
2396
+ return wrap(async () => {
2397
+ const [log, ...pairs] = await evalScript(RECLAIM_BATCH_LUA, sessionId, [], [256]);
2398
+ if (log === void 0) return {
2399
+ deleted: 0,
2400
+ done: true
2401
+ };
2402
+ const ids = [];
2403
+ for (let i = 1; i < pairs.length; i += 2) ids.push(pairs[i]);
2404
+ const [deleted, done] = await evalScript(RECLAIM_LUA, sessionId, [log, ...ids.map((id) => idKey(id))], [
2405
+ sessionId,
2406
+ ids.length,
2407
+ ...pairs
2408
+ ]);
2409
+ return {
2410
+ deleted: Number(deleted),
2411
+ done: Number(done) === 1
2412
+ };
2413
+ });
2414
+ },
2415
+ async readEvents(sessionId, afterIndex) {
2416
+ return wrap(async () => {
2417
+ return (await call("XRANGE", storeKey(sessionId), `${afterIndex + 1}-0`, "+", "COUNT", 256)).map((entry) => toEvent(sessionId, entry));
2418
+ });
2419
+ },
2420
+ async readFeedPage(sessionId, afterIndex) {
2421
+ return wrap(async () => {
2422
+ const range = [
2423
+ storeKey(sessionId),
2424
+ `${afterIndex + 1}-0`,
2425
+ "+",
2426
+ "COUNT",
2427
+ FEED_PAGE_SIZE
2428
+ ];
2429
+ if (callBytes) return (await callBytes("XRANGE", ...range)).map((entry) => bytesToSerializedEvent(sessionId, entry));
2430
+ return (await call("XRANGE", ...range)).map((entry) => toSerializedEvent(sessionId, entry));
2431
+ });
2432
+ }
2433
+ };
2434
+ }
2435
+ //#endregion
2436
+ //#region src/store-redis-notify.ts
2437
+ /** A feed that falls this far behind drops its inbox and catches up. */
2438
+ const INBOX_BYTES = 524288;
2439
+ /**
2440
+ * Parses a wake-up message: `{"f":frontier}` or a bare frontier number
2441
+ * announces events without carrying them; `{"f","t","e"}` carries them.
2442
+ * Returns null for anything else, which callers treat as a hint to catch up.
2443
+ */
2444
+ const parseWake = (message) => {
2445
+ let parsed;
2446
+ try {
2447
+ parsed = JSON.parse(message);
2448
+ } catch {
2449
+ return null;
2450
+ }
2451
+ if (typeof parsed === "number") return Number.isSafeInteger(parsed) ? {
2452
+ frontier: parsed,
2453
+ createdAt: 0,
2454
+ entries: []
2455
+ } : null;
2456
+ if (typeof parsed !== "object" || parsed === null) return null;
2457
+ const { f, t, e } = parsed;
2458
+ if (typeof f !== "number" || !Number.isSafeInteger(f)) return null;
2459
+ if (e === void 0) return {
2460
+ frontier: f,
2461
+ createdAt: 0,
2462
+ entries: []
2463
+ };
2464
+ if (!Array.isArray(e) || typeof t !== "number" || !Number.isFinite(t)) return null;
2465
+ 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;
2466
+ return {
2467
+ frontier: f,
2468
+ createdAt: t,
2469
+ entries: e
2470
+ };
2471
+ };
2472
+ function createRedisNotifyStore(options) {
2473
+ const { client, clock, ids, keyPrefix: prefix } = options;
2474
+ const notifyChannel = (sessionId) => `${prefix}:${sessionId}:notify`;
2475
+ const presenceChannel = (ns) => `${prefix}:${ns}:presence`;
2476
+ const instanceId = crypto.randomUUID();
2477
+ const core = createRedisStoreCore({
2478
+ call: async (command, ...args) => {
2479
+ return (await client()).call(command, ...args);
2480
+ },
2481
+ clock,
2482
+ ids,
2483
+ keyPrefix: prefix,
2484
+ presenceSource: instanceId,
2485
+ ...options.callBytes ? { callBytes: options.callBytes } : {},
2486
+ ...options.readStream ? { readStream: options.readStream } : {}
2487
+ });
2488
+ let severed = false;
2489
+ let subscriber = null;
2490
+ /** channel → live event feeds, registered before they subscribe. */
2491
+ const feeds = /* @__PURE__ */ new Map();
2492
+ /** channel → refcounted subscription held for each live iterator. */
2493
+ const subscriptions = /* @__PURE__ */ new Map();
2494
+ const presenceSubscriptions = /* @__PURE__ */ new Map();
2495
+ const pollingStreams = /* @__PURE__ */ new Set();
2496
+ options.onReconnect?.((channel) => {
2497
+ for (const feed of feeds.get(channel) ?? []) {
2498
+ feed.dirty = true;
2499
+ feed.wake?.();
2500
+ }
2501
+ presenceSubscriptions.get(channel)?.recovery?.reconnect().catch(() => {});
2502
+ });
2503
+ const deliverRemotePresence = ({ subscription, message }) => {
2504
+ let parsed;
2505
+ try {
2506
+ parsed = JSON.parse(message);
2507
+ } catch {
2508
+ return;
2509
+ }
2510
+ if (parsed.src === instanceId) return;
2511
+ const patch = {
2512
+ participant: parsed.participant,
2513
+ values: parsed.values,
2514
+ seen: parsed.seen,
2515
+ at: new Date(parsed.at)
2516
+ };
2517
+ subscription.recovery?.update(patch);
2518
+ for (const listener of subscription.listeners) listener(patch);
2519
+ };
2520
+ const getSubscriber = () => {
2521
+ subscriber ??= (async () => {
2522
+ const conn = (await client()).duplicate();
2523
+ conn.on("message", (channel, message) => {
2524
+ for (const feed of feeds.get(channel) ?? []) {
2525
+ if (feed.bytes + message.length <= INBOX_BYTES) {
2526
+ feed.inbox.push(message);
2527
+ feed.bytes += message.length;
2528
+ } else {
2529
+ feed.inbox.length = 0;
2530
+ feed.bytes = 0;
2531
+ feed.dirty = true;
2532
+ }
2533
+ feed.wake?.();
2534
+ }
2535
+ const subscription = presenceSubscriptions.get(channel);
2536
+ if (subscription) deliverRemotePresence({
2537
+ subscription,
2538
+ message
2539
+ });
2540
+ });
2541
+ return conn;
2542
+ })();
2543
+ return subscriber;
2544
+ };
2545
+ /**
2546
+ * The subscriber, gated on connection readiness — for channel
2547
+ * commands only (`close` uses the ungated promise, so it can never
2548
+ * hang on an unreachable server). SUBSCRIBE carries redis's
2549
+ * ok-loading flag, so ioredis writes it mid-handshake, ahead of the
2550
+ * ready check's INFO — which then finds the connection already in
2551
+ * subscriber mode and fails the check (a spurious error event plus
2552
+ * a reconnect). ECHO has no such flag: it resolves only once the
2553
+ * connection is ready, so every channel command chained behind it
2554
+ * lands after the check.
2555
+ */
2556
+ let subscriberGate = null;
2557
+ const gatedSubscriber = () => {
2558
+ subscriberGate ??= getSubscriber().then(async (conn) => {
2559
+ await conn.call("echo", "ready");
2560
+ return conn;
2561
+ });
2562
+ return subscriberGate;
2563
+ };
2564
+ const acquireChannel = async (channel) => {
2565
+ const existing = subscriptions.get(channel);
2566
+ if (existing) {
2567
+ existing.refs += 1;
2568
+ try {
2569
+ await existing.ready;
2570
+ } catch (err) {
2571
+ existing.refs -= 1;
2572
+ throw err;
2573
+ }
2574
+ return;
2575
+ }
2576
+ const lease = {
2577
+ refs: 1,
2578
+ ready: gatedSubscriber().then(async (conn) => {
2579
+ await conn.call("subscribe", channel);
2580
+ if (subscriptions.get(channel) !== lease || severed) return;
2581
+ const presence = presenceSubscriptions.get(channel);
2582
+ if (options.polling?.() && presence) {
2583
+ presence.recovery = void 0;
2584
+ const ns = channel.slice(prefix.length + 1, -9);
2585
+ presence.poll = pollPresence({
2586
+ read: () => core.presence.read(ns),
2587
+ deliver: (patch) => {
2588
+ for (const listener of presence.listeners) listener(patch);
2589
+ }
2590
+ });
2591
+ await presence.poll.ready;
2592
+ }
2593
+ })
2594
+ };
2595
+ subscriptions.set(channel, lease);
2596
+ try {
2597
+ await lease.ready;
2598
+ } catch (err) {
2599
+ if (subscriptions.get(channel) === lease) {
2600
+ subscriptions.delete(channel);
2601
+ const presence = presenceSubscriptions.get(channel);
2602
+ presence?.poll?.stop();
2603
+ if (presence) presence.poll = void 0;
2604
+ }
2605
+ throw err;
2606
+ }
2607
+ };
2608
+ const releaseChannel = (channel) => {
2609
+ const lease = subscriptions.get(channel);
2610
+ if (!lease) return;
2611
+ lease.refs -= 1;
2612
+ if (lease.refs > 0) return;
2613
+ subscriptions.delete(channel);
2614
+ gatedSubscriber().then((conn) => conn.call("unsubscribe", channel)).catch(() => {});
2615
+ };
2616
+ const store = {
2617
+ append: core.append,
2618
+ cursors: core.cursors,
2619
+ read: core.read,
2620
+ readHistory: core.readHistory,
2621
+ claimAvailable: core.claimAvailable,
2622
+ renewClaims: core.renewClaims,
2623
+ completeAttempt: core.completeAttempt,
2624
+ failAttempt: core.failAttempt,
2625
+ readState: core.readState,
2626
+ putSnapshots: core.putSnapshots,
2627
+ clear: core.clear,
2628
+ reclaim: core.reclaim,
2629
+ deadLetters: core.deadLetters,
2630
+ redrive: core.redrive,
2631
+ wakeLog: core.wakeLog,
2632
+ presence: {
2633
+ async set(ns, participant, values, meta) {
2634
+ const patch = await core.presence.set(ns, participant, values, meta);
2635
+ if (!patch) return;
2636
+ const channel = presenceChannel(ns);
2637
+ const subscription = presenceSubscriptions.get(channel);
2638
+ if (subscription) {
2639
+ subscription.poll?.update(patch);
2640
+ subscription.recovery?.update(patch);
2641
+ for (const listener of subscription.listeners) listener(patch);
2642
+ }
2643
+ },
2644
+ async read(ns) {
2645
+ const channel = presenceChannel(ns);
2646
+ await subscriptions.get(channel)?.ready;
2647
+ return presenceSubscriptions.get(channel)?.recovery?.read() ?? core.presence.read(ns);
2648
+ },
2649
+ subscribe(ns, onPatch) {
2650
+ const channel = presenceChannel(ns);
2651
+ let subscription = presenceSubscriptions.get(channel);
2652
+ if (!subscription) {
2653
+ subscription = { listeners: /* @__PURE__ */ new Set() };
2654
+ presenceSubscriptions.set(channel, subscription);
2655
+ if (options.onReconnect && !options.polling?.()) {
2656
+ const current = subscription;
2657
+ subscription.recovery = presenceRecovery({
2658
+ read: () => core.presence.read(ns),
2659
+ deliver: (patch) => {
2660
+ for (const listener of current.listeners) listener(patch);
2661
+ },
2662
+ isActive: () => !severed && presenceSubscriptions.get(channel) === current
2663
+ });
2664
+ }
2665
+ }
2666
+ subscription.listeners.add(onPatch);
2667
+ acquireChannel(channel).catch(() => {});
2668
+ let stopped = false;
2669
+ return () => {
2670
+ if (stopped) return;
2671
+ stopped = true;
2672
+ subscription.listeners.delete(onPatch);
2673
+ if (subscription.listeners.size === 0) {
2674
+ subscription.poll?.stop();
2675
+ presenceSubscriptions.delete(channel);
2676
+ }
2677
+ releaseChannel(channel);
2678
+ };
2679
+ }
2680
+ },
2681
+ stream(sessionId, opts) {
2682
+ const startAfter = opts?.startAfter ?? 0;
2683
+ const channel = notifyChannel(sessionId);
2684
+ return { [Symbol.asyncIterator]() {
2685
+ let last = startAfter;
2686
+ let buffer = [];
2687
+ let closed = false;
2688
+ let subscribed = false;
2689
+ let pending = null;
2690
+ let poller;
2691
+ let stopActivity;
2692
+ let catchup;
2693
+ let stoppingRead;
2694
+ const stopRead = () => {
2695
+ const reading = catchup;
2696
+ catchup = void 0;
2697
+ if (reading) {
2698
+ reading.controller.abort();
2699
+ stoppingRead = reading.iterator.return(void 0).then(() => void 0).catch(() => {});
2700
+ }
2701
+ return stoppingRead ?? Promise.resolve();
2702
+ };
2703
+ const nextRead = async (after) => {
2704
+ if (!catchup) {
2705
+ const controller = new AbortController();
2706
+ catchup = {
2707
+ controller,
2708
+ delivered: false,
2709
+ iterator: core.streamEvents({
2710
+ sessionId,
2711
+ afterIndex: after,
2712
+ signal: controller.signal
2713
+ })[Symbol.asyncIterator]()
2714
+ };
2715
+ }
2716
+ const reading = catchup;
2717
+ const result = await reading.iterator.next();
2718
+ if (result.done && catchup === reading) {
2719
+ catchup = void 0;
2720
+ if (reading.delivered) feed.dirty = true;
2721
+ } else if (!result.done) reading.delivered = true;
2722
+ return result;
2723
+ };
2724
+ const feed = {
2725
+ inbox: [],
2726
+ bytes: 0,
2727
+ dirty: true,
2728
+ wake: null,
2729
+ stopRead
2730
+ };
2731
+ const activity = createStreamActivity(() => {
2732
+ if (poller) streamActivities.get(poller)?.activate();
2733
+ });
2734
+ const release = () => {
2735
+ stopRead();
2736
+ stopActivity?.();
2737
+ if (poller) {
2738
+ poller.return?.();
2739
+ pollingStreams.delete(poller);
2740
+ }
2741
+ if (!subscribed) return;
2742
+ subscribed = false;
2743
+ const set = feeds.get(channel);
2744
+ set?.delete(feed);
2745
+ if (set?.size === 0) feeds.delete(channel);
2746
+ releaseChannel(channel);
2747
+ };
2748
+ /**
2749
+ * Moves carried events that continue this feed's sequence into
2750
+ * the buffer. A gap, a message without events, or an unreadable
2751
+ * message marks the feed dirty so it catches up with XRANGE.
2752
+ */
2753
+ const applyInbox = () => {
2754
+ feed.bytes = 0;
2755
+ for (const message of feed.inbox.splice(0)) {
2756
+ const wake = parseWake(message);
2757
+ if (!wake) {
2758
+ feed.dirty = true;
2759
+ continue;
2760
+ }
2761
+ for (const [index, id, type, payload] of wake.entries) {
2762
+ const expected = (buffer.at(-1)?.index ?? last) + 1;
2763
+ if (index < expected) continue;
2764
+ if (index > expected) {
2765
+ feed.dirty = true;
2766
+ break;
2767
+ }
2768
+ buffer.push({
2769
+ id,
2770
+ type,
2771
+ payload,
2772
+ index,
2773
+ sessionId,
2774
+ createdAt: new Date(wake.createdAt)
2775
+ });
2776
+ }
2777
+ if (wake.frontier > (buffer.at(-1)?.index ?? last)) feed.dirty = true;
2778
+ }
2779
+ };
2780
+ const iterator = {
2781
+ async next() {
2782
+ for (;;) {
2783
+ if (closed) {
2784
+ release();
2785
+ return {
2786
+ value: void 0,
2787
+ done: true
2788
+ };
2789
+ }
2790
+ const row = buffer.shift();
2791
+ if (row) {
2792
+ let event;
2793
+ try {
2794
+ event = isSerializedEvent(row) ? parseSerializedEvent(row) : row;
2795
+ } catch (cause) {
2796
+ buffer = [];
2797
+ feed.dirty = true;
2798
+ release();
2799
+ throw new A2Error("STORE_UNAVAILABLE", "redis stream read failed", { cause });
2800
+ }
2801
+ last = row.index;
2802
+ return {
2803
+ value: event,
2804
+ done: false
2805
+ };
2806
+ }
2807
+ if (severed) {
2808
+ release();
2809
+ throw new A2Error("STORE_UNAVAILABLE", "the store was closed");
2810
+ }
2811
+ try {
2812
+ if (!subscribed) {
2813
+ subscribed = true;
2814
+ let set = feeds.get(channel);
2815
+ if (!set) {
2816
+ set = /* @__PURE__ */ new Set();
2817
+ feeds.set(channel, set);
2818
+ }
2819
+ set.add(feed);
2820
+ await acquireChannel(channel);
2821
+ if (closed || severed) continue;
2822
+ }
2823
+ if (options.polling?.()) {
2824
+ if (!poller) {
2825
+ poller = pollingStream((after) => core.readEvents(sessionId, after), { startAfter: last })[Symbol.asyncIterator]();
2826
+ pollingStreams.add(poller);
2827
+ stopActivity = streamActivities.get(poller)?.subscribe(activity.setIdle);
2828
+ }
2829
+ const result = await poller.next();
2830
+ if (!result.done) presenceSubscriptions.get(presenceChannel(sessionId))?.poll?.wake();
2831
+ if (severed) throw new A2Error("STORE_UNAVAILABLE", "the store was closed");
2832
+ return result;
2833
+ }
2834
+ if (catchup) {
2835
+ const result = await nextRead(last);
2836
+ if (closed || severed) continue;
2837
+ if (!result.done) buffer = result.value;
2838
+ continue;
2839
+ }
2840
+ applyInbox();
2841
+ if (buffer.length > 0) continue;
2842
+ if (feed.dirty) {
2843
+ feed.dirty = false;
2844
+ if (core.streamEvents) {
2845
+ const result = await nextRead(last);
2846
+ if (closed || severed) continue;
2847
+ if (!result.done) buffer = result.value;
2848
+ continue;
2849
+ }
2850
+ buffer = await core.readFeedPage(sessionId, last);
2851
+ if (buffer.length >= 1e3) feed.dirty = true;
2852
+ continue;
2853
+ }
2854
+ const woken = new Promise((resolve) => {
2855
+ feed.wake = () => resolve("wake");
2856
+ });
2857
+ pending = defaultSleep(NOTIFY_TIMINGS.safetyReadMs);
2858
+ const outcome = await Promise.race([woken, pending.promise.then(() => "timer")]);
2859
+ pending.cancel();
2860
+ pending = null;
2861
+ if (outcome === "timer") feed.dirty = true;
2862
+ } catch (err) {
2863
+ release();
2864
+ if (closed) return {
2865
+ value: void 0,
2866
+ done: true
2867
+ };
2868
+ if (err instanceof A2Error) throw err;
2869
+ throw new A2Error("STORE_UNAVAILABLE", "redis store operation failed", { cause: err });
2870
+ } finally {
2871
+ feed.wake = null;
2872
+ }
2873
+ }
2874
+ },
2875
+ async return() {
2876
+ closed = true;
2877
+ pending?.cancel();
2878
+ feed.wake?.();
2879
+ release();
2880
+ await stopRead();
2881
+ return {
2882
+ value: void 0,
2883
+ done: true
2884
+ };
2885
+ }
2886
+ };
2887
+ streamActivities.set(iterator, activity);
2888
+ bufferedEvents.set(iterator, () => {
2889
+ if (closed) return void 0;
2890
+ const row = buffer.shift();
2891
+ if (row) last = row.index;
2892
+ return row;
2893
+ });
2894
+ return iterator;
2895
+ } };
2896
+ },
2897
+ async close() {
2898
+ severed = true;
2899
+ for (const subscription of presenceSubscriptions.values()) subscription.poll?.stop();
2900
+ presenceSubscriptions.clear();
2901
+ for (const poller of pollingStreams) poller.return?.();
2902
+ pollingStreams.clear();
2903
+ const reads = [];
2904
+ for (const set of feeds.values()) for (const feed of set) {
2905
+ feed.wake?.();
2906
+ reads.push(feed.stopRead());
2907
+ }
2908
+ await Promise.allSettled(reads);
2909
+ if (subscriber) (await subscriber.catch(() => null))?.disconnect();
2910
+ await options.closeClient();
2911
+ }
2912
+ };
2913
+ stateTailReaders.set(store, core.readTail);
2914
+ return store;
2915
+ }
2916
+ //#endregion
2917
+ export { createRedisNotifyStore as t };
2918
+
2919
+ //# sourceMappingURL=store-redis-notify-IiVdlYHc.js.map