experimental-a2 0.19.0 → 0.21.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (320) hide show
  1. package/CHANGELOG.md +50 -0
  2. package/README.md +1 -1
  3. package/dist/{actor-C7b-1arn.d.ts → actor-HnGmrOZZ.d.ts} +5 -5
  4. package/dist/{actor-C7b-1arn.d.ts.map → actor-HnGmrOZZ.d.ts.map} +1 -1
  5. package/dist/actor-client.d.ts +1 -1
  6. package/dist/actor-client.js +2 -2
  7. package/dist/actor-react.d.ts +3 -3
  8. package/dist/actor-react.js +5 -5
  9. package/dist/{actor-shared-t7w8xIKP.js → actor-shared-DFET5q16.js} +3 -3
  10. package/dist/{actor-shared-t7w8xIKP.js.map → actor-shared-DFET5q16.js.map} +1 -1
  11. package/dist/actor.d.ts +1 -1
  12. package/dist/actor.js +54 -43
  13. package/dist/actor.js.map +1 -1
  14. package/dist/{ai-Cz3hqVhk.d.ts → ai-CvEnqVfo.d.ts} +8 -8
  15. package/dist/{ai-Cz3hqVhk.d.ts.map → ai-CvEnqVfo.d.ts.map} +1 -1
  16. package/dist/{ai-mMiWul1T.js → ai-CzE672Q3.js} +9 -9
  17. package/dist/ai-CzE672Q3.js.map +1 -0
  18. package/dist/ai-client.d.ts +2 -2
  19. package/dist/ai-client.js +2 -2
  20. package/dist/ai-fx.d.ts +1 -1
  21. package/dist/ai-fx.d.ts.map +1 -1
  22. package/dist/ai-fx.js +36 -9
  23. package/dist/ai-fx.js.map +1 -1
  24. package/dist/{ai-harness-CV6rXbB3.d.ts → ai-harness-Bfk-Lycn.d.ts} +2 -2
  25. package/dist/ai-harness-Bfk-Lycn.d.ts.map +1 -0
  26. package/dist/{ai-harness-BuuC-gJ3.js → ai-harness-dQeJaR1t.js} +17 -4
  27. package/dist/ai-harness-dQeJaR1t.js.map +1 -0
  28. package/dist/ai-pi.d.ts +1 -1
  29. package/dist/ai-pi.js +4 -4
  30. package/dist/ai-pi.js.map +1 -1
  31. package/dist/ai-react.d.ts +5 -5
  32. package/dist/ai-react.js +2 -2
  33. package/dist/ai-server.d.ts +14 -17
  34. package/dist/ai-server.d.ts.map +1 -1
  35. package/dist/ai-server.js +84 -75
  36. package/dist/ai-server.js.map +1 -1
  37. package/dist/ai.d.ts +2 -2
  38. package/dist/ai.js +2 -2
  39. package/dist/{client-BhHLoNKF.js → client-9IeTsiAs.js} +5 -5
  40. package/dist/{client-BhHLoNKF.js.map → client-9IeTsiAs.js.map} +1 -1
  41. package/dist/{client-h_DKFAjj.d.ts → client-CUMbS_38.d.ts} +3 -3
  42. package/dist/{client-h_DKFAjj.d.ts.map → client-CUMbS_38.d.ts.map} +1 -1
  43. package/dist/client.d.ts +3 -3
  44. package/dist/client.js +1 -1
  45. package/dist/{contract-BYKASlCV.js → contract-CWukt01d.js} +2 -2
  46. package/dist/contract-CWukt01d.js.map +1 -0
  47. package/dist/index.d.ts +5 -5
  48. package/dist/index.js +3 -3
  49. package/dist/{initial-snapshot-Bx-c0pDP.d.ts → initial-snapshot-D3Lve43o.d.ts} +4 -4
  50. package/dist/initial-snapshot-D3Lve43o.d.ts.map +1 -0
  51. package/dist/{internal-CscSY7K_.js → internal-Bc4PvfHk.js} +10 -3
  52. package/dist/internal-Bc4PvfHk.js.map +1 -0
  53. package/dist/otel.d.ts +1 -1
  54. package/dist/otel.js +1 -1
  55. package/dist/otel.js.map +1 -1
  56. package/dist/{react-BBXgHWFc.d.ts → react-BoHvC3v9.d.ts} +4 -4
  57. package/dist/{react-BBXgHWFc.d.ts.map → react-BoHvC3v9.d.ts.map} +1 -1
  58. package/dist/react.d.ts +1 -1
  59. package/dist/react.js +1 -1
  60. package/dist/{reducer-CqA59Q6U.d.ts → reducer-CYPHsSmd.d.ts} +2 -2
  61. package/dist/{reducer-CqA59Q6U.d.ts.map → reducer-CYPHsSmd.d.ts.map} +1 -1
  62. package/dist/{reducer-BAXc8JyF.js → reducer-CjmvZSAb.js} +2 -2
  63. package/dist/{reducer-BAXc8JyF.js.map → reducer-CjmvZSAb.js.map} +1 -1
  64. package/dist/runtime-consumer-BWmht60z.js +12 -0
  65. package/dist/runtime-consumer-BWmht60z.js.map +1 -0
  66. package/dist/scheduler-qstash.d.ts +2 -2
  67. package/dist/scheduler-qstash.d.ts.map +1 -1
  68. package/dist/scheduler-qstash.js +7 -7
  69. package/dist/scheduler-qstash.js.map +1 -1
  70. package/dist/scheduler-task-X1SToDjP.js.map +1 -1
  71. package/dist/scheduler-vercel.d.ts +2 -2
  72. package/dist/scheduler-vercel.d.ts.map +1 -1
  73. package/dist/scheduler-vercel.js +5 -4
  74. package/dist/scheduler-vercel.js.map +1 -1
  75. package/dist/{server-DYAATFuT.d.ts → server-BshFZ0eL.d.ts} +108 -41
  76. package/dist/server-BshFZ0eL.d.ts.map +1 -0
  77. package/dist/{server-D-Y4pqwu.js → server-DvIB8kj5.js} +423 -135
  78. package/dist/server-DvIB8kj5.js.map +1 -0
  79. package/dist/server.browser.js +1 -1
  80. package/dist/server.browser.js.map +1 -1
  81. package/dist/server.d.ts +4 -4
  82. package/dist/server.js +2 -2
  83. package/dist/{session-clear-C-QwTXV1.d.ts → session-clear-Bk_29eEw.d.ts} +2 -2
  84. package/dist/session-clear-Bk_29eEw.d.ts.map +1 -0
  85. package/dist/{session-clear-DGI4jTQ5.js → session-clear-wHd5GCpZ.js} +5 -4
  86. package/dist/session-clear-wHd5GCpZ.js.map +1 -0
  87. package/dist/store-N8PXxDAS.js.map +1 -1
  88. package/dist/store-Z2qjw50n.d.ts +523 -0
  89. package/dist/store-Z2qjw50n.d.ts.map +1 -0
  90. package/dist/store-codec-DwrWlgQx.js +65 -0
  91. package/dist/store-codec-DwrWlgQx.js.map +1 -0
  92. package/dist/store-memory.d.ts +1 -1
  93. package/dist/store-memory.d.ts.map +1 -1
  94. package/dist/store-memory.js +393 -210
  95. package/dist/store-memory.js.map +1 -1
  96. package/dist/{store-polling-7WJV0YWP.js → store-polling-DYNyxCev.js} +2 -2
  97. package/dist/{store-polling-7WJV0YWP.js.map → store-polling-DYNyxCev.js.map} +1 -1
  98. package/dist/store-postgres.d.ts +1 -1
  99. package/dist/store-postgres.d.ts.map +1 -1
  100. package/dist/store-postgres.js +1595 -673
  101. package/dist/store-postgres.js.map +1 -1
  102. package/dist/{store-presence-polling-B1ixeqIS.js → store-presence-polling-CzQ0wdtM.js} +3 -3
  103. package/dist/{store-presence-polling-B1ixeqIS.js.map → store-presence-polling-CzQ0wdtM.js.map} +1 -1
  104. package/dist/store-redis-http.d.ts +1 -1
  105. package/dist/store-redis-http.js +4 -4
  106. package/dist/store-redis-notify-IiVdlYHc.js +2919 -0
  107. package/dist/store-redis-notify-IiVdlYHc.js.map +1 -0
  108. package/dist/store-redis.d.ts +1 -1
  109. package/dist/store-redis.js +2 -2
  110. package/dist/store-sqlite.d.ts +1 -1
  111. package/dist/store-sqlite.d.ts.map +1 -1
  112. package/dist/store-sqlite.js +517 -287
  113. package/dist/store-sqlite.js.map +1 -1
  114. package/dist/{telemetry-Cyfqo6Qt.d.ts → telemetry-CWwsh-Gx.d.ts} +3 -3
  115. package/dist/telemetry-CWwsh-Gx.d.ts.map +1 -0
  116. package/dist/{wire-BKxH1vbP.js → wire-CZGqNZRx.js} +5 -4
  117. package/dist/wire-CZGqNZRx.js.map +1 -0
  118. package/docs/01-quickstart.mdx +28 -19
  119. package/docs/02-examples.mdx +1 -1
  120. package/docs/actors/01-introduction.mdx +9 -1
  121. package/docs/actors/02-concurrency.mdx +1 -1
  122. package/docs/actors/meta.ts +1 -1
  123. package/docs/agents/01-introduction.mdx +1 -1
  124. package/docs/agents/02-tools.mdx +13 -12
  125. package/docs/agents/03-conversations.mdx +31 -21
  126. package/docs/agents/04-harnesses.mdx +7 -3
  127. package/docs/agents/meta.ts +1 -1
  128. package/docs/client/03-presence.mdx +18 -14
  129. package/docs/client/meta.ts +1 -1
  130. package/docs/contract/01-events.mdx +2 -2
  131. package/docs/contract/02-state.mdx +18 -14
  132. package/docs/contract/03-sessions.mdx +32 -25
  133. package/docs/index.mdx +31 -26
  134. package/docs/reference/01-core.mdx +12 -12
  135. package/docs/reference/02-server.mdx +247 -192
  136. package/docs/reference/04-ai.mdx +29 -20
  137. package/docs/reference/05-telemetry.mdx +146 -0
  138. package/docs/reference/06-errors.mdx +24 -18
  139. package/docs/reference/meta.ts +1 -1
  140. package/docs/schedulers/01-overview.mdx +112 -0
  141. package/docs/schedulers/02-vercel-queues.mdx +87 -0
  142. package/docs/schedulers/03-qstash.mdx +123 -0
  143. package/docs/schedulers/meta.ts +1 -0
  144. package/docs/server/01-consumers.mdx +415 -0
  145. package/docs/server/02-timers.mdx +33 -23
  146. package/docs/server/03-cancellation.mdx +52 -44
  147. package/docs/server/04-routes.mdx +4 -1
  148. package/docs/server/05-sql-projections.mdx +391 -0
  149. package/docs/server/06-guarantees.mdx +60 -50
  150. package/docs/stores/01-overview.mdx +37 -0
  151. package/docs/stores/02-redis.mdx +57 -0
  152. package/docs/stores/03-redis-http.mdx +56 -0
  153. package/docs/stores/04-postgres.mdx +172 -0
  154. package/docs/stores/05-sqlite.mdx +36 -0
  155. package/docs/stores/06-memory.mdx +22 -0
  156. package/docs/stores/meta.ts +1 -0
  157. package/docs/upgrades/01-overview.mdx +30 -0
  158. package/docs/upgrades/79-0-21.mdx +165 -0
  159. package/docs/upgrades/80-0-20.mdx +43 -0
  160. package/docs/upgrades/81-0-19.mdx +33 -0
  161. package/docs/upgrades/82-0-18.mdx +33 -0
  162. package/docs/upgrades/83-0-17.mdx +69 -0
  163. package/docs/upgrades/meta.ts +3 -0
  164. package/examples/playground/DEPLOY.md +48 -11
  165. package/examples/playground/app/a0/apps/[appId]/route.ts +6 -10
  166. package/examples/playground/app/a0/chat.test.tsx +421 -330
  167. package/examples/playground/app/a0/sandbox-log.ts +17 -15
  168. package/examples/playground/app/a0/server.ts +4 -4
  169. package/examples/playground/app/a0/shell.tsx +1 -1
  170. package/examples/playground/app/a0/uploads/route.test.ts +59 -0
  171. package/examples/playground/app/a2slack/a2slack.css +1067 -0
  172. package/examples/playground/app/a2slack/c/[channel]/page.tsx +20 -0
  173. package/examples/playground/app/a2slack/consumers/agent.ts +63 -0
  174. package/examples/playground/app/a2slack/consumers/feed.ts +164 -0
  175. package/examples/playground/app/a2slack/consumers/notify.ts +116 -0
  176. package/examples/playground/app/a2slack/consumers/search.ts +37 -0
  177. package/examples/playground/app/a2slack/consumers/triage.ts +31 -0
  178. package/examples/playground/app/a2slack/consumers/welcome.ts +25 -0
  179. package/examples/playground/app/a2slack/db/a2.ts +206 -0
  180. package/examples/playground/app/a2slack/db/index.ts +73 -0
  181. package/examples/playground/app/a2slack/db/migrations/0000_extensions.sql +4 -0
  182. package/examples/playground/app/a2slack/db/migrations/0001_workspace.sql +74 -0
  183. package/examples/playground/app/a2slack/db/migrations/0002_a2.sql +215 -0
  184. package/examples/playground/app/a2slack/db/migrations/0003_a2_batch.sql +67 -0
  185. package/examples/playground/app/a2slack/db/migrations/meta/0000_snapshot.json +18 -0
  186. package/examples/playground/app/a2slack/db/migrations/meta/0001_snapshot.json +500 -0
  187. package/examples/playground/app/a2slack/db/migrations/meta/0002_snapshot.json +1159 -0
  188. package/examples/playground/app/a2slack/db/migrations/meta/0003_snapshot.json +1159 -0
  189. package/examples/playground/app/a2slack/db/migrations/meta/_journal.json +34 -0
  190. package/examples/playground/app/a2slack/db/queries.ts +263 -0
  191. package/examples/playground/app/a2slack/db/schema.ts +145 -0
  192. package/examples/playground/app/a2slack/drills/a2slack.drill.ts +708 -0
  193. package/examples/playground/app/a2slack/drills/harness.ts +244 -0
  194. package/examples/playground/app/a2slack/events/route.ts +36 -0
  195. package/examples/playground/app/a2slack/identity.ts +44 -0
  196. package/examples/playground/app/a2slack/inbox/route.ts +8 -0
  197. package/examples/playground/app/a2slack/lag/data/route.ts +8 -0
  198. package/examples/playground/app/a2slack/lag/page.tsx +101 -0
  199. package/examples/playground/app/a2slack/lag.ts +73 -0
  200. package/examples/playground/app/a2slack/layout.tsx +43 -0
  201. package/examples/playground/app/a2slack/messages/route.ts +17 -0
  202. package/examples/playground/app/a2slack/model.test.ts +208 -0
  203. package/examples/playground/app/a2slack/model.ts +369 -0
  204. package/examples/playground/app/a2slack/models.test.ts +72 -0
  205. package/examples/playground/app/a2slack/page.tsx +10 -0
  206. package/examples/playground/app/a2slack/scheduler/route.ts +5 -0
  207. package/examples/playground/app/a2slack/search/route.ts +40 -0
  208. package/examples/playground/app/a2slack/search-index.ts +108 -0
  209. package/examples/playground/app/a2slack/server.test.ts +315 -0
  210. package/examples/playground/app/a2slack/server.ts +55 -0
  211. package/examples/playground/app/a2slack/sessions.ts +50 -0
  212. package/examples/playground/app/a2slack/thread/route.ts +8 -0
  213. package/examples/playground/app/a2slack/ui/ChannelView.tsx +87 -0
  214. package/examples/playground/app/a2slack/ui/Composer.tsx +117 -0
  215. package/examples/playground/app/a2slack/ui/DotsShadow.tsx +42 -0
  216. package/examples/playground/app/a2slack/ui/Feed.tsx +157 -0
  217. package/examples/playground/app/a2slack/ui/InboxContext.tsx +63 -0
  218. package/examples/playground/app/a2slack/ui/InboxView.tsx +82 -0
  219. package/examples/playground/app/a2slack/ui/Menu.tsx +128 -0
  220. package/examples/playground/app/a2slack/ui/MessageRow.tsx +314 -0
  221. package/examples/playground/app/a2slack/ui/NameGate.tsx +35 -0
  222. package/examples/playground/app/a2slack/ui/SearchContext.tsx +146 -0
  223. package/examples/playground/app/a2slack/ui/SearchResults.tsx +97 -0
  224. package/examples/playground/app/a2slack/ui/Shell.tsx +34 -0
  225. package/examples/playground/app/a2slack/ui/Sidebar.tsx +199 -0
  226. package/examples/playground/app/a2slack/ui/ThreadPanel.tsx +134 -0
  227. package/examples/playground/app/a2slack/ui/useInbox.ts +71 -0
  228. package/examples/playground/app/agent/[agentId]/agent-queue.test.tsx +116 -90
  229. package/examples/playground/app/agent/[agentId]/compaction-panel.test.tsx +107 -76
  230. package/examples/playground/app/agent/ingress.test.ts +41 -17
  231. package/examples/playground/app/agent/model-input.test.tsx +92 -43
  232. package/examples/playground/app/agent/server.ts +3 -3
  233. package/examples/playground/app/chat/[chatId]/agent-stream-drawer.test.tsx +2 -2
  234. package/examples/playground/app/chat/[chatId]/chat-client.tsx +1 -1
  235. package/examples/playground/app/chat/server.test.ts +38 -65
  236. package/examples/playground/app/chat/server.ts +45 -35
  237. package/examples/playground/app/components/activity-history.test.tsx +60 -18
  238. package/examples/playground/app/components/demo-icons.tsx +7 -0
  239. package/examples/playground/app/components/demos.tsx +7 -0
  240. package/examples/playground/app/components/fast-link.test.tsx +3 -5
  241. package/examples/playground/app/initial-state.test.tsx +86 -45
  242. package/examples/playground/app/orders/[orderId]/order-client.tsx +2 -2
  243. package/examples/playground/app/orders/page.tsx +1 -1
  244. package/examples/playground/app/orders/server.ts +49 -30
  245. package/examples/playground/app/recovery/[recoveryId]/recovery-client.tsx +14 -15
  246. package/examples/playground/app/recovery/page.tsx +4 -4
  247. package/examples/playground/app/recovery/server.ts +23 -32
  248. package/examples/playground/drizzle.config.ts +14 -0
  249. package/examples/playground/next.config.ts +6 -1
  250. package/examples/playground/package.json +13 -1
  251. package/examples/playground/scripts/migrate.mjs +31 -0
  252. package/examples/playground/test/agent.ts +35 -0
  253. package/examples/playground/test/model.ts +149 -0
  254. package/examples/playground/test/serve.ts +184 -0
  255. package/examples/playground/test/server-only.ts +1 -0
  256. package/examples/playground/vercel.json +9 -0
  257. package/examples/playground/vitest.config.ts +27 -0
  258. package/examples/playground/vitest.drills.config.ts +32 -0
  259. package/package.json +7 -2
  260. package/sql/postgres/drizzle-schema.ts +206 -0
  261. package/sql/postgres/notifications.sql +24 -0
  262. package/sql/postgres/schema.sql +276 -0
  263. package/src/actor.ts +101 -71
  264. package/src/ai-agent.ts +154 -115
  265. package/src/ai-control-server.ts +8 -8
  266. package/src/ai-fx.ts +41 -9
  267. package/src/ai-harness.ts +28 -2
  268. package/src/ai-pi.ts +2 -2
  269. package/src/ai-server.ts +2 -2
  270. package/src/ai.ts +7 -7
  271. package/src/contract.ts +1 -1
  272. package/src/deterministic-id.ts +26 -3
  273. package/src/errors.ts +9 -6
  274. package/src/idempotent-replay.ts +0 -1
  275. package/src/index.ts +1 -1
  276. package/src/internal.ts +17 -6
  277. package/src/otel.ts +1 -1
  278. package/src/postgres-notifications.ts +0 -22
  279. package/src/postgres-resources.ts +6 -9
  280. package/src/postgres-schema.ts +121 -0
  281. package/src/push-envelope.ts +20 -0
  282. package/src/runtime-consumer.ts +10 -0
  283. package/src/scheduler-qstash.ts +12 -5
  284. package/src/scheduler-task.ts +2 -2
  285. package/src/scheduler-vercel.ts +10 -4
  286. package/src/server.browser.ts +1 -1
  287. package/src/server.ts +839 -238
  288. package/src/session-socket.ts +1 -1
  289. package/src/store-codec.ts +66 -0
  290. package/src/store-memory.ts +581 -255
  291. package/src/store-postgres.ts +1984 -943
  292. package/src/store-redis-core.ts +1624 -1152
  293. package/src/store-redis-notify.ts +2 -0
  294. package/src/store-sqlite.ts +891 -486
  295. package/src/store.ts +227 -97
  296. package/src/telemetry.ts +2 -1
  297. package/src/wire.ts +1 -0
  298. package/dist/ai-harness-BuuC-gJ3.js.map +0 -1
  299. package/dist/ai-harness-CV6rXbB3.d.ts.map +0 -1
  300. package/dist/ai-mMiWul1T.js.map +0 -1
  301. package/dist/contract-BYKASlCV.js.map +0 -1
  302. package/dist/idempotent-replay-JdgMQRRm.js +0 -21
  303. package/dist/idempotent-replay-JdgMQRRm.js.map +0 -1
  304. package/dist/initial-snapshot-Bx-c0pDP.d.ts.map +0 -1
  305. package/dist/internal-CscSY7K_.js.map +0 -1
  306. package/dist/server-D-Y4pqwu.js.map +0 -1
  307. package/dist/server-DYAATFuT.d.ts.map +0 -1
  308. package/dist/session-clear-C-QwTXV1.d.ts.map +0 -1
  309. package/dist/session-clear-DGI4jTQ5.js.map +0 -1
  310. package/dist/store-CyLUy86v.d.ts +0 -404
  311. package/dist/store-CyLUy86v.d.ts.map +0 -1
  312. package/dist/store-codec-DTG0Ftek.js +0 -10
  313. package/dist/store-codec-DTG0Ftek.js.map +0 -1
  314. package/dist/store-redis-notify-CM0AfLY0.js +0 -2544
  315. package/dist/store-redis-notify-CM0AfLY0.js.map +0 -1
  316. package/dist/telemetry-Cyfqo6Qt.d.ts.map +0 -1
  317. package/dist/wire-BKxH1vbP.js.map +0 -1
  318. package/docs/reference/05-adapters.mdx +0 -351
  319. package/docs/server/01-handlers.mdx +0 -200
  320. package/docs/server/05-production.mdx +0 -323
