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