experimental-a2 0.20.0 → 0.22.0

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