experimental-a2 0.20.0 → 0.22.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (309) hide show
  1. package/CHANGELOG.md +55 -0
  2. package/README.md +1 -1
  3. package/dist/{actor-C7b-1arn.d.ts → actor-CqibLgjQ.d.ts} +15 -7
  4. package/dist/actor-CqibLgjQ.d.ts.map +1 -0
  5. package/dist/actor-client.d.ts +1 -1
  6. package/dist/actor-client.js +2 -2
  7. package/dist/actor-react.d.ts +3 -3
  8. package/dist/actor-react.js +5 -5
  9. package/dist/{actor-shared-t7w8xIKP.js → actor-shared-DFET5q16.js} +3 -3
  10. package/dist/actor-shared-DFET5q16.js.map +1 -0
  11. package/dist/actor.d.ts +1 -1
  12. package/dist/actor.js +54 -43
  13. package/dist/actor.js.map +1 -1
  14. package/dist/{ai-Cz3hqVhk.d.ts → ai-DAu77CDr.d.ts} +8 -8
  15. package/dist/{ai-Cz3hqVhk.d.ts.map → ai-DAu77CDr.d.ts.map} +1 -1
  16. package/dist/{ai-mMiWul1T.js → ai-EEfKyc-z.js} +9 -9
  17. package/dist/ai-EEfKyc-z.js.map +1 -0
  18. package/dist/ai-client.d.ts +2 -2
  19. package/dist/ai-client.js +2 -2
  20. package/dist/ai-fx.d.ts +4 -2
  21. package/dist/ai-fx.d.ts.map +1 -1
  22. package/dist/ai-fx.js +214 -67
  23. package/dist/ai-fx.js.map +1 -1
  24. package/dist/{ai-harness-CV6rXbB3.d.ts → ai-harness-DTjkAr2T.d.ts} +2 -2
  25. package/dist/{ai-harness-CV6rXbB3.d.ts.map → ai-harness-DTjkAr2T.d.ts.map} +1 -1
  26. package/dist/{ai-harness-Clbg5ei7.js → ai-harness-dQeJaR1t.js} +7 -3
  27. package/dist/ai-harness-dQeJaR1t.js.map +1 -0
  28. package/dist/ai-pi.d.ts +1 -1
  29. package/dist/ai-pi.js +3 -3
  30. package/dist/ai-pi.js.map +1 -1
  31. package/dist/ai-react.d.ts +5 -5
  32. package/dist/ai-react.js +2 -2
  33. package/dist/ai-server.d.ts +14 -17
  34. package/dist/ai-server.d.ts.map +1 -1
  35. package/dist/ai-server.js +86 -73
  36. package/dist/ai-server.js.map +1 -1
  37. package/dist/ai.d.ts +2 -2
  38. package/dist/ai.js +2 -2
  39. package/dist/{client-BhHLoNKF.js → client-BQS4i_V9.js} +29 -6
  40. package/dist/client-BQS4i_V9.js.map +1 -0
  41. package/dist/{client-h_DKFAjj.d.ts → client-CUMbS_38.d.ts} +3 -3
  42. package/dist/{client-h_DKFAjj.d.ts.map → client-CUMbS_38.d.ts.map} +1 -1
  43. package/dist/client.d.ts +3 -3
  44. package/dist/client.js +1 -1
  45. package/dist/{contract-BYKASlCV.js → contract-CWukt01d.js} +2 -2
  46. package/dist/contract-CWukt01d.js.map +1 -0
  47. package/dist/index.d.ts +5 -5
  48. package/dist/index.js +3 -3
  49. package/dist/{initial-snapshot-Bx-c0pDP.d.ts → initial-snapshot-D3Lve43o.d.ts} +4 -4
  50. package/dist/initial-snapshot-D3Lve43o.d.ts.map +1 -0
  51. package/dist/{internal-CscSY7K_.js → internal-CvsrprnU.js} +40 -16
  52. package/dist/internal-CvsrprnU.js.map +1 -0
  53. package/dist/otel.d.ts +1 -1
  54. package/dist/otel.js +1 -1
  55. package/dist/otel.js.map +1 -1
  56. package/dist/{react-BBXgHWFc.d.ts → react-BoHvC3v9.d.ts} +4 -4
  57. package/dist/{react-BBXgHWFc.d.ts.map → react-BoHvC3v9.d.ts.map} +1 -1
  58. package/dist/react.d.ts +1 -1
  59. package/dist/react.js +1 -1
  60. package/dist/{reducer-CqA59Q6U.d.ts → reducer-CYPHsSmd.d.ts} +2 -2
  61. package/dist/{reducer-CqA59Q6U.d.ts.map → reducer-CYPHsSmd.d.ts.map} +1 -1
  62. package/dist/{reducer-BAXc8JyF.js → reducer-CjmvZSAb.js} +2 -2
  63. package/dist/{reducer-BAXc8JyF.js.map → reducer-CjmvZSAb.js.map} +1 -1
  64. package/dist/runtime-consumer-CIeL33wj.js +12 -0
  65. package/dist/runtime-consumer-CIeL33wj.js.map +1 -0
  66. package/dist/scheduler-qstash.d.ts +2 -2
  67. package/dist/scheduler-qstash.js +3 -3
  68. package/dist/scheduler-task-X1SToDjP.js.map +1 -1
  69. package/dist/scheduler-vercel.d.ts +2 -2
  70. package/dist/scheduler-vercel.js +3 -3
  71. package/dist/scheduler-vercel.js.map +1 -1
  72. package/dist/{server-qyb5aJJG.js → server-DtFrWxi3.js} +412 -116
  73. package/dist/server-DtFrWxi3.js.map +1 -0
  74. package/dist/{server-DYAATFuT.d.ts → server-qgez0Xny.d.ts} +108 -41
  75. package/dist/server-qgez0Xny.d.ts.map +1 -0
  76. package/dist/server.browser.js +1 -1
  77. package/dist/server.browser.js.map +1 -1
  78. package/dist/server.d.ts +4 -4
  79. package/dist/server.js +2 -2
  80. package/dist/{session-clear-C-QwTXV1.d.ts → session-clear-D1PFe7xl.d.ts} +2 -2
  81. package/dist/session-clear-D1PFe7xl.d.ts.map +1 -0
  82. package/dist/{session-clear-DGI4jTQ5.js → session-clear-wHd5GCpZ.js} +5 -4
  83. package/dist/session-clear-wHd5GCpZ.js.map +1 -0
  84. package/dist/store-D0MhBvg6.d.ts +532 -0
  85. package/dist/store-D0MhBvg6.d.ts.map +1 -0
  86. package/dist/store-N8PXxDAS.js.map +1 -1
  87. package/dist/store-codec-DwrWlgQx.js +65 -0
  88. package/dist/store-codec-DwrWlgQx.js.map +1 -0
  89. package/dist/store-memory.d.ts +1 -1
  90. package/dist/store-memory.d.ts.map +1 -1
  91. package/dist/store-memory.js +406 -225
  92. package/dist/store-memory.js.map +1 -1
  93. package/dist/{store-polling-7WJV0YWP.js → store-polling-BZt2Rf3D.js} +2 -2
  94. package/dist/{store-polling-7WJV0YWP.js.map → store-polling-BZt2Rf3D.js.map} +1 -1
  95. package/dist/store-postgres.d.ts +1 -1
  96. package/dist/store-postgres.d.ts.map +1 -1
  97. package/dist/store-postgres.js +1598 -587
  98. package/dist/store-postgres.js.map +1 -1
  99. package/dist/store-presence-polling-JuAy2Ck2.js +111 -0
  100. package/dist/store-presence-polling-JuAy2Ck2.js.map +1 -0
  101. package/dist/store-redis-http.d.ts +1 -1
  102. package/dist/store-redis-http.js +4 -4
  103. package/dist/store-redis-notify-CsJw_hfD.js +3060 -0
  104. package/dist/store-redis-notify-CsJw_hfD.js.map +1 -0
  105. package/dist/store-redis.d.ts +1 -1
  106. package/dist/store-redis.d.ts.map +1 -1
  107. package/dist/store-redis.js +4 -5
  108. package/dist/store-redis.js.map +1 -1
  109. package/dist/store-sqlite.d.ts +1 -1
  110. package/dist/store-sqlite.d.ts.map +1 -1
  111. package/dist/store-sqlite.js +527 -299
  112. package/dist/store-sqlite.js.map +1 -1
  113. package/dist/stream-buffer-DrTyiIuo.js +57 -0
  114. package/dist/stream-buffer-DrTyiIuo.js.map +1 -0
  115. package/dist/{telemetry-Cyfqo6Qt.d.ts → telemetry-CWwsh-Gx.d.ts} +3 -3
  116. package/dist/telemetry-CWwsh-Gx.d.ts.map +1 -0
  117. package/dist/{wire-BKxH1vbP.js → wire-CV-Z7wz-.js} +6 -5
  118. package/dist/wire-CV-Z7wz-.js.map +1 -0
  119. package/docs/01-quickstart.mdx +21 -15
  120. package/docs/02-examples.mdx +1 -1
  121. package/docs/actors/01-introduction.mdx +13 -2
  122. package/docs/actors/02-concurrency.mdx +1 -1
  123. package/docs/agents/01-introduction.mdx +1 -1
  124. package/docs/agents/02-tools.mdx +12 -11
  125. package/docs/agents/03-conversations.mdx +36 -25
  126. package/docs/agents/04-harnesses.mdx +14 -7
  127. package/docs/client/03-presence.mdx +18 -14
  128. package/docs/contract/01-events.mdx +2 -2
  129. package/docs/contract/02-state.mdx +18 -14
  130. package/docs/contract/03-sessions.mdx +32 -25
  131. package/docs/index.mdx +29 -24
  132. package/docs/reference/01-core.mdx +7 -6
  133. package/docs/reference/02-server.mdx +250 -191
  134. package/docs/reference/04-ai.mdx +33 -23
  135. package/docs/reference/05-telemetry.mdx +27 -13
  136. package/docs/reference/06-errors.mdx +25 -19
  137. package/docs/reference/meta.ts +1 -1
  138. package/docs/schedulers/01-overview.mdx +8 -8
  139. package/docs/schedulers/02-vercel-queues.mdx +2 -2
  140. package/docs/schedulers/03-qstash.mdx +4 -4
  141. package/docs/server/01-consumers.mdx +418 -0
  142. package/docs/server/02-timers.mdx +31 -21
  143. package/docs/server/03-cancellation.mdx +52 -44
  144. package/docs/server/04-routes.mdx +4 -1
  145. package/docs/server/05-sql-projections.mdx +391 -0
  146. package/docs/server/06-guarantees.mdx +61 -51
  147. package/docs/stores/01-overview.mdx +3 -3
  148. package/docs/stores/02-redis.mdx +24 -10
  149. package/docs/stores/03-redis-http.mdx +8 -6
  150. package/docs/stores/04-postgres.mdx +65 -14
  151. package/docs/stores/05-sqlite.mdx +5 -3
  152. package/docs/upgrades/01-overview.mdx +31 -0
  153. package/docs/upgrades/78-0-22.mdx +115 -0
  154. package/docs/upgrades/79-0-21.mdx +165 -0
  155. package/docs/upgrades/80-0-20.mdx +43 -0
  156. package/docs/upgrades/81-0-19.mdx +33 -0
  157. package/docs/upgrades/82-0-18.mdx +33 -0
  158. package/docs/upgrades/83-0-17.mdx +69 -0
  159. package/docs/upgrades/meta.ts +3 -0
  160. package/examples/playground/DEPLOY.md +39 -7
  161. package/examples/playground/app/a0/chat.test.tsx +2 -2
  162. package/examples/playground/app/a0/sandbox-log.ts +17 -15
  163. package/examples/playground/app/a0/sandbox.ts +58 -13
  164. package/examples/playground/app/a0/server.ts +4 -4
  165. package/examples/playground/app/a2slack/a2slack.css +1067 -0
  166. package/examples/playground/app/a2slack/c/[channel]/page.tsx +20 -0
  167. package/examples/playground/app/a2slack/consumers/agent.ts +63 -0
  168. package/examples/playground/app/a2slack/consumers/feed.ts +164 -0
  169. package/examples/playground/app/a2slack/consumers/notify.ts +116 -0
  170. package/examples/playground/app/a2slack/consumers/search.ts +37 -0
  171. package/examples/playground/app/a2slack/consumers/triage.ts +31 -0
  172. package/examples/playground/app/a2slack/consumers/welcome.ts +25 -0
  173. package/examples/playground/app/a2slack/db/a2.ts +209 -0
  174. package/examples/playground/app/a2slack/db/index.ts +73 -0
  175. package/examples/playground/app/a2slack/db/migrations/0000_extensions.sql +4 -0
  176. package/examples/playground/app/a2slack/db/migrations/0001_workspace.sql +74 -0
  177. package/examples/playground/app/a2slack/db/migrations/0002_a2.sql +215 -0
  178. package/examples/playground/app/a2slack/db/migrations/0003_a2_batch.sql +67 -0
  179. package/examples/playground/app/a2slack/db/migrations/0004_a2_session_event_ids.sql +2 -0
  180. package/examples/playground/app/a2slack/db/migrations/meta/0000_snapshot.json +18 -0
  181. package/examples/playground/app/a2slack/db/migrations/meta/0001_snapshot.json +500 -0
  182. package/examples/playground/app/a2slack/db/migrations/meta/0002_snapshot.json +1159 -0
  183. package/examples/playground/app/a2slack/db/migrations/meta/0003_snapshot.json +1159 -0
  184. package/examples/playground/app/a2slack/db/migrations/meta/0004_snapshot.json +1165 -0
  185. package/examples/playground/app/a2slack/db/migrations/meta/_journal.json +41 -0
  186. package/examples/playground/app/a2slack/db/queries.ts +263 -0
  187. package/examples/playground/app/a2slack/db/schema.ts +145 -0
  188. package/examples/playground/app/a2slack/drills/a2slack.drill.ts +708 -0
  189. package/examples/playground/app/a2slack/drills/harness.ts +244 -0
  190. package/examples/playground/app/a2slack/events/route.ts +36 -0
  191. package/examples/playground/app/a2slack/identity.ts +44 -0
  192. package/examples/playground/app/a2slack/inbox/route.ts +8 -0
  193. package/examples/playground/app/a2slack/lag/data/route.ts +8 -0
  194. package/examples/playground/app/a2slack/lag/page.tsx +101 -0
  195. package/examples/playground/app/a2slack/lag.ts +73 -0
  196. package/examples/playground/app/a2slack/layout.tsx +43 -0
  197. package/examples/playground/app/a2slack/messages/route.ts +17 -0
  198. package/examples/playground/app/a2slack/model.test.ts +208 -0
  199. package/examples/playground/app/a2slack/model.ts +369 -0
  200. package/examples/playground/app/a2slack/models.test.ts +72 -0
  201. package/examples/playground/app/a2slack/page.tsx +10 -0
  202. package/examples/playground/app/a2slack/scheduler/route.ts +5 -0
  203. package/examples/playground/app/a2slack/search/route.ts +40 -0
  204. package/examples/playground/app/a2slack/search-index.ts +108 -0
  205. package/examples/playground/app/a2slack/server.test.ts +315 -0
  206. package/examples/playground/app/a2slack/server.ts +55 -0
  207. package/examples/playground/app/a2slack/sessions.ts +50 -0
  208. package/examples/playground/app/a2slack/thread/route.ts +8 -0
  209. package/examples/playground/app/a2slack/ui/ChannelView.tsx +87 -0
  210. package/examples/playground/app/a2slack/ui/Composer.tsx +117 -0
  211. package/examples/playground/app/a2slack/ui/DotsShadow.tsx +42 -0
  212. package/examples/playground/app/a2slack/ui/Feed.tsx +157 -0
  213. package/examples/playground/app/a2slack/ui/InboxContext.tsx +63 -0
  214. package/examples/playground/app/a2slack/ui/InboxView.tsx +82 -0
  215. package/examples/playground/app/a2slack/ui/Menu.tsx +128 -0
  216. package/examples/playground/app/a2slack/ui/MessageRow.tsx +314 -0
  217. package/examples/playground/app/a2slack/ui/NameGate.tsx +35 -0
  218. package/examples/playground/app/a2slack/ui/SearchContext.tsx +146 -0
  219. package/examples/playground/app/a2slack/ui/SearchResults.tsx +97 -0
  220. package/examples/playground/app/a2slack/ui/Shell.tsx +34 -0
  221. package/examples/playground/app/a2slack/ui/Sidebar.tsx +199 -0
  222. package/examples/playground/app/a2slack/ui/ThreadPanel.tsx +134 -0
  223. package/examples/playground/app/a2slack/ui/useInbox.ts +71 -0
  224. package/examples/playground/app/agent/server.ts +3 -3
  225. package/examples/playground/app/chat/[chatId]/chat-client.tsx +1 -1
  226. package/examples/playground/app/chat/server.ts +45 -35
  227. package/examples/playground/app/components/demo-icons.tsx +7 -0
  228. package/examples/playground/app/components/demos.tsx +7 -0
  229. package/examples/playground/app/orders/[orderId]/order-client.tsx +2 -2
  230. package/examples/playground/app/orders/page.tsx +1 -1
  231. package/examples/playground/app/orders/server.ts +49 -30
  232. package/examples/playground/app/recovery/[recoveryId]/recovery-client.tsx +14 -15
  233. package/examples/playground/app/recovery/page.tsx +4 -4
  234. package/examples/playground/app/recovery/server.ts +23 -32
  235. package/examples/playground/drizzle.config.ts +14 -0
  236. package/examples/playground/lib/store.ts +1 -1
  237. package/examples/playground/next.config.ts +6 -1
  238. package/examples/playground/package.json +12 -1
  239. package/examples/playground/scripts/migrate.mjs +31 -0
  240. package/examples/playground/vercel.json +9 -0
  241. package/examples/playground/vitest.drills.config.ts +32 -0
  242. package/package.json +5 -2
  243. package/sql/postgres/drizzle-schema.ts +209 -0
  244. package/sql/postgres/schema.sql +227 -67
  245. package/src/actor-shared.ts +10 -2
  246. package/src/actor.ts +101 -71
  247. package/src/ai-agent.ts +162 -111
  248. package/src/ai-control-server.ts +8 -8
  249. package/src/ai-fx.ts +324 -82
  250. package/src/ai-harness.ts +17 -2
  251. package/src/ai-pi.ts +2 -2
  252. package/src/ai-server.ts +2 -2
  253. package/src/ai.ts +7 -7
  254. package/src/client.ts +19 -2
  255. package/src/contract.ts +1 -1
  256. package/src/deterministic-id.ts +27 -4
  257. package/src/errors.ts +8 -5
  258. package/src/idempotent-replay.ts +0 -1
  259. package/src/index.ts +1 -1
  260. package/src/internal.ts +57 -16
  261. package/src/otel.ts +1 -1
  262. package/src/postgres-schema.ts +98 -57
  263. package/src/presence-recovery.ts +18 -11
  264. package/src/presence-tombstones.ts +52 -0
  265. package/src/push-envelope.ts +20 -0
  266. package/src/runtime-consumer.ts +10 -0
  267. package/src/scheduler-task.ts +2 -2
  268. package/src/scheduler-vercel.ts +1 -1
  269. package/src/server.browser.ts +1 -1
  270. package/src/server.ts +819 -218
  271. package/src/session-socket.ts +1 -1
  272. package/src/store-codec.ts +66 -0
  273. package/src/store-memory.ts +604 -286
  274. package/src/store-postgres.ts +2006 -857
  275. package/src/store-presence-polling.ts +59 -34
  276. package/src/store-redis-core.ts +1961 -1333
  277. package/src/store-redis-notify.ts +76 -26
  278. package/src/store-redis.ts +2 -3
  279. package/src/store-sqlite.ts +915 -516
  280. package/src/store.ts +241 -102
  281. package/src/telemetry.ts +2 -1
  282. package/src/wire.ts +1 -0
  283. package/dist/actor-C7b-1arn.d.ts.map +0 -1
  284. package/dist/actor-shared-t7w8xIKP.js.map +0 -1
  285. package/dist/ai-harness-Clbg5ei7.js.map +0 -1
  286. package/dist/ai-mMiWul1T.js.map +0 -1
  287. package/dist/client-BhHLoNKF.js.map +0 -1
  288. package/dist/contract-BYKASlCV.js.map +0 -1
  289. package/dist/idempotent-replay-JdgMQRRm.js +0 -21
  290. package/dist/idempotent-replay-JdgMQRRm.js.map +0 -1
  291. package/dist/initial-snapshot-Bx-c0pDP.d.ts.map +0 -1
  292. package/dist/internal-CscSY7K_.js.map +0 -1
  293. package/dist/server-DYAATFuT.d.ts.map +0 -1
  294. package/dist/server-qyb5aJJG.js.map +0 -1
  295. package/dist/session-clear-C-QwTXV1.d.ts.map +0 -1
  296. package/dist/session-clear-DGI4jTQ5.js.map +0 -1
  297. package/dist/store-CyLUy86v.d.ts +0 -404
  298. package/dist/store-CyLUy86v.d.ts.map +0 -1
  299. package/dist/store-codec-DTG0Ftek.js +0 -10
  300. package/dist/store-codec-DTG0Ftek.js.map +0 -1
  301. package/dist/store-presence-polling-B1ixeqIS.js +0 -94
  302. package/dist/store-presence-polling-B1ixeqIS.js.map +0 -1
  303. package/dist/store-redis-notify-CM0AfLY0.js +0 -2544
  304. package/dist/store-redis-notify-CM0AfLY0.js.map +0 -1
  305. package/dist/stream-buffer-Cx7AAPGq.js +0 -23
  306. package/dist/stream-buffer-Cx7AAPGq.js.map +0 -1
  307. package/dist/telemetry-Cyfqo6Qt.d.ts.map +0 -1
  308. package/dist/wire-BKxH1vbP.js.map +0 -1
  309. package/docs/server/01-handlers.mdx +0 -227
