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
@@ -18,16 +18,26 @@ import { DatabaseSync } from 'node:sqlite'
18
18
  import { A2Error, supersededAttemptError } from './errors.ts'
19
19
  import { CLEAR_EVENT_TYPE, sessionClearedError } from './session-clear.ts'
20
20
  import { idempotentReplay } from './idempotent-replay.ts'
21
- import { decodeReturnedEventIds } from './store-codec.ts'
21
+ import {
22
+ cursorKeyOf,
23
+ decodeCause,
24
+ decodeReturnedEventIds,
25
+ idleLogCursor,
26
+ } from './store-codec.ts'
22
27
  import { pollingStream } from './store-polling.ts'
23
28
  import {
24
29
  RANDOM_IDS,
25
30
  SYSTEM_CLOCK,
26
31
  type A2Store,
32
+ type AppendEvent,
27
33
  type Clock,
28
34
  type Event,
29
35
  type EventCause,
36
+ type HeadEvent,
37
+ type HeadRef,
30
38
  type IdSource,
39
+ type StoreConsumer,
40
+ type StoredCursor,
31
41
  type StoredEvent,
32
42
  } from './store.ts'
33
43
 
@@ -45,7 +55,15 @@ export type SqliteStore = A2Store & {
45
55
  close(): void
46
56
  }
47
57
 
58
+ /** The cursor layout; databases from before it are rejected, not migrated. */
59
+ const LAYOUT = 'cursors-1'
60
+
48
61
  const SCHEMA = `
62
+ create table if not exists a2_meta (
63
+ key text not null primary key,
64
+ value text not null
65
+ ) strict;
66
+
49
67
  create table if not exists a2_events (
50
68
  session_id text not null,
51
69
  idx integer not null,
@@ -54,35 +72,50 @@ create table if not exists a2_events (
54
72
  event_id text not null,
55
73
  created_at integer not null,
56
74
  cause text,
75
+ primary key (session_id, idx)
76
+ ) strict;
77
+
78
+ create unique index if not exists a2_events_event_id on a2_events (event_id);
79
+ create index if not exists a2_events_types on a2_events (session_id, event_type, idx);
80
+
81
+ create table if not exists a2_cursors (
82
+ session_id text not null,
83
+ consumer text not null,
84
+ cursor_key text not null,
57
85
  lane text,
58
- lane_ready integer not null default 0,
59
- processed_at integer,
60
- processed_by_attempt integer,
61
- returned_event_ids text,
86
+ kind text not null,
87
+ position integer not null,
88
+ head integer,
89
+ types text,
90
+ attempt_count integer not null default 0,
62
91
  first_claimed_at integer,
63
92
  last_claimed_at integer,
64
- attempt_count integer not null default 0,
93
+ claim_holder text,
94
+ claim_expires_at integer,
65
95
  failure_count integer not null default 0,
66
96
  last_failed_at integer,
67
97
  last_failed_attempt integer,
68
98
  last_error text,
69
99
  failed_at integer,
70
- claim_holder text,
71
- claim_expires_at integer,
72
- primary key (session_id, idx)
100
+ completed_idx integer,
101
+ completed_attempt integer,
102
+ returned_event_ids text,
103
+ primary key (session_id, consumer, cursor_key)
73
104
  ) strict;
74
105
 
75
- create unique index if not exists a2_events_event_id on a2_events (event_id);
76
- create index if not exists a2_events_unprocessed
77
- on a2_events (session_id, idx) where processed_at is null;
78
- create index if not exists a2_events_lane_pending
79
- on a2_events (session_id, lane, idx) where processed_at is null;
80
- create index if not exists a2_events_dispatch_ready
81
- on a2_events (session_id, idx)
82
- where lane_ready = 1 and processed_at is null and failed_at is null;
83
- create index if not exists a2_events_claim_expiry
84
- on a2_events (session_id, claim_expires_at)
85
- where lane_ready = 1 and processed_at is null and failed_at is null;
106
+ create index if not exists a2_cursors_heads
107
+ on a2_cursors (session_id, head) where head is not null;
108
+
109
+ create table if not exists a2_lane_events (
110
+ session_id text not null,
111
+ consumer text not null,
112
+ lane text not null,
113
+ idx integer not null,
114
+ done integer not null default 0,
115
+ completed_attempt integer,
116
+ returned_event_ids text,
117
+ primary key (session_id, consumer, lane, idx)
118
+ ) strict;
86
119
 
87
120
  create table if not exists a2_snapshots (
88
121
  session_id text not null,
@@ -104,10 +137,12 @@ create table if not exists a2_snapshot_history (
104
137
 
105
138
  create table if not exists a2_snapshot_pins (
106
139
  session_id text not null,
140
+ consumer text not null,
141
+ cursor_key text not null,
107
142
  event_index integer not null,
108
143
  reducer_name text not null,
109
144
  up_to_index integer not null,
110
- primary key (session_id, event_index, reducer_name, up_to_index)
145
+ primary key (session_id, consumer, cursor_key, event_index, reducer_name, up_to_index)
111
146
  ) strict;
112
147
 
113
148
  create index if not exists a2_snapshot_pins_checkpoint
@@ -165,34 +200,51 @@ const wrap = <T>(fn: () => T): T => {
165
200
  const toDate = (ms: number | bigint | null): Date | null =>
166
201
  ms === null ? null : new Date(Number(ms))
167
202
 
168
- const toCause = (json: string | null): EventCause | null => {
169
- if (json === null) return null
170
- const value = JSON.parse(json) as {
171
- index?: unknown
172
- attempt?: unknown
173
- batchSize?: unknown
174
- }
175
- if (
176
- !Number.isInteger(value.index) ||
177
- Number(value.index) < 1 ||
178
- !Number.isInteger(value.attempt) ||
179
- Number(value.attempt) < 1
180
- ) {
181
- throw new TypeError('stored event has an invalid cause')
182
- }
183
- if (
184
- value.batchSize !== undefined &&
185
- (!Number.isInteger(value.batchSize) || Number(value.batchSize) < 1)
186
- ) {
187
- throw new TypeError('stored event has an invalid cause')
188
- }
189
- return {
190
- index: Number(value.index),
191
- attempt: Number(value.attempt),
192
- ...(value.batchSize === undefined
193
- ? {}
194
- : { batchSize: Number(value.batchSize) }),
195
- }
203
+ const toNumber = (value: number | bigint | null): number | null =>
204
+ value === null ? null : Number(value)
205
+
206
+ type EventRow = {
207
+ session_id: string
208
+ idx: number | bigint
209
+ event_type: string
210
+ payload: string
211
+ event_id: string
212
+ created_at: number | bigint
213
+ cause: string | null
214
+ }
215
+
216
+ type CursorRow = {
217
+ session_id: string
218
+ consumer: string
219
+ cursor_key: string
220
+ lane: string | null
221
+ kind: 'log' | 'lane' | 'event'
222
+ position: number | bigint
223
+ head: number | bigint | null
224
+ types: string | null
225
+ attempt_count: number | bigint
226
+ first_claimed_at: number | bigint | null
227
+ last_claimed_at: number | bigint | null
228
+ claim_holder: string | null
229
+ claim_expires_at: number | bigint | null
230
+ failure_count: number | bigint
231
+ last_failed_at: number | bigint | null
232
+ last_failed_attempt: number | bigint | null
233
+ last_error: string | null
234
+ failed_at: number | bigint | null
235
+ completed_idx: number | bigint | null
236
+ completed_attempt: number | bigint | null
237
+ returned_event_ids: string | null
238
+ }
239
+
240
+ /** A cursor joined with its head event's columns, prefixed `e_`. */
241
+ type HeadRow = CursorRow & {
242
+ e_idx: number | bigint
243
+ e_event_type: string
244
+ e_payload: string
245
+ e_event_id: string
246
+ e_created_at: number | bigint
247
+ e_cause: string | null
196
248
  }
197
249
 
198
250
  const toStored = (row: EventRow): StoredEvent => ({
@@ -202,26 +254,7 @@ const toStored = (row: EventRow): StoredEvent => ({
202
254
  index: Number(row.idx),
203
255
  sessionId: row.session_id,
204
256
  createdAt: new Date(Number(row.created_at)),
205
- cause: toCause(row.cause),
206
- lane: row.lane,
207
- processedAt: toDate(row.processed_at),
208
- processedByAttempt:
209
- row.processed_by_attempt === null ? null : Number(row.processed_by_attempt),
210
- returnedEventIds:
211
- row.returned_event_ids === null
212
- ? null
213
- : decodeReturnedEventIds(row.returned_event_ids),
214
- firstClaimedAt: toDate(row.first_claimed_at),
215
- lastClaimedAt: toDate(row.last_claimed_at),
216
- attemptCount: Number(row.attempt_count),
217
- failureCount: Number(row.failure_count),
218
- lastFailedAt: toDate(row.last_failed_at),
219
- lastFailedAttempt:
220
- row.last_failed_attempt === null ? null : Number(row.last_failed_attempt),
221
- lastError: row.last_error,
222
- failedAt: toDate(row.failed_at),
223
- claimHolder: row.claim_holder,
224
- claimExpiresAt: toDate(row.claim_expires_at),
257
+ cause: decodeCause(row.cause),
225
258
  })
226
259
 
227
260
  const toEvent = (row: EventRow): Event => ({
@@ -233,28 +266,37 @@ const toEvent = (row: EventRow): Event => ({
233
266
  createdAt: new Date(Number(row.created_at)),
234
267
  })
235
268
 
236
- type EventRow = {
237
- session_id: string
238
- idx: number | bigint
239
- event_type: string
240
- payload: string
241
- event_id: string
242
- created_at: number | bigint
243
- cause: string | null
244
- lane: string | null
245
- processed_at: number | bigint | null
246
- processed_by_attempt: number | bigint | null
247
- returned_event_ids: string | null
248
- first_claimed_at: number | bigint | null
249
- last_claimed_at: number | bigint | null
250
- attempt_count: number | bigint
251
- failure_count: number | bigint
252
- last_failed_at: number | bigint | null
253
- last_failed_attempt: number | bigint | null
254
- last_error: string | null
255
- failed_at: number | bigint | null
256
- claim_holder: string | null
257
- claim_expires_at: number | bigint | null
269
+ const toCursor = (row: CursorRow): StoredCursor => ({
270
+ consumer: row.consumer,
271
+ lane: row.lane,
272
+ position: Number(row.position),
273
+ head: toNumber(row.head),
274
+ firstClaimedAt: toDate(row.first_claimed_at),
275
+ lastClaimedAt: toDate(row.last_claimed_at),
276
+ attemptCount: Number(row.attempt_count),
277
+ claimHolder: row.claim_holder,
278
+ claimExpiresAt: toDate(row.claim_expires_at),
279
+ failureCount: Number(row.failure_count),
280
+ lastFailedAt: toDate(row.last_failed_at),
281
+ lastFailedAttempt: toNumber(row.last_failed_attempt),
282
+ lastError: row.last_error,
283
+ failedAt: toDate(row.failed_at),
284
+ })
285
+
286
+ const toHead = (row: HeadRow): HeadEvent => {
287
+ const { position: _position, head: _head, ...dispatch } = toCursor(row)
288
+ return {
289
+ ...toStored({
290
+ session_id: row.session_id,
291
+ idx: row.e_idx,
292
+ event_type: row.e_event_type,
293
+ payload: row.e_payload,
294
+ event_id: row.e_event_id,
295
+ created_at: row.e_created_at,
296
+ cause: row.e_cause,
297
+ }),
298
+ ...dispatch,
299
+ }
258
300
  }
259
301
 
260
302
  type PresenceDbRow = {
@@ -280,16 +322,30 @@ type SnapshotHeadRow = {
280
322
  updated_at: number | bigint
281
323
  }
282
324
 
283
- type AppendStatusRow = {
284
- max: number | bigint
285
- has_pending: number | bigint
286
- }
325
+ const RECLAIM_BATCH = 256
287
326
 
288
- type ExistingEventRow = EventRow & {
289
- session_has_pending: number | bigint
290
- }
327
+ /** The head columns every claim, dead letter, and listing joins in. */
328
+ const HEAD_SELECT = `
329
+ select cursor.*,
330
+ event.idx as e_idx, event.event_type as e_event_type,
331
+ event.payload as e_payload, event.event_id as e_event_id,
332
+ event.created_at as e_created_at, event.cause as e_cause
333
+ from a2_cursors cursor
334
+ join a2_events event
335
+ on event.session_id = cursor.session_id and event.idx = cursor.head`
291
336
 
292
- const RECLAIM_BATCH = 256
337
+ /** A fresh head's dispatch columns, for `set` clauses. */
338
+ const FRESH_DISPATCH = `
339
+ attempt_count = 0, first_claimed_at = null, last_claimed_at = null,
340
+ claim_holder = null, claim_expires_at = null, failure_count = 0,
341
+ last_failed_at = null, last_failed_attempt = null, last_error = null,
342
+ failed_at = null`
343
+
344
+ const placeholders = (count: number): string =>
345
+ Array.from({ length: count }, () => '?').join(', ')
346
+
347
+ const compareText = (a: string, b: string): number =>
348
+ a < b ? -1 : a > b ? 1 : 0
293
349
 
294
350
  export function sqlite(options: SqliteStoreOptions = {}): SqliteStore {
295
351
  const path = options.path ?? '.a2/dev.db'
@@ -311,43 +367,123 @@ export function sqlite(options: SqliteStoreOptions = {}): SqliteStore {
311
367
  db.exec('pragma busy_timeout = 5000')
312
368
  setWalJournalMode(db)
313
369
  db.exec('pragma synchronous = normal')
370
+ const legacy = db
371
+ .prepare("select 1 from pragma_table_info('a2_events') where name = ?")
372
+ .get('processed_at')
373
+ if (legacy) {
374
+ db.close()
375
+ throw new A2Error(
376
+ 'STORE_NOT_CONFIGURED',
377
+ `the sqlite database at '${path}' has the A2 layout from before consumers; delete it, or point the store at a fresh database file`,
378
+ )
379
+ }
314
380
  db.exec(SCHEMA)
381
+ db.prepare(
382
+ "insert into a2_meta (key, value) values ('layout', ?) on conflict (key) do nothing",
383
+ ).run(LAYOUT)
315
384
 
316
385
  const insertEvent = db.prepare(
317
386
  `insert into a2_events
