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,12 +1,11 @@
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 } from "./internal-CscSY7K_.js";
3
- import { n as pollingStream, t as defaultSleep } from "./store-polling-7WJV0YWP.js";
1
+ import { i as A2Error, r as sessionClearedError, s as supersededAttemptError, t as CLEAR_EVENT_TYPE } from "./session-clear-wHd5GCpZ.js";
2
+ import { i as NOTIFY_TIMINGS, y as nullProtoRecord } from "./internal-CvsrprnU.js";
3
+ import { n as pollingStream, t as defaultSleep } from "./store-polling-BZt2Rf3D.js";
4
4
  import { r as platformWaitUntil } from "./platform-B4TnJtWu.js";
5
5
  import { t as retryableLazy } from "./retryable-lazy-DZWmHpii.js";
6
- import { t as idempotentReplay } from "./idempotent-replay-JdgMQRRm.js";
6
+ import { a as idempotentReplay, i as idleLogCursor, n as decodeCause, r as decodeReturnedEventIds, t as cursorKeyOf } from "./store-codec-DwrWlgQx.js";
7
7
  import { n as SYSTEM_CLOCK, t as RANDOM_IDS } from "./store-N8PXxDAS.js";
8
- import { t as decodeReturnedEventIds } from "./store-codec-DTG0Ftek.js";
9
- import { t as pollPresence } from "./store-presence-polling-B1ixeqIS.js";
8
+ import { t as pollPresence } from "./store-presence-polling-JuAy2Ck2.js";
10
9
  import { createHash } from "node:crypto";
11
10
  //#region src/postgres-notifications.ts
12
11
  const postgresNotificationChannel = ({ kind, sessionId }) => `a2_${kind}_${createHash("md5").update(sessionId).digest("hex")}`;
@@ -171,35 +170,72 @@ function postgresNotifications(createConnection) {
171
170
  }
172
171
  };
173
172
  }
173
+ //#endregion
174
+ //#region src/postgres-schema.ts
175
+ /**
176
+ * The Postgres schema contract. A2 never runs DDL in a Postgres database:
177
+ * `sql/postgres/schema.sql`, or the tables in `drizzle-schema.ts` next to it,
178
+ * create
179
+ * the tables, and `notifications.sql` the triggers `listenConnectionString`
180
+ * needs. A store checks nothing up front. A query that finds a table or column
181
+ * missing fails anyway, so the store only turns that failure into one that
182
+ * says what to apply. Missing triggers fail no query, so a listening store
183
+ * looks for them when its listener starts.
184
+ */
174
185
  const PACKAGE = "node_modules/experimental-a2";
175
- const apply = (file) => `apply ${PACKAGE}/sql/postgres/${file} to this database`;
176
186
  const SEE_DOCS = `See ${PACKAGE}/docs/stores/04-postgres.mdx.`;