@@ -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,52 @@ 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_session_event_id
79
+ on a2_events (session_id, event_id);
80
+ drop index if exists a2_events_event_id;
81
+ create index if not exists a2_events_types on a2_events (session_id, event_type, idx);
82
+
83
+ create table if not exists a2_cursors (
84
+ session_id text not null,
85
+ consumer text not null,
86
+ cursor_key text not null,
57
87
  lane text,
58
- lane_ready integer not null default 0,
59
- processed_at integer,
60
- processed_by_attempt integer,
61
- returned_event_ids text,
88
+ kind text not null,
89
+ position integer not null,
90
+ head integer,
91
+ types text,
92
+ attempt_count integer not null default 0,
62
93
  first_claimed_at integer,
63
94
  last_claimed_at integer,
64
- attempt_count integer not null default 0,
95
+ claim_holder text,
96
+ claim_expires_at integer,
65
97
  failure_count integer not null default 0,
66
98
  last_failed_at integer,
67
99
  last_failed_attempt integer,
68
100
  last_error text,
69
101
  failed_at integer,
70
- claim_holder text,
71
- claim_expires_at integer,
72
- primary key (session_id, idx)
102
+ completed_idx integer,
103
+ completed_attempt integer,
104
+ returned_event_ids text,
105
+ primary key (session_id, consumer, cursor_key)
73
106
  ) strict;