@@ -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 { _ as nullProtoRecord, r as NOTIFY_TIMINGS } from "./internal-Bc4PvfHk.js";
3
+ import { n as pollingStream, t as defaultSleep } from "./store-polling-DYNyxCev.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-CzQ0wdtM.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,27 +170,73 @@ function postgresNotifications(createConnection) {
171
170
  }
172
171
  };
173
172
  }
174
- const POSTGRES_NOTIFICATIONS_SQL = `
175
- create or replace function a2_notify_events() returns trigger language plpgsql as $$
176
- begin
177
- perform pg_notify('a2_events_' || md5(new.session_id), '');
178
- return null;
179
- end $$;
180
- create or replace function a2_notify_presence() returns trigger language plpgsql as $$
181
- begin
182
- perform pg_notify('a2_presence_' || md5(case when TG_OP = 'DELETE' then old.ns else new.ns end),
183
- coalesce(current_setting('a2.presence_source', true), ''));
184
- return null;
185
- end $$;
186
- do $$ begin
187
- if not exists (select 1 from pg_trigger where tgname = 'a2_notify_events' and tgrelid = 'a2_events'::regclass) then
188
- create trigger a2_notify_events after insert on a2_events for each row execute function a2_notify_events();
189
- end if;
190
- if not exists (select 1 from pg_trigger where tgname = 'a2_notify_presence' and tgrelid = 'a2_presence'::regclass) then
191
- create trigger a2_notify_presence after insert or update or delete on a2_presence for each row execute function a2_notify_presence();
192
- end if;
193
- end $$;
194
- `;
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
+ */
185
+ const PACKAGE = "node_modules/experimental-a2";
186
+ const SEE_DOCS = `See ${PACKAGE}/docs/stores/04-postgres.mdx.`;
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;
207
+ }
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}`);
239
+ };
195
240
  //#endregion
196
241
  //#region src/postgres-notification-scope.ts
197
242
  const failure = (error) => error instanceof A2Error ? error : new A2Error("STORE_UNAVAILABLE", "Postgres subscription failed", { cause: error });
@@ -446,7 +491,7 @@ function postgresConnectionKey({ connectionString, defaults }) {
446
491
  }
447
492
  const queries = (raw) => {
448
493
  let pending = Promise.resolve();
449
- const schemas = /* @__PURE__ */ new Map();
494
+ const initialized = /* @__PURE__ */ new Map();
450
495
  const exclusive = (fn) => {
451
496
  const result = pending.then(fn);
452
497
  pending = result.catch(() => {});
@@ -456,13 +501,13 @@ const queries = (raw) => {
456
501
  raw,
457
502
  client: raw.connect ? raw : { query: (text, values) => exclusive(() => raw.query(text, values)) },
458
503
  exclusive,
459
- initialize({ schema, run }) {
460
- const existing = schemas.get(schema);
504
+ initialize({ key, run }) {
505
+ const existing = initialized.get(key);
461
506
  if (existing) return existing;
462
507
  const attempt = run();
463
- schemas.set(schema, attempt);
508
+ initialized.set(key, attempt);
464
509
  attempt.catch(() => {
465
- if (schemas.get(schema) === attempt) schemas.delete(schema);
510
+ if (initialized.get(key) === attempt) initialized.delete(key);
466
511
  });
467
512
  return attempt;
468
513
  },
@@ -501,96 +546,26 @@ const sharePostgresListener = ({ key, create }) => acquire({
501
546
  *
502
547
  * Semantics mirror the sqlite backend: `index` is stored as `idx`,
503
548
  * timestamps are epoch milliseconds written from the injected clock —
504
- * never SQL `now()`. Streams poll by default; an optional direct
505
- * 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
506
552
  * `pg_advisory_xact_lock`, so concurrent writers can't race the index
507
- * 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.
508
566
  */
509
- const SCHEMA = `
510
- create table if not exists a2_events (
511
- session_id text not null,
512
- idx bigint not null,
513
- event_type text not null,
514
- payload jsonb not null,
515
- event_id text not null,
516
- created_at bigint not null,
517
- cause jsonb,
518
- lane text,
519
- lane_ready boolean not null default false,
520
- processed_at bigint,
521
- processed_by_attempt integer,
522
- returned_event_ids jsonb,
523
- first_claimed_at bigint,
524
- last_claimed_at bigint,
525
- attempt_count integer not null default 0,
526
- failure_count integer not null default 0,
527
- last_failed_at bigint,
528
- last_failed_attempt integer,
529
- last_error text,
530
- failed_at bigint,
531
- claim_holder text,
532
- claim_expires_at bigint,
533
- primary key (session_id, idx)
534
- );
535
-
536
- create unique index if not exists a2_events_event_id on a2_events (event_id);
537
- create index if not exists a2_events_unprocessed
538
- on a2_events (session_id, idx) where processed_at is null;
539
- create index if not exists a2_events_lane_pending
540
- on a2_events (session_id, lane, idx) where processed_at is null;
541
- create index if not exists a2_events_dispatch_ready
542
- on a2_events (session_id, idx)
543
- where lane_ready and processed_at is null and failed_at is null;
544
- create index if not exists a2_events_claim_expiry
545
- on a2_events (session_id, claim_expires_at)
546
- where lane_ready and processed_at is null and failed_at is null;
547
-
548
- create table if not exists a2_snapshots (
549
- session_id text not null,
550
- reducer_name text not null,
551
- up_to_index bigint not null,
552
- state jsonb not null,
553
- updated_at bigint not null,
554
- primary key (session_id, reducer_name)
555
- );
556
-
557
- create table if not exists a2_snapshot_history (
558
- session_id text not null,
559
- reducer_name text not null,
560
- up_to_index bigint not null,
561
- state jsonb not null,
562
- updated_at bigint not null,
563
- primary key (session_id, reducer_name, up_to_index)
564
- );
565
-
566
- create table if not exists a2_snapshot_pins (
567
- session_id text not null,
568
- event_index bigint not null,
569
- reducer_name text not null,
570
- up_to_index bigint not null,
571
- primary key (session_id, event_index, reducer_name, up_to_index)
572
- );
573
-
574
- create index if not exists a2_snapshot_pins_checkpoint
575
- on a2_snapshot_pins (session_id, reducer_name, up_to_index);
576
-
577
- create table if not exists a2_clears (
578
- session_id text primary key,
579
- clear_index bigint not null
580
- );
581
-
582
- create table if not exists a2_presence (
583
- ns text not null,
584
- participant text not null,
585
- field text not null,
586
- value jsonb not null,
587
- seen bigint not null,
588
- at bigint not null,
589
- expires_at bigint not null,
590
- primary key (ns, participant, field)
591
- );
592
- `;
593
567
  const asNumber = (value) => Number(value);
568
+ const asNullableNumber = (value) => value === null || value === void 0 ? null : asNumber(value);
594
569
  /**
595
570
  * The session's latest clear marker, or 0, as a primary-key subquery. Read
596
571
  * it in a statement that runs after the operation's lock: a statement's
@@ -598,50 +573,178 @@ const asNumber = (value) => Number(value);
598
573
  * miss a clear that committed meanwhile.
599
574
  */
600
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`;
601
598
  const RECLAIM_BATCH = 256;
602
- const toDate = (ms) => ms === null || ms === void 0 ? null : new Date(asNumber(ms));
603
- const toCause = (raw) => {
604
- if (raw === null || raw === void 0) return null;
605
- const value = typeof raw === "string" ? JSON.parse(raw) : raw;
606
- 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");
607
- if (value.batchSize !== void 0 && (!Number.isInteger(value.batchSize) || Number(value.batchSize) < 1)) throw new TypeError("stored event has an invalid cause");
608
- return {
609
- index: Number(value.index),
610
- attempt: Number(value.attempt),
611
- ...value.batchSize === void 0 ? {} : { batchSize: Number(value.batchSize) }
612
- };
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)`
613
629
  };
614
- 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) => ({
615
679
  id: row["event_id"],
616
680
  type: row["event_type"],
617
681
  payload: row["payload"],
618
682
  index: asNumber(row["idx"]),
619
683
  sessionId: row["session_id"],
620
- createdAt: new Date(asNumber(row["created_at"])),
621
- 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"],
622
692
  lane: row["lane"] ?? null,
623
- processedAt: toDate(row["processed_at"]),
624
- processedByAttempt: row["processed_by_attempt"] === null || row["processed_by_attempt"] === void 0 ? null : asNumber(row["processed_by_attempt"]),
625
- returnedEventIds: row["returned_event_ids"] === null || row["returned_event_ids"] === void 0 ? null : decodeReturnedEventIds(row["returned_event_ids"]),
626
693
  firstClaimedAt: toDate(row["first_claimed_at"]),
627
694
  lastClaimedAt: toDate(row["last_claimed_at"]),
628
695
  attemptCount: asNumber(row["attempt_count"]),
696
+ claimHolder: row["claim_holder"] ?? null,
697
+ claimExpiresAt: toDate(row["claim_expires_at"]),
629
698
  failureCount: asNumber(row["failure_count"]),
630
699
  lastFailedAt: toDate(row["last_failed_at"]),
631
- 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"]),
632
701
  lastError: row["last_error"] ?? null,
633
- failedAt: toDate(row["failed_at"]),
634
- claimHolder: row["claim_holder"] ?? null,
635
- claimExpiresAt: toDate(row["claim_expires_at"])
702
+ failedAt: toDate(row["failed_at"])
636
703
  });
637
- const toEvent = (row) => ({
638
- id: row["event_id"],
639
- type: row["event_type"],
640
- payload: row["payload"],
641
- index: asNumber(row["idx"]),
642
- sessionId: row["session_id"],
643
- 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)
644
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);
645
748
  const wrap = async (fn) => {
646
749
  try {
647
750
  return await fn();
@@ -651,25 +754,6 @@ const wrap = async (fn) => {
651
754
  }
652
755
  };
653
756
  const jsonb = (value) => JSON.stringify(value) ?? "null";
654
- const initializeSchema = async ({ client, listen }) => {
655
- const schema = SCHEMA + (listen ? POSTGRES_NOTIFICATIONS_SQL : "");
656
- if (!client.connect) {
657
- await client.query(schema);
658
- return;
659
- }
660
- const conn = await client.connect();
661
- try {
662
- await conn.query("begin");
663
- await conn.query("select pg_advisory_xact_lock(hashtext('a2:postgres:schema'))");
664
- await conn.query(schema);
665
- await conn.query("commit");
666
- } catch (err) {
667
- await conn.query("rollback").catch(() => {});
668
- throw err;
669
- } finally {
670
- conn.release();
671
- }
672
- };
673
757
  const loadPg = async () => import("pg").catch(() => {
674
758
  throw new A2Error("STORE_NOT_CONFIGURED", "Postgres connections created by A2 require the optional 'pg' package");
675
759
  });
@@ -694,10 +778,16 @@ const createListener = ({ pg, connectionString }) => postgresNotifications(async
694
778
  }
695
779
  };
696
780
  });
697
- /** A dead-lettered event's snapshot pins, and history no pin keeps. */
698
- 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 }) => {
699
783
  await query(`delete from a2_snapshot_pins