177
- const VERSION = "select version from a2_schema";
178
- const VERSION_AND_TRIGGERS = `select version, (select count(*) from pg_trigger
179
- where (tgname = 'a2_notify_events' and tgrelid = to_regclass('a2_events'))
180
- or (tgname = 'a2_notify_presence' and tgrelid = to_regclass('a2_presence')))
181
- as notify_triggers from a2_schema`;
182
- /** Explains a failed version read, or returns undefined when the schema is not the cause. */
183
- const diagnose = async (client) => {
184
- const [found] = (await client.query(`select
185
- to_regclass('a2_schema') is not null as marked,
186
- to_regclass('a2_events') is not null as installed,
187
- has_table_privilege(to_regclass('a2_schema'), 'select') as readable`)).rows;
188
- if (!found) return void 0;
189
- if (!found["marked"]) return new A2Error("STORE_NOT_CONFIGURED", found["installed"] ? `This Postgres database has A2's tables but no a2_schema version. That is expected once after upgrading from a release that created its tables at runtime, and your events are untouched. A2 doesn't create or change tables at runtime: ${apply("schema.sql")}. ${SEE_DOCS}` : `A2's tables aren't in this Postgres database, and A2 doesn't create tables at runtime: ${apply("schema.sql")}. ${SEE_DOCS}`);
190
- if (found["readable"] === false) return new A2Error("STORE_NOT_CONFIGURED", `The role this store connects as can't read a2_schema. Apply schema.sql as that role, or grant it select on a2_schema and select, insert, update, and delete on A2's other tables. ${SEE_DOCS}`);
191
- };
192
- /** Reads the schema version (and, with a listener, the triggers) in one query that takes no lock a writer holds. */
193
- const checkPostgresSchema = async ({ client, listen }) => {
194
- let row;
195
- try {
196
- row = (await client.query(listen ? VERSION_AND_TRIGGERS : VERSION)).rows[0];
197
- } catch (err) {
198
- throw await diagnose(client).catch(() => void 0) ?? err;
187
+ /** Postgres error codes a store that hasn't been set up raises. */
188
+ const UNDEFINED_TABLE = "42P01";
189
+ const UNDEFINED_COLUMN = "42703";
190
+ const UNDEFINED_FUNCTION = "42883";
191
+ /** `a2_batch` refuses to run outside read committed. */
192
+ const BATCH_ISOLATION = "A2002";
193
+ const INSUFFICIENT_PRIVILEGE = "42501";
194
+ /** NUL, which Postgres can't store: in jsonb (`\u0000`), or in text. */
195
+ const UNTRANSLATABLE_CHARACTER = "22P05";
196
+ const CHARACTER_NOT_IN_REPERTOIRE = "22021";
197
+ /** The Postgres error in `error`'s cause chain, for clients that wrap theirs. */
198
+ const postgresError = (error) => {
199
+ for (let current = error, depth = 0; depth < 5; depth += 1) {
200
+ if (typeof current !== "object" || current === null) return void 0;
201
+ const { code, message, cause } = current;
202
+ if (typeof code === "string" && /^[0-9A-Z]{5}$/.test(code)) return {
203
+ code,
204
+ message: typeof message === "string" ? message : code
205
+ };
206
+ current = cause;
199
207
  }
200
- const version = Number(row?.["version"] ?? 0);
201
- if (version < 1) throw new A2Error("STORE_NOT_CONFIGURED", `This Postgres database has A2 schema version ${version}, and this release of experimental-a2 needs version 1: ${apply("schema.sql")}. ${SEE_DOCS}`);
202
- if (listen && Number(row?.["notify_triggers"]) < 2) throw new A2Error("STORE_NOT_CONFIGURED", `listenConnectionString needs A2's notification triggers, which aren't in this Postgres database. After schema.sql, ${apply("notifications.sql")}. ${SEE_DOCS}`);
208
+ };
209
+ /** Explains a query that failed because A2's tables aren't set up, or on a NUL, or returns `error`. */
210
+ const explainPostgresError = (error) => {
211
+ const found = postgresError(error);
212
+ if (found?.code === UNDEFINED_TABLE || found?.code === UNDEFINED_COLUMN || found?.code === UNDEFINED_FUNCTION) return new A2Error("STORE_NOT_CONFIGURED", `${found.message}: A2's tables or its function are missing, or from an earlier release, and A2 doesn't create or change them at runtime. Apply ${PACKAGE}/sql/postgres/schema.sql to this database, or migrate it to the tables in drizzle-schema.ts next to it, with schema.sql's a2_batch function. ${SEE_DOCS}`, { cause: error });
213
+ if (found?.code === INSUFFICIENT_PRIVILEGE) return new A2Error("STORE_NOT_CONFIGURED", `${found.message}: the role this store connects as needs select, insert, update, and delete on A2's tables, and execute on a2_batch. Create them as that role, or grant it those privileges. ${SEE_DOCS}`, { cause: error });
214
+ if (found?.code === UNTRANSLATABLE_CHARACTER || found?.code === CHARACTER_NOT_IN_REPERTOIRE) return new A2Error("INVALID_PAYLOAD", `${found.message}: Postgres can't store a NUL character (\\u0000) in text or jsonb, so nothing was written. Remove it before appending.`, { cause: error });
215
+ if (found?.code === BATCH_ISOLATION) return new A2Error("STORE_NOT_CONFIGURED", `${found.message}: A2 reads what it locks in read committed, Postgres's default, and its connections here default to a stricter level. Set default_transaction_isolation to 'read committed' for the role or database this store connects to. ${SEE_DOCS}`, { cause: error });
216
+ return error;
217
+ };
218
+ const explained = (promise) => promise.catch((error) => {
219
+ throw explainPostgresError(error);
220
+ });
221
+ /** `client`, with every query's setup failures explained. */
222
+ const explainingPostgresClient = (client) => ({
223
+ query: (text, values) => explained(client.query(text, values)),
224
+ ...client.connect && { connect: async () => {
225
+ const connection = await client.connect();
226
+ return {
227
+ query: (text, values) => explained(connection.query(text, values)),
228
+ release: () => connection.release()
229
+ };
230
+ } },
231
+ ...client.end && { end: () => client.end() }
232
+ });
233
+ /** Fails when the triggers `listenConnectionString` listens to are missing. */
234
+ const checkPostgresTriggers = async (client) => {
235
+ const { rows } = await client.query(`select count(*) as triggers from pg_trigger
236
+ where (tgname = 'a2_notify_events' and tgrelid = to_regclass('a2_events'))
237
+ or (tgname = 'a2_notify_presence' and tgrelid = to_regclass('a2_presence'))`);
238
+ if (Number(rows[0]?.["triggers"]) < 2) throw new A2Error("STORE_NOT_CONFIGURED", `listenConnectionString needs A2's notification triggers, which aren't in this Postgres database. After schema.sql, apply ${PACKAGE}/sql/postgres/notifications.sql to this database. ${SEE_DOCS}`);
203
239
  };
204
240
  //#endregion
205
241
  //#region src/postgres-notification-scope.ts
@@ -510,12 +546,26 @@ const sharePostgresListener = ({ key, create }) => acquire({
510
546
  *
511
547
  * Semantics mirror the sqlite backend: `index` is stored as `idx`,
512
548
  * timestamps are epoch milliseconds written from the injected clock —
513
- * never SQL `now()`. Streams poll by default; an optional direct
514
- * connection enables LISTEN/NOTIFY for events and presence. Appends serialize per session via
549
+ * never SQL `now()`, and dispatch state lives on consumer cursors, not on
550
+ * events. Streams poll by default; an optional direct connection enables
551
+ * LISTEN/NOTIFY for events and presence. Appends serialize per session via
515
552
  * `pg_advisory_xact_lock`, so concurrent writers can't race the index
516
- * assignment.
553
+ * assignment, and each runs in one round trip (`a2_batch`, below), so the
554
+ * lock is held only while the server runs it.
555
+ *
556
+ * Lock discipline. Completions lock their cursor first, then the session lock
557
+ * when they append, or when their cursor goes idle (`completeAttempt`);
558
+ * everything else that takes the session lock takes it first. Every
559
+ * operation that locks a cursor row locks it only in the exact state it acts
560
+ * on (a head, an attempt). Under the session lock, an append never writes a
561
+ * cursor row that exists: a lane or log cursor's runs are rows keyed by the
562
+ * head each run started at (its epoch), so starting an idle cursor inserts a
563
+ * new run and retires the idle row with `skip locked`. An append therefore
564
+ * never waits on a cursor row, in particular not on one a completion waiting
565
+ * for the session lock holds, or one a clear left behind.
517
566
  */
518
567
  const asNumber = (value) => Number(value);
568
+ const asNullableNumber = (value) => value === null || value === void 0 ? null : asNumber(value);
519
569
  /**
520
570
  * The session's latest clear marker, or 0, as a primary-key subquery. Read
521
571
  * it in a statement that runs after the operation's lock: a statement's
@@ -523,50 +573,178 @@ const asNumber = (value) => Number(value);
523
573
  * miss a clear that committed meanwhile.
524
574
  */
525
575
  const clearIndexOf = (session) => `coalesce((select clear_index from a2_clears where session_id = ${session}), 0)`;
576
+ /**
577
+ * Whether a served consumer has a cursor with a head in the session. A null
578
+ * `served` array serves every consumer.
579
+ */
580
+ const pendingIn = (session, served) => `exists(
581
+ select 1 from a2_cursors pending
582
+ where pending.session_id = ${session}
583
+ and pending.head > ${clearIndexOf(session)}
584
+ and pending.failed_at is null
585
+ and (${served}::text[] is null or pending.consumer = any(${served}::text[]))
586
+ )`;
587
+ /** A fresh head's dispatch columns, for `set` clauses. */
588
+ const FRESH_DISPATCH = `
589
+ attempt_count = 0, first_claimed_at = null, last_claimed_at = null,
590
+ claim_holder = null, claim_expires_at = null, failure_count = 0,
591
+ last_failed_at = null, last_failed_attempt = null, last_error = null,
592
+ failed_at = null`;
593
+ /** The head event's columns a claim, dead letter, or listing joins in, prefixed `e_`. */
594
+ const HEAD_COLUMNS = `
595
+ event.idx as e_idx, event.event_type as e_event_type,
596
+ event.payload as e_payload, event.event_id as e_event_id,
597
+ event.created_at as e_created_at, event.cause as e_cause`;
526
598
  const RECLAIM_BATCH = 256;
527
- const toDate = (ms) => ms === null || ms === void 0 ? null : new Date(asNumber(ms));
528
- const toCause = (raw) => {
529
- if (raw === null || raw === void 0) return null;
530
- const value = typeof raw === "string" ? JSON.parse(raw) : raw;
531
- 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");
532
- if (value.batchSize !== void 0 && (!Number.isInteger(value.batchSize) || Number(value.batchSize) < 1)) throw new TypeError("stored event has an invalid cause");
533
- return {
534
- index: Number(value.index),
535
- attempt: Number(value.attempt),
536
- ...value.batchSize === void 0 ? {} : { batchSize: Number(value.batchSize) }
537
- };
599
+ /**
600
+ * A cursor run reclaim deletes once the session is cleared through `$2`:
601
+ * one from before the clear, or one a later run of its cursor replaced. A
602
+ * log cursor's last run stays: it records that the consumer counts from the
603
+ * marker.
604
+ */
605
+ const SUPERSEDED_CURSOR = `
606
+ cur.session_id = $1 and (
607
+ (cur.epoch < $2 and cur.kind <> 'log')
608
+ or ((cur.head is null or cur.head <= $2) and exists (
609
+ select 1 from a2_cursors later
610
+ where later.session_id = cur.session_id
611
+ and later.cursor_key = cur.cursor_key
612
+ and later.consumer = cur.consumer
613
+ and later.epoch > cur.epoch
614
+ ))
615
+ )`;
616
+ /**
617
+ * A value of `a2_batch`'s one parameter, `$1`: a JSON object the store builds
618
+ * for each call. JSON null reads as SQL null.
619
+ */
620
+ const param = {
621
+ text: (key) => `($1->>'${key}')`,
622
+ bigint: (key) => `($1->>'${key}')::bigint`,
623
+ int: (key) => `($1->>'${key}')::int`,
624
+ boolean: (key) => `($1->>'${key}')::boolean`,
625
+ jsonb: (key) => `nullif($1->'${key}', 'null'::jsonb)`,
626
+ /** A JSON array of strings as text[], or null when the value isn't an array. */
627
+ texts: (key) => `(case when jsonb_typeof($1->'${key}') = 'array'
628
+ then array(select jsonb_array_elements_text($1->'${key}')) end)`
538
629
  };
539
- const toStored = (row) => ({
630
+ /** Every batch names its session. */
631
+ const SESSION = param.text("session");
632
+ /**
633
+ * The next event after `after` a log cursor takes, as a scalar subquery:
634
+ * `types` is its JSON array, or null for every type but the clear marker
635
+ * (`clearType`). Each listed type is one probe of `a2_events_types`, so a
636
+ * consumer of rare types never scans the events it skips.
637
+ */
638
+ const nextLogHead = ({ types, after, clearType = param.text("clearType") }) => `
639
+ case when ${types} is null then (
640
+ select event.idx from a2_events event
641
+ where event.session_id = ${SESSION} and event.idx > ${after}
642
+ and event.event_type <> ${clearType}
643
+ order by event.idx limit 1
644
+ ) else (
645
+ select min(next.idx)
646
+ from jsonb_array_elements_text(${types}) as wanted(event_type)
647
+ cross join lateral (
648
+ select event.idx from a2_events event
649
+ where event.session_id = ${SESSION} and event.event_type = wanted.event_type
650
+ and event.idx > ${after}
651
+ order by event.idx limit 1
652
+ ) next
653
+ where wanted.event_type <> ${clearType}
654
+ ) end`;
655
+ /** The latest run of `target`'s cursor, as a lateral subquery. */
656
+ const LATEST_RUN = (target) => `
657
+ select cur.epoch, cur.position, cur.head from a2_cursors cur
658
+ where cur.session_id = ${SESSION} and cur.cursor_key = ${target}.cursor_key
659
+ and cur.consumer = ${target}.consumer
660
+ order by cur.epoch desc limit 1`;
661
+ /**
662
+ * Retires the runs `target` replaces, those idle or superseded by the clear
663
+ * marker `clearIndex`, skipping any another operation holds: a later reclaim
664
+ * deletes those.
665
+ */
666
+ const RETIRE_RUNS = (target, clearIndex) => `
667
+ idle as materialized (
668
+ select cur.ctid from a2_cursors cur
669
+ join ${target} on cur.cursor_key = ${target}.cursor_key
670
+ and cur.consumer = ${target}.consumer
671
+ where cur.session_id = ${SESSION}
672
+ and (cur.head is null or cur.head <= ${clearIndex})
673
+ for update of cur skip locked
674
+ ), retired as (
675
+ delete from a2_cursors old using idle where old.ctid = idle.ctid
676
+ )`;
677
+ const toDate = (ms) => ms === null || ms === void 0 ? null : new Date(asNumber(ms));
678
+ const toEvent = (row) => ({
540
679
  id: row["event_id"],
541
680
  type: row["event_type"],
542
681
  payload: row["payload"],
543
682
  index: asNumber(row["idx"]),
544
683
  sessionId: row["session_id"],
545
- createdAt: new Date(asNumber(row["created_at"])),
546
- cause: toCause(row["cause"]),
684
+ createdAt: new Date(asNumber(row["created_at"]))
685
+ });
686
+ const toStored = (row) => ({
687
+ ...toEvent(row),
688
+ cause: decodeCause(row["cause"])
689
+ });
690
+ const toDispatch = (row) => ({
691
+ consumer: row["consumer"],
547
692
  lane: row["lane"] ?? null,
548
- processedAt: toDate(row["processed_at"]),
549
- processedByAttempt: row["processed_by_attempt"] === null || row["processed_by_attempt"] === void 0 ? null : asNumber(row["processed_by_attempt"]),
550
- returnedEventIds: row["returned_event_ids"] === null || row["returned_event_ids"] === void 0 ? null : decodeReturnedEventIds(row["returned_event_ids"]),
551
693
  firstClaimedAt: toDate(row["first_claimed_at"]),
552
694
  lastClaimedAt: toDate(row["last_claimed_at"]),
553
695
  attemptCount: asNumber(row["attempt_count"]),
696
+ claimHolder: row["claim_holder"] ?? null,
697
+ claimExpiresAt: toDate(row["claim_expires_at"]),
554
698
  failureCount: asNumber(row["failure_count"]),
555
699
  lastFailedAt: toDate(row["last_failed_at"]),
556
- lastFailedAttempt: row["last_failed_attempt"] === null || row["last_failed_attempt"] === void 0 ? null : asNumber(row["last_failed_attempt"]),
700
+ lastFailedAttempt: asNullableNumber(row["last_failed_attempt"]),
557
701
  lastError: row["last_error"] ?? null,
558
- failedAt: toDate(row["failed_at"]),
559
- claimHolder: row["claim_holder"] ?? null,
560
- claimExpiresAt: toDate(row["claim_expires_at"])
702
+ failedAt: toDate(row["failed_at"])
561
703
  });
562
- const toEvent = (row) => ({
563
- id: row["event_id"],
564
- type: row["event_type"],
565
- payload: row["payload"],
566
- index: asNumber(row["idx"]),
567
- sessionId: row["session_id"],
568
- createdAt: new Date(asNumber(row["created_at"]))
704
+ const freshDispatch = (consumer, lane) => ({
705
+ consumer,
706
+ lane,
707
+ firstClaimedAt: null,
708
+ lastClaimedAt: null,
709
+ attemptCount: 0,
710
+ claimHolder: null,
711
+ claimExpiresAt: null,
712
+ failureCount: 0,
713
+ lastFailedAt: null,
714
+ lastFailedAttempt: null,
715
+ lastError: null,
716
+ failedAt: null
717
+ });
718
+ /** A cursor row joined with its head event's `e_` columns. */
719
+ const toHead = (row) => ({
720
+ ...toStored({
721
+ session_id: row["session_id"],
722
+ idx: row["e_idx"],
723
+ event_type: row["e_event_type"],
724
+ payload: row["e_payload"],
725
+ event_id: row["e_event_id"],
726
+ created_at: row["e_created_at"],
727
+ cause: row["e_cause"]
728
+ }),
729
+ ...toDispatch(row)
569
730
  });
731
+ /** A cursor run as `cursors()` reports it, given the session's clear marker. */
732
+ const toCursor = (row, clearIndex) => {
733
+ const position = asNumber(row["position"]);
734
+ if (row["kind"] !== "log" || asNumber(row["epoch"]) > clearIndex) return Object.assign(toDispatch(row), {
735
+ position,
736
+ head: asNullableNumber(row["head"])
737
+ });
738
+ return Object.assign(freshDispatch(row["consumer"], row["lane"]), {
739
+ position: Math.max(position, clearIndex),
740
+ head: null
741
+ });
742
+ };
743
+ const compareText = (a, b) => a < b ? -1 : a > b ? 1 : 0;
744
+ const byHead = (a, b) => a.index - b.index || compareText(a.consumer, b.consumer);
745
+ /** Consumer, then lane, with concurrent cursors last by head. */
746
+ const byCursor = (a, b) => compareText(a.consumer, b.consumer) || (a.lane === null ? b.lane === null ? (a.head ?? 0) - (b.head ?? 0) : 1 : b.lane === null ? -1 : compareText(a.lane, b.lane));
747
+ const servedNames = (consumers) => consumers === void 0 ? null : consumers.map((consumer) => consumer.name);
570
748
  const wrap = async (fn) => {
571
749
  try {
572
750
  return await fn();
@@ -600,10 +778,16 @@ const createListener = ({ pg, connectionString }) => postgresNotifications(async
600
778
  }
601
779
  };
602
780
  });
603
- /** A dead-lettered event's snapshot pins, and history no pin keeps. */
604
- const releasePins = async ({ query, sessionId, index }) => {
781
+ /** A finished or dead head's snapshot pins, and history no pin keeps. */
782
+ const releasePins = async ({ query, sessionId, consumer, cursorKey, index }) => {
605
783
  await query(`delete from a2_snapshot_pins
606
- where session_id = $1 and event_index = $2`, [sessionId, index]);
784
+ where session_id = $1 and event_index = $2
785
+ and consumer = $3 and cursor_key = $4`, [
786
+ sessionId,
787
+ index,
788
+ consumer,
789
+ cursorKey
790
+ ]);
607
791
  await query(`delete from a2_snapshot_history history
608
792
  where history.session_id = $1
609
793
  and not exists (
@@ -613,6 +797,777 @@ const releasePins = async ({ query, sessionId, index }) => {
613
797
  and pin.up_to_index = history.up_to_index
614
798
  )`, [sessionId]);
615
799
  };
800
+ const lockSession = (query, sessionId) => query("select pg_advisory_xact_lock(hashtext($1))", [sessionId]);
801
+ /** Whether the session has an event at `index`. */
802
+ const hasEvent = async (query, sessionId, index) => (await query("select 1 from a2_events where session_id = $1 and idx = $2", [sessionId, index])).rows.length > 0;
803
+ /**
804
+ * The attempt-currency fence shared by consumer appends and clears, under
805
+ * the session lock. The cursor is read locked (`for share` by an append,
806
+ * `for update` by a clear, which then releases the head): it serializes
807
+ * with every writer of the cursor (claims, completions, failures), so a
808
+ * stale read cannot pass the fence against a takeover committing beside
809
+ * it. The lock is only taken while the head and attempt still match.
810
+ */
811
+ const assertCurrentCause = async ({ query, sessionId, cause, clearIndex, lock }) => {
812
+ if (cause.index <= clearIndex) throw supersededAttemptError(sessionId, cause);
813
+ const { rows } = await query(`select 1 from a2_cursors
814
+ where session_id = $1 and cursor_key = $2 and consumer = $3
815
+ and head = $4 and attempt_count = $5 and failed_at is null
816
+ for ${lock}`, [
817
+ sessionId,
818
+ cursorKeyOf(cause.lane, cause.index),
819
+ cause.consumer,
820
+ cause.index,
821
+ cause.attempt
822
+ ]);
823
+ if (rows.length === 0) throw supersededAttemptError(sessionId, cause);
824
+ };
825
+ const BATCH = "select r from a2_batch($1::text[], $2::jsonb) as r";
826
+ /**
827
+ * Each statement as a batch sends it, whitespace collapsed: a batch sends all
828
+ * its statements on every call. They hold no comments or spaced literals, and
829
+ * they're this module's constants, so the cache stays this size.
830
+ */
831
+ const compacted = /* @__PURE__ */ new Map();
832
+ const compact = (statement) => {
833
+ let sent = compacted.get(statement);
834
+ if (sent === void 0) {
835
+ if (statement.includes("--")) throw new TypeError("a batch statement holds a comment");
836
+ sent = statement.replace(/\s+/g, " ").trim();
837
+ compacted.set(statement, sent);
838
+ }
839
+ return sent;
840
+ };
841
+ /** The SQLSTATE `a2_batch` raises an `abort` row with, as its detail. */
842
+ const BATCH_ABORTED = "A2001";
843
+ /** The row an aborted `a2_batch` call raised, from `error` or its cause chain. */
844
+ const abortedBatch = (error) => {
845
+ for (let current = error, depth = 0; depth < 5; depth += 1) {
846
+ if (typeof current !== "object" || current === null) return void 0;
847
+ const { code, detail, cause } = current;
848
+ if (code === BATCH_ABORTED && typeof detail === "string") return JSON.parse(detail);
849
+ current = cause;
850
+ }
851
+ };
852
+ const LOCK_SESSION = `
853
+ select jsonb_build_object('locked', true)
854
+ from pg_advisory_xact_lock(hashtext(${SESSION}))`;
855
+ const INDEX = param.bigint("index");
856
+ const CONSUMER = param.text("consumer");
857
+ /**
858
+ * The index before the events this call inserted, the last `count` of the
859
+ * session's: read under the session lock, after the insert.
860
+ */
861
+ const INSERTED_AFTER = `(
862
+ select coalesce(max(idx), 0) - ${param.bigint("count")}
863
+ from a2_events where session_id = ${SESSION})`;
864
+ /**
865
+ * An append's checks, under the session lock: the session's head and pending
866
+ * state, and what its supplied `ids` say. Ids are unique per session. When
867
+ * every event's id belongs to this session already, the batch is a retry: it
868
+ * stops with the committed rows. Some owned aborts, as does scheduled work
869
+ * from at or below the clear marker (`origin`). Rows below the marker own no
870
+ * id.
871
+ */
872
+ const APPEND_CHECKS = `
873
+ with boundary as materialized (
874
+ select coalesce(max(idx), 0) as base,
875
+ ${clearIndexOf(SESSION)} as clear_index,
876
+ ${pendingIn(SESSION, param.texts("served"))} as has_pending
877
+ from a2_events where session_id = ${SESSION}
878
+ ), owned as materialized (
879
+ select event.session_id, event.idx, event.event_type, event.payload,
880
+ event.event_id, event.created_at, event.cause
881
+ from a2_events event, boundary
882
+ where event.session_id = ${SESSION}
883
+ and event.event_id = any(${param.texts("ids")})
884
+ and event.idx >= boundary.clear_index
885
+ )
886
+ select case
887
+ when (select count(*) from owned) = ${param.bigint("count")} then
888
+ jsonb_build_object('stop', 'replay',
889
+ 'has_pending', boundary.has_pending,
890
+ 'events', (select jsonb_agg(to_jsonb(owned) order by owned.idx)
891
+ from owned))
892
+ when exists (select 1 from owned) then
893
+ jsonb_build_object('abort', 'mixed', 'owned',
894
+ (select count(*) from owned))
895
+ when ${param.bigint("origin")} <= boundary.clear_index then
896
+ jsonb_build_object('abort', 'cleared')
897
+ else jsonb_build_object('base', boundary.base,
898
+ 'has_pending', boundary.has_pending)
899
+ end
900
+ from boundary`;
901
+ /**
902
+ * The attempt-currency fence of a consumer append, under the session lock.
903
+ * Each cause's cursor is read `for share`: that serializes with every writer
904
+ * of the cursor (claims, completions, failures), so a stale read cannot pass
905
+ * the fence against a takeover committing beside it. A row is locked only
906
+ * while its head and attempt still match. The first cause that fails aborts.
907
+ */
908
+ const FENCE = `
909
+ with cause as (
910
+ select * from jsonb_to_recordset($1->'causes')
911
+ as cause(ord int, consumer text, cursor_key text, idx bigint, attempt int)
912
+ ), held as materialized (
913
+ select cause.ord from cause
914
+ join a2_cursors cur
915
+ on cur.session_id = ${SESSION} and cur.cursor_key = cause.cursor_key
916
+ and cur.consumer = cause.consumer and cur.head = cause.idx
917
+ and cur.attempt_count = cause.attempt and cur.failed_at is null
918
+ where cause.idx > ${clearIndexOf(SESSION)}
919
+ for share of cur
920
+ )
921
+ select jsonb_build_object('abort', 'superseded', 'ord', cause.ord)
922
+ from cause where cause.ord not in (select ord from held)
923
+ order by cause.ord limit 1`;
924
+ /**
925
+ * Frees the `ids` this session's rows below its clear marker hold, so the
926
+ * batch can take them. It never waits on a row: a reclaim deleting one may
927
+ * hold it, and one it skips aborts.
928
+ */
929
+ const REUSE_IDS = `
930
+ with stale as materialized (
931
+ select event.idx from a2_events event
932
+ where event.session_id = ${SESSION}
933
+ and event.event_id = any(${param.texts("ids")})
934
+ and event.idx < ${clearIndexOf(SESSION)}
935
+ order by event.idx
936
+ for update skip locked
937
+ ), deleted as (
938
+ delete from a2_events event using stale
939
+ where event.session_id = ${SESSION} and event.idx = stale.idx
940
+ returning event.idx
941
+ )
942
+ select jsonb_build_object('abort', 'held')
943
+ where (select count(*) from deleted) < (
944
+ select count(*) from a2_events event
945
+ where event.session_id = ${SESSION}
946
+ and event.event_id = any(${param.texts("ids")})
947
+ and event.idx < ${clearIndexOf(SESSION)})`;
948
+ /** Inserts `events` after the session's head, under the session lock. */
949
+ const INSERT_EVENTS = `
950
+ with inserted as (
951
+ insert into a2_events
952
+ (session_id, idx, event_type, payload, event_id, created_at, cause)
953
+ select ${SESSION}, base.base + event.ord, event.type,
954
+ coalesce(event.payload, 'null'::jsonb), event.id,
955
+ ${param.bigint("now")}, event.cause
956
+ from (select coalesce(max(idx), 0) as base from a2_events
957
+ where session_id = ${SESSION}) base,
958
+ jsonb_to_recordset($1->'events')
959
+ as event(ord bigint, type text, payload jsonb, id text, cause jsonb)
960
+ returning 1
961
+ )
962
+ select jsonb_build_object('inserted', count(*)) from inserted`;
963
+ /** A cursor for each concurrent target: the event alone. */
964
+ const START_EVENTS = `
965
+ with started as (
966
+ insert into a2_cursors
967
+ (session_id, consumer, cursor_key, epoch, lane, kind, position, head)
968
+ select ${SESSION}, target.consumer, '#' || target.idx, target.idx, null,
969
+ 'event', target.idx - 1, target.idx
970
+ from (
971
+ select single.consumer, ${INSERTED_AFTER} + single.ord as idx
972
+ from jsonb_to_recordset($1->'singles')
973
+ as single(consumer text, ord bigint)
974
+ ) target
975
+ on conflict do nothing
976
+ returning 1
977
+ )
978
+ select jsonb_build_object('started', count(*)) from started`;
979
+ /** Queues each lane target's event in its lane. */
980
+ const QUEUE_LANES = `
981
+ with queued as (
982
+ insert into a2_lane_events (session_id, consumer, lane, idx)
983
+ select ${SESSION}, queued.consumer, queued.lane,
984
+ ${INSERTED_AFTER} + queued.ord
985
+ from jsonb_to_recordset($1->'queued')
986
+ as queued(consumer text, lane text, ord bigint)
987
+ on conflict do nothing
988
+ returning 1
989
+ )
990
+ select jsonb_build_object('queued', count(*)) from queued`;
991
+ /**
992
+ * Starts a run for each lane cursor the batch queues work for that has none
993
+ * running: one that is idle, superseded by the clear marker, or new. It starts
994
+ * at the lane's first event in the batch and counts from the marker.
995
+ */
996
+ const START_LANES = `
997
+ with boundary as materialized (
998
+ select ${INSERTED_AFTER} as base, ${clearIndexOf(SESSION)} as clear_index
999
+ ), target as (
1000
+ select first.consumer, first.cursor_key, first.lane,
1001
+ boundary.base + first.ord as idx
1002
+ from boundary, jsonb_to_recordset($1->'lanes')
1003
+ as first(consumer text, cursor_key text, lane text, ord bigint)
1004
+ ), started as materialized (
1005
+ select target.*, latest.position
1006
+ from target
1007
+ left join lateral (${LATEST_RUN("target")}) latest on true
1008
+ where latest.head is null
1009
+ or latest.head <= (select clear_index from boundary)
1010
+ ), ${RETIRE_RUNS("started", "(select clear_index from boundary)")},
1011
+ inserted as (
1012
+ insert into a2_cursors
1013
+ (session_id, consumer, cursor_key, epoch, lane, kind, position, head)
1014
+ select ${SESSION}, started.consumer, started.cursor_key, started.idx,
1015
+ started.lane, 'lane',
1016
+ greatest(coalesce(started.position, 0),
1017
+ (select clear_index from boundary)),
1018
+ started.idx
1019
+ from started
1020
+ on conflict do nothing
1021
+ returning 1
1022
+ )
1023
+ select jsonb_build_object('lanes', count(*)) from inserted`;
1024
+ /**
1025
+ * Starts a run for each served log cursor the batch carries types for that
1026
+ * has none running. A missing cursor starts at the batch's first event of its
1027
+ * types, or at the beginning of the log with backfill. One that exists starts
1028
+ * at the next event it takes after its position: a deployment that does not
1029
+ * serve the consumer may have appended some while it was idle. A cursor from
1030
+ * before the clear marker counts from it.
1031
+ */
1032
+ const WAKE_LOGS = `
1033
+ with boundary as materialized (
1034
+ select ${INSERTED_AFTER} as base, ${clearIndexOf(SESSION)} as clear_index
1035
+ ), target as (
1036
+ select log.consumer, log.cursor_key, log.lane,
1037
+ boundary.base + log.ord as first, log.types, log.backfill
1038
+ from boundary, jsonb_to_recordset($1->'logs')
1039
+ as log(consumer text, cursor_key text, lane text, ord bigint,
1040
+ types jsonb, backfill boolean)
1041
+ ), woken as materialized (
1042
+ select target.*,
1043
+ case when latest.epoch is not null
1044
+ then greatest(latest.position,
1045
+ (select clear_index from boundary))
1046
+ when target.backfill then (select clear_index from boundary)
1047
+ else (select base from boundary) end as position,
1048
+ latest.epoch is not null or target.backfill as resumes
1049
+ from target
1050
+ left join lateral (${LATEST_RUN("target")}) latest on true
1051
+ where latest.head is null
1052
+ or latest.head <= (select clear_index from boundary)
1053
+ ), ${RETIRE_RUNS("woken", "(select clear_index from boundary)")}, run as (
1054
+ select woken.*,
1055
+ case when woken.resumes then coalesce(${nextLogHead({
1056
+ types: "woken.types",
1057
+ after: "woken.position"
1058
+ })}, woken.first) else woken.first end as head
1059
+ from woken
1060
+ ), inserted as (
1061
+ insert into a2_cursors
1062
+ (session_id, consumer, cursor_key, epoch, lane, kind, position, head,
1063
+ types)
1064
+ select ${SESSION}, run.consumer, run.cursor_key, run.head, run.lane, 'log',
1065
+ run.position, run.head, run.types
1066
+ from run
1067
+ on conflict do nothing
1068
+ returning 1
1069
+ )
1070
+ select jsonb_build_object('logs', count(*)) from inserted`;
1071
+ /**
1072
+ * The heads a claim may take, up to `through` when there is one, as
1073
+ * `from ... where` for `cur`. `for update` makes a claim racing another wait
1074
+ * and then re-check the row it waited for.
1075
+ */
1076
+ const ELIGIBLE = `
1077
+ from a2_cursors cur
1078
+ where cur.session_id = ${SESSION}
1079
+ and cur.head > ${clearIndexOf(SESSION)}
1080
+ and (${param.bigint("through")} is null
1081
+ or cur.head <= ${param.bigint("through")})
1082
+ and cur.failed_at is null
1083
+ and (${param.texts("served")} is null
1084
+ or cur.consumer = any(${param.texts("served")}))
1085
+ and not exists (
1086
+ select 1 from jsonb_to_recordset($1->'excluded')
1087
+ as skip(consumer text, cursor_key text)
1088
+ where skip.consumer = cur.consumer and skip.cursor_key = cur.cursor_key
1089
+ )`;
1090
+ /** A lapsed claim's takeover that spends the last failed attempt dead-letters the head instead. */
1091
+ const EXHAUSTED = `(cur.claim_holder is not null
1092
+ and cur.failure_count + 1 >= ${param.int("maxFailures")})`;
1093
+ /**
1094
+ * Claims every eligible head, returning each with its event. A head the claim
1095
+ * waited on may have moved to an event its snapshot predates: that row comes
1096
+ * back without one (`e_idx` null), for `claimAvailable` to read. It stops
1097
+ * there unless one dead-lettered, whose pins `RELEASE_DEAD` then releases, or
1098
+ * none was claimed, for `RETRY_AT`.
1099
+ */
1100
+ const CLAIM = `
1101
+ with eligible as materialized (
1102
+ select cur.consumer, cur.cursor_key, cur.epoch
1103
+ ${ELIGIBLE}
1104
+ and (cur.claim_expires_at is null
1105
+ or cur.claim_expires_at <= ${param.bigint("now")})
1106
+ order by cur.head, cur.consumer
1107
+ for update of cur
1108
+ ), claimed as (
1109
+ update a2_cursors cur set
1110
+ attempt_count = cur.attempt_count + 1,
1111
+ failure_count = cur.failure_count
1112
+ + case when cur.claim_holder is null then 0 else 1 end,
1113
+ failed_at = case when ${EXHAUSTED} then ${param.bigint("now")}
1114
+ else cur.failed_at end,
1115
+ first_claimed_at = case when ${EXHAUSTED} then cur.first_claimed_at
1116
+ else coalesce(cur.first_claimed_at, ${param.bigint("now")}) end,
1117
+ last_claimed_at = case when ${EXHAUSTED} then cur.last_claimed_at
1118
+ else ${param.bigint("now")} end,
1119
+ claim_holder = case when ${EXHAUSTED} then null
1120
+ else ${param.text("holder")} end,
1121
+ claim_expires_at = case when ${EXHAUSTED} then null
1122
+ else ${param.bigint("expiresAt")} end
1123
+ from eligible
1124
+ where cur.session_id = ${SESSION}
1125
+ and cur.cursor_key = eligible.cursor_key
1126
+ and cur.consumer = eligible.consumer
1127
+ and cur.epoch = eligible.epoch
1128
+ returning cur.*
1129
+ ), heads as (
1130
+ select claimed.*, ${HEAD_COLUMNS},
1131
+ ${clearIndexOf(SESSION)} as clear_index,
1132
+ (select coalesce(max(idx), 0) from a2_events
1133
+ where session_id = ${SESSION}) as session_head
1134
+ from claimed
1135
+ left join a2_events event
1136
+ on event.session_id = ${SESSION} and event.idx = claimed.head
1137
+ )
1138
+ select r from (
1139
+ select 0 as ord, to_jsonb(heads) as r from heads
1140
+ union all
1141
+ select 1, jsonb_build_object('stop', 'claimed')
1142
+ where exists (select 1 from heads)
1143
+ and not exists (select 1 from heads where failed_at is not null)
1144
+ ) claim
1145
+ order by ord`;
1146
+ /**
1147
+ * Releases the pins of the heads dead-lettered at `now`, the claim's among
1148
+ * them, in a snapshot after the claim's locks, with the history no other pin
1149
+ * keeps.
1150
+ */
1151
+ const RELEASE_DEAD = `
1152
+ with dead as materialized (
1153
+ select cur.consumer, cur.cursor_key, cur.head from a2_cursors cur
1154
+ where cur.session_id = ${SESSION} and cur.head is not null
1155
+ and cur.failed_at = ${param.bigint("now")} and cur.claim_holder is null
1156
+ ), unpinned as (
1157
+ delete from a2_snapshot_pins pin using dead
1158
+ where pin.session_id = ${SESSION} and pin.event_index = dead.head
1159
+ and pin.consumer = dead.consumer and pin.cursor_key = dead.cursor_key
1160
+ returning 1
1161
+ ), unkept as (
1162
+ delete from a2_snapshot_history history
1163
+ where history.session_id = ${SESSION}
1164
+ and exists (select 1 from dead)
1165
+ and not exists (
1166
+ select 1 from a2_snapshot_pins pin
1167
+ where pin.session_id = history.session_id
1168
+ and pin.reducer_name = history.reducer_name
1169
+ and pin.up_to_index = history.up_to_index
1170
+ and not exists (
1171
+ select 1 from dead
1172
+ where pin.event_index = dead.head
1173
+ and pin.consumer = dead.consumer
1174
+ and pin.cursor_key = dead.cursor_key)
1175
+ )
1176
+ returning 1
1177
+ )
1178
+ select jsonb_build_object('released', (select count(*) from unpinned))`;
1179
+ /** When nothing was claimed: the earliest live claim or retry hold that blocks a head. */
1180
+ const RETRY_AT = `
1181
+ select jsonb_build_object('retry_at', min(cur.claim_expires_at))
1182
+ ${ELIGIBLE}
1183
+ and cur.claim_expires_at > ${param.bigint("now")}`;
1184
+ /** The cursor row `alias` whose head this attempt holds. */
1185
+ const heldBy = (alias) => `
1186
+ ${alias}.session_id = ${SESSION}
1187
+ and ${alias}.cursor_key = ${param.text("cursorKey")}
1188
+ and ${alias}.consumer = ${CONSUMER} and ${alias}.head = ${INDEX}
1189
+ and ${alias}.attempt_count = ${param.int("attempt")}
1190
+ and ${alias}.failed_at is null and ${alias}.claim_holder is not null`;
1191
+ /**
1192
+ * Locks the cursor while this attempt holds its head, and stops unless it
1193
+ * does: a completion locks its cursor first. A cleared head is never locked.
1194
+ */
1195
+ const HOLD = `
1196
+ with boundary as materialized (
1197
+ select ${clearIndexOf(SESSION)} as clear_index
1198
+ ), held as materialized (
1199
+ select cur.kind from a2_cursors cur, boundary
1200
+ where ${heldBy("cur")} and ${INDEX} > boundary.clear_index
1201
+ for update of cur
1202
+ )
1203
+ select case
1204
+ when ${INDEX} <= boundary.clear_index then
1205
+ jsonb_build_object('stop', 'superseded')
1206
+ when not exists (select 1 from held) then
1207
+ jsonb_build_object('stop', 'unheld')
1208
+ else jsonb_build_object('held', (select kind from held limit 1))
1209
+ end
1210
+ from boundary`;
1211
+ /** A log cursor's types once it moves: the served consumer's (`types`, when `retype`), else its own. */
1212
+ const logTypesOf = (cur) => `(case when ${param.boolean("retype")} then ${param.jsonb("types")}
1213
+ else ${cur}.types end)`;
1214
+ /**
1215
+ * The head cursor `cur` moves to from its head: its lane's next unfinished
1216
+ * event, the next event of a log cursor's types, or none for a concurrent
1217
+ * cursor. The lane's lookup is ordered by its whole key, so the lane's
1218
+ * primary key serves it.
1219
+ */
1220
+ const nextHeadOf = (cur) => `
1221
+ case ${cur}.kind
1222
+ when 'lane' then (
1223
+ select lane.idx from a2_lane_events lane
1224
+ where lane.session_id = ${SESSION} and lane.consumer = ${cur}.consumer
1225
+ and lane.lane = ${cur}.lane and not lane.done
1226
+ and lane.idx > ${cur}.head
1227
+ order by lane.consumer, lane.lane, lane.idx limit 1)
1228
+ when 'log' then (${nextLogHead({
1229
+ types: logTypesOf(cur),
1230
+ after: `${cur}.head`
1231
+ })})
1232
+ end`;
1233
+ /**
1234
+ * Takes the session lock when the held cursor would go idle: a lane or log
1235
+ * cursor with no next head. See `completeAttempt` for why a completion that
1236
+ * moves to a next head needs no lock.
1237
+ */
1238
+ const LOCK_IF_IDLE = `
1239
+ select jsonb_build_object('locked',
1240
+ pg_advisory_xact_lock(hashtext(${SESSION}))::text is not null)
1241
+ from a2_cursors cur
1242
+ where ${heldBy("cur")} and cur.kind <> 'event'
1243
+ and (${nextHeadOf("cur")}) is null`;
1244
+ /**
1245
+ * Moves the cursor `target` selects past its head `index`, if its head lies
1246
+ * above the clear marker: marks the lane's event done (with this attempt's
1247
+ * receipt, `receipt`), releases the head's snapshot pins with the history no
1248
+ * other pin keeps, and gives the cursor its next head with fresh dispatch
1249
+ * columns (a log or concurrent cursor keeps the receipt on the run). The
1250
+ * marker and the next head are read in one snapshot, after the cursor's lock.
1251
+ * Without a row it writes nothing, and stops if `stop`.
1252
+ */
1253
+ const advance = ({ target, receipt, missing }) => `
1254
+ with target as materialized (
1255
+ select cur.* from a2_cursors cur
1256
+ where ${target} and ${INDEX} > ${clearIndexOf(SESSION)}
1257
+ order by cur.cursor_key limit 1
1258
+ for update of cur
1259
+ ), done as (
1260
+ update a2_lane_events lane set done = true${receipt ? `, completed_attempt = ${param.int("attempt")},
1261
+ returned_event_ids = ${param.jsonb("ids")}` : ""}
1262
+ from target
1263
+ where target.kind = 'lane' and lane.session_id = ${SESSION}
1264
+ and lane.consumer = target.consumer and lane.lane = target.lane
1265
+ and lane.idx = target.head
1266
+ returning 1
1267
+ ), unpinned as (
1268
+ delete from a2_snapshot_pins pin using target
1269
+ where pin.session_id = ${SESSION} and pin.event_index = target.head
1270
+ and pin.consumer = target.consumer and pin.cursor_key = target.cursor_key
1271
+ returning 1
1272
+ ), unkept as (
1273
+ delete from a2_snapshot_history history using target
1274
+ where history.session_id = ${SESSION}
1275
+ and not exists (
1276
+ select 1 from a2_snapshot_pins pin
1277
+ where pin.session_id = history.session_id
1278
+ and pin.reducer_name = history.reducer_name
1279
+ and pin.up_to_index = history.up_to_index
1280
+ and not (pin.event_index = target.head
1281
+ and pin.consumer = target.consumer
1282
+ and pin.cursor_key = target.cursor_key)
1283
+ )
1284
+ returning 1
1285
+ ), moved as (
1286
+ update a2_cursors cur
1287
+ set position = target.head, head = ${nextHeadOf("target")},
1288
+ ${FRESH_DISPATCH},
1289
+ types = case when target.kind = 'log'
1290
+ then ${logTypesOf("target")} else cur.types end${receipt ? `,
1291
+ completed_idx = case when target.kind = 'lane'
1292
+ then cur.completed_idx else target.head end,
1293
+ completed_attempt = case when target.kind = 'lane'
1294
+ then cur.completed_attempt
1295
+ else ${param.int("attempt")} end,
1296
+ returned_event_ids = case when target.kind = 'lane'
1297
+ then cur.returned_event_ids
1298
+ else ${param.jsonb("ids")} end` : ""}
1299
+ from target
1300
+ where cur.session_id = ${SESSION} and cur.cursor_key = target.cursor_key
1301
+ and cur.consumer = target.consumer and cur.epoch = target.epoch
1302
+ returning cur.head
1303
+ )
1304
+ select case when exists (select 1 from moved)
1305
+ then jsonb_build_object('advanced', true)
1306
+ else jsonb_build_object(${missing === "continue" ? `'advanced', false` : `'${missing}', 'superseded'`}) end`;
1307
+ /** Completing a held head, once the cursor's lock is taken. */
1308
+ const COMPLETE = advance({
1309
+ target: heldBy("cur"),
1310
+ receipt: true,
1311
+ missing: "stop"
1312
+ });
1313
+ /**
1314
+ * Completing a held head after appending its events, under the session lock:
1315
+ * the head can't have moved, but if it had, the events must not stand.
1316
+ */
1317
+ const COMPLETE_APPENDED = advance({
1318
+ target: heldBy("cur"),
1319
+ receipt: true,
1320
+ missing: "abort"
1321
+ });
1322
+ /**
1323
+ * A completion's checks before it appends, under the session lock, which the
1324
+ * cursor's lock preceded: a head the clear marker passed meanwhile stops, and
1325
+ * a returned id a live row owns aborts.
1326
+ */
1327
+ const RETURN_CHECKS = `
1328
+ select case
1329
+ when ${INDEX} <= ${clearIndexOf(SESSION)} then
1330
+ jsonb_build_object('stop', 'superseded')
1331
+ when exists (
1332
+ select 1 from a2_events event
1333
+ where event.session_id = ${SESSION}
1334
+ and event.event_id = any(${param.texts("ids")})
1335
+ and event.idx >= ${clearIndexOf(SESSION)}
1336
+ ) then jsonb_build_object('abort', 'owned')
1337
+ else jsonb_build_object('base', (
1338
+ select coalesce(max(idx), 0) from a2_events
1339
+ where session_id = ${SESSION}))
1340
+ end`;
1341
+ /** The consumer's dead-lettered head at `index`, locked. */
1342
+ const deadAt = (alias) => `
1343
+ ${alias}.session_id = ${SESSION} and ${alias}.consumer = ${CONSUMER}
1344
+ and ${alias}.head = ${INDEX} and ${alias}.failed_at is not null`;
1345
+ /**
1346
+ * Locks the dead letter, or stops: the statements after it act only on the
1347
+ * row this one locked.
1348
+ */
1349
+ const LOCK_DEAD = `
1350
+ with dead as materialized (
1351
+ select cur.cursor_key from a2_cursors cur
1352
+ where ${deadAt("cur")} and ${INDEX} > ${clearIndexOf(SESSION)}
1353
+ order by cur.cursor_key limit 1
1354
+ for update
1355
+ )
1356
+ select case when exists (select 1 from dead)
1357
+ then jsonb_build_object('redriven', true)
1358
+ else jsonb_build_object('stop', 'alive', 'redriven', false,
1359
+ 'has_pending',
1360
+ ${pendingIn(SESSION, param.texts("served"))}) end`;
1361
+ /** Clears a dead letter, keeping its claim history and last failure. */
1362
+ const RETRY_DEAD = `
1363
+ with target as materialized (
1364
+ select cur.cursor_key, cur.epoch from a2_cursors cur
1365
+ where ${deadAt("cur")} and ${INDEX} > ${clearIndexOf(SESSION)}
1366
+ order by cur.cursor_key limit 1
1367
+ for update of cur
1368
+ ), retried as (
1369
+ update a2_cursors cur set failed_at = null, failure_count = 0
1370
+ from target
1371
+ where cur.session_id = ${SESSION} and cur.cursor_key = target.cursor_key
1372
+ and cur.consumer = ${CONSUMER} and cur.epoch = target.epoch
1373
+ returning 1
1374
+ )
1375
+ select jsonb_build_object('retried', count(*)) from retried`;
1376
+ const SKIP_DEAD = advance({
1377
+ target: deadAt("cur"),
1378
+ receipt: false,
1379
+ missing: "continue"
1380
+ });
1381
+ const PENDING = `
1382
+ select jsonb_build_object('has_pending',
1383
+ ${pendingIn(SESSION, param.texts("served"))})`;
1384
+ /**
1385
+ * One log cursor as `wakeLog` finds it: the session's head and clear marker,
1386
+ * the cursor's latest run, where a run would start (`start`), and the next
1387
+ * event it takes from there unless the run has a head. `retyped` says whether
1388
+ * the run's types are stale. CTEs, for a `with`.
1389
+ */
1390
+ const LOG_CURSOR = `
1391
+ boundary as materialized (
1392
+ select coalesce(max(idx), 0) as session_head,
1393
+ ${clearIndexOf(SESSION)} as clear_index
1394
+ from a2_events where session_id = ${SESSION}
1395
+ ), latest as materialized (
1396
+ select boundary.*, cur.*,
1397
+ case when cur.epoch is not null
1398
+ then greatest(cur.position, boundary.clear_index)
1399
+ when ${param.boolean("backfill")} then boundary.clear_index
1400
+ else boundary.session_head end as start
1401
+ from boundary
1402
+ left join lateral (
1403
+ select * from a2_cursors cur
1404
+ where cur.session_id = ${SESSION}
1405
+ and cur.cursor_key = ${param.text("cursorKey")}
1406
+ and cur.consumer = ${CONSUMER}
1407
+ order by cur.epoch desc limit 1
1408
+ ) cur on true
1409
+ ), found as materialized (
1410
+ select latest.*,
1411
+ latest.types is distinct from ${param.jsonb("types")} as retyped,
1412
+ case when latest.head > latest.clear_index then null
1413
+ else ${nextLogHead({
1414
+ types: param.jsonb("types"),
1415
+ after: "latest.start"
1416
+ })} end as next_head
1417
+ from latest
1418
+ )`;
1419
+ /**
1420
+ * A run with a head gets no next one: it is left as it is, claim and all,
1421
+ * and so is an idle run with nothing new to take. Only their types change.
1422
+ */
1423
+ const KEEPS_RUN = "found.epoch is not null and found.next_head is null";
1424
+ /** No run yet, and nothing after the latest clear: none is started. */
1425
+ const NO_RUN_NEEDED = "found.epoch is null and found.session_head = found.clear_index";
1426
+ /** `wakeLog` without the lock: stops when there is nothing to write. */
1427
+ const READ_LOG = `
1428
+ with ${LOG_CURSOR}
1429
+ select case when ${KEEPS_RUN} and not found.retyped
1430
+ then jsonb_build_object('stop', 'unchanged',
1431
+ 'cursor', to_jsonb(found))
1432
+ when ${NO_RUN_NEEDED}
1433
+ then jsonb_build_object('stop', 'empty', 'empty', true,
1434
+ 'head', found.session_head)
1435
+ else jsonb_build_object('stale', true) end
1436
+ from found`;
1437
+ /**
1438
+ * `wakeLog` under the session lock. A kept run takes the consumer's types,
1439
+ * unless another operation holds its row: the session lock is held, so this
1440
+ * waits for none. Otherwise a run starts at `start`, retiring the runs it
1441
+ * replaces. One with a head is keyed by it; one with nothing to run is idle
1442
+ * at its position, as a run that finished its last head is, keyed by that
1443
+ * position so the next run, which starts after it, sorts later.
1444
+ */
1445
+ const WAKE_LOG = `
1446
+ with ${LOG_CURSOR}, held as materialized (
1447
+ select cur.ctid from a2_cursors cur, found
1448
+ where ${KEEPS_RUN} and found.retyped
1449
+ and cur.session_id = ${SESSION}
1450
+ and cur.cursor_key = ${param.text("cursorKey")}
1451
+ and cur.consumer = ${CONSUMER} and cur.epoch = found.epoch
1452
+ and cur.types is distinct from ${param.jsonb("types")}
1453
+ for update of cur skip locked
1454
+ ), retype as (
1455
+ update a2_cursors cur set types = ${param.jsonb("types")} from held
1456
+ where cur.ctid = held.ctid
1457
+ ), target as (
1458
+ select ${param.text("cursorKey")} as cursor_key, ${CONSUMER} as consumer
1459
+ from found where not (${KEEPS_RUN}) and not (${NO_RUN_NEEDED})
1460
+ ), ${RETIRE_RUNS("target", "(select clear_index from found)")}, started as (
1461
+ insert into a2_cursors
1462
+ (session_id, consumer, cursor_key, epoch, lane, kind, position, head,
1463
+ types)
1464
+ select ${SESSION}, target.consumer, target.cursor_key,
1465
+ coalesce(found.next_head, found.start), ${param.text("lane")}, 'log',
1466
+ found.start, found.next_head, ${param.jsonb("types")}
1467
+ from found, target
1468
+ returning *
1469
+ )
1470
+ select jsonb_build_object(
1471
+ 'cursor', coalesce((select to_jsonb(started) from started),
1472
+ to_jsonb(found)),
1473
+ 'head', found.session_head, 'clear_index', found.clear_index,
1474
+ 'empty', ${NO_RUN_NEEDED})
1475
+ from found`;
1476
+ /**
1477
+ * The statements that start the cursor work a batch of `events` carries,
1478
+ * with their parameters: each event by its position in the batch, from 1,
1479
+ * since its index is assigned under the lock. `started` says whether it
1480
+ * starts work for a served consumer.
1481
+ */
1482
+ const enqueue = (events, consumers) => {
1483
+ const served = consumers && new Set(consumers.map(({ name }) => name));
1484
+ let started = false;
1485
+ const singles = [];
1486
+ const queued = [];
1487
+ const lanes = /* @__PURE__ */ new Map();
1488
+ events.forEach((event, offset) => {
1489
+ const ord = offset + 1;
1490
+ for (const target of event.targets ?? []) {
1491
+ if (!served || served.has(target.consumer)) started = true;
1492
+ if (target.lane === null) {
1493
+ singles.push({
1494
+ consumer: target.consumer,
1495
+ ord
1496
+ });
1497
+ continue;
1498
+ }
1499
+ queued.push({
1500
+ consumer: target.consumer,
1501
+ lane: target.lane,
1502
+ ord
1503
+ });
1504
+ const key = cursorKeyOf(target.lane, ord);
1505
+ const first = `${target.consumer}\u0000${key}`;
1506
+ if (!lanes.has(first)) lanes.set(first, {
1507
+ consumer: target.consumer,
1508
+ cursor_key: key,
1509
+ lane: target.lane,
1510
+ ord
1511
+ });
1512
+ }
1513
+ });
1514
+ const logs = [];
1515
+ for (const consumer of consumers ?? []) {
1516
+ const log = consumer.log;
1517
+ if (!log) continue;
1518
+ const offset = events.findIndex((event) => event.type !== "a2.cleared" && (log.types === null || log.types.includes(event.type)));
1519
+ if (offset === -1) continue;
1520
+ logs.push({
1521
+ consumer: consumer.name,
1522
+ cursor_key: cursorKeyOf(log.lane, 0),
1523
+ lane: log.lane,
1524
+ ord: offset + 1,
1525
+ types: log.types,
1526
+ backfill: log.backfill
1527
+ });
1528
+ }
1529
+ if (logs.length > 0) started = true;
1530
+ return {
1531
+ statements: [
1532
+ singles.length > 0 && START_EVENTS,
1533
+ queued.length > 0 && QUEUE_LANES,
1534
+ queued.length > 0 && START_LANES,
1535
+ logs.length > 0 && WAKE_LOGS
1536
+ ],
1537
+ params: {
1538
+ singles,
1539
+ queued,
1540
+ lanes: [...lanes.values()],
1541
+ logs,
1542
+ clearType: CLEAR_EVENT_TYPE
1543
+ },
1544
+ started
1545
+ };
1546
+ };
1547
+ /**
1548
+ * The rows a batch inserted at `base + 1`, as the store returns them.
1549
+ * `events` are what the batch sent.
1550
+ */
1551
+ const insertedRows = ({ sessionId, events, base, now }) => events.map((event, offset) => ({
1552
+ id: event.id,
1553
+ type: event.type,
1554
+ payload: structuredClone(event.payload),
1555
+ index: base + 1 + offset,
1556
+ sessionId,
1557
+ createdAt: new Date(now),
1558
+ cause: event.cause ? { ...event.cause } : null
1559
+ }));
1560
+ /** A served log consumer's types, for a cursor that moves past its head. */
1561
+ const retypeOf = (consumer, consumers) => {
1562
+ const log = consumers?.find(({ name }) => name === consumer)?.log;
1563
+ return log ? {
1564
+ retype: true,
1565
+ types: log.types
1566
+ } : {
1567
+ retype: false,
1568
+ types: null
1569
+ };
1570
+ };
616
1571
  function postgres(options = {}) {
617
1572
  const clock = options.clock ?? SYSTEM_CLOCK;
618
1573
  const generateId = options.ids ?? RANDOM_IDS;
@@ -639,30 +1594,23 @@ function postgres(options = {}) {
639
1594
  }
640
1595
  });
641
1596
  }
642
- try {
643
- const listen = Boolean(options.listenConnectionString);
644
- const resource = lease.value;
645
- await resource.initialize({
646
- key: listen ? "schema+triggers" : "schema",
647
- run: () => checkPostgresSchema({
648
- client: resource.client,
649
- listen
650
- })
651
- });
652
- return lease;
653
- } catch (error) {
654
- if (!injected) await lease.release().catch(() => {});
655
- throw error;
656
- }
1597
+ return {
1598
+ lease,
1599
+ client: explainingPostgresClient(lease.value.client)
1600
+ };
657
1601
  });
658
1602
  const client = async () => {
659
1603
  if (closed) throw new A2Error("STORE_UNAVAILABLE", "the store was closed");
660
- const lease = await connection.get();
1604
+ const { client: explaining } = await connection.get();
661
1605
  if (closed) throw new A2Error("STORE_UNAVAILABLE", "the store was closed");
662
- return lease.value.client;
1606
+ return explaining;
663
1607
  };
664
1608
  const notifications = options.listenConnectionString ? postgresNotificationScope(async () => {
665
- await client();
1609
+ const c = await client();
1610
+ await (await connection.get()).lease.value.initialize({
1611
+ key: "triggers",
1612
+ run: () => checkPostgresTriggers(c)
1613
+ });
666
1614
  const pg = await loadPg();
667
1615
  const connectionString = options.listenConnectionString;
668
1616
  const key = postgresConnectionKey({
@@ -791,12 +1739,14 @@ function postgres(options = {}) {
791
1739
  conn.release();
792
1740
  }
793
1741
  }
794
- const resource = (await connection.get()).value;
1742
+ const resource = (await connection.get()).lease.value;
795
1743
  return resource.exclusive(async () => {
796
1744
  const raw = resource.raw;
797
1745
  await raw.query(begin);
798
1746
  try {
799
- const result = await fn((text, values) => raw.query(text, values));
1747
+ const result = await fn((text, values) => raw.query(text, values).catch((error) => {
1748
+ throw explainPostgresError(error);
1749
+ }));
800
1750
  await raw.query("commit");
801
1751
  return result;
802
1752
  } catch (err) {
@@ -806,171 +1756,246 @@ function postgres(options = {}) {
806
1756
  });
807
1757
  };
808
1758
  /**
809
- * The rows owning `ids`. This session's rows below its clear marker do
810
- * not own their ids: they are deleted here so the ids can be reused.
1759
+ * Runs `statements` (the falsy ones dropped) as one `a2_batch` call with
1760
+ * `params`, and the session's id as `session`. Its rows come back in order;
1761
+ * an aborted call throws what `abort` makes of the aborting row.
811
1762
  */
812
- const liveOwnersOf = async ({ query, sessionId, clearIndex, ids }) => {
813
- if (ids.length === 0) return [];
814
- const { rows } = await query("select * from a2_events where event_id = any($1::text[]) order by idx", [ids]);
815
- const stale = rows.filter((row) => row["session_id"] === sessionId && asNumber(row["idx"]) < clearIndex);
816
- if (stale.length === 0) return rows;
817
- const { rows: deleted } = await query(`with stale as materialized (
818
- select idx from a2_events
819
- where session_id = $1 and event_id = any($2::text[])
820
- order by idx
821
- for update skip locked
822
- )
823
- delete from a2_events event using stale
824
- where event.session_id = $1 and event.idx = stale.idx
825
- returning event.idx`, [sessionId, stale.map((row) => row["event_id"])]);
826
- if (deleted.length < stale.length) throw new A2Error("STORE_UNAVAILABLE", `an event id being reused is still held by cleared work in session '${sessionId}'`);
827
- return rows.filter((row) => !stale.includes(row));
1763
+ const run = async ({ sessionId, statements, params, abort = () => new A2Error("STORE_UNAVAILABLE", "postgres store operation failed") }) => {
1764
+ const c = await client();
1765
+ try {
1766
+ const { rows } = await c.query(BATCH, [statements.flatMap((statement) => statement === false ? [] : [compact(statement)]), jsonb({
1767
+ ...params,
1768
+ session: sessionId
1769
+ })]);
1770
+ return rows.map((row) => row["r"]);
1771
+ } catch (error) {
1772
+ const reason = abortedBatch(error);
1773
+ if (reason) throw abort(reason);
1774
+ throw error;
1775
+ }
828
1776
  };
829
1777
  /** Whether `index` is at or below the boundary, read in a fresh statement. */
830
1778
  const clearedAt = async ({ query, sessionId, index }) => {
831
1779
  const { rows } = await query(`select ${clearIndexOf("$1")} as clear_index`, [sessionId]);
832
1780
  return index <= asNumber(rows[0]?.["clear_index"]);
833
1781
  };
834
- /** Insert one event; its lane is ready unless an unfinished lane event of this lifetime precedes it. */
835
- const insertEvent = async ({ query, row, clearIndex, returning = false }) => {
836
- const { rows } = await query(`insert into a2_events
837
- (session_id, idx, event_type, payload, event_id, created_at,
838
- cause, lane, lane_ready, processed_at)
839
- values (
840
- $1, $2, $3, $4::jsonb, $5, $6, $7::jsonb, $8,
841
- $8::text is null or not exists (
842
- select 1 from a2_events
843
- where session_id = $1 and lane = $8 and processed_at is null
844
- and idx > $10
845
- ),
846
- $9
847
- )${returning ? " returning *" : ""}`, [
848
- row.sessionId,
849
- row.index,
850
- row.type,
851
- jsonb(row.payload),
852
- row.id,
853
- row.createdAt,
854
- row.cause ? jsonb(row.cause) : null,
855
- row.lane,
856
- row.processedAt,
857
- clearIndex
1782
+ /**
1783
+ * A completion that does not hold its head: a stale attempt, or a retry
1784
+ * after an unknown acknowledgement, which replays the committed children.
1785
+ * Takes no lock.
1786
+ */
1787
+ const settleUnheld = async ({ query, sessionId, consumer, lane, index, attempt, events }) => {
1788
+ const superseded = { outcome: "superseded" };
1789
+ const { rows: cursorRows } = await query(`select latest.kind, latest.head, covering.completed_idx,
1790
+ covering.completed_attempt, covering.returned_event_ids
1791
+ from (
1792
+ select kind, head from a2_cursors
1793
+ where session_id = $1 and cursor_key = $2 and consumer = $3
1794
+ order by epoch desc limit 1
1795
+ ) latest
1796
+ left join lateral (
1797
+ select completed_idx, completed_attempt, returned_event_ids
1798
+ from a2_cursors
1799
+ where session_id = $1 and cursor_key = $2 and consumer = $3
1800
+ and epoch <= $4
1801
+ order by epoch desc limit 1
1802
+ ) covering on true`, [
1803
+ sessionId,
1804
+ cursorKeyOf(lane, index),
1805
+ consumer,
1806
+ index
858
1807
  ]);
859
- return rows[0];
1808
+ const cursor = cursorRows[0];
1809
+ if (!cursor) {
1810
+ if (await hasEvent(query, sessionId, index)) return superseded;
1811
+ if (await clearedAt({
1812
+ query,
1813
+ sessionId,
1814
+ index
1815
+ })) return superseded;
1816
+ throw new TypeError(`no event at index ${index} in session '${sessionId}'`);
1817
+ }
1818
+ const ids = events.map((event) => event.id);
1819
+ if (new Set(ids).size !== ids.length) throw new A2Error("PARTIAL_DUPLICATE_BATCH", "returned event batch contains the same event id more than once");
1820
+ if (asNullableNumber(cursor["head"]) === index) return superseded;
1821
+ let completion;
1822
+ if (cursor["kind"] === "lane") {
1823
+ const { rows } = await query(`select completed_attempt, returned_event_ids from a2_lane_events
1824
+ where session_id = $1 and consumer = $2 and lane = $3 and idx = $4`, [
1825
+ sessionId,
1826
+ consumer,
1827
+ lane,
1828
+ index
1829
+ ]);
1830
+ completion = rows[0] && {
1831
+ attempt: asNullableNumber(rows[0]["completed_attempt"]),
1832
+ ids: rows[0]["returned_event_ids"]
1833
+ };
1834
+ } else if (asNullableNumber(cursor["completed_idx"]) === index) completion = {
1835
+ attempt: asNullableNumber(cursor["completed_attempt"]),
1836
+ ids: cursor["returned_event_ids"]
1837
+ };
1838
+ if (!completion || completion.attempt !== attempt) return superseded;
1839
+ const returnedIds = completion.ids === null || completion.ids === void 0 ? null : decodeReturnedEventIds(completion.ids);
1840
+ if (returnedIds === null || returnedIds.length !== ids.length || returnedIds.some((id, offset) => id !== ids[offset])) throw new A2Error("PARTIAL_DUPLICATE_BATCH", "completed attempt does not match the returned event batch");
1841
+ if (events.length === 0) return {
1842
+ outcome: "completed",
1843
+ events: []
1844
+ };
1845
+ const { rows: existing } = await query(`select session_id, idx, event_type, payload, event_id, created_at, cause
1846
+ from a2_events where session_id = $1 and event_id = any($2::text[])`, [sessionId, ids]);
1847
+ const byId = new Map(existing.map((row) => [row["event_id"], row]));
1848
+ const ordered = ids.map((id) => byId.get(id));
1849
+ if (ordered.some((row) => !row) && await clearedAt({
1850
+ query,
1851
+ sessionId,
1852
+ index
1853
+ })) return superseded;
1854
+ if (ordered.some((row) => {
1855
+ const cause = row ? decodeCause(row["cause"]) : null;
1856
+ return !row || cause?.consumer !== consumer || cause.lane !== lane || cause.index !== index || cause.attempt !== attempt;
1857
+ })) throw new A2Error("PARTIAL_DUPLICATE_BATCH", "returned event retry does not match the committed batch");
1858
+ return {
1859
+ outcome: "completed",
1860
+ events: idempotentReplay(events, ordered.map(toStored))
1861
+ };
860
1862
  };
861
1863
  const store = {
862
1864
  async append(sessionId, events, appendOptions) {
1865
+ const consumers = appendOptions?.consumers;
1866
+ const served = servedNames(consumers);
863
1867
  if (events.length === 0) return wrap(async () => {
864
- const { rows } = await (await client()).query(`select exists(
865
- select 1 from a2_events
866
- where session_id = $1 and processed_at is null
867
- and idx > ${clearIndexOf("$1")}
868
- ) as has_pending`, [sessionId]);
1868
+ const { rows } = await (await client()).query(`select ${pendingIn("$1", "$2")} as has_pending`, [sessionId, served]);
869
1869
  return {
870
1870
  events: [],
871
1871
  hasPending: rows[0]?.["has_pending"] === true
872
1872
  };
873
1873
  });
874
- return wrap(() => withTx(async (query) => {
875
- await query("select pg_advisory_xact_lock(hashtext($1))", [sessionId]);
876
- const { rows: statusRows } = await query(`select coalesce(max(idx), 0) as max,
877
- ${clearIndexOf("$1")} as clear_index,
878
- exists(
879
- select 1 from a2_events pending
880
- where pending.session_id = $1
881
- and pending.processed_at is null
882
- and pending.idx > ${clearIndexOf("$1")}
883
- ) as session_has_pending
884
- from a2_events
885
- where session_id = $1`, [sessionId]);
886
- const base = asNumber(statusRows[0]?.["max"]);
887
- const clearIndex = asNumber(statusRows[0]?.["clear_index"]);
888
- const sessionHasPending = statusRows[0]?.["session_has_pending"] === true;
1874
+ return wrap(async () => {
889
1875
  const suppliedIds = events.filter((e) => e.id !== void 0).map((e) => e.id);
890
1876
  if (new Set(suppliedIds).size !== suppliedIds.length) throw new A2Error("PARTIAL_DUPLICATE_BATCH", "batch contains the same event id more than once");
891
- if (suppliedIds.length > 0) {
892
- const existing = await liveOwnersOf({
893
- query,
894
- sessionId,
895
- clearIndex,
896
- ids: suppliedIds
897
- });
898
- if (existing.length > 0) {
899
- const foreign = existing.find((row) => row["session_id"] !== sessionId);
900
- if (foreign) throw new A2Error("PARTIAL_DUPLICATE_BATCH", `event id '${String(foreign["event_id"])}' already exists in another session`);
901
- if (existing.length === events.length) return {
902
- events: idempotentReplay(events, existing.map(toStored)),
903
- hasPending: sessionHasPending
904
- };
905
- throw new A2Error("PARTIAL_DUPLICATE_BATCH", `batch mixes ${existing.length} already-appended and ${events.length - existing.length} fresh events`);
906
- }
907
- }
1877
+ const now = clock.now().getTime();
1878
+ const rows = events.map((event, offset) => ({
1879
+ ord: offset + 1,
1880
+ id: event.id ?? generateId(),
1881
+ type: event.type,
1882
+ payload: event.payload,
1883
+ cause: event.cause ?? null
1884
+ }));
1885
+ const causes = [...new Map(events.flatMap((event) => event.cause ? [[jsonb(event.cause), event.cause]] : [])).values()];
1886
+ const work = enqueue(events, consumers);
908
1887
  const originIndex = appendOptions?.originIndex;
909
- if (originIndex !== void 0 && originIndex <= clearIndex) throw sessionClearedError(`scheduled work from event ${originIndex} was cleared in session '${sessionId}'`);
910
- for (const e of events) if (e.cause && e.cause.index <= clearIndex) throw supersededAttemptError(sessionId, e.cause);
911
- const causeIndexes = [...new Set(events.filter((e) => e.cause !== void 0).map((e) => e.cause.index))];
912
- if (causeIndexes.length > 0) {
913
- const { rows: parents } = await query(`select idx, attempt_count, failed_at from a2_events
914
- where session_id = $1 and idx = any($2::bigint[])
915
- for update`, [sessionId, causeIndexes]);
916
- const byIndex = new Map(parents.map((row) => [asNumber(row["idx"]), row]));
917
- for (const e of events) {
918
- if (!e.cause) continue;
919
- const parent = byIndex.get(e.cause.index);
920
- if (!parent) throw new TypeError(`no event at index ${e.cause.index} in session '${sessionId}'`);
921
- if (asNumber(parent["attempt_count"]) !== e.cause.attempt || parent["failed_at"] !== null) throw supersededAttemptError(sessionId, e.cause);
1888
+ const checked = (await run({
1889
+ sessionId,
1890
+ statements: [
1891
+ LOCK_SESSION,
1892
+ APPEND_CHECKS,
1893
+ causes.length > 0 && FENCE,
1894
+ suppliedIds.length > 0 && REUSE_IDS,
1895
+ INSERT_EVENTS,
1896
+ ...work.statements
1897
+ ],
1898
+ params: {
1899
+ ...work.params,
1900
+ served,
1901
+ ids: suppliedIds,
1902
+ count: events.length,
1903
+ origin: originIndex ?? null,
1904
+ causes: causes.map((cause, offset) => ({
1905
+ ord: offset + 1,
1906
+ consumer: cause.consumer,
1907
+ cursor_key: cursorKeyOf(cause.lane, cause.index),
1908
+ idx: cause.index,
1909
+ attempt: cause.attempt
1910
+ })),
1911
+ events: rows,
1912
+ now
1913
+ },
1914
+ abort: (reason) => {
1915
+ switch (reason["abort"]) {
1916
+ case "mixed": {
1917
+ const owned = asNumber(reason["owned"]);
1918
+ return new A2Error("PARTIAL_DUPLICATE_BATCH", `batch mixes ${owned} already-appended and ${events.length - owned} fresh events`);
1919
+ }
1920
+ case "cleared": return sessionClearedError(`scheduled work from event ${originIndex} was cleared in session '${sessionId}'`);
1921
+ case "superseded": return supersededAttemptError(sessionId, causes[asNumber(reason["ord"]) - 1]);
1922
+ case "held": return new A2Error("STORE_UNAVAILABLE", `an event id being reused is still held by cleared work in session '${sessionId}'`);
1923
+ default: return new A2Error("STORE_UNAVAILABLE", "postgres store operation failed");
1924
+ }
922
1925
  }
923
- }
924
- const now = clock.now().getTime();
925
- const inserted = [];
926
- for (let i = 0; i < events.length; i += 1) {
927
- const e = events[i];
928
- const id = e.id ?? generateId();
929
- const index = base + 1 + i;
930
- await insertEvent({
931
- query,
932
- row: {
933
- sessionId,
934
- index,
935
- type: e.type,
936
- payload: e.payload,
937
- id,
938
- createdAt: now,
939
- cause: e.cause ?? null,
940
- lane: e.lane ?? null,
941
- processedAt: e.settled ? now : null
942
- },
943
- clearIndex
944
- });
945
- inserted.push({
946
- id,
947
- type: e.type,
948
- payload: structuredClone(e.payload),
949
- index,
1926
+ })).find((row) => row["stop"] === "replay" || "base" in row);
1927
+ if (checked["stop"] === "replay") return {
1928
+ events: idempotentReplay(events, checked["events"].map(toStored)),
1929
+ hasPending: checked["has_pending"] === true
1930
+ };
1931
+ return {
1932
+ events: insertedRows({
950
1933
  sessionId,
951
- createdAt: new Date(now),
952
- cause: e.cause ? { ...e.cause } : null,
953
- lane: e.lane ?? null,
954
- processedAt: e.settled ? new Date(now) : null,
955
- processedByAttempt: null,
956
- returnedEventIds: null,
957
- firstClaimedAt: null,
958
- lastClaimedAt: null,
959
- attemptCount: 0,
960
- failureCount: 0,
961
- lastFailedAt: null,
962
- lastFailedAttempt: null,
963
- lastError: null,
964
- failedAt: null,
965
- claimHolder: null,
966
- claimExpiresAt: null
967
- });
1934
+ events: rows,
1935
+ base: asNumber(checked["base"]),
1936
+ now
1937
+ }),
1938
+ hasPending: checked["has_pending"] === true || work.started
1939
+ };
1940
+ });
1941
+ },
1942
+ async wakeLog({ sessionId, consumer }) {
1943
+ const { log } = consumer;
1944
+ return wrap(async () => {
1945
+ const found = (await run({
1946
+ sessionId,
1947
+ statements: [
1948
+ READ_LOG,
1949
+ LOCK_SESSION,
1950
+ WAKE_LOG
1951
+ ],
1952
+ params: {
1953
+ cursorKey: cursorKeyOf(log.lane, 0),
1954
+ consumer: consumer.name,
1955
+ lane: log.lane,
1956
+ types: log.types,
1957
+ backfill: log.backfill,
1958
+ clearType: CLEAR_EVENT_TYPE
1959
+ }
1960
+ })).at(-1);
1961
+ if (found["empty"] === true) {
1962
+ const head = asNumber(found["head"]);
1963
+ return {
1964
+ cursor: idleLogCursor(consumer.name, log.lane, head),
1965
+ head
1966
+ };
968
1967
  }
1968
+ const cursor = found["cursor"];
1969
+ const clearIndex = asNumber(found["stop"] === "unchanged" ? cursor["clear_index"] : found["clear_index"]);
969
1970
  return {
970
- events: inserted,
971
- hasPending: sessionHasPending || events.some((event) => event.settled !== true)
1971
+ cursor: toCursor(cursor, clearIndex),
1972
+ head: asNumber(found["stop"] === "unchanged" ? cursor["session_head"] : found["head"])
972
1973
  };
973
- }));
1974
+ });
1975
+ },
1976
+ async cursors(sessionId) {
1977
+ return wrap(async () => {
1978
+ const { rows } = await (await client()).query(`with boundary as materialized (
1979
+ select ${clearIndexOf("$1")} as clear_index
1980
+ )
1981
+ select cur.*, boundary.clear_index
1982
+ from a2_cursors cur, boundary
1983
+ where cur.session_id = $1
1984
+ and (
1985
+ cur.head > boundary.clear_index
1986
+ or (cur.kind <> 'event' and not exists (
1987
+ select 1 from a2_cursors later
1988
+ where later.session_id = cur.session_id
1989
+ and later.cursor_key = cur.cursor_key
1990
+ and later.consumer = cur.consumer
1991
+ and later.epoch > cur.epoch
1992
+ ) and (
1993
+ cur.kind = 'log'
1994
+ or (cur.head is null and cur.position > boundary.clear_index)
1995
+ ))
1996
+ )`, [sessionId]);
1997
+ return rows.map((row) => toCursor(row, asNumber(row["clear_index"]))).toSorted(byCursor);
1998
+ });
974
1999
  },
975
2000
  async read(sessionId, opts) {
976
2001
  return wrap(async () => {
@@ -986,7 +2011,8 @@ function postgres(options = {}) {
986
2011
  conditions.push(`idx <= $${params.length}`);
987
2012
  }
988
2013
  conditions.push(`idx >= ${clearIndexOf("$1")}`);
989
- const { rows } = await c.query(`select * from a2_events where ${conditions.join(" and ")} order by idx`, params);
2014
+ const { rows } = await c.query(`select session_id, idx, event_type, payload, event_id, created_at, cause
2015
+ from a2_events where ${conditions.join(" and ")} order by idx`, params);
990
2016
  return rows.map(toStored);
991
2017
  });
992
2018
  },
@@ -1010,79 +2036,57 @@ function postgres(options = {}) {
1010
2036
  return rows.map(toEvent);
1011
2037
  });
1012
2038
  },
1013
- async claimAvailable({ sessionId, holder, ttlMs, expiresAtMs, excludeIndexes = [], maxFailures }) {
1014
- return wrap(() => withTx(async (query) => {
2039
+ async claimAvailable({ sessionId, holder, ttlMs, expiresAtMs, exclude = [], consumers, maxFailures, through }) {
2040
+ return wrap(async () => {
1015
2041
  const now = clock.now().getTime();
1016
- const expiresAt = expiresAtMs ?? now + ttlMs;
1017
- const exhausted = `(claimed.claim_holder is not null
1018
- and claimed.failure_count + 1 >= $6::int)`;
1019
- const { rows } = await query(`with eligible as materialized (
1020
- select event.idx
1021
- from a2_events event
1022
- where event.session_id = $1
1023
- and event.processed_at is null
1024
- and event.failed_at is null
1025
- and event.lane_ready
1026
- and event.idx > ${clearIndexOf("$1")}
1027
- and (event.claim_expires_at is null or event.claim_expires_at <= $2)
1028
- and not (event.idx = any($5::bigint[]))
1029
- for update of event
1030
- )
1031
- update a2_events claimed set
1032
- attempt_count = claimed.attempt_count + 1,
1033
- failure_count = claimed.failure_count
1034
- + case when claimed.claim_holder is null then 0 else 1 end,
1035
- failed_at = case when ${exhausted} then $2::bigint else claimed.failed_at end,
1036
- first_claimed_at = case when ${exhausted}
1037
- then claimed.first_claimed_at
1038
- else coalesce(claimed.first_claimed_at, $2::bigint) end,
1039
- last_claimed_at = case when ${exhausted}
1040
- then claimed.last_claimed_at else $2::bigint end,
1041
- claim_holder = case when ${exhausted} then null else $3::text end,
1042
- claim_expires_at = case when ${exhausted} then null else $4::bigint end
1043
- from eligible
1044
- where claimed.session_id = $1 and claimed.idx = eligible.idx
1045
- returning claimed.*, ${clearIndexOf("$1")} as clear_index`, [
1046
- sessionId,
1047
- now,
1048
- holder,
1049
- expiresAt,
1050
- [...excludeIndexes],
1051
- maxFailures ?? null
1052
- ]);
1053
- for (const row of rows) if (row["failed_at"] !== null) await releasePins({
1054
- query,
2042
+ const results = await run({
1055
2043
  sessionId,
1056
- index: asNumber(row["idx"])
2044
+ statements: [
2045
+ CLAIM,
2046
+ RELEASE_DEAD,
2047
+ RETRY_AT
2048
+ ],
2049
+ params: {
2050
+ now,
2051
+ served: servedNames(consumers),
2052
+ excluded: exclude.map((ref) => ({
2053
+ consumer: ref.consumer,
2054
+ cursor_key: cursorKeyOf(ref.lane, ref.index)
2055
+ })),
2056
+ holder,
2057
+ expiresAt: expiresAtMs ?? now + ttlMs,
2058
+ maxFailures: maxFailures ?? null,
2059
+ through: through ?? null
2060
+ }
1057
2061
  });
1058
- if (rows.length > 0) return {
1059
- outcome: "claimed",
1060
- events: rows.map((row) => {
1061
- const stored = toStored(row);
2062
+ const rows = results.filter((row) => "e_idx" in row);
2063
+ const unseen = rows.filter((row) => row["e_idx"] === null);
2064
+ if (unseen.length > 0) {
2065
+ const { rows: events } = await (await client()).query(`select ${HEAD_COLUMNS} from a2_events event
2066
+ where event.session_id = $1 and event.idx = any($2::bigint[])`, [sessionId, unseen.map((row) => row["head"])]);
2067
+ const byIndex = new Map(events.map((event) => [asNumber(event["e_idx"]), event]));
2068
+ for (const row of unseen) Object.assign(row, byIndex.get(asNumber(row["head"])));
2069
+ }
2070
+ const heads = rows.filter((row) => row["e_idx"] !== null);
2071
+ if (heads.length > 0) {
2072
+ const events = heads.map((row) => {
2073
+ const head = toHead(row);
1062
2074
  const clearIndex = asNumber(row["clear_index"]);
1063
- if (clearIndex > 0) stored.clearIndex = clearIndex;
1064
- return stored;
1065
- }).toSorted((a, b) => a.index - b.index)
1066
- };
1067
- const { rows: activeRows } = await query(`select min(claim_expires_at) as retry_at
1068
- from a2_events
1069
- where session_id = $1
1070
- and processed_at is null
1071
- and failed_at is null
1072
- and lane_ready
1073
- and idx > ${clearIndexOf("$1")}
1074
- and claim_expires_at > $2
1075
- and not (idx = any($3::bigint[]))`, [
1076
- sessionId,
1077
- now,
1078
- [...excludeIndexes]
1079
- ]);
1080
- const retryAt = activeRows[0]?.["retry_at"];
2075
+ if (clearIndex > 0) head.clearIndex = clearIndex;
2076
+ return head;
2077
+ }).toSorted(byHead);
2078
+ return {
2079
+ outcome: "claimed",
2080
+ events,
2081
+ head: Math.max(...heads.map((row) => asNumber(row["session_head"])), ...events.map((event) => event.index))
2082
+ };
2083
+ }
2084
+ const retryAt = results.at(-1)["retry_at"];
1081
2085
  return retryAt === null || retryAt === void 0 ? { outcome: "settled" } : {
1082
2086
  outcome: "busy",
1083
2087
  retryAt: new Date(asNumber(retryAt))
1084
2088
  };
1085
- }));
2089
+ });
1086
2090
  },
1087
2091
  async renewClaims({ sessionId, holder, claims, ttlMs, expiresAtMs }) {
1088
2092
  if (claims.length === 0) return {
@@ -1093,233 +2097,203 @@ function postgres(options = {}) {
1093
2097
  const c = await client();
1094
2098
  const now = clock.now().getTime();
1095
2099
  const expiresAt = expiresAtMs ?? now + ttlMs;
1096
- const indexes = claims.map((claim) => claim.index);
1097
- const attempts = new Map(claims.map((claim) => [claim.index, claim.attempt]));
2100
+ const ordered = claims.toSorted((a, b) => a.index - b.index || compareText(a.consumer, b.consumer));
1098
2101
  const { rows } = await c.query(`with listed as (
1099
- select idx, attempt
1100
- from unnest($3::bigint[], $6::int[]) as listed(idx, attempt)
1101
- ),
1102
- renewed as (
1103
- update a2_events event set claim_expires_at = $1
2102
+ select * from unnest($3::text[], $4::text[], $5::bigint[], $6::int[])
2103
+ with ordinality as listed(consumer, cursor_key, idx, attempt, ord)
2104
+ ), renewed as (
2105
+ update a2_cursors cur set claim_expires_at = $7
1104
2106
  from listed
1105
- where event.session_id = $2
1106
- and event.idx = listed.idx
1107
- and event.idx > ${clearIndexOf("$2")}
1108
- and event.attempt_count = listed.attempt
1109
- and event.processed_at is null
1110
- and event.failed_at is null
1111
- and event.claim_holder = $4
1112
- and event.claim_expires_at > $5
1113
- returning event.idx
2107
+ where cur.session_id = $1
2108
+ and cur.cursor_key = listed.cursor_key
2109
+ and cur.consumer = listed.consumer
2110
+ and cur.head = listed.idx
2111
+ and listed.idx > ${clearIndexOf("$1")}
2112
+ and cur.attempt_count = listed.attempt
2113
+ and cur.failed_at is null
2114
+ and cur.claim_holder = $2
2115
+ and cur.claim_expires_at > $8
2116
+ returning cur.cursor_key, cur.consumer, cur.head
1114
2117
  )
1115
- select listed.idx, event.attempt_count,
1116
- exists(select 1 from renewed where renewed.idx = listed.idx)
1117
- as renewed,
1118
- listed.idx <= ${clearIndexOf("$2")} as cleared
2118
+ select listed.ord, latest.head, latest.position, latest.attempt_count,
2119
+ latest.epoch is not null as found,
2120
+ exists(
2121
+ select 1 from renewed
2122
+ where renewed.cursor_key = listed.cursor_key
2123
+ and renewed.consumer = listed.consumer
2124
+ and renewed.head = listed.idx
2125
+ ) as renewed,
2126
+ listed.idx <= ${clearIndexOf("$1")} as cleared
1119
2127
  from listed
1120
- left join a2_events event
1121
- on event.session_id = $2 and event.idx = listed.idx`, [
1122
- expiresAt,
2128
+ left join lateral (
2129
+ select cur.epoch, cur.head, cur.position, cur.attempt_count
2130
+ from a2_cursors cur
2131
+ where cur.session_id = $1
2132
+ and cur.cursor_key = listed.cursor_key
2133
+ and cur.consumer = listed.consumer
2134
+ order by cur.epoch desc limit 1
2135
+ ) latest on true
2136
+ order by listed.ord`, [
1123
2137
  sessionId,
1124
- indexes,
1125
2138
  holder,
1126
- now,
1127
- claims.map((claim) => claim.attempt)
2139
+ ordered.map((claim) => claim.consumer),
2140
+ ordered.map((claim) => cursorKeyOf(claim.lane, claim.index)),
2141
+ ordered.map((claim) => claim.index),
2142
+ ordered.map((claim) => claim.attempt),
2143
+ expiresAt,
2144
+ now
1128
2145
  ]);
1129
2146
  const renewed = [];
1130
2147
  const superseded = [];
1131
2148
  for (const row of rows) {
1132
- const index = asNumber(row["idx"]);
1133
- if (row["renewed"] === true) renewed.push(index);
1134
- else if (row["cleared"] === true || row["attempt_count"] !== null && asNumber(row["attempt_count"]) > (attempts.get(index) ?? 0)) superseded.push(index);
2149
+ const { attempt, ...ref } = ordered[asNumber(row["ord"]) - 1];
2150
+ if (row["renewed"] === true) {
2151
+ renewed.push(ref);
2152
+ continue;
2153
+ }
2154
+ if (row["cleared"] === true) {
2155
+ superseded.push(ref);
2156
+ continue;
2157
+ }
2158
+ if (row["found"] !== true) continue;
2159
+ const head = asNullableNumber(row["head"]);
2160
+ if (head !== ref.index && asNumber(row["position"]) < ref.index) continue;
2161
+ if (head !== ref.index || asNumber(row["attempt_count"]) > attempt) superseded.push(ref);
1135
2162
  }
1136
2163
  return {
1137
- renewed: renewed.toSorted((a, b) => a - b),
1138
- superseded: superseded.toSorted((a, b) => a - b)
2164
+ renewed,
2165
+ superseded
1139
2166
  };
1140
2167
  });
1141
2168
  },
1142
- async completeAttempt({ sessionId, index, attempt, events }) {
1143
- return wrap(() => withTx(async (query) => {
1144
- const { rows: parentRows } = await query(`select boundary.clear_index, parent.*
1145
- from (select ${clearIndexOf("$1")} as clear_index) boundary
1146
- left join lateral (
1147
- select * from a2_events
1148
- where session_id = $1 and idx = $2 and idx > boundary.clear_index
1149
- for update
1150
- ) parent on true`, [sessionId, index]);
1151
- if (index <= asNumber(parentRows[0]?.["clear_index"])) return { outcome: "superseded" };
1152
- const parent = parentRows[0]?.["idx"] == null ? void 0 : parentRows[0];
1153
- if (!parent) {
1154
- if (await clearedAt({
1155
- query,
1156
- sessionId,
1157
- index
1158
- })) return { outcome: "superseded" };
1159
- throw new TypeError(`no event at index ${index} in session '${sessionId}'`);
1160
- }
2169
+ async completeAttempt({ sessionId, consumer, lane, index, attempt, events, consumers }) {
2170
+ return wrap(async () => {
1161
2171
  const ids = events.map((event) => event.id);
1162
- if (new Set(ids).size !== ids.length) throw new A2Error("PARTIAL_DUPLICATE_BATCH", "returned event batch contains the same event id more than once");
1163
- if (parent["processed_at"] !== null) {
1164
- if (asNumber(parent["processed_by_attempt"]) !== attempt) return { outcome: "superseded" };
1165
- const returnedIds = parent["returned_event_ids"] === null || parent["returned_event_ids"] === void 0 ? null : decodeReturnedEventIds(parent["returned_event_ids"]);
1166
- if (returnedIds === null || returnedIds.length !== ids.length || returnedIds.some((id, offset) => id !== ids[offset])) throw new A2Error("PARTIAL_DUPLICATE_BATCH", "completed attempt does not match the returned event batch");
1167
- if (events.length === 0) return {
1168
- outcome: "completed",
1169
- events: []
1170
- };
1171
- const { rows: existing } = await query(`select * from a2_events
1172
- where event_id = any($1::text[])`, [ids]);
1173
- const byId = new Map(existing.map((row) => [row["event_id"], row]));
1174
- const ordered = ids.map((id) => byId.get(id));
1175
- if (ordered.some((row) => !row) && await clearedAt({
1176
- query,
1177
- sessionId,
1178
- index
1179
- })) return { outcome: "superseded" };
1180
- if (ordered.some((row) => {
1181
- const cause = row ? toCause(row["cause"]) : null;
1182
- return !row || row["session_id"] !== sessionId || cause?.index !== index || cause.attempt !== attempt;
1183
- })) throw new A2Error("PARTIAL_DUPLICATE_BATCH", "returned event retry does not match the committed batch");
1184
- return {
1185
- outcome: "completed",
1186
- events: idempotentReplay(events, ordered.map(toStored))
1187
- };
1188
- }
1189
- if (asNumber(parent["attempt_count"]) !== attempt || parent["claim_holder"] === null || parent["claim_holder"] === void 0 || parent["failed_at"] !== null) return { outcome: "superseded" };
1190
- await query("select pg_advisory_xact_lock(hashtext($1))", [sessionId]);
1191
- const { rows: maxRows } = await query(`select coalesce(max(idx), 0) as max, ${clearIndexOf("$1")} as clear_index
1192
- from a2_events where session_id = $1`, [sessionId]);
1193
- const clearIndex = asNumber(maxRows[0]?.["clear_index"]);
1194
- if (index <= clearIndex) return { outcome: "superseded" };
1195
- if (ids.length > 0) {
1196
- if ((await liveOwnersOf({
1197
- query,
1198
- sessionId,
1199
- clearIndex,
1200
- ids
1201
- })).length > 0) throw new A2Error("PARTIAL_DUPLICATE_BATCH", "returned event batch contains an already-appended event id");
1202
- }
1203
- const base = asNumber(maxRows[0]?.["max"]);
2172
+ const duplicated = new Set(ids).size !== ids.length;
2173
+ const cause = {
2174
+ consumer,
2175
+ lane,
2176
+ index,
2177
+ attempt
2178
+ };
1204
2179
  const now = clock.now().getTime();
1205
- const inserted = [];
1206
- for (let offset = 0; offset < events.length; offset += 1) {
1207
- const event = events[offset];
1208
- const childIndex = base + offset + 1;
1209
- const row = await insertEvent({
1210
- query,
1211
- row: {
1212
- sessionId,
1213
- index: childIndex,
1214
- type: event.type,
1215
- payload: event.payload,
1216
- id: event.id,
1217
- createdAt: now,
1218
- cause: {
1219
- index,
1220
- attempt
1221
- },
1222
- lane: event.lane ?? null,
1223
- processedAt: event.settled ? now : null
1224
- },
1225
- clearIndex,
1226
- returning: true
2180
+ const rows = events.map((event, offset) => ({
2181
+ ord: offset + 1,
2182
+ id: event.id,
2183
+ type: event.type,
2184
+ payload: event.payload,
2185
+ cause
2186
+ }));
2187
+ const work = events.length > 0 && !duplicated ? enqueue(events, consumers) : void 0;
2188
+ const results = await run({
2189
+ sessionId,
2190
+ statements: duplicated ? [HOLD] : work ? [
2191
+ HOLD,
2192
+ LOCK_SESSION,
2193
+ RETURN_CHECKS,
2194
+ REUSE_IDS,
2195
+ INSERT_EVENTS,
2196
+ ...work.statements,
2197
+ COMPLETE_APPENDED
2198
+ ] : [
2199
+ HOLD,
2200
+ LOCK_IF_IDLE,
2201
+ COMPLETE
2202
+ ],
2203
+ params: {
2204
+ ...work?.params,
2205
+ ...retypeOf(consumer, consumers),
2206
+ cursorKey: cursorKeyOf(lane, index),
2207
+ consumer,
2208
+ index,
2209
+ attempt,
2210
+ ids,
2211
+ count: events.length,
2212
+ events: rows,
2213
+ now,
2214
+ clearType: CLEAR_EVENT_TYPE
2215
+ },
2216
+ abort: (reason) => reason["abort"] === "owned" ? new A2Error("PARTIAL_DUPLICATE_BATCH", "returned event batch contains an already-appended event id") : new A2Error("STORE_UNAVAILABLE", reason["abort"] === "held" ? `an event id being reused is still held by cleared work in session '${sessionId}'` : "postgres store operation failed")
2217
+ });
2218
+ const held = results[0];
2219
+ if (held["stop"] === "superseded") return { outcome: "superseded" };
2220
+ if (held["stop"] === "unheld") {
2221
+ const c = await client();
2222
+ return settleUnheld({
2223
+ query: (text, values) => c.query(text, values),
2224
+ sessionId,
2225
+ consumer,
2226
+ lane,
2227
+ index,
2228
+ attempt,
2229
+ events
1227
2230
  });
1228
- inserted.push(toStored(row));
1229
2231
  }
1230
- await query(`update a2_events set
1231
- processed_at = $1,
1232
- processed_by_attempt = $2,
1233
- returned_event_ids = $5::jsonb,
1234
- lane_ready = false,
1235
- claim_holder = null,
1236
- claim_expires_at = null
1237
- where session_id = $3 and idx = $4`, [
1238
- now,
1239
- attempt,
1240
- sessionId,
1241
- index,
1242
- jsonb(ids)
1243
- ]);
1244
- await query(`delete from a2_snapshot_pins
1245
- where session_id = $1 and event_index = $2`, [sessionId, index]);
1246
- await query(`delete from a2_snapshot_history history
1247
- where history.session_id = $1
1248
- and not exists (
1249
- select 1 from a2_snapshot_pins pin
1250
- where pin.session_id = history.session_id
1251
- and pin.reducer_name = history.reducer_name
1252
- and pin.up_to_index = history.up_to_index
1253
- )`, [sessionId]);
1254
- const parentLane = parent["lane"] ?? null;
1255
- if (parentLane !== null) await query(`update a2_events set lane_ready = true
1256
- where session_id = $1
1257
- and idx = (
1258
- select idx from a2_events
1259
- where session_id = $1 and lane = $2 and processed_at is null
1260
- and idx > $3
1261
- order by idx limit 1
1262
- )`, [
1263
- sessionId,
1264
- parentLane,
1265
- clearIndex
1266
- ]);
2232
+ if (duplicated) throw new A2Error("PARTIAL_DUPLICATE_BATCH", "returned event batch contains the same event id more than once");
2233
+ if (results.at(-1)["stop"] === "superseded") return { outcome: "superseded" };
2234
+ const checked = results.find((row) => "base" in row);
1267
2235
  return {
1268
2236
  outcome: "completed",
1269
- events: inserted
2237
+ events: checked ? insertedRows({
2238
+ sessionId,
2239
+ events: rows,
2240
+ base: asNumber(checked["base"]),
2241
+ now
2242
+ }) : []
1270
2243
  };
1271
- }));
2244
+ });
1272
2245
  },
1273
- async failAttempt({ sessionId, index, attempt, error, maxFailures, retryAfterMs }) {
1274
- const now = clock.now().getTime();
2246
+ async failAttempt({ sessionId, consumer, lane, index, attempt, error, maxFailures, retryAfterMs }) {
1275
2247
  return wrap(() => withTx(async (query) => {
2248
+ const now = clock.now().getTime();
2249
+ const cursorKey = cursorKeyOf(lane, index);
1276
2250
  const { rows } = await query(`with current as materialized (
1277
- select e.* from a2_events e
1278
- where e.session_id = $1 and e.idx = $2
1279
- and e.idx > ${clearIndexOf("$1")}
1280
- for update of e
2251
+ select cur.* from a2_cursors cur
2252
+ where cur.session_id = $1 and cur.cursor_key = $2
2253
+ and cur.consumer = $3 and cur.head = $4
2254
+ and cur.head > ${clearIndexOf("$1")}
2255
+ for update of cur
1281
2256
  ), updated as (
1282
- update a2_events e set
1283
- failure_count = e.failure_count + 1,
1284
- last_error = $4,
1285
- last_failed_at = $6,
1286
- last_failed_attempt = $3,
2257
+ update a2_cursors cur set
2258
+ failure_count = cur.failure_count + 1,
2259
+ last_error = $6,
2260
+ last_failed_at = $8,
2261
+ last_failed_attempt = $5,
1287
2262
  failed_at = case
1288
- when e.failure_count + 1 >= $5 then $6
1289
- else e.failed_at
2263
+ when cur.failure_count + 1 >= $7 then $8
2264
+ else cur.failed_at
1290
2265
  end,
1291
2266
  claim_holder = null,
1292
2267
  claim_expires_at = case
1293
- when e.failure_count + 1 >= $5 then null
1294
- else $7::bigint
2268
+ when cur.failure_count + 1 >= $7 then null
2269
+ else $9::bigint
1295
2270
  end
1296
2271
  from current
1297
- where e.session_id = $1
1298
- and e.idx = $2
1299
- and current.processed_at is null
2272
+ where cur.session_id = $1 and cur.cursor_key = $2
2273
+ and cur.consumer = $3 and cur.epoch = current.epoch
2274
+ and current.attempt_count = $5
1300
2275
  and current.failed_at is null
1301
- and current.attempt_count = $3
1302
2276
  and current.claim_holder is not null
1303
- returning e.failure_count, e.failed_at
2277
+ returning cur.failure_count, cur.failed_at
1304
2278
  )
1305
2279
  select case
1306
- when $2 <= ${clearIndexOf("$1")} then 'cleared'
2280
+ when $4 <= ${clearIndexOf("$1")} then 'cleared'
1307
2281
  when not exists (select 1 from current) then 'missing'
1308
2282
  when exists (
1309
2283
  select 1 from updated where failed_at is not null
1310
2284
  ) then 'dead_lettered'
1311
2285
  when exists (select 1 from updated) then 'failed'
1312
2286
  when exists (
1313
- select 1 from current
1314
- where processed_at is not null or attempt_count <> $3
2287
+ select 1 from current where attempt_count <> $5
1315
2288
  ) then 'superseded'
1316
- when exists (
1317
- select 1 from current where failed_at is not null
1318
- ) then 'dead_lettered'
1319
2289
  when exists (
1320
2290
  select 1 from current
1321
- where claim_holder is null and last_failed_attempt = $3
2291
+ where claim_holder is null and last_failed_attempt = $5
2292
+ and failed_at is null
1322
2293
  ) then 'failed'
2294
+ when exists (
2295
+ select 1 from current where failed_at is not null
2296
+ ) then 'dead_lettered'
1323
2297
  else 'superseded'
1324
2298
  end as outcome,
1325
2299
  coalesce(
@@ -1327,6 +2301,8 @@ function postgres(options = {}) {
1327
2301
  (select failure_count from current)
1328
2302
  ) as failure_count`, [
1329
2303
  sessionId,
2304
+ cursorKey,
2305
+ consumer,
1330
2306
  index,
1331
2307
  attempt,
1332
2308
  error,
@@ -1340,7 +2316,13 @@ function postgres(options = {}) {
1340
2316
  failureCount: 0
1341
2317
  };
1342
2318
  if (!row || row["outcome"] === "missing") {
1343
- if (await clearedAt({
2319
+ const { rows: cursorRows } = await query(`select 1 from a2_cursors
2320
+ where session_id = $1 and cursor_key = $2 and consumer = $3`, [
2321
+ sessionId,
2322
+ cursorKey,
2323
+ consumer
2324
+ ]);
2325
+ if (cursorRows.length > 0 || await hasEvent(query, sessionId, index) || await clearedAt({
1344
2326
  query,
1345
2327
  sessionId,
1346
2328
  index
@@ -1353,6 +2335,8 @@ function postgres(options = {}) {
1353
2335
  if (row["outcome"] === "dead_lettered") await releasePins({
1354
2336
  query,
1355
2337
  sessionId,
2338
+ consumer,
2339
+ cursorKey,
1356
2340
  index
1357
2341
  });
1358
2342
  return {
@@ -1363,54 +2347,39 @@ function postgres(options = {}) {
1363
2347
  },
1364
2348
  async deadLetters(sessionId) {
1365
2349
  return wrap(async () => {
1366
- const { rows } = await (await client()).query(`select * from a2_events
1367
- where session_id = $1
1368
- and processed_at is null
1369
- and failed_at is not null
1370
- and idx > ${clearIndexOf("$1")}
1371
- order by idx`, [sessionId]);
1372
- return rows.map(toStored);
2350
+ const { rows } = await (await client()).query(`select cur.*, ${HEAD_COLUMNS}
2351
+ from a2_cursors cur
2352
+ join a2_events event
2353
+ on event.session_id = cur.session_id and event.idx = cur.head
2354
+ where cur.session_id = $1
2355
+ and cur.failed_at is not null
2356
+ and cur.head > ${clearIndexOf("$1")}`, [sessionId]);
2357
+ return rows.map(toHead).toSorted(byHead);
1373
2358
  });
1374
2359
  },
1375
- async redrive({ sessionId, index, action }) {
1376
- return wrap(() => withTx(async (query) => {
1377
- if (action === "skip") await query("select pg_advisory_xact_lock(hashtext($1))", [sessionId]);
1378
- const { rows } = await query(`select lane from a2_events
1379
- where session_id = $1 and idx = $2
1380
- and idx > ${clearIndexOf("$1")}
1381
- and processed_at is null
1382
- and failed_at is not null
1383
- for update`, [sessionId, index]);
1384
- const row = rows[0];
1385
- if (row && action === "retry") await query(`update a2_events set failed_at = null, failure_count = 0
1386
- where session_id = $1 and idx = $2`, [sessionId, index]);
1387
- else if (row) {
1388
- await query(`update a2_events set processed_at = $3, lane_ready = false
1389
- where session_id = $1 and idx = $2`, [
1390
- sessionId,
2360
+ async redrive({ sessionId, consumer, index, action, consumers }) {
2361
+ return wrap(async () => {
2362
+ const results = await run({
2363
+ sessionId,
2364
+ statements: [
2365
+ action === "skip" && LOCK_SESSION,
2366
+ LOCK_DEAD,
2367
+ action === "skip" ? SKIP_DEAD : RETRY_DEAD,
2368
+ PENDING
2369
+ ],
2370
+ params: {
2371
+ ...retypeOf(consumer, consumers),
2372
+ consumer,
1391
2373
  index,
1392
- clock.now().getTime()
1393
- ]);
1394
- const lane = row["lane"] ?? null;
1395
- if (lane !== null) await query(`update a2_events set lane_ready = true
1396
- where session_id = $1
1397
- and idx = (
1398
- select idx from a2_events
1399
- where session_id = $1 and lane = $2 and processed_at is null
1400
- and idx > ${clearIndexOf("$1")}
1401
- order by idx limit 1
1402
- )`, [sessionId, lane]);
1403
- }
1404
- const { rows: status } = await query(`select exists(
1405
- select 1 from a2_events
1406
- where session_id = $1 and processed_at is null
1407
- and idx > ${clearIndexOf("$1")}
1408
- ) as has_pending`, [sessionId]);
2374
+ served: servedNames(consumers),
2375
+ clearType: CLEAR_EVENT_TYPE
2376
+ }
2377
+ });
1409
2378
  return {
1410
- redriven: row !== void 0,
1411
- hasPending: status[0]?.["has_pending"] === true
2379
+ redriven: results.find((row) => "redriven" in row)?.["redriven"] === true,
2380
+ hasPending: results.at(-1)["has_pending"] === true
1412
2381
  };
1413
- }));
2382
+ });
1414
2383
  },
1415
2384
  async readState(sessionId, reducerName, stateOptions) {
1416
2385
  return wrap(async () => (await readStateBatch([{
@@ -1438,25 +2407,41 @@ function postgres(options = {}) {
1438
2407
  const normalized = requested.filter((write) => write.index >= clearIndex);
1439
2408
  if (normalized.length === 0) return;
1440
2409
  const currentHead = headRows[0]?.["up_to_index"] == null ? void 0 : headRows[0];
1441
- const requestedPins = normalized.flatMap((write) => (write.pinEventIndexes ?? []).map((eventIndex) => ({
1442
- eventIndex,
2410
+ const requestedPins = normalized.flatMap((write) => (write.pins ?? []).map((pin) => ({
2411
+ eventIndex: pin.index,
2412
+ consumer: pin.consumer,
2413
+ cursorKey: cursorKeyOf(pin.lane, pin.index),
1443
2414
  snapshotIndex: write.index
1444
2415
  })));
1445
2416
  if (requestedPins.length > 0) {
1446
- const { rows: events } = await query(`select idx, processed_at, failed_at from a2_events
1447
- where session_id = $1 and idx = any($2::bigint[]) and idx > $3
1448
- for update`, [
2417
+ const heads = [...new Map(requestedPins.map((pin) => [`${pin.consumer}\u0000${pin.cursorKey}\u0000${pin.eventIndex}`, pin])).values()];
2418
+ const { rows: live } = await query(`select cur.consumer, cur.cursor_key, cur.head
2419
+ from a2_cursors cur
2420
+ join unnest($2::text[], $3::text[], $4::bigint[])
2421
+ as pin(consumer, cursor_key, idx)
2422
+ on cur.consumer = pin.consumer
2423
+ and cur.cursor_key = pin.cursor_key
2424
+ and cur.head = pin.idx
2425
+ where cur.session_id = $1 and cur.head > $5
2426
+ and cur.failed_at is null
2427
+ order by cur.cursor_key, cur.consumer
2428
+ for share of cur`, [
1449
2429
  sessionId,
1450
- [...new Set(requestedPins.map((pin) => pin.eventIndex))],
2430
+ heads.map((pin) => pin.consumer),
2431
+ heads.map((pin) => pin.cursorKey),
2432
+ heads.map((pin) => pin.eventIndex),
1451
2433
  clearIndex
1452
2434
  ]);
1453
- const unfinished = new Set(events.filter((row) => row["processed_at"] === null && row["failed_at"] === null).map((row) => asNumber(row["idx"])));
1454
- const accepted = requestedPins.filter((pin) => unfinished.has(pin.eventIndex));
2435
+ const unfinished = new Set(live.map((row) => `${String(row["consumer"])}\u0000${String(row["cursor_key"])}\u0000${asNumber(row["head"])}`));
2436
+ const accepted = requestedPins.filter((pin) => unfinished.has(`${pin.consumer}\u0000${pin.cursorKey}\u0000${pin.eventIndex}`));
1455
2437
  if (accepted.length > 0) await query(`insert into a2_snapshot_pins
1456
- (session_id, event_index, reducer_name, up_to_index)
1457
- select $1, pin."eventIndex", $2, pin."snapshotIndex"
2438
+ (session_id, event_index, consumer, cursor_key, reducer_name, up_to_index)
2439
+ select $1, pin."eventIndex", pin.consumer, pin."cursorKey",
2440
+ $2, pin."snapshotIndex"
1458
2441
  from jsonb_to_recordset($3::jsonb) as pin(
1459
2442
  "eventIndex" bigint,
2443
+ consumer text,
2444
+ "cursorKey" text,
1460
2445
  "snapshotIndex" bigint
1461
2446
  )
1462
2447
  on conflict do nothing`, [
@@ -1537,33 +2522,29 @@ function postgres(options = {}) {
1537
2522
  if (entries.length === 0) return;
1538
2523
  await wrap(async () => {
1539
2524
  const atMs = meta.at.getTime();
1540
- const expiresAtMs = clock.now().getTime() + meta.ttlMs;
2525
+ const nowMs = clock.now().getTime();
2526
+ const expiresAtMs = nowMs + meta.ttlMs;
1541
2527
  const applied = nullProtoRecord();
1542
2528
  const write = async (query) => {
1543
- for (const [field, value] of entries) if ((await (value === null ? query(`delete from a2_presence
1544
- where ns = $1 and participant = $2 and field = $3
1545
- and at <= $4${notifications ? " returning field" : ""}`, [
1546
- ns,
1547
- participant,
1548
- field,
1549
- atMs
1550
- ]) : query(`insert into a2_presence
1551
- (ns, participant, field, value, seen, at, expires_at)
1552
- values ($1, $2, $3, $4::jsonb, $5, $6, $7)
1553
- on conflict (ns, participant, field) do update set
1554
- value = excluded.value,
1555
- seen = excluded.seen,
1556
- at = excluded.at,
1557
- expires_at = excluded.expires_at
1558
- where a2_presence.at <= excluded.at${notifications ? " returning field" : ""}`, [
2529
+ for (const [field, value] of entries) if ((await query(`insert into a2_presence
2530
+ (ns, participant, field, value, seen, at, expires_at)
2531
+ values ($1, $2, $3, $4::jsonb, $5, $6, $7)
2532
+ on conflict (ns, participant, field) do update set
2533
+ value = excluded.value,
2534
+ seen = excluded.seen,
2535
+ at = excluded.at,
2536
+ expires_at = excluded.expires_at
2537
+ where a2_presence.at <= excluded.at
2538
+ or a2_presence.expires_at <= $8${notifications ? " returning field" : ""}`, [
1559
2539
  ns,
1560
2540
  participant,
1561
2541
  field,
1562
2542
  jsonb(value),
1563
2543
  meta.seen,
1564
2544
  atMs,
1565
- expiresAtMs
1566
- ]))).rows.length) applied[field] = structuredClone(value);
2545
+ expiresAtMs,
2546
+ nowMs
2547
+ ])).rows.length) applied[field] = structuredClone(value);
1567
2548
  };
1568
2549
  if (notifications) await withTx(write);
1569
2550
  else {
@@ -1593,7 +2574,7 @@ function postgres(options = {}) {
1593
2574
  )
1594
2575
  select participant, field, value, seen, at, expires_at
1595
2576
  from a2_presence
1596
- where ns = $1 and expires_at > $2
2577
+ where ns = $1 and expires_at > $2 and value <> 'null'::jsonb
1597
2578
  order by participant, field`, [ns, now]);
1598
2579
  return rows.map((row) => ({
1599
2580
  participant: row["participant"],
@@ -1627,21 +2608,19 @@ function postgres(options = {}) {
1627
2608
  },
1628
2609
  async clear({ sessionId, ifLastIndex, cause }) {
1629
2610
  return wrap(() => withTx(async (query) => {
1630
- await query("select pg_advisory_xact_lock(hashtext($1))", [sessionId]);
2611
+ await lockSession(query, sessionId);
1631
2612
  const { rows: headRows } = await query(`select coalesce(max(idx), 0) as head,
1632
2613
  ${clearIndexOf("$1")} as clear_index
1633
2614
  from a2_events where session_id = $1`, [sessionId]);
1634
2615
  const head = asNumber(headRows[0]?.["head"]);
1635
2616
  const clearIndex = asNumber(headRows[0]?.["clear_index"]);
1636
- if (cause) {
1637
- if (cause.index <= clearIndex) throw supersededAttemptError(sessionId, cause);
1638
- const { rows: parents } = await query(`select attempt_count, failed_at from a2_events
1639
- where session_id = $1 and idx = $2
1640
- for update`, [sessionId, cause.index]);
1641
- const parent = parents[0];
1642
- if (!parent) throw new TypeError(`no event at index ${cause.index} in session '${sessionId}'`);
1643
- if (asNumber(parent["attempt_count"]) !== cause.attempt || parent["failed_at"] !== null) throw supersededAttemptError(sessionId, cause);
1644
- }
2617
+ if (cause) await assertCurrentCause({
2618
+ query,
2619
+ sessionId,
2620
+ cause,
2621
+ clearIndex,
2622
+ lock: "update"
2623
+ });
1645
2624
  if (ifLastIndex !== void 0 && clearIndex === ifLastIndex + 1) return {
1646
2625
  cleared: true,
1647
2626
  index: head,
@@ -1658,31 +2637,27 @@ function postgres(options = {}) {
1658
2637
  clearIndex
1659
2638
  };
1660
2639
  const marker = head + 1;
1661
- const now = clock.now().getTime();
1662
- await insertEvent({
1663
- query,
1664
- row: {
1665
- sessionId,
1666
- index: marker,
1667
- type: CLEAR_EVENT_TYPE,
1668
- payload: null,
1669
- id: generateId(),
1670
- createdAt: now,
1671
- cause: null,
1672
- lane: null,
1673
- processedAt: now
1674
- },
1675
- clearIndex
1676
- });
2640
+ await query(`insert into a2_events
2641
+ (session_id, idx, event_type, payload, event_id, created_at, cause)
2642
+ values ($1, $2, $3, 'null'::jsonb, $4, $5, null)`, [
2643
+ sessionId,
2644
+ marker,
2645
+ CLEAR_EVENT_TYPE,
2646
+ generateId(),
2647
+ clock.now().getTime()
2648
+ ]);
1677
2649
  await query(`with released as (
1678
- update a2_events
1679
- set lane_ready = false, claim_holder = null, claim_expires_at = null
1680
- where session_id = $1 and idx = $3
2650
+ update a2_cursors
2651
+ set head = null, ${FRESH_DISPATCH}
2652
+ where session_id = $1 and cursor_key = $3 and consumer = $4
2653
+ and head = $5
1681
2654
  )
1682
2655
  insert into a2_clears (session_id, clear_index) values ($1, $2)
1683
2656
  on conflict (session_id) do update set clear_index = excluded.clear_index`, [
1684
2657
  sessionId,
1685
2658
  marker,
2659
+ cause ? cursorKeyOf(cause.lane, cause.index) : null,
2660
+ cause?.consumer ?? null,
1686
2661
  cause?.index ?? null
1687
2662
  ]);
1688
2663
  return {
@@ -1710,6 +2685,26 @@ function postgres(options = {}) {
1710
2685
  ), pins as (
1711
2686
  delete from a2_snapshot_pins pin using deleted
1712
2687
  where pin.session_id = $1 and pin.event_index = deleted.idx
2688
+ ), lanes as materialized (
2689
+ -- Each lane's queue through the batch, by the lane's own key.
2690
+ select lane.ctid from a2_cursors cur
2691
+ join a2_lane_events lane
2692
+ on lane.session_id = cur.session_id
2693
+ and lane.consumer = cur.consumer and lane.lane = cur.lane
2694
+ where cur.session_id = $1 and cur.kind = 'lane'
2695
+ and lane.idx <= (select max(idx) from batch)
2696
+ for update of lane skip locked
2697
+ ), gone_lanes as (
2698
+ delete from a2_lane_events target using lanes
2699
+ where target.ctid = lanes.ctid
2700
+ ), singles as materialized (
2701
+ select cur.ctid from a2_cursors cur
2702
+ join batch on cur.cursor_key = '#' || batch.idx
2703
+ where cur.session_id = $1 and cur.kind = 'event'
2704
+ for update of cur skip locked
2705
+ ), gone_singles as (
2706
+ delete from a2_cursors target using singles
2707
+ where target.ctid = singles.ctid
1713
2708
  )
1714
2709
  select (select count(*) from deleted) as deleted,
1715
2710
  (select clear_index from boundary) as clear_index`, [sessionId, RECLAIM_BATCH]);
@@ -1719,16 +2714,20 @@ function postgres(options = {}) {
1719
2714
  deleted: 0,
1720
2715
  done: true
1721
2716
  };
1722
- if (deleted > 0) return {
1723
- deleted,
1724
- done: false
1725
- };
1726
2717
  const { rows: remaining } = await query("select 1 from a2_events where session_id = $1 and idx < $2 limit 1", [sessionId, clearIndex]);
1727
2718
  if (remaining.length > 0) return {
1728
- deleted: 0,
2719
+ deleted,
1729
2720
  done: false
1730
2721
  };
1731
- const { rows: swept } = await query(`with pins as materialized (
2722
+ const { rows: swept } = await query(`with lanes as materialized (
2723
+ select ctid from a2_lane_events
2724
+ where session_id = $1 and idx < $2
2725
+ for update skip locked
2726
+ ), cursors as materialized (
2727
+ select cur.ctid from a2_cursors cur
2728
+ where ${SUPERSEDED_CURSOR}
2729
+ for update skip locked
2730
+ ), pins as materialized (
1732
2731
  select ctid from a2_snapshot_pins
1733
2732
  where session_id = $1 and up_to_index < $2
1734
2733
  for update skip locked
@@ -1740,6 +2739,12 @@ function postgres(options = {}) {
1740
2739
  select ctid from a2_snapshots
1741
2740
  where session_id = $1 and up_to_index < $2
1742
2741
  for update skip locked
2742
+ ), gone_lanes as (
2743
+ delete from a2_lane_events target using lanes
2744
+ where target.ctid = lanes.ctid
2745
+ ), gone_cursors as (
2746
+ delete from a2_cursors target using cursors
2747
+ where target.ctid = cursors.ctid
1743
2748
  ), gone_pins as (
1744
2749
  delete from a2_snapshot_pins target using pins where target.ctid = pins.ctid
1745
2750
  ), gone_history as (
@@ -1748,17 +2753,23 @@ function postgres(options = {}) {
1748
2753
  ), gone_heads as (
1749
2754
  delete from a2_snapshots target using heads where target.ctid = heads.ctid
1750
2755
  )
1751
- select (select count(*) from a2_snapshot_pins
2756
+ select (select count(*) from a2_lane_events
2757
+ where session_id = $1 and idx < $2)
2758
+ + (select count(*) from a2_cursors cur
2759
+ where ${SUPERSEDED_CURSOR})
2760
+ + (select count(*) from a2_snapshot_pins
1752
2761
  where session_id = $1 and up_to_index < $2)
1753
2762
  + (select count(*) from a2_snapshot_history
1754
2763
  where session_id = $1 and up_to_index < $2)
1755
2764
  + (select count(*) from a2_snapshots
1756
2765
  where session_id = $1 and up_to_index < $2)
2766
+ - (select count(*) from lanes)
2767
+ - (select count(*) from cursors)
1757
2768
  - (select count(*) from pins)
1758
2769
  - (select count(*) from history)
1759
2770
  - (select count(*) from heads) as skipped`, [sessionId, clearIndex]);
1760
2771
  return {
1761
- deleted: 0,
2772
+ deleted,
1762
2773
  done: asNumber(swept[0]?.["skipped"]) === 0
1763
2774
  };
1764
2775
  }));
@@ -1769,8 +2780,8 @@ function postgres(options = {}) {
1769
2780
  for (const subscription of presenceSubscriptions.values()) subscription.stop();
1770
2781
  presenceSubscriptions.clear();
1771
2782
  await notifications?.close();
1772
- const lease = await connection.peek()?.catch(() => void 0);
1773
- await (injected ?? lease)?.release();
2783
+ const opened = await connection.peek()?.catch(() => void 0);
2784
+ await (injected ?? opened?.lease)?.release();
1774
2785
  }
1775
2786
  };
1776
2787
  if (notifications) {
@@ -1780,7 +2791,7 @@ function postgres(options = {}) {
1780
2791
  const subscription = presenceSubscriptions.get(ns);
1781
2792
  await subscription?.ready;
1782
2793
  await subscription?.poll.ready;
1783
- return read(ns);
2794
+ return subscription ? subscription.poll.snapshot(() => read(ns)) : read(ns);
1784
2795
  };
1785
2796
  presence.subscribe = (ns, onPatch) => {
1786
2797
  if (closed) throw new A2Error("STORE_UNAVAILABLE", "the store was closed");