74
107
 
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;
108
+ create index if not exists a2_cursors_heads
109
+ on a2_cursors (session_id, head) where head is not null;
110
+
111
+ create table if not exists a2_lane_events (
112
+ session_id text not null,
113
+ consumer text not null,
114
+ lane text not null,
115
+ idx integer not null,
116
+ done integer not null default 0,
117
+ completed_attempt integer,
118
+ returned_event_ids text,
119
+ primary key (session_id, consumer, lane, idx)
120
+ ) strict;
86
121
 
87
122
  create table if not exists a2_snapshots (
88
123
  session_id text not null,
@@ -104,10 +139,12 @@ create table if not exists a2_snapshot_history (
104
139
 
105
140
  create table if not exists a2_snapshot_pins (
106
141
  session_id text not null,
142
+ consumer text not null,
143
+ cursor_key text not null,
107
144
  event_index integer not null,
108
145
  reducer_name text not null,
109
146
  up_to_index integer not null,
110
- primary key (session_id, event_index, reducer_name, up_to_index)
147
+ primary key (session_id, consumer, cursor_key, event_index, reducer_name, up_to_index)
111
148
  ) strict;
112
149
 
113
150
  create index if not exists a2_snapshot_pins_checkpoint
@@ -165,34 +202,51 @@ const wrap = <T>(fn: () => T): T => {
165
202
  const toDate = (ms: number | bigint | null): Date | null =>
166
203
  ms === null ? null : new Date(Number(ms))
167
204
 
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
- }
205
+ const toNumber = (value: number | bigint | null): number | null =>
206
+ value === null ? null : Number(value)
207
+
208
+ type EventRow = {
209
+ session_id: string
210
+ idx: number | bigint
211
+ event_type: string
212
+ payload: string
213
+ event_id: string
214
+ created_at: number | bigint
215
+ cause: string | null
216
+ }
217
+
218
+ type CursorRow = {
219
+ session_id: string
220
+ consumer: string
221
+ cursor_key: string
222
+ lane: string | null
223
+ kind: 'log' | 'lane' | 'event'
224
+ position: number | bigint
225
+ head: number | bigint | null
226
+ types: string | null
227
+ attempt_count: number | bigint
228
+ first_claimed_at: number | bigint | null
229
+ last_claimed_at: number | bigint | null
230
+ claim_holder: string | null
231
+ claim_expires_at: number | bigint | null
232
+ failure_count: number | bigint
233
+ last_failed_at: number | bigint | null
234
+ last_failed_attempt: number | bigint | null
235
+ last_error: string | null
236
+ failed_at: number | bigint | null
237
+ completed_idx: number | bigint | null
238
+ completed_attempt: number | bigint | null
239
+ returned_event_ids: string | null
240
+ }
241
+
242
+ /** A cursor joined with its head event's columns, prefixed `e_`. */
243
+ type HeadRow = CursorRow & {
244
+ e_idx: number | bigint
245
+ e_event_type: string
246
+ e_payload: string
247
+ e_event_id: string
248
+ e_created_at: number | bigint
249
+ e_cause: string | null
196
250
  }
197
251
 
198
252
  const toStored = (row: EventRow): StoredEvent => ({
@@ -202,26 +256,7 @@ const toStored = (row: EventRow): StoredEvent => ({
202
256
  index: Number(row.idx),
203
257
  sessionId: row.session_id,
204
258
  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),
259
+ cause: decodeCause(row.cause),
225
260
  })
226
261
 
227
262
  const toEvent = (row: EventRow): Event => ({
@@ -233,28 +268,37 @@ const toEvent = (row: EventRow): Event => ({
233
268
  createdAt: new Date(Number(row.created_at)),
234
269
  })
235
270
 
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
271
+ const toCursor = (row: CursorRow): StoredCursor => ({
272
+ consumer: row.consumer,
273
+ lane: row.lane,
274
+ position: Number(row.position),
275
+ head: toNumber(row.head),
276
+ firstClaimedAt: toDate(row.first_claimed_at),
277
+ lastClaimedAt: toDate(row.last_claimed_at),
278
+ attemptCount: Number(row.attempt_count),
279
+ claimHolder: row.claim_holder,
280
+ claimExpiresAt: toDate(row.claim_expires_at),
281
+ failureCount: Number(row.failure_count),
282
+ lastFailedAt: toDate(row.last_failed_at),
283
+ lastFailedAttempt: toNumber(row.last_failed_attempt),
284
+ lastError: row.last_error,
285
+ failedAt: toDate(row.failed_at),
286
+ })
287
+
288
+ const toHead = (row: HeadRow): HeadEvent => {
289
+ const { position: _position, head: _head, ...dispatch } = toCursor(row)
290
+ return {
291
+ ...toStored({
292
+ session_id: row.session_id,
293
+ idx: row.e_idx,
294
+ event_type: row.e_event_type,
295
+ payload: row.e_payload,
296
+ event_id: row.e_event_id,
297
+ created_at: row.e_created_at,
298
+ cause: row.e_cause,
299
+ }),
300
+ ...dispatch,
301
+ }
258
302
  }
259
303
 
260
304
  type PresenceDbRow = {
@@ -280,16 +324,30 @@ type SnapshotHeadRow = {
280
324
  updated_at: number | bigint
281
325
  }
282
326
 
283
- type AppendStatusRow = {
284
- max: number | bigint
285
- has_pending: number | bigint
286
- }
327
+ const RECLAIM_BATCH = 256
287
328
 
288
- type ExistingEventRow = EventRow & {
289
- session_has_pending: number | bigint
290
- }
329
+ /** The head columns every claim, dead letter, and listing joins in. */
330
+ const HEAD_SELECT = `
331
+ select cursor.*,
332
+ event.idx as e_idx, event.event_type as e_event_type,
333
+ event.payload as e_payload, event.event_id as e_event_id,
334
+ event.created_at as e_created_at, event.cause as e_cause
335
+ from a2_cursors cursor
336
+ join a2_events event
337
+ on event.session_id = cursor.session_id and event.idx = cursor.head`
291
338
 
292
- const RECLAIM_BATCH = 256
339
+ /** A fresh head's dispatch columns, for `set` clauses. */
340
+ const FRESH_DISPATCH = `
341
+ attempt_count = 0, first_claimed_at = null, last_claimed_at = null,
342
+ claim_holder = null, claim_expires_at = null, failure_count = 0,
343
+ last_failed_at = null, last_failed_attempt = null, last_error = null,
344
+ failed_at = null`
345
+
346
+ const placeholders = (count: number): string =>
347
+ Array.from({ length: count }, () => '?').join(', ')
348
+
349
+ const compareText = (a: string, b: string): number =>
350
+ a < b ? -1 : a > b ? 1 : 0
293
351
 
294
352
  export function sqlite(options: SqliteStoreOptions = {}): SqliteStore {
295
353
  const path = options.path ?? '.a2/dev.db'
@@ -311,44 +369,126 @@ export function sqlite(options: SqliteStoreOptions = {}): SqliteStore {
311
369
  db.exec('pragma busy_timeout = 5000')
312
370
  setWalJournalMode(db)
313
371
  db.exec('pragma synchronous = normal')
372
+ const legacy = db
373
+ .prepare("select 1 from pragma_table_info('a2_events') where name = ?")
374
+ .get('processed_at')
375
+ if (legacy) {
376
+ db.close()
377
+ throw new A2Error(
378
+ 'STORE_NOT_CONFIGURED',
379
+ `the sqlite database at '${path}' has the A2 layout from before consumers; delete it, or point the store at a fresh database file`,
380
+ )
381
+ }
314
382
  db.exec(SCHEMA)
383
+ db.prepare(
384
+ "insert into a2_meta (key, value) values ('layout', ?) on conflict (key) do nothing",
385
+ ).run(LAYOUT)
315
386
 
316
387
  const insertEvent = db.prepare(
317
388
  `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
- ?`,
389
+ (session_id, idx, event_type, payload, event_id, created_at, cause)
390
+ values (?, ?, ?, ?, ?, ?, ?)`,
391
+ )
392
+ const selectHead = db.prepare(
393
+ 'select coalesce(max(idx), 0) as max from a2_events where session_id = ?',
394
+ )
395
+ const headOf = (sessionId: string): number =>
396
+ Number((selectHead.get(sessionId) as { max: number | bigint }).max)
397
+ const selectEvent = db.prepare(
398
+ 'select 1 from a2_events where session_id = ? and idx = ?',
399
+ )
400
+ const selectCursor = db.prepare(
401
+ 'select * from a2_cursors where session_id = ? and consumer = ? and cursor_key = ?',
331
402
  )
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
- )`,
403
+ const cursorOf = (sessionId: string, ref: HeadRef): CursorRow | undefined =>
404
+ selectCursor.get(
405
+ sessionId,
406
+ ref.consumer,
407
+ cursorKeyOf(ref.lane, ref.index),
408
+ ) as CursorRow | undefined
409
+ const insertEventCursor = db.prepare(
410
+ `insert into a2_cursors
411
+ (session_id, consumer, cursor_key, lane, kind, position, head)
412
+ values (?, ?, ?, null, 'event', ?, ?)`,
413
+ )
414
+ const insertLaneEvent = db.prepare(
415
+ `insert into a2_lane_events (session_id, consumer, lane, idx)
416
+ values (?, ?, ?, ?)`,
417
+ )
418
+ // A lane cursor whose head lies at or below the clear marker is idle: the
419
+ // marker superseded everything it had queued.
420
+ const queueLaneHead = db.prepare(
421
+ `insert into a2_cursors
422
+ (session_id, consumer, cursor_key, lane, kind, position, head)
423
+ values (?, ?, ?, ?, 'lane', ?, ?)
424
+ on conflict (session_id, consumer, cursor_key) do update set
425
+ head = excluded.head,
426
+ position = max(a2_cursors.position, excluded.position),
427
+ ${FRESH_DISPATCH}
428
+ where a2_cursors.head is null or a2_cursors.head <= excluded.position`,
429
+ )
430
+ const openLogCursor = db.prepare(
431
+ `insert into a2_cursors
432
+ (session_id, consumer, cursor_key, lane, kind, position, head, types)
433
+ values (?, ?, ?, ?, 'log', ?, ?, ?)`,
434
+ )
435
+ const wakeLogCursor = db.prepare(
436
+ `update a2_cursors set types = ?, head = ?
437
+ where session_id = ? and consumer = ? and cursor_key = ?`,
438
+ )
439
+ const refreshTypes = db.prepare(
440
+ `update a2_cursors set types = ?
441
+ where session_id = ? and consumer = ? and kind = 'log'
442
+ and types is not ?`,
443
+ )
444
+ const nextLogHeadAll = db.prepare(
445
+ `select min(idx) as next from a2_events
446
+ where session_id = ? and idx > ? and event_type != ?`,
447
+ )
448
+ const nextLogHeadOf = db.prepare(
449
+ `select min(idx) as next from a2_events
450
+ where session_id = ? and idx > ? and event_type != ?
451
+ and event_type in (select value from json_each(?))`,
452
+ )
453
+ const nextLogHead = (
454
+ sessionId: string,
455
+ after: number,
456
+ types: string | null,
457
+ ): number | null =>
458
+ toNumber(
459
+ (
460
+ (types === null
461
+ ? nextLogHeadAll.get(sessionId, after, CLEAR_EVENT_TYPE)
462
+ : nextLogHeadOf.get(sessionId, after, CLEAR_EVENT_TYPE, types)) as {
463
+ next: number | bigint | null
464
+ }
465
+ ).next,
466
+ )
467
+ const nextLaneHead = db.prepare(
468
+ `select min(idx) as next from a2_lane_events
469
+ where session_id = ? and consumer = ? and lane = ? and done = 0
470
+ and idx > ?`,
471
+ )
472
+ const selectLaneEvent = db.prepare(
473
+ `select completed_attempt, returned_event_ids from a2_lane_events
474
+ where session_id = ? and consumer = ? and lane = ? and idx = ?`,
341
475
  )
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`,
476
+ const finishLaneEvent = db.prepare(
477
+ `update a2_lane_events
478
+ set done = 1, completed_attempt = ?, returned_event_ids = ?
479
+ where session_id = ? and consumer = ? and lane = ? and idx = ?`,
351
480
  )
481
+ const moveCursor = db.prepare(
482
+ `update a2_cursors set position = ?, head = ?, ${FRESH_DISPATCH}
483
+ where session_id = ? and consumer = ? and cursor_key = ?`,
484
+ )
485
+ const recordCompletion = db.prepare(
486
+ `update a2_cursors
487
+ set completed_idx = ?, completed_attempt = ?, returned_event_ids = ?
488
+ where session_id = ? and consumer = ? and cursor_key = ?`,
489
+ )
490
+ // A JSON null value is a tombstone: a delete's `at`, kept until it
491
+ // expires so an older set arriving after it still loses.
352
492
  const upsertPresence = db.prepare(
353
493
  `insert into a2_presence (ns, participant, field, value, seen, at, expires_at)
354
494
  values (?, ?, ?, ?, ?, ?, ?)
@@ -357,11 +497,7 @@ export function sqlite(options: SqliteStoreOptions = {}): SqliteStore {
357
497
  seen = excluded.seen,
358
498
  at = excluded.at,
359
499
  expires_at = excluded.expires_at
360
- where a2_presence.at <= excluded.at`,
361
- )
362
- const deletePresence = db.prepare(
363
- `delete from a2_presence
364
- where ns = ? and participant = ? and field = ? and at <= ?`,
500
+ where a2_presence.at <= excluded.at or a2_presence.expires_at <= ?`,
365
501
  )
366
502
  const sweepPresence = db.prepare(
367
503
  'delete from a2_presence where ns = ? and expires_at <= ?',
@@ -369,21 +505,14 @@ export function sqlite(options: SqliteStoreOptions = {}): SqliteStore {
369
505
  const selectPresence = db.prepare(
370
506
  `select participant, field, value, seen, at, expires_at
371
507
  from a2_presence
372
- where ns = ? and expires_at > ?
508
+ where ns = ? and expires_at > ? and value != 'null'
373
509
  order by participant, field`,
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 session_id = ? and event_id in (${placeholders(count)})
515
+ order by idx`,
387
516
  )
388
517
 
389
518
  const selectClearIndex = db.prepare(
@@ -400,33 +529,59 @@ 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
- * The rows owning `ids`. This session's rows below its clear marker do
405
- * not own their ids: they are deleted here so the ids can be reused.
564
+ * The session's rows owning `ids`. Rows below its clear marker do not
565
+ * own their ids: they are deleted here so the ids can be reused.
406
566
  */
407
567
  const liveOwnersOf = (
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
573
  const rows = selectByIds(ids.length).all(
414
574
  sessionId,
415
- clearIndex,
416
575
  ...ids,
417
- ) as unknown as ExistingEventRow[]
576
+ ) as unknown as EventRow[]
418
577
  return rows.filter((row) => {
419
- if (row.session_id !== sessionId || Number(row.idx) >= clearIndex)
420
- return true
578
+ if (Number(row.idx) >= clearIndex) return true
421
579
  deleteEventById.run(sessionId, row.event_id)
422
580
  return false
423
581
  })
424
582
  }
425
583
 
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. */
584
+ /** The attempt-currency fence shared by consumer appends and clears. */
430
585
  const assertCurrentCause = ({
431
586
  sessionId,
432
587
  cause,
@@ -438,16 +593,12 @@ export function sqlite(options: SqliteStoreOptions = {}): SqliteStore {
438
593
  }): void => {
439
594
  if (cause.index <= clearIndex)
440
595
  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
- }
596
+ const cursor = cursorOf(sessionId, cause)
448
597
  if (
449
- Number(parent.attempt_count) !== cause.attempt ||
450
- parent.failed_at !== null
598
+ !cursor ||
599
+ toNumber(cursor.head) !== cause.index ||
600
+ Number(cursor.attempt_count) !== cause.attempt ||
601
+ cursor.failed_at !== null
451
602
  ) {
452
603
  throw supersededAttemptError(sessionId, cause)
453
604
  }
@@ -470,11 +621,17 @@ export function sqlite(options: SqliteStoreOptions = {}): SqliteStore {
470
621
  }
471
622
  }
472
623
 
473
- /** A dead-lettered event's snapshot pins, and history no pin keeps. */
474
- const releasePins = (sessionId: string, index: number): void => {
624
+ /** A finished or dead head's snapshot pins, and history no pin keeps. */
625
+ const releasePins = (
626
+ sessionId: string,
627
+ consumer: string,
628
+ cursorKey: string,
629
+ index: number,
630
+ ): void => {
475
631
  db.prepare(
476
- 'delete from a2_snapshot_pins where session_id = ? and event_index = ?',
477
- ).run(sessionId, index)
632
+ `delete from a2_snapshot_pins
633
+ where session_id = ? and consumer = ? and cursor_key = ? and event_index = ?`,
634
+ ).run(sessionId, consumer, cursorKey, index)
478
635
  db.prepare(
479
636
  `delete from a2_snapshot_history
480
637
  where session_id = ?
@@ -486,20 +643,201 @@ export function sqlite(options: SqliteStoreOptions = {}): SqliteStore {
486
643
  )`,
487
644
  ).run(sessionId)
488
645
  }
646
+
647
+ /** Moves a cursor past its head; the next head starts fresh. */
648
+ const advance = (sessionId: string, cursor: CursorRow): void => {
649
+ const done = Number(cursor.head)
650
+ releasePins(sessionId, cursor.consumer, cursor.cursor_key, done)
651
+ let next: number | null
652
+ if (cursor.kind === 'event') {
653
+ next = null
654
+ } else if (cursor.kind === 'lane') {
655
+ next = toNumber(
656
+ (
657
+ nextLaneHead.get(
658
+ sessionId,
659
+ cursor.consumer,
660
+ cursor.lane,
661
+ Math.max(done, clearIndexOf(sessionId)),
662
+ ) as { next: number | bigint | null }
663
+ ).next,
664
+ )
665
+ } else {
666
+ const { types } = selectCursor.get(
667
+ sessionId,
668
+ cursor.consumer,
669
+ cursor.cursor_key,
670
+ ) as CursorRow
671
+ next = nextLogHead(sessionId, done, types)
672
+ }
673
+ moveCursor.run(done, next, sessionId, cursor.consumer, cursor.cursor_key)
674
+ }
675
+
676
+ /**
677
+ * Brings a log consumer's cursor up to date and records its types. A
678
+ * missing cursor opens at the clear marker with `backfill`, otherwise at
679
+ * `base`; an idle one takes the next event of its types after its
680
+ * position; one with a head keeps it, claim and all. Returns its key.
681
+ */
682
+ const wakeLogAt = ({
683
+ sessionId,
684
+ consumer,
685
+ log,
686
+ base,
687
+ clearIndex,
688
+ }: {
689
+ sessionId: string
690
+ consumer: string
691
+ log: NonNullable<StoreConsumer['log']>
692
+ base: number
693
+ clearIndex: number
694
+ }): string => {
695
+ const types = log.types === null ? null : JSON.stringify(log.types)
696
+ const key = cursorKeyOf(log.lane, 0)
697
+ const existing = selectCursor.get(sessionId, consumer, key) as
698
+ CursorRow | undefined
699
+ if (existing) {
700
+ wakeLogCursor.run(
701
+ types,
702
+ existing.head === null
703
+ ? // A deployment that doesn't serve this consumer may have
704
+ // appended events it takes since it went idle.
705
+ nextLogHead(sessionId, Number(existing.position), types)
706
+ : Number(existing.head),
707
+ sessionId,
708
+ consumer,
709
+ key,
710
+ )
711
+ return key
712
+ }
713
+ const position = log.backfill ? clearIndex : base
714
+ openLogCursor.run(
715
+ sessionId,
716
+ consumer,
717
+ key,
718
+ log.lane,
719
+ position,
720
+ nextLogHead(sessionId, position, types),
721
+ types,
722
+ )
723
+ return key
724
+ }
725
+
726
+ /** Starts the cursor work an inserted batch carries. */
727
+ const enqueue = ({
728
+ sessionId,
729
+ events,
730
+ base,
731
+ clearIndex,
732
+ consumers,
733
+ }: {
734
+ sessionId: string
735
+ events: readonly AppendEvent[]
736
+ base: number
737
+ clearIndex: number
738
+ consumers: readonly StoreConsumer[] | undefined
739
+ }): void => {
740
+ events.forEach((event, offset) => {
741
+ const index = base + 1 + offset
742
+ for (const target of event.targets ?? []) {
743
+ if (target.lane === null) {
744
+ insertEventCursor.run(
745
+ sessionId,
746
+ target.consumer,
747
+ cursorKeyOf(null, index),
748
+ index - 1,
749
+ index,
750
+ )
751
+ continue
752
+ }
753
+ insertLaneEvent.run(sessionId, target.consumer, target.lane, index)
754
+ queueLaneHead.run(
755
+ sessionId,
756
+ target.consumer,
757
+ cursorKeyOf(target.lane, index),
758
+ target.lane,
759
+ clearIndex,
760
+ index,
761
+ )
762
+ }
763
+ })
764
+ // The batch is inserted already, so a cursor it wakes finds the batch's
765
+ // first event of its types, or an earlier one.
766
+ for (const consumer of consumers ?? []) {
767
+ const log = consumer.log
768
+ if (!log) continue
769
+ const carries = events.some(
770
+ (event) =>
771
+ event.type !== CLEAR_EVENT_TYPE &&
772
+ (log.types === null || log.types.includes(event.type)),
773
+ )
774
+ if (!carries) continue
775
+ wakeLogAt({ sessionId, consumer: consumer.name, log, base, clearIndex })
776
+ }
777
+ }
778
+
779
+ const refreshLogTypes = (
780
+ sessionId: string,
781
+ consumers: readonly StoreConsumer[] | undefined,
782
+ ): void => {
783
+ for (const consumer of consumers ?? []) {
784
+ if (!consumer.log) continue
785
+ const types =
786
+ consumer.log.types === null ? null : JSON.stringify(consumer.log.types)
787
+ refreshTypes.run(types, sessionId, consumer.name, types)
788
+ }
789
+ }
790
+
791
+ const insertBatch = ({
792
+ sessionId,
793
+ events,
794
+ clearIndex,
795
+ consumers,
796
+ cause,
797
+ }: {
798
+ sessionId: string
799
+ events: readonly AppendEvent[]
800
+ clearIndex: number
801
+ consumers: readonly StoreConsumer[] | undefined
802
+ cause?: EventCause
803
+ }): StoredEvent[] => {
804
+ const base = headOf(sessionId)
805
+ const now = clock.now().getTime()
806
+ const inserted = events.map((event, offset): StoredEvent => {
807
+ const id = event.id ?? generateId()
808
+ const index = base + 1 + offset
809
+ const eventCause = cause ?? event.cause ?? null
810
+ insertEvent.run(
811
+ sessionId,
812
+ index,
813
+ event.type,
814
+ JSON.stringify(event.payload) ?? 'null',
815
+ id,
816
+ now,
817
+ eventCause ? JSON.stringify(eventCause) : null,
818
+ )
819
+ return {
820
+ id,
821
+ type: event.type,
822
+ payload: structuredClone(event.payload),
823
+ index,
824
+ sessionId,
825
+ createdAt: new Date(now),
826
+ cause: eventCause ? { ...eventCause } : null,
827
+ }
828
+ })
829
+ enqueue({ sessionId, events, base, clearIndex, consumers })
830
+ return inserted
831
+ }
832
+
489
833
  return {
490
834
  async append(sessionId, events, appendOptions) {
835
+ const consumers = appendOptions?.consumers
491
836
  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
- })
837
+ return wrap(() => ({
838
+ events: [],
839
+ hasPending: hasPending(sessionId, clearIndexOf(sessionId), consumers),
840
+ }))
503
841
  }
504
842
  return wrap(() =>
505
843
  tx(() => {
@@ -515,19 +853,10 @@ export function sqlite(options: SqliteStoreOptions = {}): SqliteStore {
515
853
  if (suppliedIds.length > 0) {
516
854
  const existing = liveOwnersOf(sessionId, clearIndex, suppliedIds)
517
855
  if (existing.length > 0) {
518
- const foreign = existing.find(
519
- (row) => row.session_id !== sessionId,
520
- )
521
- if (foreign) {
522
- throw new A2Error(
523
- 'PARTIAL_DUPLICATE_BATCH',
524
- `event id '${foreign.event_id}' already exists in another session`,
525
- )
526
- }
527
856
  if (existing.length === events.length) {
528
857
  return {
529
858
  events: idempotentReplay(events, existing.map(toStored)),
530
- hasPending: Number(existing[0]!.session_has_pending) === 1,
859
+ hasPending: hasPending(sessionId, clearIndex, consumers),
531
860
  }
532
861
  }
533
862
  throw new A2Error(
@@ -543,8 +872,8 @@ export function sqlite(options: SqliteStoreOptions = {}): SqliteStore {
543
872
  `scheduled work from event ${originIndex} was cleared in session '${sessionId}'`,
544
873
  )
545
874
  }
546
- // Attempt-currency fence: a fresh handler append commits only
547
- // while its causal attempt is still the parent's latest
875
+ // Attempt-currency fence: a fresh consumer append commits only
876
+ // while its causal attempt still owns the cursor's head
548
877
  // (store.ts `append`). `begin immediate` serializes this read
549
878
  // against a concurrent claim.
550
879
  for (const e of events) {
@@ -552,65 +881,69 @@ export function sqlite(options: SqliteStoreOptions = {}): SqliteStore {
552
881
  assertCurrentCause({ sessionId, cause: e.cause, clearIndex })
553
882
  }
554
883
 
555
- const status = appendStatus.get(
556
- sessionId,
884
+ const inserted = insertBatch({
557
885
  sessionId,
886
+ events,
558
887
  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
- }
888
+ consumers,
603
889
  })
604
890
  return {
605
891
  events: inserted,
606
- hasPending:
607
- Number(status.has_pending) === 1 ||
608
- events.some((event) => event.settled !== true),
892
+ hasPending: hasPending(sessionId, clearIndex, consumers),
609
893
  }
610
894
  }),
611
895
  )
612
896
  },
613
897
 
898
+ async wakeLog({ sessionId, consumer }) {
899
+ return wrap(() => {
900
+ // Nothing after the latest clear: a missing cursor stays missing,
901
+ // and nothing takes the write lock.
902
+ const { lane } = consumer.log
903
+ const marker = clearIndexOf(sessionId)
904
+ if (
905
+ headOf(sessionId) === marker &&
906
+ !selectCursor.get(sessionId, consumer.name, cursorKeyOf(lane, 0))
907
+ ) {
908
+ return {
909
+ cursor: idleLogCursor(consumer.name, lane, marker),
910
+ head: marker,
911
+ }
912
+ }
913
+ return tx(() => {
914
+ const head = headOf(sessionId)
915
+ const key = wakeLogAt({
916
+ sessionId,
917
+ consumer: consumer.name,
918
+ log: consumer.log,
919
+ base: head,
920
+ clearIndex: clearIndexOf(sessionId),
921
+ })
922
+ const cursor = selectCursor.get(sessionId, consumer.name, key)
923
+ return { cursor: toCursor(cursor as CursorRow), head }
924
+ })
925
+ })
926
+ },
927
+
928
+ async cursors(sessionId) {
929
+ return wrap(() => {
930
+ const clearIndex = clearIndexOf(sessionId)
931
+ const rows = db
932
+ .prepare(
933
+ `select * from a2_cursors
934
+ where session_id = ?
935
+ and (
936
+ kind = 'log'
937
+ or head > ?
938
+ or (kind = 'lane' and head is null and position > ?)
939
+ )
940
+ order by consumer, lane is null, lane, head`,
941
+ )
942
+ .all(sessionId, clearIndex, clearIndex) as unknown as CursorRow[]
943
+ return rows.map(toCursor)
944
+ })
945
+ },
946
+
614
947
  async read(sessionId, opts) {
615
948
  return wrap(() => {
616
949
  // Reads start at the latest clear marker.
@@ -641,44 +974,61 @@ export function sqlite(options: SqliteStoreOptions = {}): SqliteStore {
641
974
  holder,
642
975
  ttlMs,
643
976
  expiresAtMs,
644
- excludeIndexes = [],
977
+ exclude = [],
978
+ consumers,
645
979
  maxFailures,
980
+ through,
646
981
  }) {
647
982
  return wrap(() =>
648
983
  tx(() => {
984
+ refreshLogTypes(sessionId, consumers)
649
985
  const now = clock.now().getTime()
650
986
  const expiresAt = expiresAtMs ?? now + ttlMs
651
987
  const clearIndex = clearIndexOf(sessionId)
988
+ const served = servedClause(consumers)
989
+ const excludedKeys = exclude.map(
990
+ (ref) => `${ref.consumer}\u0000${cursorKeyOf(ref.lane, ref.index)}`,
991
+ )
652
992
  const exclusion =
653
- excludeIndexes.length === 0
993
+ excludedKeys.length === 0
654
994
  ? ''
655
- : `and event.idx not in (${excludeIndexes.map(() => '?').join(', ')})`
995
+ : `and cursor.consumer || char(0) || cursor.cursor_key
996
+ not in (${placeholders(excludedKeys.length)})`
997
+ const bounded = through === undefined ? '' : 'and cursor.head <= ?'
998
+ const filter = `
999
+ where cursor.session_id = ?
1000
+ and cursor.head > ?
1001
+ and cursor.failed_at is null
1002
+ ${bounded}
1003
+ ${served.sql}
1004
+ ${exclusion}`
1005
+ const params = [
1006
+ sessionId,
1007
+ clearIndex,
1008
+ ...(through === undefined ? [] : [through]),
1009
+ ...served.params,
1010
+ ...excludedKeys,
1011
+ now,
1012
+ ]
656
1013
  const eligible = db
657
1014
  .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`,
1015
+ `select cursor.consumer, cursor.cursor_key,
1016
+ cursor.claim_holder, cursor.failure_count
1017
+ from a2_cursors cursor
1018
+ ${filter}
1019
+ and (cursor.claim_expires_at is null
1020
+ or cursor.claim_expires_at <= ?)
1021
+ order by cursor.head, cursor.consumer`,
668
1022
  )
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
- }>
1023
+ .all(...params) as unknown as Array<
1024
+ Pick<
1025
+ CursorRow,
1026
+ 'consumer' | 'cursor_key' | 'claim_holder' | 'failure_count'
1027
+ >
1028
+ >
679
1029
  if (eligible.length > 0) {
680
- const update = db.prepare(
681
- `update a2_events set
1030
+ const claimHead = db.prepare(
1031
+ `update a2_cursors set
682
1032
  attempt_count = attempt_count + 1,
683
1033
  failure_count = failure_count
684
1034
  + case when claim_holder is null then 0 else 1 end,
@@ -686,55 +1036,71 @@ export function sqlite(options: SqliteStoreOptions = {}): SqliteStore {
686
1036
  last_claimed_at = ?,
687
1037
  claim_holder = ?,
688
1038
  claim_expires_at = ?
689
- where session_id = ? and idx = ?
690
- returning *`,
1039
+ where session_id = ? and consumer = ? and cursor_key = ?`,
691
1040
  )
692
1041
  // 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
- )
1042
+ const deadLetterHead = db.prepare(
1043
+ `update a2_cursors set
1044
+ attempt_count = attempt_count + 1,
1045
+ failure_count = failure_count + 1,
1046
+ failed_at = ?,
1047
+ claim_holder = null,
1048
+ claim_expires_at = null
1049
+ where session_id = ? and consumer = ? and cursor_key = ?`,
1050
+ )
1051
+ const selectHeadRow = db.prepare(
1052
+ `${HEAD_SELECT}
1053
+ where cursor.session_id = ? and cursor.consumer = ?
1054
+ and cursor.cursor_key = ?`,
1055
+ )
704
1056
  const events = eligible.map((row) => {
705
1057
  // A lapsed claim's takeover that spends the last failed attempt.
706
1058
  const exhausted =
707
1059
  row.claim_holder !== null &&
708
1060
  maxFailures !== undefined &&
709
1061
  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
1062
+ if (exhausted) {
1063
+ deadLetterHead.run(now, sessionId, row.consumer, row.cursor_key)
1064
+ } else {
1065
+ claimHead.run(
1066
+ now,
1067
+ now,
1068
+ holder,
1069
+ expiresAt,
1070
+ sessionId,
1071
+ row.consumer,
1072
+ row.cursor_key,
1073
+ )
1074
+ }
1075
+ const claimed = toHead(
1076
+ selectHeadRow.get(
1077
+ sessionId,
1078
+ row.consumer,
1079
+ row.cursor_key,
1080
+ ) as HeadRow,
1081
+ )
1082
+ if (exhausted) {
1083
+ releasePins(
1084
+ sessionId,
1085
+ row.consumer,
1086
+ row.cursor_key,
1087
+ claimed.index,
1088
+ )
1089
+ }
1090
+ if (clearIndex > 0) claimed.clearIndex = clearIndex
1091
+ return claimed
719
1092
  })
720
- return { outcome: 'claimed', events }
1093
+ return { outcome: 'claimed', events, head: headOf(sessionId) }
721
1094
  }
722
1095
 
723
1096
  const active = db
724
1097
  .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}`,
1098
+ `select min(cursor.claim_expires_at) as retry_at
1099
+ from a2_cursors cursor
1100
+ ${filter}
1101
+ and cursor.claim_expires_at > ?`,
734
1102
  )
735
- .get(sessionId, clearIndex, now, ...excludeIndexes) as {
736
- retry_at: number | bigint | null
737
- }
1103
+ .get(...params) as { retry_at: number | bigint | null }
738
1104
  return active.retry_at === null
739
1105
  ? { outcome: 'settled' }
740
1106
  : { outcome: 'busy', retryAt: new Date(Number(active.retry_at)) }
@@ -748,65 +1114,76 @@ export function sqlite(options: SqliteStoreOptions = {}): SqliteStore {
748
1114
  tx(() => {
749
1115
  const now = clock.now().getTime()
750
1116
  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
- )
1117
+ const renewed: HeadRef[] = []
1118
+ const superseded: HeadRef[] = []
756
1119
  const extend = db.prepare(
757
- `update a2_events set claim_expires_at = ?
758
- where session_id = ?
759
- and idx = ?
1120
+ `update a2_cursors set claim_expires_at = ?
1121
+ where session_id = ? and consumer = ? and cursor_key = ?
1122
+ and head = ?
760
1123
  and attempt_count = ?
761
- and processed_at is null
762
1124
  and failed_at is null
763
1125
  and claim_holder = ?
764
1126
  and claim_expires_at > ?`,
765
1127
  )
766
1128
  const clearIndex = clearIndexOf(sessionId)
767
- for (const claim of claims) {
768
- if (claim.index <= clearIndex) {
769
- superseded.push(claim.index)
1129
+ const ordered = claims.toSorted(
1130
+ (a, b) => a.index - b.index || compareText(a.consumer, b.consumer),
1131
+ )
1132
+ for (const { attempt, ...ref } of ordered) {
1133
+ if (ref.index <= clearIndex) {
1134
+ superseded.push(ref)
770
1135
  continue
771
1136
  }
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)
1137
+ const cursor = cursorOf(sessionId, ref)
1138
+ if (!cursor) continue
1139
+ const head = toNumber(cursor.head)
1140
+ // An event the cursor has not reached yet holds no claim to renew.
1141
+ if (head !== ref.index && Number(cursor.position) < ref.index)
1142
+ continue
1143
+ if (head !== ref.index || Number(cursor.attempt_count) > attempt) {
1144
+ superseded.push(ref)
777
1145
  continue
778
1146
  }
779
1147
  const updated = extend.run(
780
1148
  expiresAt,
781
1149
  sessionId,
782
- claim.index,
783
- claim.attempt,
1150
+ ref.consumer,
1151
+ cursor.cursor_key,
1152
+ ref.index,
1153
+ attempt,
784
1154
  holder,
785
1155
  now,
786
1156
  )
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),
1157
+ if (Number(updated.changes) === 1) renewed.push(ref)
792
1158
  }
1159
+ return { renewed, superseded }
793
1160
  }),
794
1161
  )
795
1162
  },
796
1163
 
797
- async completeAttempt({ sessionId, index, attempt, events }) {
1164
+ async completeAttempt({
1165
+ sessionId,
1166
+ consumer,
1167
+ lane,
1168
+ index,
1169
+ attempt,
1170
+ events,
1171
+ consumers,
1172
+ }) {
798
1173
  return wrap(() =>
799
1174
  tx(() => {
800
1175
  const clearIndex = clearIndexOf(sessionId)
801
1176
  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
- )
1177
+ const cursor = cursorOf(sessionId, { consumer, lane, index })
1178
+ if (!cursor) {
1179
+ if (!selectEvent.get(sessionId, index)) {
1180
+ throw new TypeError(
1181
+ `no event at index ${index} in session '${sessionId}'`,
1182
+ )
1183
+ }
1184
+ return { outcome: 'superseded' }
809
1185
  }
1186
+ refreshLogTypes(sessionId, consumers)
810
1187
  const ids = events.map((event) => event.id)
811
1188
  if (new Set(ids).size !== ids.length) {
812
1189
  throw new A2Error(
@@ -815,14 +1192,38 @@ export function sqlite(options: SqliteStoreOptions = {}): SqliteStore {
815
1192
  )
816
1193
  }
817
1194
 
818
- if (parent.processed_at !== null) {
819
- if (Number(parent.processed_by_attempt) !== attempt) {
1195
+ if (toNumber(cursor.head) !== index) {
1196
+ let completion:
1197
+ { attempt: number | null; ids: string | null } | undefined
1198
+ if (cursor.kind === 'lane') {
1199
+ const row = selectLaneEvent.get(
1200
+ sessionId,
1201
+ consumer,
1202
+ lane,
1203
+ index,
1204
+ ) as
1205
+ | {
1206
+ completed_attempt: number | bigint | null
1207
+ returned_event_ids: string | null
1208
+ }
1209
+ | undefined
1210
+ completion = row && {
1211
+ attempt: toNumber(row.completed_attempt),
1212
+ ids: row.returned_event_ids,
1213
+ }
1214
+ } else if (toNumber(cursor.completed_idx) === index) {
1215
+ completion = {
1216
+ attempt: toNumber(cursor.completed_attempt),
1217
+ ids: cursor.returned_event_ids,
1218
+ }
1219
+ }
1220
+ if (!completion || completion.attempt !== attempt) {
820
1221
  return { outcome: 'superseded' }
821
1222
  }
822
1223
  const returnedIds =
823
- parent.returned_event_ids === null
1224
+ completion.ids === null
824
1225
  ? null
825
- : decodeReturnedEventIds(parent.returned_event_ids)
1226
+ : decodeReturnedEventIds(completion.ids)
826
1227
  if (
827
1228
  returnedIds === null ||
828
1229
  returnedIds.length !== ids.length ||
@@ -838,20 +1239,21 @@ export function sqlite(options: SqliteStoreOptions = {}): SqliteStore {
838
1239
  ? []
839
1240
  : (selectByIds(ids.length).all(
840
1241
  sessionId,
841
- clearIndex,
842
1242
  ...ids,
843
- ) as unknown as ExistingEventRow[])
1243
+ ) as unknown as EventRow[])
844
1244
  const byId = new Map(existing.map((row) => [row.event_id, row]))
845
1245
  const ordered = ids.map((id) => byId.get(id))
846
1246
  if (
847
- ordered.some(
848
- (row) =>
1247
+ ordered.some((row) => {
1248
+ const cause = row ? decodeCause(row.cause) : null
1249
+ return (
849
1250
  !row ||
850
- row.session_id !== sessionId ||
851
- row.cause === null ||
852
- toCause(row.cause)?.index !== index ||
853
- toCause(row.cause)?.attempt !== attempt,
854
- )
1251
+ cause?.consumer !== consumer ||
1252
+ cause.lane !== lane ||
1253
+ cause.index !== index ||
1254
+ cause.attempt !== attempt
1255
+ )
1256
+ })
855
1257
  ) {
856
1258
  throw new A2Error(
857
1259
  'PARTIAL_DUPLICATE_BATCH',
@@ -860,16 +1262,19 @@ export function sqlite(options: SqliteStoreOptions = {}): SqliteStore {
860
1262
  }
861
1263
  return {
862
1264
  outcome: 'completed',
863
- events: idempotentReplay(
864
- events,
865
- (ordered as ExistingEventRow[]).map(toStored),
866
- ),
1265
+ events:
1266
+ events.length === 0
1267
+ ? []
1268
+ : idempotentReplay(
1269
+ events,
1270
+ (ordered as EventRow[]).map(toStored),
1271
+ ),
867
1272
  }
868
1273
  }
869
1274
  if (
870
- Number(parent.attempt_count) !== attempt ||
871
- parent.claim_holder === null ||
872
- parent.failed_at !== null
1275
+ Number(cursor.attempt_count) !== attempt ||
1276
+ cursor.claim_holder === null ||
1277
+ cursor.failed_at !== null
873
1278
  ) {
874
1279
  return { outcome: 'superseded' }
875
1280
  }
@@ -881,65 +1286,34 @@ export function sqlite(options: SqliteStoreOptions = {}): SqliteStore {
881
1286
  )
882
1287
  }
883
1288
 
884
- const status = appendStatus.get(
885
- sessionId,
1289
+ const inserted = insertBatch({
886
1290
  sessionId,
1291
+ events,
887
1292
  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,
1293
+ consumers,
1294
+ cause: { consumer, lane, index, attempt },
1295
+ })
1296
+ const returned = JSON.stringify(ids)
1297
+ if (cursor.kind === 'lane') {
1298
+ finishLaneEvent.run(
1299
+ attempt,
1300
+ returned,
930
1301
  sessionId,
931
- event.lane ?? null,
932
- clearIndex,
933
- event.settled ? now : null,
1302
+ consumer,
1303
+ lane,
1304
+ index,
934
1305
  )
935
- return toStored(
936
- db
937
- .prepare(
938
- 'select * from a2_events where session_id = ? and idx = ?',
939
- )
940
- .get(sessionId, childIndex) as EventRow,
1306
+ } else {
1307
+ recordCompletion.run(
1308
+ index,
1309
+ attempt,
1310
+ returned,
1311
+ sessionId,
1312
+ consumer,
1313
+ cursor.cursor_key,
941
1314
  )
942
- })
1315
+ }
1316
+ advance(sessionId, cursor)
943
1317
  return { outcome: 'completed', events: inserted }
944
1318
  }),
945
1319
  )
@@ -947,6 +1321,8 @@ export function sqlite(options: SqliteStoreOptions = {}): SqliteStore {
947
1321
 
948
1322
  async failAttempt({
949
1323
  sessionId,
1324
+ consumer,
1325
+ lane,
950
1326
  index,
951
1327
  attempt,
952
1328
  error,
@@ -958,30 +1334,34 @@ export function sqlite(options: SqliteStoreOptions = {}): SqliteStore {
958
1334
  if (index <= clearIndexOf(sessionId)) {
959
1335
  return { outcome: 'superseded', failureCount: 0 }
960
1336
  }
961
- const current = db
962
- .prepare('select * from a2_events where session_id = ? and idx = ?')
963
- .get(sessionId, index) as EventRow | undefined
1337
+ const current = cursorOf(sessionId, { consumer, lane, index })
964
1338
  if (!current) {
965
- throw new TypeError(
966
- `no event at index ${index} in session '${sessionId}'`,
967
- )
1339
+ if (!selectEvent.get(sessionId, index)) {
1340
+ throw new TypeError(
1341
+ `no event at index ${index} in session '${sessionId}'`,
1342
+ )
1343
+ }
1344
+ return { outcome: 'superseded', failureCount: 0 }
1345
+ }
1346
+ if (toNumber(current.head) !== index) {
1347
+ return { outcome: 'superseded', failureCount: 0 }
968
1348
  }
969
1349
  const failureCount = Number(current.failure_count)
970
- if (
971
- current.processed_at !== null ||
972
- Number(current.attempt_count) !== attempt
973
- ) {
1350
+ if (Number(current.attempt_count) !== attempt) {
974
1351
  return { outcome: 'superseded', failureCount }
975
1352
  }
976
- if (current.failed_at !== null) {
977
- return { outcome: 'dead_lettered', failureCount }
978
- }
979
1353
  if (
980
1354
  current.claim_holder === null &&
981
1355
  current.last_failed_attempt !== null &&
982
1356
  Number(current.last_failed_attempt) === attempt
983
1357
  ) {
984
- return { outcome: 'failed', failureCount }
1358
+ return {
1359
+ outcome: current.failed_at === null ? 'failed' : 'dead_lettered',
1360
+ failureCount,
1361
+ }
1362
+ }
1363
+ if (current.failed_at !== null) {
1364
+ return { outcome: 'dead_lettered', failureCount }
985
1365
  }
986
1366
  if (current.claim_holder === null) {
987
1367
  return { outcome: 'superseded', failureCount }
@@ -991,7 +1371,7 @@ export function sqlite(options: SqliteStoreOptions = {}): SqliteStore {
991
1371
  const nextFailureCount = failureCount + 1
992
1372
  const deadLettered = nextFailureCount >= maxFailures
993
1373
  db.prepare(
994
- `update a2_events set
1374
+ `update a2_cursors set
995
1375
  failure_count = ?,
996
1376
  last_error = ?,
997
1377
  last_failed_at = ?,
@@ -999,7 +1379,7 @@ export function sqlite(options: SqliteStoreOptions = {}): SqliteStore {
999
1379
  failed_at = ?,
1000
1380
  claim_holder = null,
1001
1381
  claim_expires_at = ?
1002
- where session_id = ? and idx = ?`,
1382
+ where session_id = ? and consumer = ? and cursor_key = ?`,
1003
1383
  ).run(
1004
1384
  nextFailureCount,
1005
1385
  error,
@@ -1010,9 +1390,12 @@ export function sqlite(options: SqliteStoreOptions = {}): SqliteStore {
1010
1390
  ? null
1011
1391
  : now + retryAfterMs,
1012
1392
  sessionId,
1013
- index,
1393
+ consumer,
1394
+ current.cursor_key,
1014
1395
  )
1015
- if (deadLettered) releasePins(sessionId, index)
1396
+ if (deadLettered) {
1397
+ releasePins(sessionId, consumer, current.cursor_key, index)
1398
+ }
1016
1399
  return {
1017
1400
  outcome: deadLettered ? 'dead_lettered' : 'failed',
1018
1401
  failureCount: nextFailureCount,
@@ -1025,58 +1408,58 @@ export function sqlite(options: SqliteStoreOptions = {}): SqliteStore {
1025
1408
  return wrap(() => {
1026
1409
  const rows = db
1027
1410
  .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(
1411
+ `${HEAD_SELECT}
1412
+ where cursor.session_id = ?
1413
+ and cursor.failed_at is not null
1414
+ and cursor.head > coalesce(
1033
1415
  (select clear_index from a2_clears where session_id = ?), 0
1034
1416
  )
1035
- order by idx`,
1417
+ order by cursor.head, cursor.consumer`,
1036
1418
  )
1037
- .all(sessionId, sessionId) as unknown as EventRow[]
1038
- return rows.map(toStored)
1419
+ .all(sessionId, sessionId) as unknown as HeadRow[]
1420
+ return rows.map(toHead)
1039
1421
  })
1040
1422
  },
1041
1423
 
1042
- async redrive({ sessionId, index, action }) {
1424
+ async redrive({ sessionId, consumer, index, action, consumers }) {
1043
1425
  return wrap(() =>
1044
1426
  tx(() => {
1045
1427
  const clearIndex = clearIndexOf(sessionId)
1046
- const row =
1428
+ const cursor =
1047
1429
  index > clearIndex
1048
1430
  ? (db
1049
1431
  .prepare(
1050
- 'select lane, processed_at, failed_at from a2_events where session_id = ? and idx = ?',
1432
+ `select * from a2_cursors
1433
+ where session_id = ? and consumer = ? and head = ?
1434
+ and failed_at is not null`,
1051
1435
  )
1052
- .get(sessionId, index) as
1053
- | Pick<EventRow, 'lane' | 'processed_at' | 'failed_at'>
1054
- | undefined)
1436
+ .get(sessionId, consumer, index) as CursorRow | undefined)
1055
1437
  : 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)
1438
+ if (cursor) {
1439
+ refreshLogTypes(sessionId, consumers)
1440
+ if (action === 'skip') {
1441
+ if (cursor.kind === 'lane') {
1442
+ finishLaneEvent.run(
1443
+ null,
1444
+ null,
1445
+ sessionId,
1446
+ consumer,
1447
+ cursor.lane,
1448
+ index,
1449
+ )
1450
+ }
1451
+ advance(sessionId, cursor)
1452
+ } else {
1453
+ db.prepare(
1454
+ `update a2_cursors set failed_at = null, failure_count = 0
1455
+ where session_id = ? and consumer = ? and cursor_key = ?`,
1456
+ ).run(sessionId, consumer, cursor.cursor_key)
1067
1457
  }
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
1458
  }
1074
- const status = appendStatus.get(
1075
- sessionId,
1076
- sessionId,
1077
- clearIndex,
1078
- ) as AppendStatusRow
1079
- return { redriven, hasPending: Number(status.has_pending) === 1 }
1459
+ return {
1460
+ redriven: cursor !== undefined,
1461
+ hasPending: hasPending(sessionId, clearIndex, consumers),
1462
+ }
1080
1463
  }),
1081
1464
  )
1082
1465
  },
@@ -1116,21 +1499,15 @@ export function sqlite(options: SqliteStoreOptions = {}): SqliteStore {
1116
1499
  (select state from snapshot) as snapshot_state,
1117
1500
  null as session_id, null as idx, null as event_type,
1118
1501
  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
1502
+ null as cause
1128
1503
  union all
1129
1504
  select 1 as row_order, 'event' as row_kind,
1130
1505
  null as clear_index,
1131
1506
  (select up_to_index from head) as head_index,
1132
1507
  null as snapshot_index, null as snapshot_state,
1133
- event.*
1508
+ event.session_id, event.idx, event.event_type,
1509
+ event.payload, event.event_id, event.created_at,
1510
+ event.cause
1134
1511
  from a2_events as event
1135
1512
  where event.session_id = ?
1136
1513
  and event.idx > max(
@@ -1182,19 +1559,19 @@ export function sqlite(options: SqliteStoreOptions = {}): SqliteStore {
1182
1559
  async putSnapshots(sessionId, reducerName, writes) {
1183
1560
  await wrap(() =>
1184
1561
  tx(() => {
1185
- const selectHead = db.prepare(
1562
+ const selectSnapshotHead = db.prepare(
1186
1563
  `select up_to_index, state, updated_at from a2_snapshots
1187
1564
  where session_id = ? and reducer_name = ?`,
1188
1565
  )
1189
1566
  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`,
1567
+ `select 1 from a2_cursors
1568
+ where session_id = ? and consumer = ? and cursor_key = ?
1569
+ and head = ? and head > ? and failed_at is null`,
1193
1570
  )
1194
1571
  const insertPin = db.prepare(
1195
1572
  `insert or ignore into a2_snapshot_pins
1196
- (session_id, event_index, reducer_name, up_to_index)
1197
- values (?, ?, ?, ?)`,
1573
+ (session_id, consumer, cursor_key, event_index, reducer_name, up_to_index)
1574
+ values (?, ?, ?, ?, ?, ?)`,
1198
1575
  )
1199
1576
  const hasPin = db.prepare(
1200
1577
  `select 1 from a2_snapshot_pins
@@ -1226,12 +1603,28 @@ export function sqlite(options: SqliteStoreOptions = {}): SqliteStore {
1226
1603
  for (const write of writes.toSorted((a, b) => a.index - b.index)) {
1227
1604
  // A fold from before the latest clear describes a lifetime that is gone.
1228
1605
  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)
1606
+ for (const pin of write.pins ?? []) {
1607
+ const cursorKey = cursorKeyOf(pin.lane, pin.index)
1608
+ if (
1609
+ unfinished.get(
1610
+ sessionId,
1611
+ pin.consumer,
1612
+ cursorKey,
1613
+ pin.index,
1614
+ clearIndex,
1615
+ )
1616
+ ) {
1617
+ insertPin.run(
1618
+ sessionId,
1619
+ pin.consumer,
1620
+ cursorKey,
1621
+ pin.index,
1622
+ reducerName,
1623
+ write.index,
1624
+ )
1232
1625
  }
1233
1626
  }
1234
- const head = selectHead.get(sessionId, reducerName) as
1627
+ const head = selectSnapshotHead.get(sessionId, reducerName) as
1235
1628
  SnapshotHeadRow | undefined
1236
1629
  const now = clock.now().getTime()
1237
1630
  if (!head || write.index >= Number(head.up_to_index)) {
@@ -1283,23 +1676,22 @@ export function sqlite(options: SqliteStoreOptions = {}): SqliteStore {
1283
1676
  const atMs = meta.at.getTime()
1284
1677
  // Expiry anchors on the storage clock — the sender's `at`
1285
1678
  // orders writes but never anchors their lifetime.
1286
- const expiresAtMs = clock.now().getTime() + meta.ttlMs
1679
+ const nowMs = clock.now().getTime()
1680
+ const expiresAtMs = nowMs + meta.ttlMs
1287
1681
  for (const [field, value] of entries) {
1288
- // Field-wise LWW: a stored row with a strictly newer `at`
1289
- // survives; ties go to the incoming write (memory parity).
1290
- if (value === null) {
1291
- deletePresence.run(ns, participant, field, atMs)
1292
- } else {
1293
- upsertPresence.run(
1294
- ns,
1295
- participant,
1296
- field,
1297
- JSON.stringify(value) ?? 'null',
1298
- meta.seen,
1299
- atMs,
1300
- expiresAtMs,
1301
- )
1302
- }
1682
+ // Field-wise LWW against values and tombstones alike: an
1683
+ // unexpired stored row with a strictly newer `at` survives;
1684
+ // ties go to the incoming write (memory parity).
1685
+ upsertPresence.run(
1686
+ ns,
1687
+ participant,
1688
+ field,
1689
+ JSON.stringify(value) ?? 'null',
1690
+ meta.seen,
1691
+ atMs,
1692
+ expiresAtMs,
1693
+ nowMs,
1694
+ )
1303
1695
  }
1304
1696
  }),
1305
1697
  )
@@ -1349,15 +1741,7 @@ export function sqlite(options: SqliteStoreOptions = {}): SqliteStore {
1349
1741
  tx(() => {
1350
1742
  const clearIndex = clearIndexOf(sessionId)
1351
1743
  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
- )
1744
+ const head = headOf(sessionId)
1361
1745
  if (ifLastIndex !== undefined && clearIndex === ifLastIndex + 1) {
1362
1746
  return { cleared: true, index: head, clearIndex }
1363
1747
  }
@@ -1368,26 +1752,32 @@ export function sqlite(options: SqliteStoreOptions = {}): SqliteStore {
1368
1752
  return { cleared: true, index: head, clearIndex }
1369
1753
  }
1370
1754
  const marker = head + 1
1371
- const now = clock.now().getTime()
1372
1755
  insertEvent.run(
1373
1756
  sessionId,
1374
1757
  marker,
1375
1758
  CLEAR_EVENT_TYPE,
1376
1759
  'null',
1377
1760
  generateId(),
1378
- now,
1379
- null,
1380
- null,
1761
+ clock.now().getTime(),
1381
1762
  null,
1382
- sessionId,
1383
- null,
1384
- clearIndex,
1385
- now,
1386
1763
  )
1387
1764
  db.prepare(
1388
1765
  `insert into a2_clears (session_id, clear_index) values (?, ?)
1389
1766
  on conflict (session_id) do update set clear_index = excluded.clear_index`,
1390
1767
  ).run(sessionId, marker)
1768
+ // One row per log consumer: they count from the marker. Lane and
1769
+ // concurrent cursors at or below it are superseded by the marker
1770
+ // alone, and reclaim deletes them.
1771
+ db.prepare(
1772
+ `update a2_cursors
1773
+ set position = ?, head = null, ${FRESH_DISPATCH},
1774
+ completed_idx = null, completed_attempt = null,
1775
+ returned_event_ids = null
1776
+ where session_id = ? and kind = 'log'`,
1777
+ ).run(marker, sessionId)
1778
+ db.prepare(
1779
+ 'delete from a2_snapshot_pins where session_id = ? and event_index < ?',
1780
+ ).run(sessionId, marker)
1391
1781
  return { cleared: true, index: marker, clearIndex: marker }
1392
1782
  }),
1393
1783
  )
@@ -1420,7 +1810,11 @@ export function sqlite(options: SqliteStoreOptions = {}): SqliteStore {
1420
1810
  .run(sessionId, through).changes,
1421
1811
  )
1422
1812
  db.prepare(
1423
- 'delete from a2_snapshot_pins where session_id = ? and event_index <= ?',
1813
+ 'delete from a2_lane_events where session_id = ? and idx <= ?',
1814
+ ).run(sessionId, through)
1815
+ db.prepare(
1816
+ `delete from a2_cursors
1817
+ where session_id = ? and kind = 'event' and position < ?`,
1424
1818
  ).run(sessionId, through)
1425
1819
  }
1426
1820
  const done =
@@ -1431,6 +1825,11 @@ export function sqlite(options: SqliteStoreOptions = {}): SqliteStore {
1431
1825
  .get(sessionId, clearIndex) === undefined
1432
1826
  // Writes serialize with the clear, so none lands below the marker later.
1433
1827
  if (done) {
1828
+ db.prepare(
1829
+ `delete from a2_cursors
1830
+ where session_id = ? and kind != 'log'
1831
+ and (head is null or head <= ?) and position <= ?`,
1832
+ ).run(sessionId, clearIndex, clearIndex)
1434
1833
  for (const table of [
1435
1834
  'a2_snapshot_pins',
1436
1835
  'a2_snapshot_history',