318
- (session_id, idx, event_type, payload, event_id, created_at,
319
- cause, lane, lane_ready, processed_at)
320
- select ?, ?, ?, ?, ?, ?, ?, ?,
321
- case
322
- when ? is null then 1
323
- when not exists (
324
- select 1 from a2_events
325
- where session_id = ? and lane = ? and processed_at is null
326
- and idx > ?
327
- ) then 1
328
- else 0
329
- end,
330
- ?`,
387
+ (session_id, idx, event_type, payload, event_id, created_at, cause)
388
+ values (?, ?, ?, ?, ?, ?, ?)`,
331
389
  )
332
- const promoteLaneHead = db.prepare(
333
- `update a2_events set lane_ready = 1
334
- where session_id = ?
335
- and idx = (
336
- select idx from a2_events
337
- where session_id = ? and lane = ? and processed_at is null
338
- and idx > ?
339
- order by idx limit 1
340
- )`,
390
+ const selectHead = db.prepare(
391
+ 'select coalesce(max(idx), 0) as max from a2_events where session_id = ?',
341
392
  )
342
- const appendStatus = db.prepare(
343
- `select coalesce(
344
- (select max(idx) from a2_events where session_id = ?),
345
- 0
346
- ) as max,
347
- exists(
348
- select 1 from a2_events
349
- where session_id = ? and processed_at is null and idx > ?
350
- ) as has_pending`,
393
+ const headOf = (sessionId: string): number =>
394
+ Number((selectHead.get(sessionId) as { max: number | bigint }).max)
395
+ const selectEvent = db.prepare(
396
+ 'select 1 from a2_events where session_id = ? and idx = ?',
397
+ )
398
+ const selectCursor = db.prepare(
399
+ 'select * from a2_cursors where session_id = ? and consumer = ? and cursor_key = ?',
400
+ )
401
+ const cursorOf = (sessionId: string, ref: HeadRef): CursorRow | undefined =>
402
+ selectCursor.get(
403
+ sessionId,
404
+ ref.consumer,
405
+ cursorKeyOf(ref.lane, ref.index),
406
+ ) as CursorRow | undefined
407
+ const insertEventCursor = db.prepare(
408
+ `insert into a2_cursors
409
+ (session_id, consumer, cursor_key, lane, kind, position, head)
410
+ values (?, ?, ?, null, 'event', ?, ?)`,
411
+ )
412
+ const insertLaneEvent = db.prepare(
413
+ `insert into a2_lane_events (session_id, consumer, lane, idx)
414
+ values (?, ?, ?, ?)`,
415
+ )
416
+ // A lane cursor whose head lies at or below the clear marker is idle: the
417
+ // marker superseded everything it had queued.
418
+ const queueLaneHead = db.prepare(
419
+ `insert into a2_cursors
420
+ (session_id, consumer, cursor_key, lane, kind, position, head)
421
+ values (?, ?, ?, ?, 'lane', ?, ?)
422
+ on conflict (session_id, consumer, cursor_key) do update set
423
+ head = excluded.head,
424
+ position = max(a2_cursors.position, excluded.position),
425
+ ${FRESH_DISPATCH}
426
+ where a2_cursors.head is null or a2_cursors.head <= excluded.position`,
427
+ )
428
+ const openLogCursor = db.prepare(
429
+ `insert into a2_cursors
430
+ (session_id, consumer, cursor_key, lane, kind, position, head, types)
431
+ values (?, ?, ?, ?, 'log', ?, ?, ?)`,
432
+ )
433
+ const wakeLogCursor = db.prepare(
434
+ `update a2_cursors set types = ?, head = ?
435
+ where session_id = ? and consumer = ? and cursor_key = ?`,
436
+ )
437
+ const refreshTypes = db.prepare(
438
+ `update a2_cursors set types = ?
439
+ where session_id = ? and consumer = ? and kind = 'log'
440
+ and types is not ?`,
441
+ )
442
+ const nextLogHeadAll = db.prepare(
443
+ `select min(idx) as next from a2_events
444
+ where session_id = ? and idx > ? and event_type != ?`,
445
+ )
446
+ const nextLogHeadOf = db.prepare(
447
+ `select min(idx) as next from a2_events
448
+ where session_id = ? and idx > ? and event_type != ?
449
+ and event_type in (select value from json_each(?))`,
450
+ )
451
+ const nextLogHead = (
452
+ sessionId: string,
453
+ after: number,
454
+ types: string | null,
455
+ ): number | null =>
456
+ toNumber(
457
+ (
458
+ (types === null
459
+ ? nextLogHeadAll.get(sessionId, after, CLEAR_EVENT_TYPE)
460
+ : nextLogHeadOf.get(sessionId, after, CLEAR_EVENT_TYPE, types)) as {
461
+ next: number | bigint | null
462
+ }
463
+ ).next,
464
+ )
465
+ const nextLaneHead = db.prepare(
466
+ `select min(idx) as next from a2_lane_events
467
+ where session_id = ? and consumer = ? and lane = ? and done = 0
468
+ and idx > ?`,
469
+ )
470
+ const selectLaneEvent = db.prepare(
471
+ `select completed_attempt, returned_event_ids from a2_lane_events
472
+ where session_id = ? and consumer = ? and lane = ? and idx = ?`,
473
+ )
474
+ const finishLaneEvent = db.prepare(
475
+ `update a2_lane_events
476
+ set done = 1, completed_attempt = ?, returned_event_ids = ?
477
+ where session_id = ? and consumer = ? and lane = ? and idx = ?`,
478
+ )
479
+ const moveCursor = db.prepare(
480
+ `update a2_cursors set position = ?, head = ?, ${FRESH_DISPATCH}
481
+ where session_id = ? and consumer = ? and cursor_key = ?`,
482
+ )
483
+ const recordCompletion = db.prepare(
484
+ `update a2_cursors
485
+ set completed_idx = ?, completed_attempt = ?, returned_event_ids = ?
486
+ where session_id = ? and consumer = ? and cursor_key = ?`,
351
487
  )
352
488
  const upsertPresence = db.prepare(
353
489
  `insert into a2_presence (ns, participant, field, value, seen, at, expires_at)
@@ -374,16 +510,9 @@ export function sqlite(options: SqliteStoreOptions = {}): SqliteStore {
374
510
  )
375
511
  const selectByIds = (count: number) =>
376
512
  db.prepare(
377
- `select stored.*,
378
- exists(
379
- select 1 from a2_events pending
380
- where pending.session_id = ?
381
- and pending.processed_at is null
382
- and pending.idx > ?
383
- ) as session_has_pending
384
- from a2_events stored
385
- where stored.event_id in (${Array.from({ length: count }, () => '?').join(', ')})
386
- order by stored.idx`,
513
+ `select * from a2_events
514
+ where event_id in (${placeholders(count)})
515
+ order by idx`,
387
516
  )
388
517
 
389
518
  const selectClearIndex = db.prepare(
@@ -400,6 +529,37 @@ export function sqlite(options: SqliteStoreOptions = {}): SqliteStore {
400
529
  'delete from a2_events where session_id = ? and event_id = ?',
401
530
  )
402
531
 
532
+ /** Served consumers as a SQL condition on `cursor.consumer`. */
533
+ const servedClause = (
534
+ consumers: readonly StoreConsumer[] | undefined,
535
+ ): { sql: string; params: string[] } =>
536
+ consumers === undefined
537
+ ? { sql: '', params: [] }
538
+ : consumers.length === 0
539
+ ? { sql: 'and 0', params: [] }
540
+ : {
541
+ sql: `and cursor.consumer in (${placeholders(consumers.length)})`,
542
+ params: consumers.map((consumer) => consumer.name),
543
+ }
544
+
545
+ const hasPending = (
546
+ sessionId: string,
547
+ clearIndex: number,
548
+ consumers: readonly StoreConsumer[] | undefined,
549
+ ): boolean => {
550
+ const served = servedClause(consumers)
551
+ return (
552
+ db
553
+ .prepare(
554
+ `select 1 from a2_cursors cursor
555
+ where cursor.session_id = ? and cursor.head > ?
556
+ and cursor.failed_at is null ${served.sql}
557
+ limit 1`,
558
+ )
559
+ .get(sessionId, clearIndex, ...served.params) !== undefined
560
+ )
561
+ }
562
+
403
563
  /**
404
564
  * The rows owning `ids`. This session's rows below its clear marker do
405
565
  * not own their ids: they are deleted here so the ids can be reused.
@@ -408,13 +568,9 @@ export function sqlite(options: SqliteStoreOptions = {}): SqliteStore {
408
568
  sessionId: string,
409
569
  clearIndex: number,
410
570
  ids: readonly string[],
411
- ): ExistingEventRow[] => {
571
+ ): EventRow[] => {
412
572
  if (ids.length === 0) return []
413
- const rows = selectByIds(ids.length).all(
414
- sessionId,
415
- clearIndex,
416
- ...ids,
417
- ) as unknown as ExistingEventRow[]
573
+ const rows = selectByIds(ids.length).all(...ids) as unknown as EventRow[]
418
574
  return rows.filter((row) => {
419
575
  if (row.session_id !== sessionId || Number(row.idx) >= clearIndex)
420
576
  return true
@@ -423,10 +579,7 @@ export function sqlite(options: SqliteStoreOptions = {}): SqliteStore {
423
579
  })
424
580
  }
425
581
 
426
- const selectCauseParent = db.prepare(
427
- 'select attempt_count, failed_at from a2_events where session_id = ? and idx = ?',
428
- )
429
- /** The attempt-currency fence shared by handler appends and clears. */
582
+ /** The attempt-currency fence shared by consumer appends and clears. */
430
583
  const assertCurrentCause = ({
431
584
  sessionId,
432
585
  cause,
@@ -438,16 +591,12 @@ export function sqlite(options: SqliteStoreOptions = {}): SqliteStore {
438
591
  }): void => {
439
592
  if (cause.index <= clearIndex)
440
593
  throw supersededAttemptError(sessionId, cause)
441
- const parent = selectCauseParent.get(sessionId, cause.index) as
442
- { attempt_count: number | bigint; failed_at: unknown } | undefined
443
- if (!parent) {
444
- throw new TypeError(
445
- `no event at index ${cause.index} in session '${sessionId}'`,
446
- )
447
- }
594
+ const cursor = cursorOf(sessionId, cause)
448
595
  if (
449
- Number(parent.attempt_count) !== cause.attempt ||
450
- parent.failed_at !== null
596
+ !cursor ||
597
+ toNumber(cursor.head) !== cause.index ||
598
+ Number(cursor.attempt_count) !== cause.attempt ||
599
+ cursor.failed_at !== null
451
600
  ) {
452
601
  throw supersededAttemptError(sessionId, cause)
453
602
  }
@@ -470,11 +619,17 @@ export function sqlite(options: SqliteStoreOptions = {}): SqliteStore {
470
619
  }
471
620
  }
472
621
 
473
- /** A dead-lettered event's snapshot pins, and history no pin keeps. */
474
- const releasePins = (sessionId: string, index: number): void => {
622
+ /** A finished or dead head's snapshot pins, and history no pin keeps. */
623
+ const releasePins = (
624
+ sessionId: string,
625
+ consumer: string,
626
+ cursorKey: string,
627
+ index: number,
628
+ ): void => {
475
629
  db.prepare(
476
- 'delete from a2_snapshot_pins where session_id = ? and event_index = ?',
477
- ).run(sessionId, index)
630
+ `delete from a2_snapshot_pins
631
+ where session_id = ? and consumer = ? and cursor_key = ? and event_index = ?`,
632
+ ).run(sessionId, consumer, cursorKey, index)
478
633
  db.prepare(
479
634
  `delete from a2_snapshot_history
480
635
  where session_id = ?
@@ -486,20 +641,201 @@ export function sqlite(options: SqliteStoreOptions = {}): SqliteStore {
486
641
  )`,
487
642
  ).run(sessionId)
488
643
  }
644
+
645
+ /** Moves a cursor past its head; the next head starts fresh. */
646
+ const advance = (sessionId: string, cursor: CursorRow): void => {
647
+ const done = Number(cursor.head)
648
+ releasePins(sessionId, cursor.consumer, cursor.cursor_key, done)
649
+ let next: number | null
650
+ if (cursor.kind === 'event') {
651
+ next = null
652
+ } else if (cursor.kind === 'lane') {
653
+ next = toNumber(
654
+ (
655
+ nextLaneHead.get(
656
+ sessionId,
657
+ cursor.consumer,
658
+ cursor.lane,
659
+ Math.max(done, clearIndexOf(sessionId)),
660
+ ) as { next: number | bigint | null }
661
+ ).next,
662
+ )
663
+ } else {
664
+ const { types } = selectCursor.get(
665
+ sessionId,
666
+ cursor.consumer,
667
+ cursor.cursor_key,
668
+ ) as CursorRow
669
+ next = nextLogHead(sessionId, done, types)
670
+ }
671
+ moveCursor.run(done, next, sessionId, cursor.consumer, cursor.cursor_key)
672
+ }
673
+
674
+ /**
675
+ * Brings a log consumer's cursor up to date and records its types. A
676
+ * missing cursor opens at the clear marker with `backfill`, otherwise at
677
+ * `base`; an idle one takes the next event of its types after its
678
+ * position; one with a head keeps it, claim and all. Returns its key.
679
+ */
680
+ const wakeLogAt = ({
681
+ sessionId,
682
+ consumer,
683
+ log,
684
+ base,
685
+ clearIndex,
686
+ }: {
687
+ sessionId: string
688
+ consumer: string
689
+ log: NonNullable<StoreConsumer['log']>
690
+ base: number
691
+ clearIndex: number
692
+ }): string => {
693
+ const types = log.types === null ? null : JSON.stringify(log.types)
694
+ const key = cursorKeyOf(log.lane, 0)
695
+ const existing = selectCursor.get(sessionId, consumer, key) as
696
+ CursorRow | undefined
697
+ if (existing) {
698
+ wakeLogCursor.run(
699
+ types,
700
+ existing.head === null
701
+ ? // A deployment that doesn't serve this consumer may have
702
+ // appended events it takes since it went idle.
703
+ nextLogHead(sessionId, Number(existing.position), types)
704
+ : Number(existing.head),
705
+ sessionId,
706
+ consumer,
707
+ key,
708
+ )
709
+ return key
710
+ }
711
+ const position = log.backfill ? clearIndex : base
712
+ openLogCursor.run(
713
+ sessionId,
714
+ consumer,
715
+ key,
716
+ log.lane,
717
+ position,
718
+ nextLogHead(sessionId, position, types),
719
+ types,
720
+ )
721
+ return key
722
+ }
723
+
724
+ /** Starts the cursor work an inserted batch carries. */
725
+ const enqueue = ({
726
+ sessionId,
727
+ events,
728
+ base,
729
+ clearIndex,
730
+ consumers,
731
+ }: {
732
+ sessionId: string
733
+ events: readonly AppendEvent[]
734
+ base: number
735
+ clearIndex: number
736
+ consumers: readonly StoreConsumer[] | undefined
737
+ }): void => {
738
+ events.forEach((event, offset) => {
739
+ const index = base + 1 + offset
740
+ for (const target of event.targets ?? []) {
741
+ if (target.lane === null) {
742
+ insertEventCursor.run(
743
+ sessionId,
744
+ target.consumer,
745
+ cursorKeyOf(null, index),
746
+ index - 1,
747
+ index,
748
+ )
749
+ continue
750
+ }
751
+ insertLaneEvent.run(sessionId, target.consumer, target.lane, index)
752
+ queueLaneHead.run(
753
+ sessionId,
754
+ target.consumer,
755
+ cursorKeyOf(target.lane, index),
756
+ target.lane,
757
+ clearIndex,
758
+ index,
759
+ )
760
+ }
761
+ })
762
+ // The batch is inserted already, so a cursor it wakes finds the batch's
763
+ // first event of its types, or an earlier one.
764
+ for (const consumer of consumers ?? []) {
765
+ const log = consumer.log
766
+ if (!log) continue
767
+ const carries = events.some(
768
+ (event) =>
769
+ event.type !== CLEAR_EVENT_TYPE &&
770
+ (log.types === null || log.types.includes(event.type)),
771
+ )
772
+ if (!carries) continue
773
+ wakeLogAt({ sessionId, consumer: consumer.name, log, base, clearIndex })
774
+ }
775
+ }
776
+
777
+ const refreshLogTypes = (
778
+ sessionId: string,
779
+ consumers: readonly StoreConsumer[] | undefined,
780
+ ): void => {
781
+ for (const consumer of consumers ?? []) {
782
+ if (!consumer.log) continue
783
+ const types =
784
+ consumer.log.types === null ? null : JSON.stringify(consumer.log.types)
785
+ refreshTypes.run(types, sessionId, consumer.name, types)
786
+ }
787
+ }
788
+
789
+ const insertBatch = ({
790
+ sessionId,
791
+ events,
792
+ clearIndex,
793
+ consumers,
794
+ cause,
795
+ }: {
796
+ sessionId: string
797
+ events: readonly AppendEvent[]
798
+ clearIndex: number
799
+ consumers: readonly StoreConsumer[] | undefined
800
+ cause?: EventCause
801
+ }): StoredEvent[] => {
802
+ const base = headOf(sessionId)
803
+ const now = clock.now().getTime()
804
+ const inserted = events.map((event, offset): StoredEvent => {
805
+ const id = event.id ?? generateId()
806
+ const index = base + 1 + offset
807
+ const eventCause = cause ?? event.cause ?? null
808
+ insertEvent.run(
809
+ sessionId,
810
+ index,
811
+ event.type,
812
+ JSON.stringify(event.payload) ?? 'null',
813
+ id,
814
+ now,
815
+ eventCause ? JSON.stringify(eventCause) : null,
816
+ )
817
+ return {
818
+ id,
819
+ type: event.type,
820
+ payload: structuredClone(event.payload),
821
+ index,
822
+ sessionId,
823
+ createdAt: new Date(now),
824
+ cause: eventCause ? { ...eventCause } : null,
825
+ }
826
+ })
827
+ enqueue({ sessionId, events, base, clearIndex, consumers })
828
+ return inserted
829
+ }
830
+
489
831
  return {
490
832
  async append(sessionId, events, appendOptions) {
833
+ const consumers = appendOptions?.consumers
491
834
  if (events.length === 0) {
492
- return wrap(() => {
493
- const status = appendStatus.get(
494
- sessionId,
495
- sessionId,
496
- clearIndexOf(sessionId),
497
- ) as AppendStatusRow
498
- return {
499
- events: [],
500
- hasPending: Number(status.has_pending) === 1,
501
- }
502
- })
835
+ return wrap(() => ({
836
+ events: [],
837
+ hasPending: hasPending(sessionId, clearIndexOf(sessionId), consumers),
838
+ }))
503
839
  }
504
840
  return wrap(() =>
505
841
  tx(() => {
@@ -527,7 +863,7 @@ export function sqlite(options: SqliteStoreOptions = {}): SqliteStore {
527
863
  if (existing.length === events.length) {
528
864
  return {
529
865
  events: idempotentReplay(events, existing.map(toStored)),
530
- hasPending: Number(existing[0]!.session_has_pending) === 1,
866
+ hasPending: hasPending(sessionId, clearIndex, consumers),
531
867
  }
532
868
  }
533
869
  throw new A2Error(
@@ -543,8 +879,8 @@ export function sqlite(options: SqliteStoreOptions = {}): SqliteStore {
543
879
  `scheduled work from event ${originIndex} was cleared in session '${sessionId}'`,
544
880
  )
545
881
  }
546
- // Attempt-currency fence: a fresh handler append commits only
547
- // while its causal attempt is still the parent's latest
882
+ // Attempt-currency fence: a fresh consumer append commits only
883
+ // while its causal attempt still owns the cursor's head
548
884
  // (store.ts `append`). `begin immediate` serializes this read
549
885
  // against a concurrent claim.
550
886
  for (const e of events) {
@@ -552,65 +888,69 @@ export function sqlite(options: SqliteStoreOptions = {}): SqliteStore {
552
888
  assertCurrentCause({ sessionId, cause: e.cause, clearIndex })
553
889
  }
554
890
 
555
- const status = appendStatus.get(
556
- sessionId,
891
+ const inserted = insertBatch({
557
892
  sessionId,
893
+ events,
558
894
  clearIndex,
559
- ) as AppendStatusRow
560
- const base = Number(status.max)
561
- const now = clock.now().getTime()
562
- const inserted: StoredEvent[] = events.map((e, i) => {
563
- const id = e.id ?? generateId()
564
- const index = base + 1 + i
565
- insertEvent.run(
566
- sessionId,
567
- index,
568
- e.type,
569
- JSON.stringify(e.payload) ?? 'null',
570
- id,
571
- now,
572
- e.cause ? JSON.stringify(e.cause) : null,
573
- e.lane ?? null,
574
- e.lane ?? null,
575
- sessionId,
576
- e.lane ?? null,
577
- clearIndex,
578
- e.settled ? now : null,
579
- )
580
- return {
581
- id,
582
- type: e.type,
583
- payload: structuredClone(e.payload),
584
- index,
585
- sessionId,
586
- createdAt: new Date(now),
587
- cause: e.cause ? { ...e.cause } : null,
588
- lane: e.lane ?? null,
589
- processedAt: e.settled ? new Date(now) : null,
590
- processedByAttempt: null,
591
- returnedEventIds: null,
592
- firstClaimedAt: null,
593
- lastClaimedAt: null,
594
- attemptCount: 0,
595
- failureCount: 0,
596
- lastFailedAt: null,
597
- lastFailedAttempt: null,
598
- lastError: null,
599
- failedAt: null,
600
- claimHolder: null,
601
- claimExpiresAt: null,
602
- }
895
+ consumers,
603
896
  })
604
897
  return {
605
898
  events: inserted,
606
- hasPending:
607
- Number(status.has_pending) === 1 ||
608
- events.some((event) => event.settled !== true),
899
+ hasPending: hasPending(sessionId, clearIndex, consumers),
609
900
  }
610
901
  }),
611
902
  )
612
903
  },
613
904
 
905
+ async wakeLog({ sessionId, consumer }) {
906
+ return wrap(() => {
907
+ // Nothing after the latest clear: a missing cursor stays missing,
908
+ // and nothing takes the write lock.
909
+ const { lane } = consumer.log
910
+ const marker = clearIndexOf(sessionId)
911
+ if (
912
+ headOf(sessionId) === marker &&
913
+ !selectCursor.get(sessionId, consumer.name, cursorKeyOf(lane, 0))
914
+ ) {
915
+ return {
916
+ cursor: idleLogCursor(consumer.name, lane, marker),
917
+ head: marker,
918
+ }
919
+ }
920
+ return tx(() => {
921
+ const head = headOf(sessionId)
922
+ const key = wakeLogAt({
923
+ sessionId,
924
+ consumer: consumer.name,
925
+ log: consumer.log,
926
+ base: head,
927
+ clearIndex: clearIndexOf(sessionId),
928
+ })
929
+ const cursor = selectCursor.get(sessionId, consumer.name, key)
930
+ return { cursor: toCursor(cursor as CursorRow), head }
931
+ })
932
+ })
933
+ },
934
+
935
+ async cursors(sessionId) {
936
+ return wrap(() => {
937
+ const clearIndex = clearIndexOf(sessionId)
938
+ const rows = db
939
+ .prepare(
940
+ `select * from a2_cursors
941
+ where session_id = ?
942
+ and (
943
+ kind = 'log'
944
+ or head > ?
945
+ or (kind = 'lane' and head is null and position > ?)
946
+ )
947
+ order by consumer, lane is null, lane, head`,
948
+ )
949
+ .all(sessionId, clearIndex, clearIndex) as unknown as CursorRow[]
950
+ return rows.map(toCursor)
951
+ })
952
+ },
953
+
614
954
  async read(sessionId, opts) {
615
955
  return wrap(() => {
616
956
  // Reads start at the latest clear marker.
@@ -641,44 +981,61 @@ export function sqlite(options: SqliteStoreOptions = {}): SqliteStore {
641
981
  holder,
642
982
  ttlMs,
643
983
  expiresAtMs,
644
- excludeIndexes = [],
984
+ exclude = [],
985
+ consumers,
645
986
  maxFailures,
987
+ through,
646
988
  }) {
647
989
  return wrap(() =>
648
990
  tx(() => {
991
+ refreshLogTypes(sessionId, consumers)
649
992
  const now = clock.now().getTime()
650
993
  const expiresAt = expiresAtMs ?? now + ttlMs
651
994
  const clearIndex = clearIndexOf(sessionId)
995
+ const served = servedClause(consumers)
996
+ const excludedKeys = exclude.map(
997
+ (ref) => `${ref.consumer}\u0000${cursorKeyOf(ref.lane, ref.index)}`,
998
+ )
652
999
  const exclusion =
653
- excludeIndexes.length === 0
1000
+ excludedKeys.length === 0
654
1001
  ? ''
655
- : `and event.idx not in (${excludeIndexes.map(() => '?').join(', ')})`
1002
+ : `and cursor.consumer || char(0) || cursor.cursor_key
1003
+ not in (${placeholders(excludedKeys.length)})`
1004
+ const bounded = through === undefined ? '' : 'and cursor.head <= ?'
1005
+ const filter = `
1006
+ where cursor.session_id = ?
1007
+ and cursor.head > ?
1008
+ and cursor.failed_at is null
1009
+ ${bounded}
1010
+ ${served.sql}
1011
+ ${exclusion}`
1012
+ const params = [
1013
+ sessionId,
1014
+ clearIndex,
1015
+ ...(through === undefined ? [] : [through]),
1016
+ ...served.params,
1017
+ ...excludedKeys,
1018
+ now,
1019
+ ]
656
1020
  const eligible = db
657
1021
  .prepare(
658
- `select event.idx, event.claim_holder, event.failure_count
659
- from a2_events event
660
- where event.session_id = ?
661
- and event.processed_at is null
662
- and event.failed_at is null
663
- and event.lane_ready = 1
664
- and event.idx > ?
665
- and (event.claim_expires_at is null or event.claim_expires_at <= ?)
666
- ${exclusion}
667
- order by event.idx`,
1022
+ `select cursor.consumer, cursor.cursor_key,
1023
+ cursor.claim_holder, cursor.failure_count
1024
+ from a2_cursors cursor
1025
+ ${filter}
1026
+ and (cursor.claim_expires_at is null
1027
+ or cursor.claim_expires_at <= ?)
1028
+ order by cursor.head, cursor.consumer`,
668
1029
  )
669
- .all(
670
- sessionId,
671
- clearIndex,
672
- now,
673
- ...excludeIndexes,
674
- ) as unknown as Array<{
675
- idx: number | bigint
676
- claim_holder: string | null
677
- failure_count: number | bigint
678
- }>
1030
+ .all(...params) as unknown as Array<
1031
+ Pick<
1032
+ CursorRow,
1033
+ 'consumer' | 'cursor_key' | 'claim_holder' | 'failure_count'
1034
+ >
1035
+ >
679
1036
  if (eligible.length > 0) {
680
- const update = db.prepare(
681
- `update a2_events set
1037
+ const claimHead = db.prepare(
1038
+ `update a2_cursors set
682
1039
  attempt_count = attempt_count + 1,
683
1040
  failure_count = failure_count
684
1041
  + case when claim_holder is null then 0 else 1 end,
@@ -686,55 +1043,71 @@ export function sqlite(options: SqliteStoreOptions = {}): SqliteStore {
686
1043
  last_claimed_at = ?,
687
1044
  claim_holder = ?,
688
1045
  claim_expires_at = ?
689
- where session_id = ? and idx = ?
690
- returning *`,
1046
+ where session_id = ? and consumer = ? and cursor_key = ?`,
691
1047
  )
692
1048
  // Advancing the attempt fences the lapsed one; nothing runs.
693
- const deadLetter = () =>
694
- db.prepare(
695
- `update a2_events set
696
- attempt_count = attempt_count + 1,
697
- failure_count = failure_count + 1,
698
- failed_at = ?,
699
- claim_holder = null,
700
- claim_expires_at = null
701
- where session_id = ? and idx = ?
702
- returning *`,
703
- )
1049
+ const deadLetterHead = db.prepare(
1050
+ `update a2_cursors set
1051
+ attempt_count = attempt_count + 1,
1052
+ failure_count = failure_count + 1,
1053
+ failed_at = ?,
1054
+ claim_holder = null,
1055
+ claim_expires_at = null
1056
+ where session_id = ? and consumer = ? and cursor_key = ?`,
1057
+ )
1058
+ const selectHeadRow = db.prepare(
1059
+ `${HEAD_SELECT}
1060
+ where cursor.session_id = ? and cursor.consumer = ?
1061
+ and cursor.cursor_key = ?`,
1062
+ )
704
1063
  const events = eligible.map((row) => {
705
1064
  // A lapsed claim's takeover that spends the last failed attempt.
706
1065
  const exhausted =
707
1066
  row.claim_holder !== null &&
708
1067
  maxFailures !== undefined &&
709
1068
  Number(row.failure_count) + 1 >= maxFailures
710
- const claimed = (
711
- exhausted
712
- ? deadLetter().get(now, sessionId, row.idx)
713
- : update.get(now, now, holder, expiresAt, sessionId, row.idx)
714
- ) as EventRow
715
- if (exhausted) releasePins(sessionId, Number(row.idx))
716
- const stored = toStored(claimed)
717
- if (clearIndex > 0) stored.clearIndex = clearIndex
718
- return stored
1069
+ if (exhausted) {
1070
+ deadLetterHead.run(now, sessionId, row.consumer, row.cursor_key)
1071
+ } else {
1072
+ claimHead.run(
1073
+ now,
1074
+ now,
1075
+ holder,
1076
+ expiresAt,
1077
+ sessionId,
1078
+ row.consumer,
1079
+ row.cursor_key,
1080
+ )
1081
+ }
1082
+ const claimed = toHead(
1083
+ selectHeadRow.get(
1084
+ sessionId,
1085
+ row.consumer,
1086
+ row.cursor_key,
1087
+ ) as HeadRow,
1088
+ )
1089
+ if (exhausted) {
1090
+ releasePins(
1091
+ sessionId,
1092
+ row.consumer,
1093
+ row.cursor_key,
1094
+ claimed.index,
1095
+ )
1096
+ }
1097
+ if (clearIndex > 0) claimed.clearIndex = clearIndex
1098
+ return claimed
719
1099
  })
720
- return { outcome: 'claimed', events }
1100
+ return { outcome: 'claimed', events, head: headOf(sessionId) }
721
1101
  }
722
1102
 
723
1103
  const active = db
724
1104
  .prepare(
725
- `select min(claim_expires_at) as retry_at
726
- from a2_events event
727
- where session_id = ?
728
- and processed_at is null
729
- and failed_at is null
730
- and lane_ready = 1
731
- and idx > ?
732
- and claim_expires_at > ?
733
- ${exclusion}`,
1105
+ `select min(cursor.claim_expires_at) as retry_at
1106
+ from a2_cursors cursor
1107
+ ${filter}
1108
+ and cursor.claim_expires_at > ?`,
734
1109
  )
735
- .get(sessionId, clearIndex, now, ...excludeIndexes) as {
736
- retry_at: number | bigint | null
737
- }
1110
+ .get(...params) as { retry_at: number | bigint | null }
738
1111
  return active.retry_at === null
739
1112
  ? { outcome: 'settled' }
740
1113
  : { outcome: 'busy', retryAt: new Date(Number(active.retry_at)) }
@@ -748,65 +1121,76 @@ export function sqlite(options: SqliteStoreOptions = {}): SqliteStore {
748
1121
  tx(() => {
749
1122
  const now = clock.now().getTime()
750
1123
  const expiresAt = expiresAtMs ?? now + ttlMs
751
- const renewed: number[] = []
752
- const superseded: number[] = []
753
- const currentAttempt = db.prepare(
754
- 'select attempt_count from a2_events where session_id = ? and idx = ?',
755
- )
1124
+ const renewed: HeadRef[] = []
1125
+ const superseded: HeadRef[] = []
756
1126
  const extend = db.prepare(
757
- `update a2_events set claim_expires_at = ?
758
- where session_id = ?
759
- and idx = ?
1127
+ `update a2_cursors set claim_expires_at = ?
1128
+ where session_id = ? and consumer = ? and cursor_key = ?
1129
+ and head = ?
760
1130
  and attempt_count = ?
761
- and processed_at is null
762
1131
  and failed_at is null
763
1132
  and claim_holder = ?
764
1133
  and claim_expires_at > ?`,
765
1134
  )
766
1135
  const clearIndex = clearIndexOf(sessionId)
767
- for (const claim of claims) {
768
- if (claim.index <= clearIndex) {
769
- superseded.push(claim.index)
1136
+ const ordered = claims.toSorted(
1137
+ (a, b) => a.index - b.index || compareText(a.consumer, b.consumer),
1138
+ )
1139
+ for (const { attempt, ...ref } of ordered) {
1140
+ if (ref.index <= clearIndex) {
1141
+ superseded.push(ref)
770
1142
  continue
771
1143
  }
772
- const row = currentAttempt.get(sessionId, claim.index) as
773
- { attempt_count: number | bigint } | undefined
774
- if (!row) continue
775
- if (Number(row.attempt_count) > claim.attempt) {
776
- superseded.push(claim.index)
1144
+ const cursor = cursorOf(sessionId, ref)
1145
+ if (!cursor) continue
1146
+ const head = toNumber(cursor.head)
1147
+ // An event the cursor has not reached yet holds no claim to renew.
1148
+ if (head !== ref.index && Number(cursor.position) < ref.index)
1149
+ continue
1150
+ if (head !== ref.index || Number(cursor.attempt_count) > attempt) {
1151
+ superseded.push(ref)
777
1152
  continue
778
1153
  }
779
1154
  const updated = extend.run(
780
1155
  expiresAt,
781
1156
  sessionId,
782
- claim.index,
783
- claim.attempt,
1157
+ ref.consumer,
1158
+ cursor.cursor_key,
1159
+ ref.index,
1160
+ attempt,
784
1161
  holder,
785
1162
  now,
786
1163
  )
787
- if (Number(updated.changes) === 1) renewed.push(claim.index)
788
- }
789
- return {
790
- renewed: renewed.toSorted((a, b) => a - b),
791
- superseded: superseded.toSorted((a, b) => a - b),
1164
+ if (Number(updated.changes) === 1) renewed.push(ref)
792
1165
  }
1166
+ return { renewed, superseded }
793
1167
  }),
794
1168
  )
795
1169
  },
796
1170
 
797
- async completeAttempt({ sessionId, index, attempt, events }) {
1171
+ async completeAttempt({
1172
+ sessionId,
1173
+ consumer,
1174
+ lane,
1175
+ index,
1176
+ attempt,
1177
+ events,
1178
+ consumers,
1179
+ }) {
798
1180
  return wrap(() =>
799
1181
  tx(() => {
800
1182
  const clearIndex = clearIndexOf(sessionId)
801
1183
  if (index <= clearIndex) return { outcome: 'superseded' }
802
- const parent = db
803
- .prepare('select * from a2_events where session_id = ? and idx = ?')
804
- .get(sessionId, index) as EventRow | undefined
805
- if (!parent) {
806
- throw new TypeError(
807
- `no event at index ${index} in session '${sessionId}'`,
808
- )
1184
+ const cursor = cursorOf(sessionId, { consumer, lane, index })
1185
+ if (!cursor) {
1186
+ if (!selectEvent.get(sessionId, index)) {
1187
+ throw new TypeError(
1188
+ `no event at index ${index} in session '${sessionId}'`,
1189
+ )
1190
+ }
1191
+ return { outcome: 'superseded' }
809
1192
  }
1193
+ refreshLogTypes(sessionId, consumers)
810
1194
  const ids = events.map((event) => event.id)
811
1195
  if (new Set(ids).size !== ids.length) {
812
1196
  throw new A2Error(
@@ -815,14 +1199,38 @@ export function sqlite(options: SqliteStoreOptions = {}): SqliteStore {
815
1199
  )
816
1200
  }
817
1201
 
818
- if (parent.processed_at !== null) {
819
- if (Number(parent.processed_by_attempt) !== attempt) {
1202
+ if (toNumber(cursor.head) !== index) {
1203
+ let completion:
1204
+ { attempt: number | null; ids: string | null } | undefined
1205
+ if (cursor.kind === 'lane') {
1206
+ const row = selectLaneEvent.get(
1207
+ sessionId,
1208
+ consumer,
1209
+ lane,
1210
+ index,
1211
+ ) as
1212
+ | {
1213
+ completed_attempt: number | bigint | null
1214
+ returned_event_ids: string | null
1215
+ }
1216
+ | undefined
1217
+ completion = row && {
1218
+ attempt: toNumber(row.completed_attempt),
1219
+ ids: row.returned_event_ids,
1220
+ }
1221
+ } else if (toNumber(cursor.completed_idx) === index) {
1222
+ completion = {
1223
+ attempt: toNumber(cursor.completed_attempt),
1224
+ ids: cursor.returned_event_ids,
1225
+ }
1226
+ }
1227
+ if (!completion || completion.attempt !== attempt) {
820
1228
  return { outcome: 'superseded' }
821
1229
  }
822
1230
  const returnedIds =
823
- parent.returned_event_ids === null
1231
+ completion.ids === null
824
1232
  ? null
825
- : decodeReturnedEventIds(parent.returned_event_ids)
1233
+ : decodeReturnedEventIds(completion.ids)
826
1234
  if (
827
1235
  returnedIds === null ||
828
1236
  returnedIds.length !== ids.length ||
@@ -836,22 +1244,21 @@ export function sqlite(options: SqliteStoreOptions = {}): SqliteStore {
836
1244
  const existing =
837
1245
  ids.length === 0
838
1246
  ? []
839
- : (selectByIds(ids.length).all(
840
- sessionId,
841
- clearIndex,
842
- ...ids,
843
- ) as unknown as ExistingEventRow[])
1247
+ : (selectByIds(ids.length).all(...ids) as unknown as EventRow[])
844
1248
  const byId = new Map(existing.map((row) => [row.event_id, row]))
845
1249
  const ordered = ids.map((id) => byId.get(id))
846
1250
  if (
847
- ordered.some(
848
- (row) =>
1251
+ ordered.some((row) => {
1252
+ const cause = row ? decodeCause(row.cause) : null
1253
+ return (
849
1254
  !row ||
850
1255
  row.session_id !== sessionId ||
851
- row.cause === null ||
852
- toCause(row.cause)?.index !== index ||
853
- toCause(row.cause)?.attempt !== attempt,
854
- )
1256
+ cause?.consumer !== consumer ||
1257
+ cause.lane !== lane ||
1258
+ cause.index !== index ||
1259
+ cause.attempt !== attempt
1260
+ )
1261
+ })
855
1262
  ) {
856
1263
  throw new A2Error(
857
1264
  'PARTIAL_DUPLICATE_BATCH',
@@ -860,16 +1267,19 @@ export function sqlite(options: SqliteStoreOptions = {}): SqliteStore {
860
1267
  }
861
1268
  return {
862
1269
  outcome: 'completed',
863
- events: idempotentReplay(
864
- events,
865
- (ordered as ExistingEventRow[]).map(toStored),
866
- ),
1270
+ events:
1271
+ events.length === 0
1272
+ ? []
1273
+ : idempotentReplay(
1274
+ events,
1275
+ (ordered as EventRow[]).map(toStored),
1276
+ ),
867
1277
  }
868
1278
  }
869
1279
  if (
870
- Number(parent.attempt_count) !== attempt ||
871
- parent.claim_holder === null ||
872
- parent.failed_at !== null
1280
+ Number(cursor.attempt_count) !== attempt ||
1281
+ cursor.claim_holder === null ||
1282
+ cursor.failed_at !== null
873
1283
  ) {
874
1284
  return { outcome: 'superseded' }
875
1285
  }
@@ -881,65 +1291,34 @@ export function sqlite(options: SqliteStoreOptions = {}): SqliteStore {
881
1291
  )
882
1292
  }
883
1293
 
884
- const status = appendStatus.get(
885
- sessionId,
1294
+ const inserted = insertBatch({
886
1295
  sessionId,
1296
+ events,
887
1297
  clearIndex,
888
- ) as AppendStatusRow
889
- const now = clock.now().getTime()
890
- db.prepare(
891
- `update a2_events set
892
- processed_at = ?,
893
- processed_by_attempt = ?,
894
- returned_event_ids = ?,
895
- lane_ready = 0,
896
- claim_holder = null,
897
- claim_expires_at = null
898
- where session_id = ? and idx = ?`,
899
- ).run(now, attempt, JSON.stringify(ids), sessionId, index)
900
- db.prepare(
901
- 'delete from a2_snapshot_pins where session_id = ? and event_index = ?',
902
- ).run(sessionId, index)
903
- db.prepare(
904
- `delete from a2_snapshot_history
905
- where session_id = ?
906
- and not exists (
907
- select 1 from a2_snapshot_pins pin
908
- where pin.session_id = a2_snapshot_history.session_id
909
- and pin.reducer_name = a2_snapshot_history.reducer_name
910
- and pin.up_to_index = a2_snapshot_history.up_to_index
911
- )`,
912
- ).run(sessionId)
913
- if (parent.lane !== null) {
914
- promoteLaneHead.run(sessionId, sessionId, parent.lane, clearIndex)
915
- }
916
- const inserted = events.map((event, offset) => {
917
- const id = event.id
918
- const childIndex = Number(status.max) + offset + 1
919
- const cause = { index, attempt }
920
- insertEvent.run(
921
- sessionId,
922
- childIndex,
923
- event.type,
924
- JSON.stringify(event.payload) ?? 'null',
925
- id,
926
- now,
927
- JSON.stringify(cause),
928
- event.lane ?? null,
929
- event.lane ?? null,
1298
+ consumers,
1299
+ cause: { consumer, lane, index, attempt },
1300
+ })
1301
+ const returned = JSON.stringify(ids)
1302
+ if (cursor.kind === 'lane') {
1303
+ finishLaneEvent.run(
1304
+ attempt,
1305
+ returned,
930
1306
  sessionId,
931
- event.lane ?? null,
932
- clearIndex,
933
- event.settled ? now : null,
1307
+ consumer,
1308
+ lane,
1309
+ index,
934
1310
  )
935
- return toStored(
936
- db
937
- .prepare(
938
- 'select * from a2_events where session_id = ? and idx = ?',
939
- )
940
- .get(sessionId, childIndex) as EventRow,
1311
+ } else {
1312
+ recordCompletion.run(
1313
+ index,
1314
+ attempt,
1315
+ returned,
1316
+ sessionId,
1317
+ consumer,
1318
+ cursor.cursor_key,
941
1319
  )
942
- })
1320
+ }
1321
+ advance(sessionId, cursor)
943
1322
  return { outcome: 'completed', events: inserted }
944
1323
  }),
945
1324
  )
@@ -947,6 +1326,8 @@ export function sqlite(options: SqliteStoreOptions = {}): SqliteStore {
947
1326
 
948
1327
  async failAttempt({
949
1328
  sessionId,
1329
+ consumer,
1330
+ lane,
950
1331
  index,
951
1332
  attempt,
952
1333
  error,
@@ -958,30 +1339,34 @@ export function sqlite(options: SqliteStoreOptions = {}): SqliteStore {
958
1339
  if (index <= clearIndexOf(sessionId)) {
959
1340
  return { outcome: 'superseded', failureCount: 0 }
960
1341
  }
961
- const current = db
962
- .prepare('select * from a2_events where session_id = ? and idx = ?')
963
- .get(sessionId, index) as EventRow | undefined
1342
+ const current = cursorOf(sessionId, { consumer, lane, index })
964
1343
  if (!current) {
965
- throw new TypeError(
966
- `no event at index ${index} in session '${sessionId}'`,
967
- )
1344
+ if (!selectEvent.get(sessionId, index)) {
1345
+ throw new TypeError(
1346
+ `no event at index ${index} in session '${sessionId}'`,
1347
+ )
1348
+ }
1349
+ return { outcome: 'superseded', failureCount: 0 }
1350
+ }
1351
+ if (toNumber(current.head) !== index) {
1352
+ return { outcome: 'superseded', failureCount: 0 }
968
1353
  }
969
1354
  const failureCount = Number(current.failure_count)
970
- if (
971
- current.processed_at !== null ||
972
- Number(current.attempt_count) !== attempt
973
- ) {
1355
+ if (Number(current.attempt_count) !== attempt) {
974
1356
  return { outcome: 'superseded', failureCount }
975
1357
  }
976
- if (current.failed_at !== null) {
977
- return { outcome: 'dead_lettered', failureCount }
978
- }
979
1358
  if (
980
1359
  current.claim_holder === null &&
981
1360
  current.last_failed_attempt !== null &&
982
1361
  Number(current.last_failed_attempt) === attempt
983
1362
  ) {
984
- return { outcome: 'failed', failureCount }
1363
+ return {
1364
+ outcome: current.failed_at === null ? 'failed' : 'dead_lettered',
1365
+ failureCount,
1366
+ }
1367
+ }
1368
+ if (current.failed_at !== null) {
1369
+ return { outcome: 'dead_lettered', failureCount }
985
1370
  }
986
1371
  if (current.claim_holder === null) {
987
1372
  return { outcome: 'superseded', failureCount }
@@ -991,7 +1376,7 @@ export function sqlite(options: SqliteStoreOptions = {}): SqliteStore {
991
1376
  const nextFailureCount = failureCount + 1
992
1377
  const deadLettered = nextFailureCount >= maxFailures
993
1378
  db.prepare(
994
- `update a2_events set
1379
+ `update a2_cursors set
995
1380
  failure_count = ?,
996
1381
  last_error = ?,
997
1382
  last_failed_at = ?,
@@ -999,7 +1384,7 @@ export function sqlite(options: SqliteStoreOptions = {}): SqliteStore {
999
1384
  failed_at = ?,
1000
1385
  claim_holder = null,
1001
1386
  claim_expires_at = ?
1002
- where session_id = ? and idx = ?`,
1387
+ where session_id = ? and consumer = ? and cursor_key = ?`,
1003
1388
  ).run(
1004
1389
  nextFailureCount,
1005
1390
  error,
@@ -1010,9 +1395,12 @@ export function sqlite(options: SqliteStoreOptions = {}): SqliteStore {
1010
1395
  ? null
1011
1396
  : now + retryAfterMs,
1012
1397
  sessionId,
1013
- index,
1398
+ consumer,
1399
+ current.cursor_key,
1014
1400
  )
1015
- if (deadLettered) releasePins(sessionId, index)
1401
+ if (deadLettered) {
1402
+ releasePins(sessionId, consumer, current.cursor_key, index)
1403
+ }
1016
1404
  return {
1017
1405
  outcome: deadLettered ? 'dead_lettered' : 'failed',
1018
1406
  failureCount: nextFailureCount,
@@ -1025,58 +1413,58 @@ export function sqlite(options: SqliteStoreOptions = {}): SqliteStore {
1025
1413
  return wrap(() => {
1026
1414
  const rows = db
1027
1415
  .prepare(
1028
- `select * from a2_events
1029
- where session_id = ?
1030
- and processed_at is null
1031
- and failed_at is not null
1032
- and idx > coalesce(
1416
+ `${HEAD_SELECT}
1417
+ where cursor.session_id = ?
1418
+ and cursor.failed_at is not null
1419
+ and cursor.head > coalesce(
1033
1420
  (select clear_index from a2_clears where session_id = ?), 0
1034
1421
  )
1035
- order by idx`,
1422
+ order by cursor.head, cursor.consumer`,
1036
1423
  )
1037
- .all(sessionId, sessionId) as unknown as EventRow[]
1038
- return rows.map(toStored)
1424
+ .all(sessionId, sessionId) as unknown as HeadRow[]
1425
+ return rows.map(toHead)
1039
1426
  })
1040
1427
  },
1041
1428
 
1042
- async redrive({ sessionId, index, action }) {
1429
+ async redrive({ sessionId, consumer, index, action, consumers }) {
1043
1430
  return wrap(() =>
1044
1431
  tx(() => {
1045
1432
  const clearIndex = clearIndexOf(sessionId)
1046
- const row =
1433
+ const cursor =
1047
1434
  index > clearIndex
1048
1435
  ? (db
1049
1436
  .prepare(
1050
- 'select lane, processed_at, failed_at from a2_events where session_id = ? and idx = ?',
1437
+ `select * from a2_cursors
1438
+ where session_id = ? and consumer = ? and head = ?
1439
+ and failed_at is not null`,
1051
1440
  )
1052
- .get(sessionId, index) as
1053
- | Pick<EventRow, 'lane' | 'processed_at' | 'failed_at'>
1054
- | undefined)
1441
+ .get(sessionId, consumer, index) as CursorRow | undefined)
1055
1442
  : undefined
1056
- const redriven =
1057
- row !== undefined &&
1058
- row.processed_at === null &&
1059
- row.failed_at !== null
1060
- if (redriven && action === 'skip') {
1061
- db.prepare(
1062
- `update a2_events set processed_at = ?, lane_ready = 0
1063
- where session_id = ? and idx = ?`,
1064
- ).run(clock.now().getTime(), sessionId, index)
1065
- if (row.lane !== null) {
1066
- promoteLaneHead.run(sessionId, sessionId, row.lane, clearIndex)
1443
+ if (cursor) {
1444
+ refreshLogTypes(sessionId, consumers)
1445
+ if (action === 'skip') {
1446
+ if (cursor.kind === 'lane') {
1447
+ finishLaneEvent.run(
1448
+ null,
1449
+ null,
1450
+ sessionId,
1451
+ consumer,
1452
+ cursor.lane,
1453
+ index,
1454
+ )
1455
+ }
1456
+ advance(sessionId, cursor)
1457
+ } else {
1458
+ db.prepare(
1459
+ `update a2_cursors set failed_at = null, failure_count = 0
1460
+ where session_id = ? and consumer = ? and cursor_key = ?`,
1461
+ ).run(sessionId, consumer, cursor.cursor_key)
1067
1462
  }
1068
- } else if (redriven) {
1069
- db.prepare(
1070
- `update a2_events set failed_at = null, failure_count = 0
1071
- where session_id = ? and idx = ?`,
1072
- ).run(sessionId, index)
1073
1463
  }
1074
- const status = appendStatus.get(
1075
- sessionId,
1076
- sessionId,
1077
- clearIndex,
1078
- ) as AppendStatusRow
1079
- return { redriven, hasPending: Number(status.has_pending) === 1 }
1464
+ return {
1465
+ redriven: cursor !== undefined,
1466
+ hasPending: hasPending(sessionId, clearIndex, consumers),
1467
+ }
1080
1468
  }),
1081
1469
  )
1082
1470
  },
@@ -1116,21 +1504,15 @@ export function sqlite(options: SqliteStoreOptions = {}): SqliteStore {
1116
1504
  (select state from snapshot) as snapshot_state,
1117
1505
  null as session_id, null as idx, null as event_type,
1118
1506
  null as payload, null as event_id, null as created_at,
1119
- null as cause, null as lane, null as lane_ready,
1120
- null as processed_at,
1121
- null as processed_by_attempt, null as returned_event_ids,
1122
- null as first_claimed_at,
1123
- null as last_claimed_at, null as attempt_count,
1124
- null as failure_count, null as last_failed_at,
1125
- null as last_failed_attempt, null as last_error,
1126
- null as failed_at, null as claim_holder,
1127
- null as claim_expires_at
1507
+ null as cause
1128
1508
  union all
1129
1509
  select 1 as row_order, 'event' as row_kind,
1130
1510
  null as clear_index,
1131
1511
  (select up_to_index from head) as head_index,
1132
1512
  null as snapshot_index, null as snapshot_state,
1133
- event.*
1513
+ event.session_id, event.idx, event.event_type,
1514
+ event.payload, event.event_id, event.created_at,
1515
+ event.cause
1134
1516
  from a2_events as event
1135
1517
  where event.session_id = ?
1136
1518
  and event.idx > max(
@@ -1182,19 +1564,19 @@ export function sqlite(options: SqliteStoreOptions = {}): SqliteStore {
1182
1564
  async putSnapshots(sessionId, reducerName, writes) {
1183
1565
  await wrap(() =>
1184
1566
  tx(() => {
1185
- const selectHead = db.prepare(
1567
+ const selectSnapshotHead = db.prepare(
1186
1568
  `select up_to_index, state, updated_at from a2_snapshots
1187
1569
  where session_id = ? and reducer_name = ?`,
1188
1570
  )
1189
1571
  const unfinished = db.prepare(
1190
- `select 1 from a2_events
1191
- where session_id = ? and idx = ? and idx > ?
1192
- and processed_at is null and failed_at is null`,
1572
+ `select 1 from a2_cursors
1573
+ where session_id = ? and consumer = ? and cursor_key = ?
1574
+ and head = ? and head > ? and failed_at is null`,
1193
1575
  )
1194
1576
  const insertPin = db.prepare(
1195
1577
  `insert or ignore into a2_snapshot_pins
1196
- (session_id, event_index, reducer_name, up_to_index)
1197
- values (?, ?, ?, ?)`,
1578
+ (session_id, consumer, cursor_key, event_index, reducer_name, up_to_index)
1579
+ values (?, ?, ?, ?, ?, ?)`,
1198
1580
  )
1199
1581
  const hasPin = db.prepare(
1200
1582
  `select 1 from a2_snapshot_pins
@@ -1226,12 +1608,28 @@ export function sqlite(options: SqliteStoreOptions = {}): SqliteStore {
1226
1608
  for (const write of writes.toSorted((a, b) => a.index - b.index)) {
1227
1609
  // A fold from before the latest clear describes a lifetime that is gone.
1228
1610
  if (write.index < clearIndex) continue
1229
- for (const eventIndex of write.pinEventIndexes ?? []) {
1230
- if (unfinished.get(sessionId, eventIndex, clearIndex)) {
1231
- insertPin.run(sessionId, eventIndex, reducerName, write.index)
1611
+ for (const pin of write.pins ?? []) {
1612
+ const cursorKey = cursorKeyOf(pin.lane, pin.index)
1613
+ if (
1614
+ unfinished.get(
1615
+ sessionId,
1616
+ pin.consumer,
1617
+ cursorKey,
1618
+ pin.index,
1619
+ clearIndex,
1620
+ )
1621
+ ) {
1622
+ insertPin.run(
1623
+ sessionId,
1624
+ pin.consumer,
1625
+ cursorKey,
1626
+ pin.index,
1627
+ reducerName,
1628
+ write.index,
1629
+ )
1232
1630
  }
1233
1631
  }
1234
- const head = selectHead.get(sessionId, reducerName) as
1632
+ const head = selectSnapshotHead.get(sessionId, reducerName) as
1235
1633
  SnapshotHeadRow | undefined
1236
1634
  const now = clock.now().getTime()
1237
1635
  if (!head || write.index >= Number(head.up_to_index)) {
@@ -1349,15 +1747,7 @@ export function sqlite(options: SqliteStoreOptions = {}): SqliteStore {
1349
1747
  tx(() => {
1350
1748
  const clearIndex = clearIndexOf(sessionId)
1351
1749
  if (cause) assertCurrentCause({ sessionId, cause, clearIndex })
1352
- const head = Number(
1353
- (
1354
- appendStatus.get(
1355
- sessionId,
1356
- sessionId,
1357
- clearIndex,
1358
- ) as AppendStatusRow
1359
- ).max,
1360
- )
1750
+ const head = headOf(sessionId)
1361
1751
  if (ifLastIndex !== undefined && clearIndex === ifLastIndex + 1) {
1362
1752
  return { cleared: true, index: head, clearIndex }
1363
1753
  }
@@ -1368,26 +1758,32 @@ export function sqlite(options: SqliteStoreOptions = {}): SqliteStore {
1368
1758
  return { cleared: true, index: head, clearIndex }
1369
1759
  }
1370
1760
  const marker = head + 1
1371
- const now = clock.now().getTime()
1372
1761
  insertEvent.run(
1373
1762
  sessionId,
1374
1763
  marker,
1375
1764
  CLEAR_EVENT_TYPE,
1376
1765
  'null',
1377
1766
  generateId(),
1378
- now,
1379
- null,
1380
- null,
1767
+ clock.now().getTime(),
1381
1768
  null,
1382
- sessionId,
1383
- null,
1384
- clearIndex,
1385
- now,
1386
1769
  )
1387
1770
  db.prepare(
1388
1771
  `insert into a2_clears (session_id, clear_index) values (?, ?)
1389
1772
  on conflict (session_id) do update set clear_index = excluded.clear_index`,
1390
1773
  ).run(sessionId, marker)
1774
+ // One row per log consumer: they count from the marker. Lane and
1775
+ // concurrent cursors at or below it are superseded by the marker
1776
+ // alone, and reclaim deletes them.
1777
+ db.prepare(
1778
+ `update a2_cursors
1779
+ set position = ?, head = null, ${FRESH_DISPATCH},
1780
+ completed_idx = null, completed_attempt = null,
1781
+ returned_event_ids = null
1782
+ where session_id = ? and kind = 'log'`,
1783
+ ).run(marker, sessionId)
1784
+ db.prepare(
1785
+ 'delete from a2_snapshot_pins where session_id = ? and event_index < ?',
1786
+ ).run(sessionId, marker)
1391
1787
  return { cleared: true, index: marker, clearIndex: marker }
1392
1788
  }),
1393
1789
  )
@@ -1420,7 +1816,11 @@ export function sqlite(options: SqliteStoreOptions = {}): SqliteStore {
1420
1816
  .run(sessionId, through).changes,
1421
1817
  )
1422
1818
  db.prepare(
1423
- 'delete from a2_snapshot_pins where session_id = ? and event_index <= ?',
1819
+ 'delete from a2_lane_events where session_id = ? and idx <= ?',
1820
+ ).run(sessionId, through)
1821
+ db.prepare(
1822
+ `delete from a2_cursors
1823
+ where session_id = ? and kind = 'event' and position < ?`,
1424
1824
  ).run(sessionId, through)
1425
1825
  }
1426
1826
  const done =
@@ -1431,6 +1831,11 @@ export function sqlite(options: SqliteStoreOptions = {}): SqliteStore {
1431
1831
  .get(sessionId, clearIndex) === undefined
1432
1832
  // Writes serialize with the clear, so none lands below the marker later.
1433
1833
  if (done) {
1834
+ db.prepare(
1835
+ `delete from a2_cursors
1836
+ where session_id = ? and kind != 'log'
1837
+ and (head is null or head <= ?) and position <= ?`,
1838
+ ).run(sessionId, clearIndex, clearIndex)
1434
1839
  for (const table of [
1435
1840
  'a2_snapshot_pins',
1436
1841
  'a2_snapshot_history',