700
- 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
+ ]);
701
791
  await query(`delete from a2_snapshot_history history
702
792
  where history.session_id = $1
703
793
  and not exists (
@@ -707,6 +797,781 @@ const releasePins = async ({ query, sessionId, index }) => {
707
797
  and pin.up_to_index = history.up_to_index
708
798
  )`, [sessionId]);
709
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. When every event's id belongs to
867
+ * this session already, the batch is a retry: it stops with the committed
868
+ * rows. Some owned, or one in another session, 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.event_id = any(${param.texts("ids")})
883
+ and not (event.session_id = ${SESSION}
884
+ and event.idx < boundary.clear_index)
885
+ )
886
+ select case
887
+ when exists (select 1 from owned where session_id <> ${SESSION}) then
888
+ jsonb_build_object('abort', 'foreign', 'event_id', (
889
+ select event_id from owned where session_id <> ${SESSION}
890
+ order by idx limit 1))
891
+ when (select count(*) from owned) = ${param.bigint("count")} then
892
+ jsonb_build_object('stop', 'replay',
893
+ 'has_pending', boundary.has_pending,
894
+ 'events', (select jsonb_agg(to_jsonb(owned) order by owned.idx)
895
+ from owned))
896
+ when exists (select 1 from owned) then
897
+ jsonb_build_object('abort', 'mixed', 'owned',
898
+ (select count(*) from owned))
899
+ when ${param.bigint("origin")} <= boundary.clear_index then
900
+ jsonb_build_object('abort', 'cleared')
901
+ else jsonb_build_object('base', boundary.base,
902
+ 'has_pending', boundary.has_pending)
903
+ end
904
+ from boundary`;
905
+ /**
906
+ * The attempt-currency fence of a consumer append, under the session lock.
907
+ * Each cause's cursor is read `for share`: that serializes with every writer
908
+ * of the cursor (claims, completions, failures), so a stale read cannot pass
909
+ * the fence against a takeover committing beside it. A row is locked only
910
+ * while its head and attempt still match. The first cause that fails aborts.
911
+ */
912
+ const FENCE = `
913
+ with cause as (
914
+ select * from jsonb_to_recordset($1->'causes')
915
+ as cause(ord int, consumer text, cursor_key text, idx bigint, attempt int)
916
+ ), held as materialized (
917
+ select cause.ord from cause
918
+ join a2_cursors cur
919
+ on cur.session_id = ${SESSION} and cur.cursor_key = cause.cursor_key
920
+ and cur.consumer = cause.consumer and cur.head = cause.idx
921
+ and cur.attempt_count = cause.attempt and cur.failed_at is null
922
+ where cause.idx > ${clearIndexOf(SESSION)}
923
+ for share of cur
924
+ )
925
+ select jsonb_build_object('abort', 'superseded', 'ord', cause.ord)
926
+ from cause where cause.ord not in (select ord from held)
927
+ order by cause.ord limit 1`;
928
+ /**
929
+ * Frees the `ids` this session's rows below its clear marker hold, so the
930
+ * batch can take them. It never waits on a row: a reclaim deleting one may
931
+ * hold it, and one it skips aborts.
932
+ */
933
+ const REUSE_IDS = `
934
+ with stale as materialized (
935
+ select event.idx from a2_events event
936
+ where event.session_id = ${SESSION}
937
+ and event.event_id = any(${param.texts("ids")})
938
+ and event.idx < ${clearIndexOf(SESSION)}
939
+ order by event.idx
940
+ for update skip locked
941
+ ), deleted as (
942
+ delete from a2_events event using stale
943
+ where event.session_id = ${SESSION} and event.idx = stale.idx
944
+ returning event.idx
945
+ )
946
+ select jsonb_build_object('abort', 'held')
947
+ where (select count(*) from deleted) < (
948
+ select count(*) from a2_events event
949
+ where event.session_id = ${SESSION}
950
+ and event.event_id = any(${param.texts("ids")})
951
+ and event.idx < ${clearIndexOf(SESSION)})`;
952
+ /** Inserts `events` after the session's head, under the session lock. */
953
+ const INSERT_EVENTS = `
954
+ with inserted as (
955
+ insert into a2_events
956
+ (session_id, idx, event_type, payload, event_id, created_at, cause)
957
+ select ${SESSION}, base.base + event.ord, event.type,
958
+ coalesce(event.payload, 'null'::jsonb), event.id,
959
+ ${param.bigint("now")}, event.cause
960
+ from (select coalesce(max(idx), 0) as base from a2_events
961
+ where session_id = ${SESSION}) base,
962
+ jsonb_to_recordset($1->'events')
963
+ as event(ord bigint, type text, payload jsonb, id text, cause jsonb)
964
+ returning 1
965
+ )
966
+ select jsonb_build_object('inserted', count(*)) from inserted`;
967
+ /** A cursor for each concurrent target: the event alone. */
968
+ const START_EVENTS = `
969
+ with started as (
970
+ insert into a2_cursors
971
+ (session_id, consumer, cursor_key, epoch, lane, kind, position, head)
972
+ select ${SESSION}, target.consumer, '#' || target.idx, target.idx, null,
973
+ 'event', target.idx - 1, target.idx
974
+ from (
975
+ select single.consumer, ${INSERTED_AFTER} + single.ord as idx
976
+ from jsonb_to_recordset($1->'singles')
977
+ as single(consumer text, ord bigint)
978
+ ) target
979
+ on conflict do nothing
980
+ returning 1
981
+ )
982
+ select jsonb_build_object('started', count(*)) from started`;
983
+ /** Queues each lane target's event in its lane. */
984
+ const QUEUE_LANES = `
985
+ with queued as (
986
+ insert into a2_lane_events (session_id, consumer, lane, idx)
987
+ select ${SESSION}, queued.consumer, queued.lane,
988
+ ${INSERTED_AFTER} + queued.ord
989
+ from jsonb_to_recordset($1->'queued')
990
+ as queued(consumer text, lane text, ord bigint)
991
+ on conflict do nothing
992
+ returning 1
993
+ )
994
+ select jsonb_build_object('queued', count(*)) from queued`;
995
+ /**
996
+ * Starts a run for each lane cursor the batch queues work for that has none
997
+ * running: one that is idle, superseded by the clear marker, or new. It starts
998
+ * at the lane's first event in the batch and counts from the marker.
999
+ */
1000
+ const START_LANES = `
1001
+ with boundary as materialized (
1002
+ select ${INSERTED_AFTER} as base, ${clearIndexOf(SESSION)} as clear_index
1003
+ ), target as (
1004
+ select first.consumer, first.cursor_key, first.lane,
1005
+ boundary.base + first.ord as idx
1006
+ from boundary, jsonb_to_recordset($1->'lanes')
1007
+ as first(consumer text, cursor_key text, lane text, ord bigint)
1008
+ ), started as materialized (
1009
+ select target.*, latest.position
1010
+ from target
1011
+ left join lateral (${LATEST_RUN("target")}) latest on true
1012
+ where latest.head is null
1013
+ or latest.head <= (select clear_index from boundary)
1014
+ ), ${RETIRE_RUNS("started", "(select clear_index from boundary)")},
1015
+ inserted as (
1016
+ insert into a2_cursors
1017
+ (session_id, consumer, cursor_key, epoch, lane, kind, position, head)
1018
+ select ${SESSION}, started.consumer, started.cursor_key, started.idx,
1019
+ started.lane, 'lane',
1020
+ greatest(coalesce(started.position, 0),
1021
+ (select clear_index from boundary)),
1022
+ started.idx
1023
+ from started
1024
+ on conflict do nothing
1025
+ returning 1
1026
+ )
1027
+ select jsonb_build_object('lanes', count(*)) from inserted`;
1028
+ /**
1029
+ * Starts a run for each served log cursor the batch carries types for that
1030
+ * has none running. A missing cursor starts at the batch's first event of its
1031
+ * types, or at the beginning of the log with backfill. One that exists starts
1032
+ * at the next event it takes after its position: a deployment that does not
1033
+ * serve the consumer may have appended some while it was idle. A cursor from
1034
+ * before the clear marker counts from it.
1035
+ */
1036
+ const WAKE_LOGS = `
1037
+ with boundary as materialized (
1038
+ select ${INSERTED_AFTER} as base, ${clearIndexOf(SESSION)} as clear_index
1039
+ ), target as (
1040
+ select log.consumer, log.cursor_key, log.lane,
1041
+ boundary.base + log.ord as first, log.types, log.backfill
1042
+ from boundary, jsonb_to_recordset($1->'logs')
1043
+ as log(consumer text, cursor_key text, lane text, ord bigint,
1044
+ types jsonb, backfill boolean)
1045
+ ), woken as materialized (
1046
+ select target.*,
1047
+ case when latest.epoch is not null
1048
+ then greatest(latest.position,
1049
+ (select clear_index from boundary))
1050
+ when target.backfill then (select clear_index from boundary)
1051
+ else (select base from boundary) end as position,
1052
+ latest.epoch is not null or target.backfill as resumes
1053
+ from target
1054
+ left join lateral (${LATEST_RUN("target")}) latest on true
1055
+ where latest.head is null
1056
+ or latest.head <= (select clear_index from boundary)
1057
+ ), ${RETIRE_RUNS("woken", "(select clear_index from boundary)")}, run as (
1058
+ select woken.*,
1059
+ case when woken.resumes then coalesce(${nextLogHead({
1060
+ types: "woken.types",
1061
+ after: "woken.position"
1062
+ })}, woken.first) else woken.first end as head
1063
+ from woken
1064
+ ), inserted as (
1065
+ insert into a2_cursors
1066
+ (session_id, consumer, cursor_key, epoch, lane, kind, position, head,
1067
+ types)
1068
+ select ${SESSION}, run.consumer, run.cursor_key, run.head, run.lane, 'log',
1069
+ run.position, run.head, run.types
1070
+ from run
1071
+ on conflict do nothing
1072
+ returning 1
1073
+ )
1074
+ select jsonb_build_object('logs', count(*)) from inserted`;
1075
+ /**
1076
+ * The heads a claim may take, up to `through` when there is one, as
1077
+ * `from ... where` for `cur`. `for update` makes a claim racing another wait
1078
+ * and then re-check the row it waited for.
1079
+ */
1080
+ const ELIGIBLE = `
1081
+ from a2_cursors cur
1082
+ where cur.session_id = ${SESSION}
1083
+ and cur.head > ${clearIndexOf(SESSION)}
1084
+ and (${param.bigint("through")} is null
1085
+ or cur.head <= ${param.bigint("through")})
1086
+ and cur.failed_at is null
1087
+ and (${param.texts("served")} is null
1088
+ or cur.consumer = any(${param.texts("served")}))
1089
+ and not exists (
1090
+ select 1 from jsonb_to_recordset($1->'excluded')
1091
+ as skip(consumer text, cursor_key text)
1092
+ where skip.consumer = cur.consumer and skip.cursor_key = cur.cursor_key
1093
+ )`;
1094
+ /** A lapsed claim's takeover that spends the last failed attempt dead-letters the head instead. */
1095
+ const EXHAUSTED = `(cur.claim_holder is not null
1096
+ and cur.failure_count + 1 >= ${param.int("maxFailures")})`;
1097
+ /**
1098
+ * Claims every eligible head, returning each with its event. A head the claim
1099
+ * waited on may have moved to an event its snapshot predates: that row comes
1100
+ * back without one (`e_idx` null), for `claimAvailable` to read. It stops
1101
+ * there unless one dead-lettered, whose pins `RELEASE_DEAD` then releases, or
1102
+ * none was claimed, for `RETRY_AT`.
1103
+ */
1104
+ const CLAIM = `
1105
+ with eligible as materialized (
1106
+ select cur.consumer, cur.cursor_key, cur.epoch
1107
+ ${ELIGIBLE}
1108
+ and (cur.claim_expires_at is null
1109
+ or cur.claim_expires_at <= ${param.bigint("now")})
1110
+ order by cur.head, cur.consumer
1111
+ for update of cur
1112
+ ), claimed as (
1113
+ update a2_cursors cur set
1114
+ attempt_count = cur.attempt_count + 1,
1115
+ failure_count = cur.failure_count
1116
+ + case when cur.claim_holder is null then 0 else 1 end,
1117
+ failed_at = case when ${EXHAUSTED} then ${param.bigint("now")}
1118
+ else cur.failed_at end,
1119
+ first_claimed_at = case when ${EXHAUSTED} then cur.first_claimed_at
1120
+ else coalesce(cur.first_claimed_at, ${param.bigint("now")}) end,
1121
+ last_claimed_at = case when ${EXHAUSTED} then cur.last_claimed_at
1122
+ else ${param.bigint("now")} end,
1123
+ claim_holder = case when ${EXHAUSTED} then null
1124
+ else ${param.text("holder")} end,
1125
+ claim_expires_at = case when ${EXHAUSTED} then null
1126
+ else ${param.bigint("expiresAt")} end
1127
+ from eligible
1128
+ where cur.session_id = ${SESSION}
1129
+ and cur.cursor_key = eligible.cursor_key
1130
+ and cur.consumer = eligible.consumer
1131
+ and cur.epoch = eligible.epoch
1132
+ returning cur.*
1133
+ ), heads as (
1134
+ select claimed.*, ${HEAD_COLUMNS},
1135
+ ${clearIndexOf(SESSION)} as clear_index,
1136
+ (select coalesce(max(idx), 0) from a2_events
1137
+ where session_id = ${SESSION}) as session_head
1138
+ from claimed
1139
+ left join a2_events event
1140
+ on event.session_id = ${SESSION} and event.idx = claimed.head
1141
+ )
1142
+ select r from (
1143
+ select 0 as ord, to_jsonb(heads) as r from heads
1144
+ union all
1145
+ select 1, jsonb_build_object('stop', 'claimed')
1146
+ where exists (select 1 from heads)
1147
+ and not exists (select 1 from heads where failed_at is not null)
1148
+ ) claim
1149
+ order by ord`;
1150
+ /**
1151
+ * Releases the pins of the heads dead-lettered at `now`, the claim's among
1152
+ * them, in a snapshot after the claim's locks, with the history no other pin
1153
+ * keeps.
1154
+ */
1155
+ const RELEASE_DEAD = `
1156
+ with dead as materialized (
1157
+ select cur.consumer, cur.cursor_key, cur.head from a2_cursors cur
1158
+ where cur.session_id = ${SESSION} and cur.head is not null
1159
+ and cur.failed_at = ${param.bigint("now")} and cur.claim_holder is null
1160
+ ), unpinned as (
1161
+ delete from a2_snapshot_pins pin using dead
1162
+ where pin.session_id = ${SESSION} and pin.event_index = dead.head
1163
+ and pin.consumer = dead.consumer and pin.cursor_key = dead.cursor_key
1164
+ returning 1
1165
+ ), unkept as (
1166
+ delete from a2_snapshot_history history
1167
+ where history.session_id = ${SESSION}
1168
+ and exists (select 1 from dead)
1169
+ and not exists (
1170
+ select 1 from a2_snapshot_pins pin
1171
+ where pin.session_id = history.session_id
1172
+ and pin.reducer_name = history.reducer_name
1173
+ and pin.up_to_index = history.up_to_index
1174
+ and not exists (
1175
+ select 1 from dead
1176
+ where pin.event_index = dead.head
1177
+ and pin.consumer = dead.consumer
1178
+ and pin.cursor_key = dead.cursor_key)
1179
+ )
1180
+ returning 1
1181
+ )
1182
+ select jsonb_build_object('released', (select count(*) from unpinned))`;
1183
+ /** When nothing was claimed: the earliest live claim or retry hold that blocks a head. */
1184
+ const RETRY_AT = `
1185
+ select jsonb_build_object('retry_at', min(cur.claim_expires_at))
1186
+ ${ELIGIBLE}
1187
+ and cur.claim_expires_at > ${param.bigint("now")}`;
1188
+ /** The cursor row `alias` whose head this attempt holds. */
1189
+ const heldBy = (alias) => `
1190
+ ${alias}.session_id = ${SESSION}
1191
+ and ${alias}.cursor_key = ${param.text("cursorKey")}
1192
+ and ${alias}.consumer = ${CONSUMER} and ${alias}.head = ${INDEX}
1193
+ and ${alias}.attempt_count = ${param.int("attempt")}
1194
+ and ${alias}.failed_at is null and ${alias}.claim_holder is not null`;
1195
+ /**
1196
+ * Locks the cursor while this attempt holds its head, and stops unless it
1197
+ * does: a completion locks its cursor first. A cleared head is never locked.
1198
+ */
1199
+ const HOLD = `
1200
+ with boundary as materialized (
1201
+ select ${clearIndexOf(SESSION)} as clear_index
1202
+ ), held as materialized (
1203
+ select cur.kind from a2_cursors cur, boundary
1204
+ where ${heldBy("cur")} and ${INDEX} > boundary.clear_index
1205
+ for update of cur
1206
+ )
1207
+ select case
1208
+ when ${INDEX} <= boundary.clear_index then
1209
+ jsonb_build_object('stop', 'superseded')
1210
+ when not exists (select 1 from held) then
1211
+ jsonb_build_object('stop', 'unheld')
1212
+ else jsonb_build_object('held', (select kind from held limit 1))
1213
+ end
1214
+ from boundary`;
1215
+ /** A log cursor's types once it moves: the served consumer's (`types`, when `retype`), else its own. */
1216
+ const logTypesOf = (cur) => `(case when ${param.boolean("retype")} then ${param.jsonb("types")}
1217
+ else ${cur}.types end)`;
1218
+ /**
1219
+ * The head cursor `cur` moves to from its head: its lane's next unfinished
1220
+ * event, the next event of a log cursor's types, or none for a concurrent
1221
+ * cursor. The lane's lookup is ordered by its whole key, so the lane's
1222
+ * primary key serves it.
1223
+ */
1224
+ const nextHeadOf = (cur) => `
1225
+ case ${cur}.kind
1226
+ when 'lane' then (
1227
+ select lane.idx from a2_lane_events lane
1228
+ where lane.session_id = ${SESSION} and lane.consumer = ${cur}.consumer
1229
+ and lane.lane = ${cur}.lane and not lane.done
1230
+ and lane.idx > ${cur}.head
1231
+ order by lane.consumer, lane.lane, lane.idx limit 1)
1232
+ when 'log' then (${nextLogHead({
1233
+ types: logTypesOf(cur),
1234
+ after: `${cur}.head`
1235
+ })})
1236
+ end`;
1237
+ /**
1238
+ * Takes the session lock when the held cursor would go idle: a lane or log
1239
+ * cursor with no next head. See `completeAttempt` for why a completion that
1240
+ * moves to a next head needs no lock.
1241
+ */
1242
+ const LOCK_IF_IDLE = `
1243
+ select jsonb_build_object('locked',
1244
+ pg_advisory_xact_lock(hashtext(${SESSION}))::text is not null)
1245
+ from a2_cursors cur
1246
+ where ${heldBy("cur")} and cur.kind <> 'event'
1247
+ and (${nextHeadOf("cur")}) is null`;
1248
+ /**
1249
+ * Moves the cursor `target` selects past its head `index`, if its head lies
1250
+ * above the clear marker: marks the lane's event done (with this attempt's
1251
+ * receipt, `receipt`), releases the head's snapshot pins with the history no
1252
+ * other pin keeps, and gives the cursor its next head with fresh dispatch
1253
+ * columns (a log or concurrent cursor keeps the receipt on the run). The
1254
+ * marker and the next head are read in one snapshot, after the cursor's lock.
1255
+ * Without a row it writes nothing, and stops if `stop`.
1256
+ */
1257
+ const advance = ({ target, receipt, missing }) => `
1258
+ with target as materialized (
1259
+ select cur.* from a2_cursors cur
1260
+ where ${target} and ${INDEX} > ${clearIndexOf(SESSION)}
1261
+ order by cur.cursor_key limit 1
1262
+ for update of cur
1263
+ ), done as (
1264
+ update a2_lane_events lane set done = true${receipt ? `, completed_attempt = ${param.int("attempt")},
1265
+ returned_event_ids = ${param.jsonb("ids")}` : ""}
1266
+ from target
1267
+ where target.kind = 'lane' and lane.session_id = ${SESSION}
1268
+ and lane.consumer = target.consumer and lane.lane = target.lane
1269
+ and lane.idx = target.head
1270
+ returning 1
1271
+ ), unpinned as (
1272
+ delete from a2_snapshot_pins pin using target
1273
+ where pin.session_id = ${SESSION} and pin.event_index = target.head
1274
+ and pin.consumer = target.consumer and pin.cursor_key = target.cursor_key
1275
+ returning 1
1276
+ ), unkept as (
1277
+ delete from a2_snapshot_history history using target
1278
+ where history.session_id = ${SESSION}
1279
+ and not exists (
1280
+ select 1 from a2_snapshot_pins pin
1281
+ where pin.session_id = history.session_id
1282
+ and pin.reducer_name = history.reducer_name
1283
+ and pin.up_to_index = history.up_to_index
1284
+ and not (pin.event_index = target.head
1285
+ and pin.consumer = target.consumer
1286
+ and pin.cursor_key = target.cursor_key)
1287
+ )
1288
+ returning 1
1289
+ ), moved as (
1290
+ update a2_cursors cur
1291
+ set position = target.head, head = ${nextHeadOf("target")},
1292
+ ${FRESH_DISPATCH},
1293
+ types = case when target.kind = 'log'
1294
+ then ${logTypesOf("target")} else cur.types end${receipt ? `,
1295
+ completed_idx = case when target.kind = 'lane'
1296
+ then cur.completed_idx else target.head end,
1297
+ completed_attempt = case when target.kind = 'lane'
1298
+ then cur.completed_attempt
1299
+ else ${param.int("attempt")} end,
1300
+ returned_event_ids = case when target.kind = 'lane'
1301
+ then cur.returned_event_ids
1302
+ else ${param.jsonb("ids")} end` : ""}
1303
+ from target
1304
+ where cur.session_id = ${SESSION} and cur.cursor_key = target.cursor_key
1305
+ and cur.consumer = target.consumer and cur.epoch = target.epoch
1306
+ returning cur.head
1307
+ )
1308
+ select case when exists (select 1 from moved)
1309
+ then jsonb_build_object('advanced', true)
1310
+ else jsonb_build_object(${missing === "continue" ? `'advanced', false` : `'${missing}', 'superseded'`}) end`;
1311
+ /** Completing a held head, once the cursor's lock is taken. */
1312
+ const COMPLETE = advance({
1313
+ target: heldBy("cur"),
1314
+ receipt: true,
1315
+ missing: "stop"
1316
+ });
1317
+ /**
1318
+ * Completing a held head after appending its events, under the session lock:
1319
+ * the head can't have moved, but if it had, the events must not stand.
1320
+ */
1321
+ const COMPLETE_APPENDED = advance({
1322
+ target: heldBy("cur"),
1323
+ receipt: true,
1324
+ missing: "abort"
1325
+ });
1326
+ /**
1327
+ * A completion's checks before it appends, under the session lock, which the
1328
+ * cursor's lock preceded: a head the clear marker passed meanwhile stops, and
1329
+ * a returned id a live row owns aborts.
1330
+ */
1331
+ const RETURN_CHECKS = `
1332
+ select case
1333
+ when ${INDEX} <= ${clearIndexOf(SESSION)} then
1334
+ jsonb_build_object('stop', 'superseded')
1335
+ when exists (
1336
+ select 1 from a2_events event
1337
+ where event.event_id = any(${param.texts("ids")})
1338
+ and not (event.session_id = ${SESSION}
1339
+ and event.idx < ${clearIndexOf(SESSION)})
1340
+ ) then jsonb_build_object('abort', 'owned')
1341
+ else jsonb_build_object('base', (
1342
+ select coalesce(max(idx), 0) from a2_events
1343
+ where session_id = ${SESSION}))
1344
+ end`;
1345
+ /** The consumer's dead-lettered head at `index`, locked. */
1346
+ const deadAt = (alias) => `
1347
+ ${alias}.session_id = ${SESSION} and ${alias}.consumer = ${CONSUMER}
1348
+ and ${alias}.head = ${INDEX} and ${alias}.failed_at is not null`;
1349
+ /**
1350
+ * Locks the dead letter, or stops: the statements after it act only on the
1351
+ * row this one locked.
1352
+ */
1353
+ const LOCK_DEAD = `
1354
+ with dead as materialized (
1355
+ select cur.cursor_key from a2_cursors cur
1356
+ where ${deadAt("cur")} and ${INDEX} > ${clearIndexOf(SESSION)}
1357
+ order by cur.cursor_key limit 1
1358
+ for update
1359
+ )
1360
+ select case when exists (select 1 from dead)
1361
+ then jsonb_build_object('redriven', true)
1362
+ else jsonb_build_object('stop', 'alive', 'redriven', false,
1363
+ 'has_pending',
1364
+ ${pendingIn(SESSION, param.texts("served"))}) end`;
1365
+ /** Clears a dead letter, keeping its claim history and last failure. */
1366
+ const RETRY_DEAD = `
1367
+ with target as materialized (
1368
+ select cur.cursor_key, cur.epoch from a2_cursors cur
1369
+ where ${deadAt("cur")} and ${INDEX} > ${clearIndexOf(SESSION)}
1370
+ order by cur.cursor_key limit 1
1371
+ for update of cur
1372
+ ), retried as (
1373
+ update a2_cursors cur set failed_at = null, failure_count = 0
1374
+ from target
1375
+ where cur.session_id = ${SESSION} and cur.cursor_key = target.cursor_key
1376
+ and cur.consumer = ${CONSUMER} and cur.epoch = target.epoch
1377
+ returning 1
1378
+ )
1379
+ select jsonb_build_object('retried', count(*)) from retried`;
1380
+ const SKIP_DEAD = advance({
1381
+ target: deadAt("cur"),
1382
+ receipt: false,
1383
+ missing: "continue"
1384
+ });
1385
+ const PENDING = `
1386
+ select jsonb_build_object('has_pending',
1387
+ ${pendingIn(SESSION, param.texts("served"))})`;
1388
+ /**
1389
+ * One log cursor as `wakeLog` finds it: the session's head and clear marker,
1390
+ * the cursor's latest run, where a run would start (`start`), and the next
1391
+ * event it takes from there unless the run has a head. `retyped` says whether
1392
+ * the run's types are stale. CTEs, for a `with`.
1393
+ */
1394
+ const LOG_CURSOR = `
1395
+ boundary as materialized (
1396
+ select coalesce(max(idx), 0) as session_head,
1397
+ ${clearIndexOf(SESSION)} as clear_index
1398
+ from a2_events where session_id = ${SESSION}
1399
+ ), latest as materialized (
1400
+ select boundary.*, cur.*,
1401
+ case when cur.epoch is not null
1402
+ then greatest(cur.position, boundary.clear_index)
1403
+ when ${param.boolean("backfill")} then boundary.clear_index
1404
+ else boundary.session_head end as start
1405
+ from boundary
1406
+ left join lateral (
1407
+ select * from a2_cursors cur
1408
+ where cur.session_id = ${SESSION}
1409
+ and cur.cursor_key = ${param.text("cursorKey")}
1410
+ and cur.consumer = ${CONSUMER}
1411
+ order by cur.epoch desc limit 1
1412
+ ) cur on true
1413
+ ), found as materialized (
1414
+ select latest.*,
1415
+ latest.types is distinct from ${param.jsonb("types")} as retyped,
1416
+ case when latest.head > latest.clear_index then null
1417
+ else ${nextLogHead({
1418
+ types: param.jsonb("types"),
1419
+ after: "latest.start"
1420
+ })} end as next_head
1421
+ from latest
1422
+ )`;
1423
+ /**
1424
+ * A run with a head gets no next one: it is left as it is, claim and all,
1425
+ * and so is an idle run with nothing new to take. Only their types change.
1426
+ */
1427
+ const KEEPS_RUN = "found.epoch is not null and found.next_head is null";
1428
+ /** No run yet, and nothing after the latest clear: none is started. */
1429
+ const NO_RUN_NEEDED = "found.epoch is null and found.session_head = found.clear_index";
1430
+ /** `wakeLog` without the lock: stops when there is nothing to write. */
1431
+ const READ_LOG = `
1432
+ with ${LOG_CURSOR}
1433
+ select case when ${KEEPS_RUN} and not found.retyped
1434
+ then jsonb_build_object('stop', 'unchanged',
1435
+ 'cursor', to_jsonb(found))
1436
+ when ${NO_RUN_NEEDED}
1437
+ then jsonb_build_object('stop', 'empty', 'empty', true,
1438
+ 'head', found.session_head)
1439
+ else jsonb_build_object('stale', true) end
1440
+ from found`;
1441
+ /**
1442
+ * `wakeLog` under the session lock. A kept run takes the consumer's types,
1443
+ * unless another operation holds its row: the session lock is held, so this
1444
+ * waits for none. Otherwise a run starts at `start`, retiring the runs it
1445
+ * replaces. One with a head is keyed by it; one with nothing to run is idle
1446
+ * at its position, as a run that finished its last head is, keyed by that
1447
+ * position so the next run, which starts after it, sorts later.
1448
+ */
1449
+ const WAKE_LOG = `
1450
+ with ${LOG_CURSOR}, held as materialized (
1451
+ select cur.ctid from a2_cursors cur, found
1452
+ where ${KEEPS_RUN} and found.retyped
1453
+ and cur.session_id = ${SESSION}
1454
+ and cur.cursor_key = ${param.text("cursorKey")}
1455
+ and cur.consumer = ${CONSUMER} and cur.epoch = found.epoch
1456
+ and cur.types is distinct from ${param.jsonb("types")}
1457
+ for update of cur skip locked
1458
+ ), retype as (
1459
+ update a2_cursors cur set types = ${param.jsonb("types")} from held
1460
+ where cur.ctid = held.ctid
1461
+ ), target as (
1462
+ select ${param.text("cursorKey")} as cursor_key, ${CONSUMER} as consumer
1463
+ from found where not (${KEEPS_RUN}) and not (${NO_RUN_NEEDED})
1464
+ ), ${RETIRE_RUNS("target", "(select clear_index from found)")}, started as (
1465
+ insert into a2_cursors
1466
+ (session_id, consumer, cursor_key, epoch, lane, kind, position, head,
1467
+ types)
1468
+ select ${SESSION}, target.consumer, target.cursor_key,
1469
+ coalesce(found.next_head, found.start), ${param.text("lane")}, 'log',
1470
+ found.start, found.next_head, ${param.jsonb("types")}
1471
+ from found, target
1472
+ returning *
1473
+ )
1474
+ select jsonb_build_object(
1475
+ 'cursor', coalesce((select to_jsonb(started) from started),
1476
+ to_jsonb(found)),
1477
+ 'head', found.session_head, 'clear_index', found.clear_index,
1478
+ 'empty', ${NO_RUN_NEEDED})
1479
+ from found`;
1480
+ /**
1481
+ * The statements that start the cursor work a batch of `events` carries,
1482
+ * with their parameters: each event by its position in the batch, from 1,
1483
+ * since its index is assigned under the lock. `started` says whether it
1484
+ * starts work for a served consumer.
1485
+ */
1486
+ const enqueue = (events, consumers) => {
1487
+ const served = consumers && new Set(consumers.map(({ name }) => name));
1488
+ let started = false;
1489
+ const singles = [];
1490
+ const queued = [];
1491
+ const lanes = /* @__PURE__ */ new Map();
1492
+ events.forEach((event, offset) => {
1493
+ const ord = offset + 1;
1494
+ for (const target of event.targets ?? []) {
1495
+ if (!served || served.has(target.consumer)) started = true;
1496
+ if (target.lane === null) {
1497
+ singles.push({
1498
+ consumer: target.consumer,
1499
+ ord
1500
+ });
1501
+ continue;
1502
+ }
1503
+ queued.push({
1504
+ consumer: target.consumer,
1505
+ lane: target.lane,
1506
+ ord
1507
+ });
1508
+ const key = cursorKeyOf(target.lane, ord);
1509
+ const first = `${target.consumer}\u0000${key}`;
1510
+ if (!lanes.has(first)) lanes.set(first, {
1511
+ consumer: target.consumer,
1512
+ cursor_key: key,
1513
+ lane: target.lane,
1514
+ ord
1515
+ });
1516
+ }
1517
+ });
1518
+ const logs = [];
1519
+ for (const consumer of consumers ?? []) {
1520
+ const log = consumer.log;
1521
+ if (!log) continue;
1522
+ const offset = events.findIndex((event) => event.type !== "a2.cleared" && (log.types === null || log.types.includes(event.type)));
1523
+ if (offset === -1) continue;
1524
+ logs.push({
1525
+ consumer: consumer.name,
1526
+ cursor_key: cursorKeyOf(log.lane, 0),
1527
+ lane: log.lane,
1528
+ ord: offset + 1,
1529
+ types: log.types,
1530
+ backfill: log.backfill
1531
+ });
1532
+ }
1533
+ if (logs.length > 0) started = true;
1534
+ return {
1535
+ statements: [
1536
+ singles.length > 0 && START_EVENTS,
1537
+ queued.length > 0 && QUEUE_LANES,
1538
+ queued.length > 0 && START_LANES,
1539
+ logs.length > 0 && WAKE_LOGS
1540
+ ],
1541
+ params: {
1542
+ singles,
1543
+ queued,
1544
+ lanes: [...lanes.values()],
1545
+ logs,
1546
+ clearType: CLEAR_EVENT_TYPE
1547
+ },
1548
+ started
1549
+ };
1550
+ };
1551
+ /**
1552
+ * The rows a batch inserted at `base + 1`, as the store returns them.
1553
+ * `events` are what the batch sent.
1554
+ */
1555
+ const insertedRows = ({ sessionId, events, base, now }) => events.map((event, offset) => ({
1556
+ id: event.id,
1557
+ type: event.type,
1558
+ payload: structuredClone(event.payload),
1559
+ index: base + 1 + offset,
1560
+ sessionId,
1561
+ createdAt: new Date(now),
1562
+ cause: event.cause ? { ...event.cause } : null
1563
+ }));
1564
+ /** A served log consumer's types, for a cursor that moves past its head. */
1565
+ const retypeOf = (consumer, consumers) => {
1566
+ const log = consumers?.find(({ name }) => name === consumer)?.log;
1567
+ return log ? {
1568
+ retype: true,
1569
+ types: log.types
1570
+ } : {
1571
+ retype: false,
1572
+ types: null
1573
+ };
1574
+ };
710
1575
  function postgres(options = {}) {
711
1576
  const clock = options.clock ?? SYSTEM_CLOCK;
712
1577
  const generateId = options.ids ?? RANDOM_IDS;
@@ -733,34 +1598,23 @@ function postgres(options = {}) {
733
1598
  }
734
1599
  });
735
1600
  }
736
- try {
737
- const listen = Boolean(options.listenConnectionString);
738
- const resource = lease.value;
739
- if (injected) await initializeSchema({
740
- client: resource.client,
741
- listen
742
- });
743
- else await resource.initialize({
744
- schema: SCHEMA + (listen ? POSTGRES_NOTIFICATIONS_SQL : ""),
745
- run: () => initializeSchema({
746
- client: resource.client,
747
- listen
748
- })
749
- });
750
- return lease;
751
- } catch (error) {
752
- if (!injected) await lease.release().catch(() => {});
753
- throw error;
754
- }
1601
+ return {
1602
+ lease,
1603
+ client: explainingPostgresClient(lease.value.client)
1604
+ };
755
1605
  });
756
1606
  const client = async () => {
757
1607
  if (closed) throw new A2Error("STORE_UNAVAILABLE", "the store was closed");
758
- const lease = await connection.get();
1608
+ const { client: explaining } = await connection.get();
759
1609
  if (closed) throw new A2Error("STORE_UNAVAILABLE", "the store was closed");
760
- return lease.value.client;
1610
+ return explaining;
761
1611
  };
762
1612
  const notifications = options.listenConnectionString ? postgresNotificationScope(async () => {
763
- await client();
1613
+ const c = await client();
1614
+ await (await connection.get()).lease.value.initialize({
1615
+ key: "triggers",
1616
+ run: () => checkPostgresTriggers(c)
1617
+ });
764
1618
  const pg = await loadPg();
765
1619
  const connectionString = options.listenConnectionString;
766
1620
  const key = postgresConnectionKey({
@@ -889,12 +1743,14 @@ function postgres(options = {}) {
889
1743
  conn.release();
890
1744
  }
891
1745
  }
892
- const resource = (await connection.get()).value;
1746
+ const resource = (await connection.get()).lease.value;
893
1747
  return resource.exclusive(async () => {
894
1748
  const raw = resource.raw;
895
1749
  await raw.query(begin);
896
1750
  try {
897
- const result = await fn((text, values) => raw.query(text, values));
1751
+ const result = await fn((text, values) => raw.query(text, values).catch((error) => {
1752
+ throw explainPostgresError(error);
1753
+ }));
898
1754
  await raw.query("commit");
899
1755
  return result;
900
1756
  } catch (err) {
@@ -904,171 +1760,247 @@ function postgres(options = {}) {
904
1760
  });
905
1761
  };
906
1762
  /**
907
- * The rows owning `ids`. This session's rows below its clear marker do
908
- * not own their ids: they are deleted here so the ids can be reused.
1763
+ * Runs `statements` (the falsy ones dropped) as one `a2_batch` call with
1764
+ * `params`, and the session's id as `session`. Its rows come back in order;
1765
+ * an aborted call throws what `abort` makes of the aborting row.
909
1766
  */
910
- const liveOwnersOf = async ({ query, sessionId, clearIndex, ids }) => {
911
- if (ids.length === 0) return [];
912
- const { rows } = await query("select * from a2_events where event_id = any($1::text[]) order by idx", [ids]);
913
- const stale = rows.filter((row) => row["session_id"] === sessionId && asNumber(row["idx"]) < clearIndex);
914
- if (stale.length === 0) return rows;
915
- const { rows: deleted } = await query(`with stale as materialized (
916
- select idx from a2_events
917
- where session_id = $1 and event_id = any($2::text[])
918
- order by idx
919
- for update skip locked
920
- )
921
- delete from a2_events event using stale
922
- where event.session_id = $1 and event.idx = stale.idx
923
- returning event.idx`, [sessionId, stale.map((row) => row["event_id"])]);
924
- if (deleted.length < stale.length) throw new A2Error("STORE_UNAVAILABLE", `an event id being reused is still held by cleared work in session '${sessionId}'`);
925
- return rows.filter((row) => !stale.includes(row));
1767
+ const run = async ({ sessionId, statements, params, abort = () => new A2Error("STORE_UNAVAILABLE", "postgres store operation failed") }) => {
1768
+ const c = await client();
1769
+ try {
1770
+ const { rows } = await c.query(BATCH, [statements.flatMap((statement) => statement === false ? [] : [compact(statement)]), jsonb({
1771
+ ...params,
1772
+ session: sessionId
1773
+ })]);
1774
+ return rows.map((row) => row["r"]);
1775
+ } catch (error) {
1776
+ const reason = abortedBatch(error);
1777
+ if (reason) throw abort(reason);
1778
+ throw error;
1779
+ }
926
1780
  };
927
1781
  /** Whether `index` is at or below the boundary, read in a fresh statement. */
928
1782
  const clearedAt = async ({ query, sessionId, index }) => {
929
1783
  const { rows } = await query(`select ${clearIndexOf("$1")} as clear_index`, [sessionId]);
930
1784
  return index <= asNumber(rows[0]?.["clear_index"]);
931
1785
  };
932
- /** Insert one event; its lane is ready unless an unfinished lane event of this lifetime precedes it. */
933
- const insertEvent = async ({ query, row, clearIndex, returning = false }) => {
934
- const { rows } = await query(`insert into a2_events
935
- (session_id, idx, event_type, payload, event_id, created_at,
936
- cause, lane, lane_ready, processed_at)
937
- values (
938
- $1, $2, $3, $4::jsonb, $5, $6, $7::jsonb, $8,
939
- $8::text is null or not exists (
940
- select 1 from a2_events
941
- where session_id = $1 and lane = $8 and processed_at is null
942
- and idx > $10
943
- ),
944
- $9
945
- )${returning ? " returning *" : ""}`, [
946
- row.sessionId,
947
- row.index,
948
- row.type,
949
- jsonb(row.payload),
950
- row.id,
951
- row.createdAt,
952
- row.cause ? jsonb(row.cause) : null,
953
- row.lane,
954
- row.processedAt,
955
- clearIndex
1786
+ /**
1787
+ * A completion that does not hold its head: a stale attempt, or a retry
1788
+ * after an unknown acknowledgement, which replays the committed children.
1789
+ * Takes no lock.
1790
+ */
1791
+ const settleUnheld = async ({ query, sessionId, consumer, lane, index, attempt, events }) => {
1792
+ const superseded = { outcome: "superseded" };
1793
+ const { rows: cursorRows } = await query(`select latest.kind, latest.head, covering.completed_idx,
1794
+ covering.completed_attempt, covering.returned_event_ids
1795
+ from (
1796
+ select kind, head from a2_cursors
1797
+ where session_id = $1 and cursor_key = $2 and consumer = $3
1798
+ order by epoch desc limit 1
1799
+ ) latest
1800
+ left join lateral (
1801
+ select completed_idx, completed_attempt, returned_event_ids
1802
+ from a2_cursors
1803
+ where session_id = $1 and cursor_key = $2 and consumer = $3
1804
+ and epoch <= $4
1805
+ order by epoch desc limit 1
1806
+ ) covering on true`, [
1807
+ sessionId,
1808
+ cursorKeyOf(lane, index),
1809
+ consumer,
1810
+ index
956
1811
  ]);
957
- return rows[0];
1812
+ const cursor = cursorRows[0];
1813
+ if (!cursor) {
1814
+ if (await hasEvent(query, sessionId, index)) return superseded;
1815
+ if (await clearedAt({
1816
+ query,
1817
+ sessionId,
1818
+ index
1819
+ })) return superseded;
1820
+ throw new TypeError(`no event at index ${index} in session '${sessionId}'`);
1821
+ }
1822
+ const ids = events.map((event) => event.id);
1823
+ if (new Set(ids).size !== ids.length) throw new A2Error("PARTIAL_DUPLICATE_BATCH", "returned event batch contains the same event id more than once");
1824
+ if (asNullableNumber(cursor["head"]) === index) return superseded;
1825
+ let completion;
1826
+ if (cursor["kind"] === "lane") {
1827
+ const { rows } = await query(`select completed_attempt, returned_event_ids from a2_lane_events
1828
+ where session_id = $1 and consumer = $2 and lane = $3 and idx = $4`, [
1829
+ sessionId,
1830
+ consumer,
1831
+ lane,
1832
+ index
1833
+ ]);
1834
+ completion = rows[0] && {
1835
+ attempt: asNullableNumber(rows[0]["completed_attempt"]),
1836
+ ids: rows[0]["returned_event_ids"]
1837
+ };
1838
+ } else if (asNullableNumber(cursor["completed_idx"]) === index) completion = {
1839
+ attempt: asNullableNumber(cursor["completed_attempt"]),
1840
+ ids: cursor["returned_event_ids"]
1841
+ };
1842
+ if (!completion || completion.attempt !== attempt) return superseded;
1843
+ const returnedIds = completion.ids === null || completion.ids === void 0 ? null : decodeReturnedEventIds(completion.ids);
1844
+ 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");
1845
+ if (events.length === 0) return {
1846
+ outcome: "completed",
1847
+ events: []
1848
+ };
1849
+ const { rows: existing } = await query(`select session_id, idx, event_type, payload, event_id, created_at, cause
1850
+ from a2_events where event_id = any($1::text[])`, [ids]);
1851
+ const byId = new Map(existing.map((row) => [row["event_id"], row]));
1852
+ const ordered = ids.map((id) => byId.get(id));
1853
+ if (ordered.some((row) => !row) && await clearedAt({
1854
+ query,
1855
+ sessionId,
1856
+ index
1857
+ })) return superseded;
1858
+ if (ordered.some((row) => {
1859
+ const cause = row ? decodeCause(row["cause"]) : null;
1860
+ return !row || row["session_id"] !== sessionId || cause?.consumer !== consumer || cause.lane !== lane || cause.index !== index || cause.attempt !== attempt;
1861
+ })) throw new A2Error("PARTIAL_DUPLICATE_BATCH", "returned event retry does not match the committed batch");
1862
+ return {
1863
+ outcome: "completed",
1864
+ events: idempotentReplay(events, ordered.map(toStored))
1865
+ };
958
1866
  };
959
1867
  const store = {
960
1868
  async append(sessionId, events, appendOptions) {
1869
+ const consumers = appendOptions?.consumers;
1870
+ const served = servedNames(consumers);
961
1871
  if (events.length === 0) return wrap(async () => {
962
- const { rows } = await (await client()).query(`select exists(
963
- select 1 from a2_events
964
- where session_id = $1 and processed_at is null
965
- and idx > ${clearIndexOf("$1")}
966
- ) as has_pending`, [sessionId]);
1872
+ const { rows } = await (await client()).query(`select ${pendingIn("$1", "$2")} as has_pending`, [sessionId, served]);
967
1873
  return {
968
1874
  events: [],
969
1875
  hasPending: rows[0]?.["has_pending"] === true
970
1876
  };
971
1877
  });
972
- return wrap(() => withTx(async (query) => {
973
- await query("select pg_advisory_xact_lock(hashtext($1))", [sessionId]);
974
- const { rows: statusRows } = await query(`select coalesce(max(idx), 0) as max,
975
- ${clearIndexOf("$1")} as clear_index,
976
- exists(
977
- select 1 from a2_events pending
978
- where pending.session_id = $1
979
- and pending.processed_at is null
980
- and pending.idx > ${clearIndexOf("$1")}
981
- ) as session_has_pending
982
- from a2_events
983
- where session_id = $1`, [sessionId]);
984
- const base = asNumber(statusRows[0]?.["max"]);
985
- const clearIndex = asNumber(statusRows[0]?.["clear_index"]);
986
- const sessionHasPending = statusRows[0]?.["session_has_pending"] === true;
1878
+ return wrap(async () => {
987
1879
  const suppliedIds = events.filter((e) => e.id !== void 0).map((e) => e.id);
988
1880
  if (new Set(suppliedIds).size !== suppliedIds.length) throw new A2Error("PARTIAL_DUPLICATE_BATCH", "batch contains the same event id more than once");
989
- if (suppliedIds.length > 0) {
990
- const existing = await liveOwnersOf({
991
- query,
992
- sessionId,
993
- clearIndex,
994
- ids: suppliedIds
995
- });
996
- if (existing.length > 0) {
997
- const foreign = existing.find((row) => row["session_id"] !== sessionId);
998
- if (foreign) throw new A2Error("PARTIAL_DUPLICATE_BATCH", `event id '${String(foreign["event_id"])}' already exists in another session`);
999
- if (existing.length === events.length) return {
1000
- events: idempotentReplay(events, existing.map(toStored)),
1001
- hasPending: sessionHasPending
1002
- };
1003
- throw new A2Error("PARTIAL_DUPLICATE_BATCH", `batch mixes ${existing.length} already-appended and ${events.length - existing.length} fresh events`);
1004
- }
1005
- }
1881
+ const now = clock.now().getTime();
1882
+ const rows = events.map((event, offset) => ({
1883
+ ord: offset + 1,
1884
+ id: event.id ?? generateId(),
1885
+ type: event.type,
1886
+ payload: event.payload,
1887
+ cause: event.cause ?? null
1888
+ }));
1889
+ const causes = [...new Map(events.flatMap((event) => event.cause ? [[jsonb(event.cause), event.cause]] : [])).values()];
1890
+ const work = enqueue(events, consumers);
1006
1891
  const originIndex = appendOptions?.originIndex;
1007
- if (originIndex !== void 0 && originIndex <= clearIndex) throw sessionClearedError(`scheduled work from event ${originIndex} was cleared in session '${sessionId}'`);
1008
- for (const e of events) if (e.cause && e.cause.index <= clearIndex) throw supersededAttemptError(sessionId, e.cause);
1009
- const causeIndexes = [...new Set(events.filter((e) => e.cause !== void 0).map((e) => e.cause.index))];
1010
- if (causeIndexes.length > 0) {
1011
- const { rows: parents } = await query(`select idx, attempt_count, failed_at from a2_events
1012
- where session_id = $1 and idx = any($2::bigint[])
1013
- for update`, [sessionId, causeIndexes]);
1014
- const byIndex = new Map(parents.map((row) => [asNumber(row["idx"]), row]));
1015
- for (const e of events) {
1016
- if (!e.cause) continue;
1017
- const parent = byIndex.get(e.cause.index);
1018
- if (!parent) throw new TypeError(`no event at index ${e.cause.index} in session '${sessionId}'`);
1019
- if (asNumber(parent["attempt_count"]) !== e.cause.attempt || parent["failed_at"] !== null) throw supersededAttemptError(sessionId, e.cause);
1892
+ const checked = (await run({
1893
+ sessionId,
1894
+ statements: [
1895
+ LOCK_SESSION,
1896
+ APPEND_CHECKS,
1897
+ causes.length > 0 && FENCE,
1898
+ suppliedIds.length > 0 && REUSE_IDS,
1899
+ INSERT_EVENTS,
1900
+ ...work.statements
1901
+ ],
1902
+ params: {
1903
+ ...work.params,
1904
+ served,
1905
+ ids: suppliedIds,
1906
+ count: events.length,
1907
+ origin: originIndex ?? null,
1908
+ causes: causes.map((cause, offset) => ({
1909
+ ord: offset + 1,
1910
+ consumer: cause.consumer,
1911
+ cursor_key: cursorKeyOf(cause.lane, cause.index),
1912
+ idx: cause.index,
1913
+ attempt: cause.attempt
1914
+ })),
1915
+ events: rows,
1916
+ now
1917
+ },
1918
+ abort: (reason) => {
1919
+ switch (reason["abort"]) {
1920
+ case "foreign": return new A2Error("PARTIAL_DUPLICATE_BATCH", `event id '${String(reason["event_id"])}' already exists in another session`);
1921
+ case "mixed": {
1922
+ const owned = asNumber(reason["owned"]);
1923
+ return new A2Error("PARTIAL_DUPLICATE_BATCH", `batch mixes ${owned} already-appended and ${events.length - owned} fresh events`);
1924
+ }
1925
+ case "cleared": return sessionClearedError(`scheduled work from event ${originIndex} was cleared in session '${sessionId}'`);
1926
+ case "superseded": return supersededAttemptError(sessionId, causes[asNumber(reason["ord"]) - 1]);
1927
+ case "held": return new A2Error("STORE_UNAVAILABLE", `an event id being reused is still held by cleared work in session '${sessionId}'`);
1928
+ default: return new A2Error("STORE_UNAVAILABLE", "postgres store operation failed");
1929
+ }
1020
1930
  }
1021
- }
1022
- const now = clock.now().getTime();
1023
- const inserted = [];
1024
- for (let i = 0; i < events.length; i += 1) {
1025
- const e = events[i];
1026
- const id = e.id ?? generateId();
1027
- const index = base + 1 + i;
1028
- await insertEvent({
1029
- query,
1030
- row: {
1031
- sessionId,
1032
- index,
1033
- type: e.type,
1034
- payload: e.payload,
1035
- id,
1036
- createdAt: now,
1037
- cause: e.cause ?? null,
1038
- lane: e.lane ?? null,
1039
- processedAt: e.settled ? now : null
1040
- },
1041
- clearIndex
1042
- });
1043
- inserted.push({
1044
- id,
1045
- type: e.type,
1046
- payload: structuredClone(e.payload),
1047
- index,
1931
+ })).find((row) => row["stop"] === "replay" || "base" in row);
1932
+ if (checked["stop"] === "replay") return {
1933
+ events: idempotentReplay(events, checked["events"].map(toStored)),
1934
+ hasPending: checked["has_pending"] === true
1935
+ };
1936
+ return {
1937
+ events: insertedRows({
1048
1938
  sessionId,
1049
- createdAt: new Date(now),
1050
- cause: e.cause ? { ...e.cause } : null,
1051
- lane: e.lane ?? null,
1052
- processedAt: e.settled ? new Date(now) : null,
1053
- processedByAttempt: null,
1054
- returnedEventIds: null,
1055
- firstClaimedAt: null,
1056
- lastClaimedAt: null,
1057
- attemptCount: 0,
1058
- failureCount: 0,
1059
- lastFailedAt: null,
1060
- lastFailedAttempt: null,
1061
- lastError: null,
1062
- failedAt: null,
1063
- claimHolder: null,
1064
- claimExpiresAt: null
1065
- });
1939
+ events: rows,
1940
+ base: asNumber(checked["base"]),
1941
+ now
1942
+ }),
1943
+ hasPending: checked["has_pending"] === true || work.started
1944
+ };
1945
+ });
1946
+ },
1947
+ async wakeLog({ sessionId, consumer }) {
1948
+ const { log } = consumer;
1949
+ return wrap(async () => {
1950
+ const found = (await run({
1951
+ sessionId,
1952
+ statements: [
1953
+ READ_LOG,
1954
+ LOCK_SESSION,
1955
+ WAKE_LOG
1956
+ ],
1957
+ params: {
1958
+ cursorKey: cursorKeyOf(log.lane, 0),
1959
+ consumer: consumer.name,
1960
+ lane: log.lane,
1961
+ types: log.types,
1962
+ backfill: log.backfill,
1963
+ clearType: CLEAR_EVENT_TYPE
1964
+ }
1965
+ })).at(-1);
1966
+ if (found["empty"] === true) {
1967
+ const head = asNumber(found["head"]);
1968
+ return {
1969
+ cursor: idleLogCursor(consumer.name, log.lane, head),
1970
+ head
1971
+ };
1066
1972
  }
1973
+ const cursor = found["cursor"];
1974
+ const clearIndex = asNumber(found["stop"] === "unchanged" ? cursor["clear_index"] : found["clear_index"]);
1067
1975
  return {
1068
- events: inserted,
1069
- hasPending: sessionHasPending || events.some((event) => event.settled !== true)
1976
+ cursor: toCursor(cursor, clearIndex),
1977
+ head: asNumber(found["stop"] === "unchanged" ? cursor["session_head"] : found["head"])
1070
1978
  };
1071
- }));
1979
+ });
1980
+ },
1981
+ async cursors(sessionId) {
1982
+ return wrap(async () => {
1983
+ const { rows } = await (await client()).query(`with boundary as materialized (
1984
+ select ${clearIndexOf("$1")} as clear_index
1985
+ )
1986
+ select cur.*, boundary.clear_index
1987
+ from a2_cursors cur, boundary
1988
+ where cur.session_id = $1
1989
+ and (
1990
+ cur.head > boundary.clear_index
1991
+ or (cur.kind <> 'event' and not exists (
1992
+ select 1 from a2_cursors later
1993
+ where later.session_id = cur.session_id
1994
+ and later.cursor_key = cur.cursor_key
1995
+ and later.consumer = cur.consumer
1996
+ and later.epoch > cur.epoch
1997
+ ) and (
1998
+ cur.kind = 'log'
1999
+ or (cur.head is null and cur.position > boundary.clear_index)
2000
+ ))
2001
+ )`, [sessionId]);
2002
+ return rows.map((row) => toCursor(row, asNumber(row["clear_index"]))).toSorted(byCursor);
2003
+ });
1072
2004
  },
1073
2005
  async read(sessionId, opts) {
1074
2006
  return wrap(async () => {
@@ -1084,7 +2016,8 @@ function postgres(options = {}) {
1084
2016
  conditions.push(`idx <= $${params.length}`);
1085
2017
  }
1086
2018
  conditions.push(`idx >= ${clearIndexOf("$1")}`);
1087
- const { rows } = await c.query(`select * from a2_events where ${conditions.join(" and ")} order by idx`, params);
2019
+ const { rows } = await c.query(`select session_id, idx, event_type, payload, event_id, created_at, cause
2020
+ from a2_events where ${conditions.join(" and ")} order by idx`, params);
1088
2021
  return rows.map(toStored);
1089
2022
  });
1090
2023
  },
@@ -1108,79 +2041,57 @@ function postgres(options = {}) {
1108
2041
  return rows.map(toEvent);
1109
2042
  });
1110
2043
  },
1111
- async claimAvailable({ sessionId, holder, ttlMs, expiresAtMs, excludeIndexes = [], maxFailures }) {
1112
- return wrap(() => withTx(async (query) => {
2044
+ async claimAvailable({ sessionId, holder, ttlMs, expiresAtMs, exclude = [], consumers, maxFailures, through }) {
2045
+ return wrap(async () => {
1113
2046
  const now = clock.now().getTime();
1114
- const expiresAt = expiresAtMs ?? now + ttlMs;
1115
- const exhausted = `(claimed.claim_holder is not null
1116
- and claimed.failure_count + 1 >= $6::int)`;
1117
- const { rows } = await query(`with eligible as materialized (
1118
- select event.idx
1119
- from a2_events event
1120
- where event.session_id = $1
1121
- and event.processed_at is null
1122
- and event.failed_at is null
1123
- and event.lane_ready
1124
- and event.idx > ${clearIndexOf("$1")}
1125
- and (event.claim_expires_at is null or event.claim_expires_at <= $2)
1126
- and not (event.idx = any($5::bigint[]))
1127
- for update of event
1128
- )
1129
- update a2_events claimed set
1130
- attempt_count = claimed.attempt_count + 1,
1131
- failure_count = claimed.failure_count
1132
- + case when claimed.claim_holder is null then 0 else 1 end,
1133
- failed_at = case when ${exhausted} then $2::bigint else claimed.failed_at end,
1134
- first_claimed_at = case when ${exhausted}
1135
- then claimed.first_claimed_at
1136
- else coalesce(claimed.first_claimed_at, $2::bigint) end,
1137
- last_claimed_at = case when ${exhausted}
1138
- then claimed.last_claimed_at else $2::bigint end,
1139
- claim_holder = case when ${exhausted} then null else $3::text end,
1140
- claim_expires_at = case when ${exhausted} then null else $4::bigint end
1141
- from eligible
1142
- where claimed.session_id = $1 and claimed.idx = eligible.idx
1143
- returning claimed.*, ${clearIndexOf("$1")} as clear_index`, [
1144
- sessionId,
1145
- now,
1146
- holder,
1147
- expiresAt,
1148
- [...excludeIndexes],
1149
- maxFailures ?? null
1150
- ]);
1151
- for (const row of rows) if (row["failed_at"] !== null) await releasePins({
1152
- query,
2047
+ const results = await run({
1153
2048
  sessionId,
1154
- index: asNumber(row["idx"])
2049
+ statements: [
2050
+ CLAIM,
2051
+ RELEASE_DEAD,
2052
+ RETRY_AT
2053
+ ],
2054
+ params: {
2055
+ now,
2056
+ served: servedNames(consumers),
2057
+ excluded: exclude.map((ref) => ({
2058
+ consumer: ref.consumer,
2059
+ cursor_key: cursorKeyOf(ref.lane, ref.index)
2060
+ })),
2061
+ holder,
2062
+ expiresAt: expiresAtMs ?? now + ttlMs,
2063
+ maxFailures: maxFailures ?? null,
2064
+ through: through ?? null
2065
+ }
1155
2066
  });
1156
- if (rows.length > 0) return {
1157
- outcome: "claimed",
1158
- events: rows.map((row) => {
1159
- const stored = toStored(row);
2067
+ const rows = results.filter((row) => "e_idx" in row);
2068
+ const unseen = rows.filter((row) => row["e_idx"] === null);
2069
+ if (unseen.length > 0) {
2070
+ const { rows: events } = await (await client()).query(`select ${HEAD_COLUMNS} from a2_events event
2071
+ where event.session_id = $1 and event.idx = any($2::bigint[])`, [sessionId, unseen.map((row) => row["head"])]);
2072
+ const byIndex = new Map(events.map((event) => [asNumber(event["e_idx"]), event]));
2073
+ for (const row of unseen) Object.assign(row, byIndex.get(asNumber(row["head"])));
2074
+ }
2075
+ const heads = rows.filter((row) => row["e_idx"] !== null);
2076
+ if (heads.length > 0) {
2077
+ const events = heads.map((row) => {
2078
+ const head = toHead(row);
1160
2079
  const clearIndex = asNumber(row["clear_index"]);
1161
- if (clearIndex > 0) stored.clearIndex = clearIndex;
1162
- return stored;
1163
- }).toSorted((a, b) => a.index - b.index)
1164
- };
1165
- const { rows: activeRows } = await query(`select min(claim_expires_at) as retry_at
1166
- from a2_events
1167
- where session_id = $1
1168
- and processed_at is null
1169
- and failed_at is null
1170
- and lane_ready
1171
- and idx > ${clearIndexOf("$1")}
1172
- and claim_expires_at > $2
1173
- and not (idx = any($3::bigint[]))`, [
1174
- sessionId,
1175
- now,
1176
- [...excludeIndexes]
1177
- ]);
1178
- const retryAt = activeRows[0]?.["retry_at"];
2080
+ if (clearIndex > 0) head.clearIndex = clearIndex;
2081
+ return head;
2082
+ }).toSorted(byHead);
2083
+ return {
2084
+ outcome: "claimed",
2085
+ events,
2086
+ head: Math.max(...heads.map((row) => asNumber(row["session_head"])), ...events.map((event) => event.index))
2087
+ };
2088
+ }
2089
+ const retryAt = results.at(-1)["retry_at"];
1179
2090
  return retryAt === null || retryAt === void 0 ? { outcome: "settled" } : {
1180
2091
  outcome: "busy",
1181
2092
  retryAt: new Date(asNumber(retryAt))
1182
2093
  };
1183
- }));
2094
+ });
1184
2095
  },
1185
2096
  async renewClaims({ sessionId, holder, claims, ttlMs, expiresAtMs }) {
1186
2097
  if (claims.length === 0) return {
@@ -1191,233 +2102,203 @@ function postgres(options = {}) {
1191
2102
  const c = await client();
1192
2103
  const now = clock.now().getTime();
1193
2104
  const expiresAt = expiresAtMs ?? now + ttlMs;
1194
- const indexes = claims.map((claim) => claim.index);
1195
- const attempts = new Map(claims.map((claim) => [claim.index, claim.attempt]));
2105
+ const ordered = claims.toSorted((a, b) => a.index - b.index || compareText(a.consumer, b.consumer));
1196
2106
  const { rows } = await c.query(`with listed as (
1197
- select idx, attempt
1198
- from unnest($3::bigint[], $6::int[]) as listed(idx, attempt)
1199
- ),
1200
- renewed as (
1201
- update a2_events event set claim_expires_at = $1
2107
+ select * from unnest($3::text[], $4::text[], $5::bigint[], $6::int[])
2108
+ with ordinality as listed(consumer, cursor_key, idx, attempt, ord)
2109
+ ), renewed as (
2110
+ update a2_cursors cur set claim_expires_at = $7
1202
2111
  from listed
1203
- where event.session_id = $2
1204
- and event.idx = listed.idx
1205
- and event.idx > ${clearIndexOf("$2")}
1206
- and event.attempt_count = listed.attempt
1207
- and event.processed_at is null
1208
- and event.failed_at is null
1209
- and event.claim_holder = $4
1210
- and event.claim_expires_at > $5
1211
- returning event.idx
2112
+ where cur.session_id = $1
2113
+ and cur.cursor_key = listed.cursor_key
2114
+ and cur.consumer = listed.consumer
2115
+ and cur.head = listed.idx
2116
+ and listed.idx > ${clearIndexOf("$1")}
2117
+ and cur.attempt_count = listed.attempt
2118
+ and cur.failed_at is null
2119
+ and cur.claim_holder = $2
2120
+ and cur.claim_expires_at > $8
2121
+ returning cur.cursor_key, cur.consumer, cur.head
1212
2122
  )
1213
- select listed.idx, event.attempt_count,
1214
- exists(select 1 from renewed where renewed.idx = listed.idx)
1215
- as renewed,
1216
- listed.idx <= ${clearIndexOf("$2")} as cleared
2123
+ select listed.ord, latest.head, latest.position, latest.attempt_count,
2124
+ latest.epoch is not null as found,
2125
+ exists(
2126
+ select 1 from renewed
2127
+ where renewed.cursor_key = listed.cursor_key
2128
+ and renewed.consumer = listed.consumer
2129
+ and renewed.head = listed.idx
2130
+ ) as renewed,
2131
+ listed.idx <= ${clearIndexOf("$1")} as cleared
1217
2132
  from listed
1218
- left join a2_events event
1219
- on event.session_id = $2 and event.idx = listed.idx`, [
1220
- expiresAt,
2133
+ left join lateral (
2134
+ select cur.epoch, cur.head, cur.position, cur.attempt_count
2135
+ from a2_cursors cur
2136
+ where cur.session_id = $1
2137
+ and cur.cursor_key = listed.cursor_key
2138
+ and cur.consumer = listed.consumer
2139
+ order by cur.epoch desc limit 1
2140
+ ) latest on true
2141
+ order by listed.ord`, [
1221
2142
  sessionId,
1222
- indexes,
1223
2143
  holder,
1224
- now,
1225
- claims.map((claim) => claim.attempt)
2144
+ ordered.map((claim) => claim.consumer),
2145
+ ordered.map((claim) => cursorKeyOf(claim.lane, claim.index)),
2146
+ ordered.map((claim) => claim.index),
2147
+ ordered.map((claim) => claim.attempt),
2148
+ expiresAt,
2149
+ now
1226
2150
  ]);
1227
2151
  const renewed = [];
1228
2152
  const superseded = [];
1229
2153
  for (const row of rows) {
1230
- const index = asNumber(row["idx"]);
1231
- if (row["renewed"] === true) renewed.push(index);
1232
- else if (row["cleared"] === true || row["attempt_count"] !== null && asNumber(row["attempt_count"]) > (attempts.get(index) ?? 0)) superseded.push(index);
2154
+ const { attempt, ...ref } = ordered[asNumber(row["ord"]) - 1];
2155
+ if (row["renewed"] === true) {
2156
+ renewed.push(ref);
2157
+ continue;
2158
+ }
2159
+ if (row["cleared"] === true) {
2160
+ superseded.push(ref);
2161
+ continue;
2162
+ }
2163
+ if (row["found"] !== true) continue;
2164
+ const head = asNullableNumber(row["head"]);
2165
+ if (head !== ref.index && asNumber(row["position"]) < ref.index) continue;
2166
+ if (head !== ref.index || asNumber(row["attempt_count"]) > attempt) superseded.push(ref);
1233
2167
  }
1234
2168
  return {
1235
- renewed: renewed.toSorted((a, b) => a - b),
1236
- superseded: superseded.toSorted((a, b) => a - b)
2169
+ renewed,
2170
+ superseded
1237
2171
  };
1238
2172
  });
1239
2173
  },
1240
- async completeAttempt({ sessionId, index, attempt, events }) {
1241
- return wrap(() => withTx(async (query) => {
1242
- const { rows: parentRows } = await query(`select boundary.clear_index, parent.*
1243
- from (select ${clearIndexOf("$1")} as clear_index) boundary
1244
- left join lateral (
1245
- select * from a2_events
1246
- where session_id = $1 and idx = $2 and idx > boundary.clear_index
1247
- for update
1248
- ) parent on true`, [sessionId, index]);
1249
- if (index <= asNumber(parentRows[0]?.["clear_index"])) return { outcome: "superseded" };
1250
- const parent = parentRows[0]?.["idx"] == null ? void 0 : parentRows[0];
1251
- if (!parent) {
1252
- if (await clearedAt({
1253
- query,
1254
- sessionId,
1255
- index
1256
- })) return { outcome: "superseded" };
1257
- throw new TypeError(`no event at index ${index} in session '${sessionId}'`);
1258
- }
2174
+ async completeAttempt({ sessionId, consumer, lane, index, attempt, events, consumers }) {
2175
+ return wrap(async () => {
1259
2176
  const ids = events.map((event) => event.id);
1260
- if (new Set(ids).size !== ids.length) throw new A2Error("PARTIAL_DUPLICATE_BATCH", "returned event batch contains the same event id more than once");
1261
- if (parent["processed_at"] !== null) {
1262
- if (asNumber(parent["processed_by_attempt"]) !== attempt) return { outcome: "superseded" };
1263
- const returnedIds = parent["returned_event_ids"] === null || parent["returned_event_ids"] === void 0 ? null : decodeReturnedEventIds(parent["returned_event_ids"]);
1264
- 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");
1265
- if (events.length === 0) return {
1266
- outcome: "completed",
1267
- events: []
1268
- };
1269
- const { rows: existing } = await query(`select * from a2_events
1270
- where event_id = any($1::text[])`, [ids]);
1271
- const byId = new Map(existing.map((row) => [row["event_id"], row]));
1272
- const ordered = ids.map((id) => byId.get(id));
1273
- if (ordered.some((row) => !row) && await clearedAt({
1274
- query,
1275
- sessionId,
1276
- index
1277
- })) return { outcome: "superseded" };
1278
- if (ordered.some((row) => {
1279
- const cause = row ? toCause(row["cause"]) : null;
1280
- return !row || row["session_id"] !== sessionId || cause?.index !== index || cause.attempt !== attempt;
1281
- })) throw new A2Error("PARTIAL_DUPLICATE_BATCH", "returned event retry does not match the committed batch");
1282
- return {
1283
- outcome: "completed",
1284
- events: idempotentReplay(events, ordered.map(toStored))
1285
- };
1286
- }
1287
- if (asNumber(parent["attempt_count"]) !== attempt || parent["claim_holder"] === null || parent["claim_holder"] === void 0 || parent["failed_at"] !== null) return { outcome: "superseded" };
1288
- await query("select pg_advisory_xact_lock(hashtext($1))", [sessionId]);
1289
- const { rows: maxRows } = await query(`select coalesce(max(idx), 0) as max, ${clearIndexOf("$1")} as clear_index
1290
- from a2_events where session_id = $1`, [sessionId]);
1291
- const clearIndex = asNumber(maxRows[0]?.["clear_index"]);
1292
- if (index <= clearIndex) return { outcome: "superseded" };
1293
- if (ids.length > 0) {
1294
- if ((await liveOwnersOf({
1295
- query,
1296
- sessionId,
1297
- clearIndex,
1298
- ids
1299
- })).length > 0) throw new A2Error("PARTIAL_DUPLICATE_BATCH", "returned event batch contains an already-appended event id");
1300
- }
1301
- const base = asNumber(maxRows[0]?.["max"]);
2177
+ const duplicated = new Set(ids).size !== ids.length;
2178
+ const cause = {
2179
+ consumer,
2180
+ lane,
2181
+ index,
2182
+ attempt
2183
+ };
1302
2184
  const now = clock.now().getTime();
1303
- const inserted = [];
1304
- for (let offset = 0; offset < events.length; offset += 1) {
1305
- const event = events[offset];
1306
- const childIndex = base + offset + 1;
1307
- const row = await insertEvent({
1308
- query,
1309
- row: {
1310
- sessionId,
1311
- index: childIndex,
1312
- type: event.type,
1313
- payload: event.payload,
1314
- id: event.id,
1315
- createdAt: now,
1316
- cause: {
1317
- index,
1318
- attempt
1319
- },
1320
- lane: event.lane ?? null,
1321
- processedAt: event.settled ? now : null
1322
- },
1323
- clearIndex,
1324
- returning: true
2185
+ const rows = events.map((event, offset) => ({
2186
+ ord: offset + 1,
2187
+ id: event.id,
2188
+ type: event.type,
2189
+ payload: event.payload,
2190
+ cause
2191
+ }));
2192
+ const work = events.length > 0 && !duplicated ? enqueue(events, consumers) : void 0;
2193
+ const results = await run({
2194
+ sessionId,
2195
+ statements: duplicated ? [HOLD] : work ? [
2196
+ HOLD,
2197
+ LOCK_SESSION,
2198
+ RETURN_CHECKS,
2199
+ REUSE_IDS,
2200
+ INSERT_EVENTS,
2201
+ ...work.statements,
2202
+ COMPLETE_APPENDED
2203
+ ] : [
2204
+ HOLD,
2205
+ LOCK_IF_IDLE,
2206
+ COMPLETE
2207
+ ],
2208
+ params: {
2209
+ ...work?.params,
2210
+ ...retypeOf(consumer, consumers),
2211
+ cursorKey: cursorKeyOf(lane, index),
2212
+ consumer,
2213
+ index,
2214
+ attempt,
2215
+ ids,
2216
+ count: events.length,
2217
+ events: rows,
2218
+ now,
2219
+ clearType: CLEAR_EVENT_TYPE
2220
+ },
2221
+ 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")
2222
+ });
2223
+ const held = results[0];
2224
+ if (held["stop"] === "superseded") return { outcome: "superseded" };
2225
+ if (held["stop"] === "unheld") {
2226
+ const c = await client();
2227
+ return settleUnheld({
2228
+ query: (text, values) => c.query(text, values),
2229
+ sessionId,
2230
+ consumer,
2231
+ lane,
2232
+ index,
2233
+ attempt,
2234
+ events
1325
2235
  });
1326
- inserted.push(toStored(row));
1327
2236
  }
1328
- await query(`update a2_events set
1329
- processed_at = $1,
1330
- processed_by_attempt = $2,
1331
- returned_event_ids = $5::jsonb,
1332
- lane_ready = false,
1333
- claim_holder = null,
1334
- claim_expires_at = null
1335
- where session_id = $3 and idx = $4`, [
1336
- now,
1337
- attempt,
1338
- sessionId,
1339
- index,
1340
- jsonb(ids)
1341
- ]);
1342
- await query(`delete from a2_snapshot_pins
1343
- where session_id = $1 and event_index = $2`, [sessionId, index]);
1344
- await query(`delete from a2_snapshot_history history
1345
- where history.session_id = $1
1346
- and not exists (
1347
- select 1 from a2_snapshot_pins pin
1348
- where pin.session_id = history.session_id
1349
- and pin.reducer_name = history.reducer_name
1350
- and pin.up_to_index = history.up_to_index
1351
- )`, [sessionId]);
1352
- const parentLane = parent["lane"] ?? null;
1353
- if (parentLane !== null) await query(`update a2_events set lane_ready = true
1354
- where session_id = $1
1355
- and idx = (
1356
- select idx from a2_events
1357
- where session_id = $1 and lane = $2 and processed_at is null
1358
- and idx > $3
1359
- order by idx limit 1
1360
- )`, [
1361
- sessionId,
1362
- parentLane,
1363
- clearIndex
1364
- ]);
2237
+ if (duplicated) throw new A2Error("PARTIAL_DUPLICATE_BATCH", "returned event batch contains the same event id more than once");
2238
+ if (results.at(-1)["stop"] === "superseded") return { outcome: "superseded" };
2239
+ const checked = results.find((row) => "base" in row);
1365
2240
  return {
1366
2241
  outcome: "completed",
1367
- events: inserted
2242
+ events: checked ? insertedRows({
2243
+ sessionId,
2244
+ events: rows,
2245
+ base: asNumber(checked["base"]),
2246
+ now
2247
+ }) : []
1368
2248
  };
1369
- }));
2249
+ });
1370
2250
  },
1371
- async failAttempt({ sessionId, index, attempt, error, maxFailures, retryAfterMs }) {
1372
- const now = clock.now().getTime();
2251
+ async failAttempt({ sessionId, consumer, lane, index, attempt, error, maxFailures, retryAfterMs }) {
1373
2252
  return wrap(() => withTx(async (query) => {
2253
+ const now = clock.now().getTime();
2254
+ const cursorKey = cursorKeyOf(lane, index);
1374
2255
  const { rows } = await query(`with current as materialized (
1375
- select e.* from a2_events e
1376
- where e.session_id = $1 and e.idx = $2
1377
- and e.idx > ${clearIndexOf("$1")}
1378
- for update of e
2256
+ select cur.* from a2_cursors cur
2257
+ where cur.session_id = $1 and cur.cursor_key = $2
2258
+ and cur.consumer = $3 and cur.head = $4
2259
+ and cur.head > ${clearIndexOf("$1")}
2260
+ for update of cur
1379
2261
  ), updated as (
1380
- update a2_events e set
1381
- failure_count = e.failure_count + 1,
1382
- last_error = $4,
1383
- last_failed_at = $6,
1384
- last_failed_attempt = $3,
2262
+ update a2_cursors cur set
2263
+ failure_count = cur.failure_count + 1,
2264
+ last_error = $6,
2265
+ last_failed_at = $8,
2266
+ last_failed_attempt = $5,
1385
2267
  failed_at = case
1386
- when e.failure_count + 1 >= $5 then $6
1387
- else e.failed_at
2268
+ when cur.failure_count + 1 >= $7 then $8
2269
+ else cur.failed_at
1388
2270
  end,
1389
2271
  claim_holder = null,
1390
2272
  claim_expires_at = case
1391
- when e.failure_count + 1 >= $5 then null
1392
- else $7::bigint
2273
+ when cur.failure_count + 1 >= $7 then null
2274
+ else $9::bigint
1393
2275
  end
1394
2276
  from current
1395
- where e.session_id = $1
1396
- and e.idx = $2
1397
- and current.processed_at is null
2277
+ where cur.session_id = $1 and cur.cursor_key = $2
2278
+ and cur.consumer = $3 and cur.epoch = current.epoch
2279
+ and current.attempt_count = $5
1398
2280
  and current.failed_at is null
1399
- and current.attempt_count = $3
1400
2281
  and current.claim_holder is not null
1401
- returning e.failure_count, e.failed_at
2282
+ returning cur.failure_count, cur.failed_at
1402
2283
  )
1403
2284
  select case
1404
- when $2 <= ${clearIndexOf("$1")} then 'cleared'
2285
+ when $4 <= ${clearIndexOf("$1")} then 'cleared'
1405
2286
  when not exists (select 1 from current) then 'missing'
1406
2287
  when exists (
1407
2288
  select 1 from updated where failed_at is not null
1408
2289
  ) then 'dead_lettered'
1409
2290
  when exists (select 1 from updated) then 'failed'
1410
2291
  when exists (
1411
- select 1 from current
1412
- where processed_at is not null or attempt_count <> $3
2292
+ select 1 from current where attempt_count <> $5
1413
2293
  ) then 'superseded'
1414
- when exists (
1415
- select 1 from current where failed_at is not null
1416
- ) then 'dead_lettered'
1417
2294
  when exists (
1418
2295
  select 1 from current
1419
- where claim_holder is null and last_failed_attempt = $3
2296
+ where claim_holder is null and last_failed_attempt = $5
2297
+ and failed_at is null
1420
2298
  ) then 'failed'
2299
+ when exists (
2300
+ select 1 from current where failed_at is not null
2301
+ ) then 'dead_lettered'
1421
2302
  else 'superseded'
1422
2303
  end as outcome,
1423
2304
  coalesce(
@@ -1425,6 +2306,8 @@ function postgres(options = {}) {
1425
2306
  (select failure_count from current)
1426
2307
  ) as failure_count`, [
1427
2308
  sessionId,
2309
+ cursorKey,
2310
+ consumer,
1428
2311
  index,
1429
2312
  attempt,
1430
2313
  error,
@@ -1438,7 +2321,13 @@ function postgres(options = {}) {
1438
2321
  failureCount: 0
1439
2322
  };
1440
2323
  if (!row || row["outcome"] === "missing") {
1441
- if (await clearedAt({
2324
+ const { rows: cursorRows } = await query(`select 1 from a2_cursors
2325
+ where session_id = $1 and cursor_key = $2 and consumer = $3`, [
2326
+ sessionId,
2327
+ cursorKey,
2328
+ consumer
2329
+ ]);
2330
+ if (cursorRows.length > 0 || await hasEvent(query, sessionId, index) || await clearedAt({
1442
2331
  query,
1443
2332
  sessionId,
1444
2333
  index
@@ -1451,6 +2340,8 @@ function postgres(options = {}) {
1451
2340
  if (row["outcome"] === "dead_lettered") await releasePins({
1452
2341
  query,
1453
2342
  sessionId,
2343
+ consumer,
2344
+ cursorKey,
1454
2345
  index
1455
2346
  });
1456
2347
  return {
@@ -1461,54 +2352,39 @@ function postgres(options = {}) {
1461
2352
  },
1462
2353
  async deadLetters(sessionId) {
1463
2354
  return wrap(async () => {
1464
- const { rows } = await (await client()).query(`select * from a2_events
1465
- where session_id = $1
1466
- and processed_at is null
1467
- and failed_at is not null
1468
- and idx > ${clearIndexOf("$1")}
1469
- order by idx`, [sessionId]);
1470
- return rows.map(toStored);
2355
+ const { rows } = await (await client()).query(`select cur.*, ${HEAD_COLUMNS}
2356
+ from a2_cursors cur
2357
+ join a2_events event
2358
+ on event.session_id = cur.session_id and event.idx = cur.head
2359
+ where cur.session_id = $1
2360
+ and cur.failed_at is not null
2361
+ and cur.head > ${clearIndexOf("$1")}`, [sessionId]);
2362
+ return rows.map(toHead).toSorted(byHead);
1471
2363
  });
1472
2364
  },
1473
- async redrive({ sessionId, index, action }) {
1474
- return wrap(() => withTx(async (query) => {
1475
- if (action === "skip") await query("select pg_advisory_xact_lock(hashtext($1))", [sessionId]);
1476
- const { rows } = await query(`select lane from a2_events
1477
- where session_id = $1 and idx = $2
1478
- and idx > ${clearIndexOf("$1")}
1479
- and processed_at is null
1480
- and failed_at is not null
1481
- for update`, [sessionId, index]);
1482
- const row = rows[0];
1483
- if (row && action === "retry") await query(`update a2_events set failed_at = null, failure_count = 0
1484
- where session_id = $1 and idx = $2`, [sessionId, index]);
1485
- else if (row) {
1486
- await query(`update a2_events set processed_at = $3, lane_ready = false
1487
- where session_id = $1 and idx = $2`, [
1488
- sessionId,
2365
+ async redrive({ sessionId, consumer, index, action, consumers }) {
2366
+ return wrap(async () => {
2367
+ const results = await run({
2368
+ sessionId,
2369
+ statements: [
2370
+ action === "skip" && LOCK_SESSION,
2371
+ LOCK_DEAD,
2372
+ action === "skip" ? SKIP_DEAD : RETRY_DEAD,
2373
+ PENDING
2374
+ ],
2375
+ params: {
2376
+ ...retypeOf(consumer, consumers),
2377
+ consumer,
1489
2378
  index,
1490
- clock.now().getTime()
1491
- ]);
1492
- const lane = row["lane"] ?? null;
1493
- if (lane !== null) await query(`update a2_events set lane_ready = true
1494
- where session_id = $1
1495
- and idx = (
1496
- select idx from a2_events
1497
- where session_id = $1 and lane = $2 and processed_at is null
1498
- and idx > ${clearIndexOf("$1")}
1499
- order by idx limit 1
1500
- )`, [sessionId, lane]);
1501
- }
1502
- const { rows: status } = await query(`select exists(
1503
- select 1 from a2_events
1504
- where session_id = $1 and processed_at is null
1505
- and idx > ${clearIndexOf("$1")}
1506
- ) as has_pending`, [sessionId]);
2379
+ served: servedNames(consumers),
2380
+ clearType: CLEAR_EVENT_TYPE
2381
+ }
2382
+ });
1507
2383
  return {
1508
- redriven: row !== void 0,
1509
- hasPending: status[0]?.["has_pending"] === true
2384
+ redriven: results.find((row) => "redriven" in row)?.["redriven"] === true,
2385
+ hasPending: results.at(-1)["has_pending"] === true
1510
2386
  };
1511
- }));
2387
+ });
1512
2388
  },
1513
2389
  async readState(sessionId, reducerName, stateOptions) {
1514
2390
  return wrap(async () => (await readStateBatch([{
@@ -1536,25 +2412,41 @@ function postgres(options = {}) {
1536
2412
  const normalized = requested.filter((write) => write.index >= clearIndex);
1537
2413
  if (normalized.length === 0) return;
1538
2414
  const currentHead = headRows[0]?.["up_to_index"] == null ? void 0 : headRows[0];
1539
- const requestedPins = normalized.flatMap((write) => (write.pinEventIndexes ?? []).map((eventIndex) => ({
1540
- eventIndex,
2415
+ const requestedPins = normalized.flatMap((write) => (write.pins ?? []).map((pin) => ({
2416
+ eventIndex: pin.index,
2417
+ consumer: pin.consumer,
2418
+ cursorKey: cursorKeyOf(pin.lane, pin.index),
1541
2419
  snapshotIndex: write.index
1542
2420
  })));
1543
2421
  if (requestedPins.length > 0) {
1544
- const { rows: events } = await query(`select idx, processed_at, failed_at from a2_events
1545
- where session_id = $1 and idx = any($2::bigint[]) and idx > $3
1546
- for update`, [
2422
+ const heads = [...new Map(requestedPins.map((pin) => [`${pin.consumer}\u0000${pin.cursorKey}\u0000${pin.eventIndex}`, pin])).values()];
2423
+ const { rows: live } = await query(`select cur.consumer, cur.cursor_key, cur.head
2424
+ from a2_cursors cur
2425
+ join unnest($2::text[], $3::text[], $4::bigint[])
2426
+ as pin(consumer, cursor_key, idx)
2427
+ on cur.consumer = pin.consumer
2428
+ and cur.cursor_key = pin.cursor_key
2429
+ and cur.head = pin.idx
2430
+ where cur.session_id = $1 and cur.head > $5
2431
+ and cur.failed_at is null
2432
+ order by cur.cursor_key, cur.consumer
2433
+ for share of cur`, [
1547
2434
  sessionId,
1548
- [...new Set(requestedPins.map((pin) => pin.eventIndex))],
2435
+ heads.map((pin) => pin.consumer),
2436
+ heads.map((pin) => pin.cursorKey),
2437
+ heads.map((pin) => pin.eventIndex),
1549
2438
  clearIndex
1550
2439
  ]);
1551
- const unfinished = new Set(events.filter((row) => row["processed_at"] === null && row["failed_at"] === null).map((row) => asNumber(row["idx"])));
1552
- const accepted = requestedPins.filter((pin) => unfinished.has(pin.eventIndex));
2440
+ const unfinished = new Set(live.map((row) => `${String(row["consumer"])}\u0000${String(row["cursor_key"])}\u0000${asNumber(row["head"])}`));
2441
+ const accepted = requestedPins.filter((pin) => unfinished.has(`${pin.consumer}\u0000${pin.cursorKey}\u0000${pin.eventIndex}`));
1553
2442
  if (accepted.length > 0) await query(`insert into a2_snapshot_pins
1554
- (session_id, event_index, reducer_name, up_to_index)
1555
- select $1, pin."eventIndex", $2, pin."snapshotIndex"
2443
+ (session_id, event_index, consumer, cursor_key, reducer_name, up_to_index)
2444
+ select $1, pin."eventIndex", pin.consumer, pin."cursorKey",
2445
+ $2, pin."snapshotIndex"
1556
2446
  from jsonb_to_recordset($3::jsonb) as pin(
1557
2447
  "eventIndex" bigint,
2448
+ consumer text,
2449
+ "cursorKey" text,
1558
2450
  "snapshotIndex" bigint
1559
2451
  )
1560
2452
  on conflict do nothing`, [
@@ -1725,21 +2617,19 @@ function postgres(options = {}) {
1725
2617
  },
1726
2618
  async clear({ sessionId, ifLastIndex, cause }) {
1727
2619
  return wrap(() => withTx(async (query) => {
1728
- await query("select pg_advisory_xact_lock(hashtext($1))", [sessionId]);
2620
+ await lockSession(query, sessionId);
1729
2621
  const { rows: headRows } = await query(`select coalesce(max(idx), 0) as head,
1730
2622
  ${clearIndexOf("$1")} as clear_index
1731
2623
  from a2_events where session_id = $1`, [sessionId]);
1732
2624
  const head = asNumber(headRows[0]?.["head"]);
1733
2625
  const clearIndex = asNumber(headRows[0]?.["clear_index"]);
1734
- if (cause) {
1735
- if (cause.index <= clearIndex) throw supersededAttemptError(sessionId, cause);
1736
- const { rows: parents } = await query(`select attempt_count, failed_at from a2_events
1737
- where session_id = $1 and idx = $2
1738
- for update`, [sessionId, cause.index]);
1739
- const parent = parents[0];
1740
- if (!parent) throw new TypeError(`no event at index ${cause.index} in session '${sessionId}'`);
1741
- if (asNumber(parent["attempt_count"]) !== cause.attempt || parent["failed_at"] !== null) throw supersededAttemptError(sessionId, cause);
1742
- }
2626
+ if (cause) await assertCurrentCause({
2627
+ query,
2628
+ sessionId,
2629
+ cause,
2630
+ clearIndex,
2631
+ lock: "update"
2632
+ });
1743
2633
  if (ifLastIndex !== void 0 && clearIndex === ifLastIndex + 1) return {
1744
2634
  cleared: true,
1745
2635
  index: head,
@@ -1756,31 +2646,27 @@ function postgres(options = {}) {
1756
2646
  clearIndex
1757
2647
  };
1758
2648
  const marker = head + 1;
1759
- const now = clock.now().getTime();
1760
- await insertEvent({
1761
- query,
1762
- row: {
1763
- sessionId,
1764
- index: marker,
1765
- type: CLEAR_EVENT_TYPE,
1766
- payload: null,
1767
- id: generateId(),
1768
- createdAt: now,
1769
- cause: null,
1770
- lane: null,
1771
- processedAt: now
1772
- },
1773
- clearIndex
1774
- });
2649
+ await query(`insert into a2_events
2650
+ (session_id, idx, event_type, payload, event_id, created_at, cause)
2651
+ values ($1, $2, $3, 'null'::jsonb, $4, $5, null)`, [
2652
+ sessionId,
2653
+ marker,
2654
+ CLEAR_EVENT_TYPE,
2655
+ generateId(),
2656
+ clock.now().getTime()
2657
+ ]);
1775
2658
  await query(`with released as (
1776
- update a2_events
1777
- set lane_ready = false, claim_holder = null, claim_expires_at = null
1778
- where session_id = $1 and idx = $3
2659
+ update a2_cursors
2660
+ set head = null, ${FRESH_DISPATCH}
2661
+ where session_id = $1 and cursor_key = $3 and consumer = $4
2662
+ and head = $5
1779
2663
  )
1780
2664
  insert into a2_clears (session_id, clear_index) values ($1, $2)
1781
2665
  on conflict (session_id) do update set clear_index = excluded.clear_index`, [
1782
2666
  sessionId,
1783
2667
  marker,
2668
+ cause ? cursorKeyOf(cause.lane, cause.index) : null,
2669
+ cause?.consumer ?? null,
1784
2670
  cause?.index ?? null
1785
2671
  ]);
1786
2672
  return {
@@ -1808,6 +2694,26 @@ function postgres(options = {}) {
1808
2694
  ), pins as (
1809
2695
  delete from a2_snapshot_pins pin using deleted
1810
2696
  where pin.session_id = $1 and pin.event_index = deleted.idx
2697
+ ), lanes as materialized (
2698
+ -- Each lane's queue through the batch, by the lane's own key.
2699
+ select lane.ctid from a2_cursors cur
2700
+ join a2_lane_events lane
2701
+ on lane.session_id = cur.session_id
2702
+ and lane.consumer = cur.consumer and lane.lane = cur.lane
2703
+ where cur.session_id = $1 and cur.kind = 'lane'
2704
+ and lane.idx <= (select max(idx) from batch)
2705
+ for update of lane skip locked
2706
+ ), gone_lanes as (
2707
+ delete from a2_lane_events target using lanes
2708
+ where target.ctid = lanes.ctid
2709
+ ), singles as materialized (
2710
+ select cur.ctid from a2_cursors cur
2711
+ join batch on cur.cursor_key = '#' || batch.idx
2712
+ where cur.session_id = $1 and cur.kind = 'event'
2713
+ for update of cur skip locked
2714
+ ), gone_singles as (
2715
+ delete from a2_cursors target using singles
2716
+ where target.ctid = singles.ctid
1811
2717
  )
1812
2718
  select (select count(*) from deleted) as deleted,
1813
2719
  (select clear_index from boundary) as clear_index`, [sessionId, RECLAIM_BATCH]);
@@ -1817,16 +2723,20 @@ function postgres(options = {}) {
1817
2723
  deleted: 0,
1818
2724
  done: true
1819
2725
  };
1820
- if (deleted > 0) return {
1821
- deleted,
1822
- done: false
1823
- };
1824
2726
  const { rows: remaining } = await query("select 1 from a2_events where session_id = $1 and idx < $2 limit 1", [sessionId, clearIndex]);
1825
2727
  if (remaining.length > 0) return {
1826
- deleted: 0,
2728
+ deleted,
1827
2729
  done: false
1828
2730
  };
1829
- const { rows: swept } = await query(`with pins as materialized (
2731
+ const { rows: swept } = await query(`with lanes as materialized (
2732
+ select ctid from a2_lane_events
2733
+ where session_id = $1 and idx < $2
2734
+ for update skip locked
2735
+ ), cursors as materialized (
2736
+ select cur.ctid from a2_cursors cur
2737
+ where ${SUPERSEDED_CURSOR}
2738
+ for update skip locked
2739
+ ), pins as materialized (
1830
2740
  select ctid from a2_snapshot_pins
1831
2741
  where session_id = $1 and up_to_index < $2
1832
2742
  for update skip locked
@@ -1838,6 +2748,12 @@ function postgres(options = {}) {
1838
2748
  select ctid from a2_snapshots
1839
2749
  where session_id = $1 and up_to_index < $2
1840
2750
  for update skip locked
2751
+ ), gone_lanes as (
2752
+ delete from a2_lane_events target using lanes
2753
+ where target.ctid = lanes.ctid
2754
+ ), gone_cursors as (
2755
+ delete from a2_cursors target using cursors
2756
+ where target.ctid = cursors.ctid
1841
2757
  ), gone_pins as (
1842
2758
  delete from a2_snapshot_pins target using pins where target.ctid = pins.ctid
1843
2759
  ), gone_history as (
@@ -1846,17 +2762,23 @@ function postgres(options = {}) {
1846
2762
  ), gone_heads as (
1847
2763
  delete from a2_snapshots target using heads where target.ctid = heads.ctid
1848
2764
  )
1849
- select (select count(*) from a2_snapshot_pins
2765
+ select (select count(*) from a2_lane_events
2766
+ where session_id = $1 and idx < $2)
2767
+ + (select count(*) from a2_cursors cur
2768
+ where ${SUPERSEDED_CURSOR})
2769
+ + (select count(*) from a2_snapshot_pins
1850
2770
  where session_id = $1 and up_to_index < $2)
1851
2771
  + (select count(*) from a2_snapshot_history
1852
2772
  where session_id = $1 and up_to_index < $2)
1853
2773
  + (select count(*) from a2_snapshots
1854
2774
  where session_id = $1 and up_to_index < $2)
2775
+ - (select count(*) from lanes)
2776
+ - (select count(*) from cursors)
1855
2777
  - (select count(*) from pins)
1856
2778
  - (select count(*) from history)
1857
2779
  - (select count(*) from heads) as skipped`, [sessionId, clearIndex]);
1858
2780
  return {
1859
- deleted: 0,
2781
+ deleted,
1860
2782
  done: asNumber(swept[0]?.["skipped"]) === 0
1861
2783
  };
1862
2784
  }));
@@ -1867,8 +2789,8 @@ function postgres(options = {}) {
1867
2789
  for (const subscription of presenceSubscriptions.values()) subscription.stop();
1868
2790
  presenceSubscriptions.clear();
1869
2791
  await notifications?.close();
1870
- const lease = await connection.peek()?.catch(() => void 0);
1871
- await (injected ?? lease)?.release();
2792
+ const opened = await connection.peek()?.catch(() => void 0);
2793
+ await (injected ?? opened?.lease)?.release();
1872
2794
  }
1873
2795
  };
1874
2796
  if (notifications) {