experimental-a2 0.20.0 → 0.22.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (309) hide show
  1. package/CHANGELOG.md +55 -0
  2. package/README.md +1 -1
  3. package/dist/{actor-C7b-1arn.d.ts → actor-CqibLgjQ.d.ts} +15 -7
  4. package/dist/actor-CqibLgjQ.d.ts.map +1 -0
  5. package/dist/actor-client.d.ts +1 -1
  6. package/dist/actor-client.js +2 -2
  7. package/dist/actor-react.d.ts +3 -3
  8. package/dist/actor-react.js +5 -5
  9. package/dist/{actor-shared-t7w8xIKP.js → actor-shared-DFET5q16.js} +3 -3
  10. package/dist/actor-shared-DFET5q16.js.map +1 -0
  11. package/dist/actor.d.ts +1 -1
  12. package/dist/actor.js +54 -43
  13. package/dist/actor.js.map +1 -1
  14. package/dist/{ai-Cz3hqVhk.d.ts → ai-DAu77CDr.d.ts} +8 -8
  15. package/dist/{ai-Cz3hqVhk.d.ts.map → ai-DAu77CDr.d.ts.map} +1 -1
  16. package/dist/{ai-mMiWul1T.js → ai-EEfKyc-z.js} +9 -9
  17. package/dist/ai-EEfKyc-z.js.map +1 -0
  18. package/dist/ai-client.d.ts +2 -2
  19. package/dist/ai-client.js +2 -2
  20. package/dist/ai-fx.d.ts +4 -2
  21. package/dist/ai-fx.d.ts.map +1 -1
  22. package/dist/ai-fx.js +214 -67
  23. package/dist/ai-fx.js.map +1 -1
  24. package/dist/{ai-harness-CV6rXbB3.d.ts → ai-harness-DTjkAr2T.d.ts} +2 -2
  25. package/dist/{ai-harness-CV6rXbB3.d.ts.map → ai-harness-DTjkAr2T.d.ts.map} +1 -1
  26. package/dist/{ai-harness-Clbg5ei7.js → ai-harness-dQeJaR1t.js} +7 -3
  27. package/dist/ai-harness-dQeJaR1t.js.map +1 -0
  28. package/dist/ai-pi.d.ts +1 -1
  29. package/dist/ai-pi.js +3 -3
  30. package/dist/ai-pi.js.map +1 -1
  31. package/dist/ai-react.d.ts +5 -5
  32. package/dist/ai-react.js +2 -2
  33. package/dist/ai-server.d.ts +14 -17
  34. package/dist/ai-server.d.ts.map +1 -1
  35. package/dist/ai-server.js +86 -73
  36. package/dist/ai-server.js.map +1 -1
  37. package/dist/ai.d.ts +2 -2
  38. package/dist/ai.js +2 -2
  39. package/dist/{client-BhHLoNKF.js → client-BQS4i_V9.js} +29 -6
  40. package/dist/client-BQS4i_V9.js.map +1 -0
  41. package/dist/{client-h_DKFAjj.d.ts → client-CUMbS_38.d.ts} +3 -3
  42. package/dist/{client-h_DKFAjj.d.ts.map → client-CUMbS_38.d.ts.map} +1 -1
  43. package/dist/client.d.ts +3 -3
  44. package/dist/client.js +1 -1
  45. package/dist/{contract-BYKASlCV.js → contract-CWukt01d.js} +2 -2
  46. package/dist/contract-CWukt01d.js.map +1 -0
  47. package/dist/index.d.ts +5 -5
  48. package/dist/index.js +3 -3
  49. package/dist/{initial-snapshot-Bx-c0pDP.d.ts → initial-snapshot-D3Lve43o.d.ts} +4 -4
  50. package/dist/initial-snapshot-D3Lve43o.d.ts.map +1 -0
  51. package/dist/{internal-CscSY7K_.js → internal-CvsrprnU.js} +40 -16
  52. package/dist/internal-CvsrprnU.js.map +1 -0
  53. package/dist/otel.d.ts +1 -1
  54. package/dist/otel.js +1 -1
  55. package/dist/otel.js.map +1 -1
  56. package/dist/{react-BBXgHWFc.d.ts → react-BoHvC3v9.d.ts} +4 -4
  57. package/dist/{react-BBXgHWFc.d.ts.map → react-BoHvC3v9.d.ts.map} +1 -1
  58. package/dist/react.d.ts +1 -1
  59. package/dist/react.js +1 -1
  60. package/dist/{reducer-CqA59Q6U.d.ts → reducer-CYPHsSmd.d.ts} +2 -2
  61. package/dist/{reducer-CqA59Q6U.d.ts.map → reducer-CYPHsSmd.d.ts.map} +1 -1
  62. package/dist/{reducer-BAXc8JyF.js → reducer-CjmvZSAb.js} +2 -2
  63. package/dist/{reducer-BAXc8JyF.js.map → reducer-CjmvZSAb.js.map} +1 -1
  64. package/dist/runtime-consumer-CIeL33wj.js +12 -0
  65. package/dist/runtime-consumer-CIeL33wj.js.map +1 -0
  66. package/dist/scheduler-qstash.d.ts +2 -2
  67. package/dist/scheduler-qstash.js +3 -3
  68. package/dist/scheduler-task-X1SToDjP.js.map +1 -1
  69. package/dist/scheduler-vercel.d.ts +2 -2
  70. package/dist/scheduler-vercel.js +3 -3
  71. package/dist/scheduler-vercel.js.map +1 -1
  72. package/dist/{server-qyb5aJJG.js → server-DtFrWxi3.js} +412 -116
  73. package/dist/server-DtFrWxi3.js.map +1 -0
  74. package/dist/{server-DYAATFuT.d.ts → server-qgez0Xny.d.ts} +108 -41
  75. package/dist/server-qgez0Xny.d.ts.map +1 -0
  76. package/dist/server.browser.js +1 -1
  77. package/dist/server.browser.js.map +1 -1
  78. package/dist/server.d.ts +4 -4
  79. package/dist/server.js +2 -2
  80. package/dist/{session-clear-C-QwTXV1.d.ts → session-clear-D1PFe7xl.d.ts} +2 -2
  81. package/dist/session-clear-D1PFe7xl.d.ts.map +1 -0
  82. package/dist/{session-clear-DGI4jTQ5.js → session-clear-wHd5GCpZ.js} +5 -4
  83. package/dist/session-clear-wHd5GCpZ.js.map +1 -0
  84. package/dist/store-D0MhBvg6.d.ts +532 -0
  85. package/dist/store-D0MhBvg6.d.ts.map +1 -0
  86. package/dist/store-N8PXxDAS.js.map +1 -1
  87. package/dist/store-codec-DwrWlgQx.js +65 -0
  88. package/dist/store-codec-DwrWlgQx.js.map +1 -0
  89. package/dist/store-memory.d.ts +1 -1
  90. package/dist/store-memory.d.ts.map +1 -1
  91. package/dist/store-memory.js +406 -225
  92. package/dist/store-memory.js.map +1 -1
  93. package/dist/{store-polling-7WJV0YWP.js → store-polling-BZt2Rf3D.js} +2 -2
  94. package/dist/{store-polling-7WJV0YWP.js.map → store-polling-BZt2Rf3D.js.map} +1 -1
  95. package/dist/store-postgres.d.ts +1 -1
  96. package/dist/store-postgres.d.ts.map +1 -1
  97. package/dist/store-postgres.js +1598 -587
  98. package/dist/store-postgres.js.map +1 -1
  99. package/dist/store-presence-polling-JuAy2Ck2.js +111 -0
  100. package/dist/store-presence-polling-JuAy2Ck2.js.map +1 -0
  101. package/dist/store-redis-http.d.ts +1 -1
  102. package/dist/store-redis-http.js +4 -4
  103. package/dist/store-redis-notify-CsJw_hfD.js +3060 -0
  104. package/dist/store-redis-notify-CsJw_hfD.js.map +1 -0
  105. package/dist/store-redis.d.ts +1 -1
  106. package/dist/store-redis.d.ts.map +1 -1
  107. package/dist/store-redis.js +4 -5
  108. package/dist/store-redis.js.map +1 -1
  109. package/dist/store-sqlite.d.ts +1 -1
  110. package/dist/store-sqlite.d.ts.map +1 -1
  111. package/dist/store-sqlite.js +527 -299
  112. package/dist/store-sqlite.js.map +1 -1
  113. package/dist/stream-buffer-DrTyiIuo.js +57 -0
  114. package/dist/stream-buffer-DrTyiIuo.js.map +1 -0
  115. package/dist/{telemetry-Cyfqo6Qt.d.ts → telemetry-CWwsh-Gx.d.ts} +3 -3
  116. package/dist/telemetry-CWwsh-Gx.d.ts.map +1 -0
  117. package/dist/{wire-BKxH1vbP.js → wire-CV-Z7wz-.js} +6 -5
  118. package/dist/wire-CV-Z7wz-.js.map +1 -0
  119. package/docs/01-quickstart.mdx +21 -15
  120. package/docs/02-examples.mdx +1 -1
  121. package/docs/actors/01-introduction.mdx +13 -2
  122. package/docs/actors/02-concurrency.mdx +1 -1
  123. package/docs/agents/01-introduction.mdx +1 -1
  124. package/docs/agents/02-tools.mdx +12 -11
  125. package/docs/agents/03-conversations.mdx +36 -25
  126. package/docs/agents/04-harnesses.mdx +14 -7
  127. package/docs/client/03-presence.mdx +18 -14
  128. package/docs/contract/01-events.mdx +2 -2
  129. package/docs/contract/02-state.mdx +18 -14
  130. package/docs/contract/03-sessions.mdx +32 -25
  131. package/docs/index.mdx +29 -24
  132. package/docs/reference/01-core.mdx +7 -6
  133. package/docs/reference/02-server.mdx +250 -191
  134. package/docs/reference/04-ai.mdx +33 -23
  135. package/docs/reference/05-telemetry.mdx +27 -13
  136. package/docs/reference/06-errors.mdx +25 -19
  137. package/docs/reference/meta.ts +1 -1
  138. package/docs/schedulers/01-overview.mdx +8 -8
  139. package/docs/schedulers/02-vercel-queues.mdx +2 -2
  140. package/docs/schedulers/03-qstash.mdx +4 -4
  141. package/docs/server/01-consumers.mdx +418 -0
  142. package/docs/server/02-timers.mdx +31 -21
  143. package/docs/server/03-cancellation.mdx +52 -44
  144. package/docs/server/04-routes.mdx +4 -1
  145. package/docs/server/05-sql-projections.mdx +391 -0
  146. package/docs/server/06-guarantees.mdx +61 -51
  147. package/docs/stores/01-overview.mdx +3 -3
  148. package/docs/stores/02-redis.mdx +24 -10
  149. package/docs/stores/03-redis-http.mdx +8 -6
  150. package/docs/stores/04-postgres.mdx +65 -14
  151. package/docs/stores/05-sqlite.mdx +5 -3
  152. package/docs/upgrades/01-overview.mdx +31 -0
  153. package/docs/upgrades/78-0-22.mdx +115 -0
  154. package/docs/upgrades/79-0-21.mdx +165 -0
  155. package/docs/upgrades/80-0-20.mdx +43 -0
  156. package/docs/upgrades/81-0-19.mdx +33 -0
  157. package/docs/upgrades/82-0-18.mdx +33 -0
  158. package/docs/upgrades/83-0-17.mdx +69 -0
  159. package/docs/upgrades/meta.ts +3 -0
  160. package/examples/playground/DEPLOY.md +39 -7
  161. package/examples/playground/app/a0/chat.test.tsx +2 -2
  162. package/examples/playground/app/a0/sandbox-log.ts +17 -15
  163. package/examples/playground/app/a0/sandbox.ts +58 -13
  164. package/examples/playground/app/a0/server.ts +4 -4
  165. package/examples/playground/app/a2slack/a2slack.css +1067 -0
  166. package/examples/playground/app/a2slack/c/[channel]/page.tsx +20 -0
  167. package/examples/playground/app/a2slack/consumers/agent.ts +63 -0
  168. package/examples/playground/app/a2slack/consumers/feed.ts +164 -0
  169. package/examples/playground/app/a2slack/consumers/notify.ts +116 -0
  170. package/examples/playground/app/a2slack/consumers/search.ts +37 -0
  171. package/examples/playground/app/a2slack/consumers/triage.ts +31 -0
  172. package/examples/playground/app/a2slack/consumers/welcome.ts +25 -0
  173. package/examples/playground/app/a2slack/db/a2.ts +209 -0
  174. package/examples/playground/app/a2slack/db/index.ts +73 -0
  175. package/examples/playground/app/a2slack/db/migrations/0000_extensions.sql +4 -0
  176. package/examples/playground/app/a2slack/db/migrations/0001_workspace.sql +74 -0
  177. package/examples/playground/app/a2slack/db/migrations/0002_a2.sql +215 -0
  178. package/examples/playground/app/a2slack/db/migrations/0003_a2_batch.sql +67 -0
  179. package/examples/playground/app/a2slack/db/migrations/0004_a2_session_event_ids.sql +2 -0
  180. package/examples/playground/app/a2slack/db/migrations/meta/0000_snapshot.json +18 -0
  181. package/examples/playground/app/a2slack/db/migrations/meta/0001_snapshot.json +500 -0
  182. package/examples/playground/app/a2slack/db/migrations/meta/0002_snapshot.json +1159 -0
  183. package/examples/playground/app/a2slack/db/migrations/meta/0003_snapshot.json +1159 -0
  184. package/examples/playground/app/a2slack/db/migrations/meta/0004_snapshot.json +1165 -0
  185. package/examples/playground/app/a2slack/db/migrations/meta/_journal.json +41 -0
  186. package/examples/playground/app/a2slack/db/queries.ts +263 -0
  187. package/examples/playground/app/a2slack/db/schema.ts +145 -0
  188. package/examples/playground/app/a2slack/drills/a2slack.drill.ts +708 -0
  189. package/examples/playground/app/a2slack/drills/harness.ts +244 -0
  190. package/examples/playground/app/a2slack/events/route.ts +36 -0
  191. package/examples/playground/app/a2slack/identity.ts +44 -0
  192. package/examples/playground/app/a2slack/inbox/route.ts +8 -0
  193. package/examples/playground/app/a2slack/lag/data/route.ts +8 -0
  194. package/examples/playground/app/a2slack/lag/page.tsx +101 -0
  195. package/examples/playground/app/a2slack/lag.ts +73 -0
  196. package/examples/playground/app/a2slack/layout.tsx +43 -0
  197. package/examples/playground/app/a2slack/messages/route.ts +17 -0
  198. package/examples/playground/app/a2slack/model.test.ts +208 -0
  199. package/examples/playground/app/a2slack/model.ts +369 -0
  200. package/examples/playground/app/a2slack/models.test.ts +72 -0
  201. package/examples/playground/app/a2slack/page.tsx +10 -0
  202. package/examples/playground/app/a2slack/scheduler/route.ts +5 -0
  203. package/examples/playground/app/a2slack/search/route.ts +40 -0
  204. package/examples/playground/app/a2slack/search-index.ts +108 -0
  205. package/examples/playground/app/a2slack/server.test.ts +315 -0
  206. package/examples/playground/app/a2slack/server.ts +55 -0
  207. package/examples/playground/app/a2slack/sessions.ts +50 -0
  208. package/examples/playground/app/a2slack/thread/route.ts +8 -0
  209. package/examples/playground/app/a2slack/ui/ChannelView.tsx +87 -0
  210. package/examples/playground/app/a2slack/ui/Composer.tsx +117 -0
  211. package/examples/playground/app/a2slack/ui/DotsShadow.tsx +42 -0
  212. package/examples/playground/app/a2slack/ui/Feed.tsx +157 -0
  213. package/examples/playground/app/a2slack/ui/InboxContext.tsx +63 -0
  214. package/examples/playground/app/a2slack/ui/InboxView.tsx +82 -0
  215. package/examples/playground/app/a2slack/ui/Menu.tsx +128 -0
  216. package/examples/playground/app/a2slack/ui/MessageRow.tsx +314 -0
  217. package/examples/playground/app/a2slack/ui/NameGate.tsx +35 -0
  218. package/examples/playground/app/a2slack/ui/SearchContext.tsx +146 -0
  219. package/examples/playground/app/a2slack/ui/SearchResults.tsx +97 -0
  220. package/examples/playground/app/a2slack/ui/Shell.tsx +34 -0
  221. package/examples/playground/app/a2slack/ui/Sidebar.tsx +199 -0
  222. package/examples/playground/app/a2slack/ui/ThreadPanel.tsx +134 -0
  223. package/examples/playground/app/a2slack/ui/useInbox.ts +71 -0
  224. package/examples/playground/app/agent/server.ts +3 -3
  225. package/examples/playground/app/chat/[chatId]/chat-client.tsx +1 -1
  226. package/examples/playground/app/chat/server.ts +45 -35
  227. package/examples/playground/app/components/demo-icons.tsx +7 -0
  228. package/examples/playground/app/components/demos.tsx +7 -0
  229. package/examples/playground/app/orders/[orderId]/order-client.tsx +2 -2
  230. package/examples/playground/app/orders/page.tsx +1 -1
  231. package/examples/playground/app/orders/server.ts +49 -30
  232. package/examples/playground/app/recovery/[recoveryId]/recovery-client.tsx +14 -15
  233. package/examples/playground/app/recovery/page.tsx +4 -4
  234. package/examples/playground/app/recovery/server.ts +23 -32
  235. package/examples/playground/drizzle.config.ts +14 -0
  236. package/examples/playground/lib/store.ts +1 -1
  237. package/examples/playground/next.config.ts +6 -1
  238. package/examples/playground/package.json +12 -1
  239. package/examples/playground/scripts/migrate.mjs +31 -0
  240. package/examples/playground/vercel.json +9 -0
  241. package/examples/playground/vitest.drills.config.ts +32 -0
  242. package/package.json +5 -2
  243. package/sql/postgres/drizzle-schema.ts +209 -0
  244. package/sql/postgres/schema.sql +227 -67
  245. package/src/actor-shared.ts +10 -2
  246. package/src/actor.ts +101 -71
  247. package/src/ai-agent.ts +162 -111
  248. package/src/ai-control-server.ts +8 -8
  249. package/src/ai-fx.ts +324 -82
  250. package/src/ai-harness.ts +17 -2
  251. package/src/ai-pi.ts +2 -2
  252. package/src/ai-server.ts +2 -2
  253. package/src/ai.ts +7 -7
  254. package/src/client.ts +19 -2
  255. package/src/contract.ts +1 -1
  256. package/src/deterministic-id.ts +27 -4
  257. package/src/errors.ts +8 -5
  258. package/src/idempotent-replay.ts +0 -1
  259. package/src/index.ts +1 -1
  260. package/src/internal.ts +57 -16
  261. package/src/otel.ts +1 -1
  262. package/src/postgres-schema.ts +98 -57
  263. package/src/presence-recovery.ts +18 -11
  264. package/src/presence-tombstones.ts +52 -0
  265. package/src/push-envelope.ts +20 -0
  266. package/src/runtime-consumer.ts +10 -0
  267. package/src/scheduler-task.ts +2 -2
  268. package/src/scheduler-vercel.ts +1 -1
  269. package/src/server.browser.ts +1 -1
  270. package/src/server.ts +819 -218
  271. package/src/session-socket.ts +1 -1
  272. package/src/store-codec.ts +66 -0
  273. package/src/store-memory.ts +604 -286
  274. package/src/store-postgres.ts +2006 -857
  275. package/src/store-presence-polling.ts +59 -34
  276. package/src/store-redis-core.ts +1961 -1333
  277. package/src/store-redis-notify.ts +76 -26
  278. package/src/store-redis.ts +2 -3
  279. package/src/store-sqlite.ts +915 -516
  280. package/src/store.ts +241 -102
  281. package/src/telemetry.ts +2 -1
  282. package/src/wire.ts +1 -0
  283. package/dist/actor-C7b-1arn.d.ts.map +0 -1
  284. package/dist/actor-shared-t7w8xIKP.js.map +0 -1
  285. package/dist/ai-harness-Clbg5ei7.js.map +0 -1
  286. package/dist/ai-mMiWul1T.js.map +0 -1
  287. package/dist/client-BhHLoNKF.js.map +0 -1
  288. package/dist/contract-BYKASlCV.js.map +0 -1
  289. package/dist/idempotent-replay-JdgMQRRm.js +0 -21
  290. package/dist/idempotent-replay-JdgMQRRm.js.map +0 -1
  291. package/dist/initial-snapshot-Bx-c0pDP.d.ts.map +0 -1
  292. package/dist/internal-CscSY7K_.js.map +0 -1
  293. package/dist/server-DYAATFuT.d.ts.map +0 -1
  294. package/dist/server-qyb5aJJG.js.map +0 -1
  295. package/dist/session-clear-C-QwTXV1.d.ts.map +0 -1
  296. package/dist/session-clear-DGI4jTQ5.js.map +0 -1
  297. package/dist/store-CyLUy86v.d.ts +0 -404
  298. package/dist/store-CyLUy86v.d.ts.map +0 -1
  299. package/dist/store-codec-DTG0Ftek.js +0 -10
  300. package/dist/store-codec-DTG0Ftek.js.map +0 -1
  301. package/dist/store-presence-polling-B1ixeqIS.js +0 -94
  302. package/dist/store-presence-polling-B1ixeqIS.js.map +0 -1
  303. package/dist/store-redis-notify-CM0AfLY0.js +0 -2544
  304. package/dist/store-redis-notify-CM0AfLY0.js.map +0 -1
  305. package/dist/stream-buffer-Cx7AAPGq.js +0 -23
  306. package/dist/stream-buffer-Cx7AAPGq.js.map +0 -1
  307. package/dist/telemetry-Cyfqo6Qt.d.ts.map +0 -1
  308. package/dist/wire-BKxH1vbP.js.map +0 -1
  309. package/docs/server/01-handlers.mdx +0 -227
@@ -1,8 +1,7 @@
1
- import { i as A2Error, r as sessionClearedError, s as supersededAttemptError, t as CLEAR_EVENT_TYPE } from "./session-clear-DGI4jTQ5.js";
2
- import { n as pollingStream } from "./store-polling-7WJV0YWP.js";
3
- import { t as idempotentReplay } from "./idempotent-replay-JdgMQRRm.js";
1
+ import { i as A2Error, r as sessionClearedError, s as supersededAttemptError, t as CLEAR_EVENT_TYPE } from "./session-clear-wHd5GCpZ.js";
2
+ import { n as pollingStream } from "./store-polling-BZt2Rf3D.js";
3
+ import { a as idempotentReplay, i as idleLogCursor, n as decodeCause, r as decodeReturnedEventIds, t as cursorKeyOf } from "./store-codec-DwrWlgQx.js";
4
4
  import { n as SYSTEM_CLOCK, t as RANDOM_IDS } from "./store-N8PXxDAS.js";
5
- import { t as decodeReturnedEventIds } from "./store-codec-DTG0Ftek.js";
6
5
  import { dirname, resolve } from "node:path";
7
6
  import { mkdirSync } from "node:fs";
8
7
  import { DatabaseSync } from "node:sqlite";
@@ -20,7 +19,14 @@ import { DatabaseSync } from "node:sqlite";
20
19
  * injected clock — never SQL `now()` — so tests can time-travel against
21
20
  * real storage.
22
21
  */
22
+ /** The cursor layout; databases from before it are rejected, not migrated. */
23
+ const LAYOUT = "cursors-1";
23
24
  const SCHEMA = `
25
+ create table if not exists a2_meta (
26
+ key text not null primary key,
27
+ value text not null
28
+ ) strict;
29
+
24
30
  create table if not exists a2_events (
25
31
  session_id text not null,
26
32
  idx integer not null,
@@ -29,35 +35,52 @@ create table if not exists a2_events (
29
35
  event_id text not null,
30
36
  created_at integer not null,
31
37
  cause text,
38
+ primary key (session_id, idx)
39
+ ) strict;
40
+
41
+ create unique index if not exists a2_events_session_event_id
42
+ on a2_events (session_id, event_id);
43
+ drop index if exists a2_events_event_id;
44
+ create index if not exists a2_events_types on a2_events (session_id, event_type, idx);
45
+
46
+ create table if not exists a2_cursors (
47
+ session_id text not null,
48
+ consumer text not null,
49
+ cursor_key text not null,
32
50
  lane text,
33
- lane_ready integer not null default 0,
34
- processed_at integer,
35
- processed_by_attempt integer,
36
- returned_event_ids text,
51
+ kind text not null,
52
+ position integer not null,
53
+ head integer,
54
+ types text,
55
+ attempt_count integer not null default 0,
37
56
  first_claimed_at integer,
38
57
  last_claimed_at integer,
39
- attempt_count integer not null default 0,
58
+ claim_holder text,
59
+ claim_expires_at integer,
40
60
  failure_count integer not null default 0,
41
61
  last_failed_at integer,
42
62
  last_failed_attempt integer,
43
63
  last_error text,
44
64
  failed_at integer,
45
- claim_holder text,
46
- claim_expires_at integer,
47
- primary key (session_id, idx)
65
+ completed_idx integer,
66
+ completed_attempt integer,
67
+ returned_event_ids text,
68
+ primary key (session_id, consumer, cursor_key)
48
69
  ) strict;
49
70
 
50
- create unique index if not exists a2_events_event_id on a2_events (event_id);
51
- create index if not exists a2_events_unprocessed
52
- on a2_events (session_id, idx) where processed_at is null;
53
- create index if not exists a2_events_lane_pending
54
- on a2_events (session_id, lane, idx) where processed_at is null;
55
- create index if not exists a2_events_dispatch_ready
56
- on a2_events (session_id, idx)
57
- where lane_ready = 1 and processed_at is null and failed_at is null;
58
- create index if not exists a2_events_claim_expiry
59
- on a2_events (session_id, claim_expires_at)
60
- where lane_ready = 1 and processed_at is null and failed_at is null;
71
+ create index if not exists a2_cursors_heads
72
+ on a2_cursors (session_id, head) where head is not null;
73
+
74
+ create table if not exists a2_lane_events (
75
+ session_id text not null,
76
+ consumer text not null,
77
+ lane text not null,
78
+ idx integer not null,
79
+ done integer not null default 0,
80
+ completed_attempt integer,
81
+ returned_event_ids text,
82
+ primary key (session_id, consumer, lane, idx)
83
+ ) strict;
61
84
 
62
85
  create table if not exists a2_snapshots (
63
86
  session_id text not null,
@@ -79,10 +102,12 @@ create table if not exists a2_snapshot_history (
79
102
 
80
103
  create table if not exists a2_snapshot_pins (
81
104
  session_id text not null,
105
+ consumer text not null,
106
+ cursor_key text not null,
82
107
  event_index integer not null,
83
108
  reducer_name text not null,
84
109
  up_to_index integer not null,
85
- primary key (session_id, event_index, reducer_name, up_to_index)
110
+ primary key (session_id, consumer, cursor_key, event_index, reducer_name, up_to_index)
86
111
  ) strict;
87
112
 
88
113
  create index if not exists a2_snapshot_pins_checkpoint
@@ -129,17 +154,7 @@ const wrap = (fn) => {
129
154
  }
130
155
  };
131
156
  const toDate = (ms) => ms === null ? null : new Date(Number(ms));
132
- const toCause = (json) => {
133
- if (json === null) return null;
134
- const value = JSON.parse(json);
135
- if (!Number.isInteger(value.index) || Number(value.index) < 1 || !Number.isInteger(value.attempt) || Number(value.attempt) < 1) throw new TypeError("stored event has an invalid cause");
136
- if (value.batchSize !== void 0 && (!Number.isInteger(value.batchSize) || Number(value.batchSize) < 1)) throw new TypeError("stored event has an invalid cause");
137
- return {
138
- index: Number(value.index),
139
- attempt: Number(value.attempt),
140
- ...value.batchSize === void 0 ? {} : { batchSize: Number(value.batchSize) }
141
- };
142
- };
157
+ const toNumber = (value) => value === null ? null : Number(value);
143
158
  const toStored = (row) => ({
144
159
  id: row.event_id,
145
160
  type: row.event_type,
@@ -147,21 +162,7 @@ const toStored = (row) => ({
147
162
  index: Number(row.idx),
148
163
  sessionId: row.session_id,
149
164
  createdAt: new Date(Number(row.created_at)),
150
- cause: toCause(row.cause),
151
- lane: row.lane,
152
- processedAt: toDate(row.processed_at),
153
- processedByAttempt: row.processed_by_attempt === null ? null : Number(row.processed_by_attempt),
154
- returnedEventIds: row.returned_event_ids === null ? null : decodeReturnedEventIds(row.returned_event_ids),
155
- firstClaimedAt: toDate(row.first_claimed_at),
156
- lastClaimedAt: toDate(row.last_claimed_at),
157
- attemptCount: Number(row.attempt_count),
158
- failureCount: Number(row.failure_count),
159
- lastFailedAt: toDate(row.last_failed_at),
160
- lastFailedAttempt: row.last_failed_attempt === null ? null : Number(row.last_failed_attempt),
161
- lastError: row.last_error,
162
- failedAt: toDate(row.failed_at),
163
- claimHolder: row.claim_holder,
164
- claimExpiresAt: toDate(row.claim_expires_at)
165
+ cause: decodeCause(row.cause)
165
166
  });
166
167
  const toEvent = (row) => ({
167
168
  id: row.event_id,
@@ -171,7 +172,55 @@ const toEvent = (row) => ({
171
172
  sessionId: row.session_id,
172
173
  createdAt: new Date(Number(row.created_at))
173
174
  });
175
+ const toCursor = (row) => ({
176
+ consumer: row.consumer,
177
+ lane: row.lane,
178
+ position: Number(row.position),
179
+ head: toNumber(row.head),
180
+ firstClaimedAt: toDate(row.first_claimed_at),
181
+ lastClaimedAt: toDate(row.last_claimed_at),
182
+ attemptCount: Number(row.attempt_count),
183
+ claimHolder: row.claim_holder,
184
+ claimExpiresAt: toDate(row.claim_expires_at),
185
+ failureCount: Number(row.failure_count),
186
+ lastFailedAt: toDate(row.last_failed_at),
187
+ lastFailedAttempt: toNumber(row.last_failed_attempt),
188
+ lastError: row.last_error,
189
+ failedAt: toDate(row.failed_at)
190
+ });
191
+ const toHead = (row) => {
192
+ const { position: _position, head: _head, ...dispatch } = toCursor(row);
193
+ return {
194
+ ...toStored({
195
+ session_id: row.session_id,
196
+ idx: row.e_idx,
197
+ event_type: row.e_event_type,
198
+ payload: row.e_payload,
199
+ event_id: row.e_event_id,
200
+ created_at: row.e_created_at,
201
+ cause: row.e_cause
202
+ }),
203
+ ...dispatch
204
+ };
205
+ };
174
206
  const RECLAIM_BATCH = 256;
207
+ /** The head columns every claim, dead letter, and listing joins in. */
208
+ const HEAD_SELECT = `
209
+ select cursor.*,
210
+ event.idx as e_idx, event.event_type as e_event_type,
211
+ event.payload as e_payload, event.event_id as e_event_id,
212
+ event.created_at as e_created_at, event.cause as e_cause
213
+ from a2_cursors cursor
214
+ join a2_events event
215
+ on event.session_id = cursor.session_id and event.idx = cursor.head`;
216
+ /** A fresh head's dispatch columns, for `set` clauses. */
217
+ const FRESH_DISPATCH = `
218
+ attempt_count = 0, first_claimed_at = null, last_claimed_at = null,
219
+ claim_holder = null, claim_expires_at = null, failure_count = 0,
220
+ last_failed_at = null, last_failed_attempt = null, last_error = null,
221
+ failed_at = null`;
222
+ const placeholders = (count) => Array.from({ length: count }, () => "?").join(", ");
223
+ const compareText = (a, b) => a < b ? -1 : a > b ? 1 : 0;
175
224
  function sqlite(options = {}) {
176
225
  const path = options.path ?? ".a2/dev.db";
177
226
  const clock = options.clock ?? SYSTEM_CLOCK;
@@ -184,37 +233,60 @@ function sqlite(options = {}) {
184
233
  db.exec("pragma busy_timeout = 5000");
185
234
  setWalJournalMode(db);
186
235
  db.exec("pragma synchronous = normal");
236
+ if (db.prepare("select 1 from pragma_table_info('a2_events') where name = ?").get("processed_at")) {
237
+ db.close();
238
+ throw new A2Error("STORE_NOT_CONFIGURED", `the sqlite database at '${path}' has the A2 layout from before consumers; delete it, or point the store at a fresh database file`);
239
+ }
187
240
  db.exec(SCHEMA);
241
+ db.prepare("insert into a2_meta (key, value) values ('layout', ?) on conflict (key) do nothing").run(LAYOUT);
188
242
  const insertEvent = db.prepare(`insert into a2_events
189
- (session_id, idx, event_type, payload, event_id, created_at,
190
- cause, lane, lane_ready, processed_at)
191
- select ?, ?, ?, ?, ?, ?, ?, ?,
192
- case
193
- when ? is null then 1
194
- when not exists (
195
- select 1 from a2_events
196
- where session_id = ? and lane = ? and processed_at is null
197
- and idx > ?
198
- ) then 1
199
- else 0
200
- end,
201
- ?`);
202
- const promoteLaneHead = db.prepare(`update a2_events set lane_ready = 1
203
- where session_id = ?
204
- and idx = (
205
- select idx from a2_events
206
- where session_id = ? and lane = ? and processed_at is null
207
- and idx > ?
208
- order by idx limit 1
209
- )`);
210
- const appendStatus = db.prepare(`select coalesce(
211
- (select max(idx) from a2_events where session_id = ?),
212
- 0
213
- ) as max,
214
- exists(
215
- select 1 from a2_events
216
- where session_id = ? and processed_at is null and idx > ?
217
- ) as has_pending`);
243
+ (session_id, idx, event_type, payload, event_id, created_at, cause)
244
+ values (?, ?, ?, ?, ?, ?, ?)`);
245
+ const selectHead = db.prepare("select coalesce(max(idx), 0) as max from a2_events where session_id = ?");
246
+ const headOf = (sessionId) => Number(selectHead.get(sessionId).max);
247
+ const selectEvent = db.prepare("select 1 from a2_events where session_id = ? and idx = ?");
248
+ const selectCursor = db.prepare("select * from a2_cursors where session_id = ? and consumer = ? and cursor_key = ?");
249
+ const cursorOf = (sessionId, ref) => selectCursor.get(sessionId, ref.consumer, cursorKeyOf(ref.lane, ref.index));
250
+ const insertEventCursor = db.prepare(`insert into a2_cursors
251
+ (session_id, consumer, cursor_key, lane, kind, position, head)
252
+ values (?, ?, ?, null, 'event', ?, ?)`);
253
+ const insertLaneEvent = db.prepare(`insert into a2_lane_events (session_id, consumer, lane, idx)
254
+ values (?, ?, ?, ?)`);
255
+ const queueLaneHead = db.prepare(`insert into a2_cursors
256
+ (session_id, consumer, cursor_key, lane, kind, position, head)
257
+ values (?, ?, ?, ?, 'lane', ?, ?)
258
+ on conflict (session_id, consumer, cursor_key) do update set
259
+ head = excluded.head,
260
+ position = max(a2_cursors.position, excluded.position),
261
+ ${FRESH_DISPATCH}
262
+ where a2_cursors.head is null or a2_cursors.head <= excluded.position`);
263
+ const openLogCursor = db.prepare(`insert into a2_cursors
264
+ (session_id, consumer, cursor_key, lane, kind, position, head, types)
265
+ values (?, ?, ?, ?, 'log', ?, ?, ?)`);
266
+ const wakeLogCursor = db.prepare(`update a2_cursors set types = ?, head = ?
267
+ where session_id = ? and consumer = ? and cursor_key = ?`);
268
+ const refreshTypes = db.prepare(`update a2_cursors set types = ?
269
+ where session_id = ? and consumer = ? and kind = 'log'
270
+ and types is not ?`);
271
+ const nextLogHeadAll = db.prepare(`select min(idx) as next from a2_events
272
+ where session_id = ? and idx > ? and event_type != ?`);
273
+ const nextLogHeadOf = db.prepare(`select min(idx) as next from a2_events
274
+ where session_id = ? and idx > ? and event_type != ?
275
+ and event_type in (select value from json_each(?))`);
276
+ const nextLogHead = (sessionId, after, types) => toNumber((types === null ? nextLogHeadAll.get(sessionId, after, CLEAR_EVENT_TYPE) : nextLogHeadOf.get(sessionId, after, CLEAR_EVENT_TYPE, types)).next);
277
+ const nextLaneHead = db.prepare(`select min(idx) as next from a2_lane_events
278
+ where session_id = ? and consumer = ? and lane = ? and done = 0
279
+ and idx > ?`);
280
+ const selectLaneEvent = db.prepare(`select completed_attempt, returned_event_ids from a2_lane_events
281
+ where session_id = ? and consumer = ? and lane = ? and idx = ?`);
282
+ const finishLaneEvent = db.prepare(`update a2_lane_events
283
+ set done = 1, completed_attempt = ?, returned_event_ids = ?
284
+ where session_id = ? and consumer = ? and lane = ? and idx = ?`);
285
+ const moveCursor = db.prepare(`update a2_cursors set position = ?, head = ?, ${FRESH_DISPATCH}
286
+ where session_id = ? and consumer = ? and cursor_key = ?`);
287
+ const recordCompletion = db.prepare(`update a2_cursors
288
+ set completed_idx = ?, completed_attempt = ?, returned_event_ids = ?
289
+ where session_id = ? and consumer = ? and cursor_key = ?`);
218
290
  const upsertPresence = db.prepare(`insert into a2_presence (ns, participant, field, value, seen, at, expires_at)
219
291
  values (?, ?, ?, ?, ?, ?, ?)
220
292
  on conflict (ns, participant, field) do update set
@@ -222,46 +294,53 @@ function sqlite(options = {}) {
222
294
  seen = excluded.seen,
223
295
  at = excluded.at,
224
296
  expires_at = excluded.expires_at
225
- where a2_presence.at <= excluded.at`);
226
- const deletePresence = db.prepare(`delete from a2_presence
227
- where ns = ? and participant = ? and field = ? and at <= ?`);
297
+ where a2_presence.at <= excluded.at or a2_presence.expires_at <= ?`);
228
298
  const sweepPresence = db.prepare("delete from a2_presence where ns = ? and expires_at <= ?");
229
299
  const selectPresence = db.prepare(`select participant, field, value, seen, at, expires_at
230
300
  from a2_presence
231
- where ns = ? and expires_at > ?
301
+ where ns = ? and expires_at > ? and value != 'null'
232
302
  order by participant, field`);
233
- const selectByIds = (count) => db.prepare(`select stored.*,
234
- exists(
235
- select 1 from a2_events pending
236
- where pending.session_id = ?
237
- and pending.processed_at is null
238
- and pending.idx > ?
239
- ) as session_has_pending
240
- from a2_events stored
241
- where stored.event_id in (${Array.from({ length: count }, () => "?").join(", ")})
242
- order by stored.idx`);
303
+ const selectByIds = (count) => db.prepare(`select * from a2_events
304
+ where session_id = ? and event_id in (${placeholders(count)})
305
+ order by idx`);
243
306
  const selectClearIndex = db.prepare("select clear_index from a2_clears where session_id = ?");
244
307
  const clearIndexOf = (sessionId) => Number(selectClearIndex.get(sessionId)?.clear_index ?? 0);
245
308
  const deleteEventById = db.prepare("delete from a2_events where session_id = ? and event_id = ?");
309
+ /** Served consumers as a SQL condition on `cursor.consumer`. */
310
+ const servedClause = (consumers) => consumers === void 0 ? {
311
+ sql: "",
312
+ params: []
313
+ } : consumers.length === 0 ? {
314
+ sql: "and 0",
315
+ params: []
316
+ } : {
317
+ sql: `and cursor.consumer in (${placeholders(consumers.length)})`,
318
+ params: consumers.map((consumer) => consumer.name)
319
+ };
320
+ const hasPending = (sessionId, clearIndex, consumers) => {
321
+ const served = servedClause(consumers);
322
+ return db.prepare(`select 1 from a2_cursors cursor
323
+ where cursor.session_id = ? and cursor.head > ?
324
+ and cursor.failed_at is null ${served.sql}
325
+ limit 1`).get(sessionId, clearIndex, ...served.params) !== void 0;
326
+ };
246
327
  /**
247
- * The rows owning `ids`. This session's rows below its clear marker do
248
- * not own their ids: they are deleted here so the ids can be reused.
328
+ * The session's rows owning `ids`. Rows below its clear marker do not
329
+ * own their ids: they are deleted here so the ids can be reused.
249
330
  */
250
331
  const liveOwnersOf = (sessionId, clearIndex, ids) => {
251
332
  if (ids.length === 0) return [];
252
- return selectByIds(ids.length).all(sessionId, clearIndex, ...ids).filter((row) => {
253
- if (row.session_id !== sessionId || Number(row.idx) >= clearIndex) return true;
333
+ return selectByIds(ids.length).all(sessionId, ...ids).filter((row) => {
334
+ if (Number(row.idx) >= clearIndex) return true;
254
335
  deleteEventById.run(sessionId, row.event_id);
255
336
  return false;
256
337
  });
257
338
  };
258
- const selectCauseParent = db.prepare("select attempt_count, failed_at from a2_events where session_id = ? and idx = ?");
259
- /** The attempt-currency fence shared by handler appends and clears. */
339
+ /** The attempt-currency fence shared by consumer appends and clears. */
260
340
  const assertCurrentCause = ({ sessionId, cause, clearIndex }) => {
261
341
  if (cause.index <= clearIndex) throw supersededAttemptError(sessionId, cause);
262
- const parent = selectCauseParent.get(sessionId, cause.index);
263
- if (!parent) throw new TypeError(`no event at index ${cause.index} in session '${sessionId}'`);
264
- if (Number(parent.attempt_count) !== cause.attempt || parent.failed_at !== null) throw supersededAttemptError(sessionId, cause);
342
+ const cursor = cursorOf(sessionId, cause);
343
+ if (!cursor || toNumber(cursor.head) !== cause.index || Number(cursor.attempt_count) !== cause.attempt || cursor.failed_at !== null) throw supersededAttemptError(sessionId, cause);
265
344
  };
266
345
  const tx = (fn) => {
267
346
  db.exec("begin immediate");
@@ -276,9 +355,10 @@ function sqlite(options = {}) {
276
355
  throw err;
277
356
  }
278
357
  };
279
- /** A dead-lettered event's snapshot pins, and history no pin keeps. */
280
- const releasePins = (sessionId, index) => {
281
- db.prepare("delete from a2_snapshot_pins where session_id = ? and event_index = ?").run(sessionId, index);
358
+ /** A finished or dead head's snapshot pins, and history no pin keeps. */
359
+ const releasePins = (sessionId, consumer, cursorKey, index) => {
360
+ db.prepare(`delete from a2_snapshot_pins
361
+ where session_id = ? and consumer = ? and cursor_key = ? and event_index = ?`).run(sessionId, consumer, cursorKey, index);
282
362
  db.prepare(`delete from a2_snapshot_history
283
363
  where session_id = ?
284
364
  and not exists (
@@ -288,15 +368,104 @@ function sqlite(options = {}) {
288
368
  and pin.up_to_index = a2_snapshot_history.up_to_index
289
369
  )`).run(sessionId);
290
370
  };
371
+ /** Moves a cursor past its head; the next head starts fresh. */
372
+ const advance = (sessionId, cursor) => {
373
+ const done = Number(cursor.head);
374
+ releasePins(sessionId, cursor.consumer, cursor.cursor_key, done);
375
+ let next;
376
+ if (cursor.kind === "event") next = null;
377
+ else if (cursor.kind === "lane") next = toNumber(nextLaneHead.get(sessionId, cursor.consumer, cursor.lane, Math.max(done, clearIndexOf(sessionId))).next);
378
+ else {
379
+ const { types } = selectCursor.get(sessionId, cursor.consumer, cursor.cursor_key);
380
+ next = nextLogHead(sessionId, done, types);
381
+ }
382
+ moveCursor.run(done, next, sessionId, cursor.consumer, cursor.cursor_key);
383
+ };
384
+ /**
385
+ * Brings a log consumer's cursor up to date and records its types. A
386
+ * missing cursor opens at the clear marker with `backfill`, otherwise at
387
+ * `base`; an idle one takes the next event of its types after its
388
+ * position; one with a head keeps it, claim and all. Returns its key.
389
+ */
390
+ const wakeLogAt = ({ sessionId, consumer, log, base, clearIndex }) => {
391
+ const types = log.types === null ? null : JSON.stringify(log.types);
392
+ const key = cursorKeyOf(log.lane, 0);
393
+ const existing = selectCursor.get(sessionId, consumer, key);
394
+ if (existing) {
395
+ wakeLogCursor.run(types, existing.head === null ? nextLogHead(sessionId, Number(existing.position), types) : Number(existing.head), sessionId, consumer, key);
396
+ return key;
397
+ }
398
+ const position = log.backfill ? clearIndex : base;
399
+ openLogCursor.run(sessionId, consumer, key, log.lane, position, nextLogHead(sessionId, position, types), types);
400
+ return key;
401
+ };
402
+ /** Starts the cursor work an inserted batch carries. */
403
+ const enqueue = ({ sessionId, events, base, clearIndex, consumers }) => {
404
+ events.forEach((event, offset) => {
405
+ const index = base + 1 + offset;
406
+ for (const target of event.targets ?? []) {
407
+ if (target.lane === null) {
408
+ insertEventCursor.run(sessionId, target.consumer, cursorKeyOf(null, index), index - 1, index);
409
+ continue;
410
+ }
411
+ insertLaneEvent.run(sessionId, target.consumer, target.lane, index);
412
+ queueLaneHead.run(sessionId, target.consumer, cursorKeyOf(target.lane, index), target.lane, clearIndex, index);
413
+ }
414
+ });
415
+ for (const consumer of consumers ?? []) {
416
+ const log = consumer.log;
417
+ if (!log) continue;
418
+ if (!events.some((event) => event.type !== "a2.cleared" && (log.types === null || log.types.includes(event.type)))) continue;
419
+ wakeLogAt({
420
+ sessionId,
421
+ consumer: consumer.name,
422
+ log,
423
+ base,
424
+ clearIndex
425
+ });
426
+ }
427
+ };
428
+ const refreshLogTypes = (sessionId, consumers) => {
429
+ for (const consumer of consumers ?? []) {
430
+ if (!consumer.log) continue;
431
+ const types = consumer.log.types === null ? null : JSON.stringify(consumer.log.types);
432
+ refreshTypes.run(types, sessionId, consumer.name, types);
433
+ }
434
+ };
435
+ const insertBatch = ({ sessionId, events, clearIndex, consumers, cause }) => {
436
+ const base = headOf(sessionId);
437
+ const now = clock.now().getTime();
438
+ const inserted = events.map((event, offset) => {
439
+ const id = event.id ?? generateId();
440
+ const index = base + 1 + offset;
441
+ const eventCause = cause ?? event.cause ?? null;
442
+ insertEvent.run(sessionId, index, event.type, JSON.stringify(event.payload) ?? "null", id, now, eventCause ? JSON.stringify(eventCause) : null);
443
+ return {
444
+ id,
445
+ type: event.type,
446
+ payload: structuredClone(event.payload),
447
+ index,
448
+ sessionId,
449
+ createdAt: new Date(now),
450
+ cause: eventCause ? { ...eventCause } : null
451
+ };
452
+ });
453
+ enqueue({
454
+ sessionId,
455
+ events,
456
+ base,
457
+ clearIndex,
458
+ consumers
459
+ });
460
+ return inserted;
461
+ };
291
462
  return {
292
463
  async append(sessionId, events, appendOptions) {
293
- if (events.length === 0) return wrap(() => {
294
- const status = appendStatus.get(sessionId, sessionId, clearIndexOf(sessionId));
295
- return {
296
- events: [],
297
- hasPending: Number(status.has_pending) === 1
298
- };
299
- });
464
+ const consumers = appendOptions?.consumers;
465
+ if (events.length === 0) return wrap(() => ({
466
+ events: [],
467
+ hasPending: hasPending(sessionId, clearIndexOf(sessionId), consumers)
468
+ }));
300
469
  return wrap(() => tx(() => {
301
470
  const clearIndex = clearIndexOf(sessionId);
302
471
  const suppliedIds = events.filter((e) => e.id !== void 0).map((e) => e.id);
@@ -304,11 +473,9 @@ function sqlite(options = {}) {
304
473
  if (suppliedIds.length > 0) {
305
474
  const existing = liveOwnersOf(sessionId, clearIndex, suppliedIds);
306
475
  if (existing.length > 0) {
307
- const foreign = existing.find((row) => row.session_id !== sessionId);
308
- if (foreign) throw new A2Error("PARTIAL_DUPLICATE_BATCH", `event id '${foreign.event_id}' already exists in another session`);
309
476
  if (existing.length === events.length) return {
310
477
  events: idempotentReplay(events, existing.map(toStored)),
311
- hasPending: Number(existing[0].session_has_pending) === 1
478
+ hasPending: hasPending(sessionId, clearIndex, consumers)
312
479
  };
313
480
  throw new A2Error("PARTIAL_DUPLICATE_BATCH", `batch mixes ${existing.length} already-appended and ${events.length - existing.length} fresh events`);
314
481
  }
@@ -320,42 +487,55 @@ function sqlite(options = {}) {
320
487
  cause: e.cause,
321
488
  clearIndex
322
489
  });
323
- const status = appendStatus.get(sessionId, sessionId, clearIndex);
324
- const base = Number(status.max);
325
- const now = clock.now().getTime();
326
490
  return {
327
- events: events.map((e, i) => {
328
- const id = e.id ?? generateId();
329
- const index = base + 1 + i;
330
- insertEvent.run(sessionId, index, e.type, JSON.stringify(e.payload) ?? "null", id, now, e.cause ? JSON.stringify(e.cause) : null, e.lane ?? null, e.lane ?? null, sessionId, e.lane ?? null, clearIndex, e.settled ? now : null);
331
- return {
332
- id,
333
- type: e.type,
334
- payload: structuredClone(e.payload),
335
- index,
336
- sessionId,
337
- createdAt: new Date(now),
338
- cause: e.cause ? { ...e.cause } : null,
339
- lane: e.lane ?? null,
340
- processedAt: e.settled ? new Date(now) : null,
341
- processedByAttempt: null,
342
- returnedEventIds: null,
343
- firstClaimedAt: null,
344
- lastClaimedAt: null,
345
- attemptCount: 0,
346
- failureCount: 0,
347
- lastFailedAt: null,
348
- lastFailedAttempt: null,
349
- lastError: null,
350
- failedAt: null,
351
- claimHolder: null,
352
- claimExpiresAt: null
353
- };
491
+ events: insertBatch({
492
+ sessionId,
493
+ events,
494
+ clearIndex,
495
+ consumers
354
496
  }),
355
- hasPending: Number(status.has_pending) === 1 || events.some((event) => event.settled !== true)
497
+ hasPending: hasPending(sessionId, clearIndex, consumers)
356
498
  };
357
499
  }));
358
500
  },
501
+ async wakeLog({ sessionId, consumer }) {
502
+ return wrap(() => {
503
+ const { lane } = consumer.log;
504
+ const marker = clearIndexOf(sessionId);
505
+ if (headOf(sessionId) === marker && !selectCursor.get(sessionId, consumer.name, cursorKeyOf(lane, 0))) return {
506
+ cursor: idleLogCursor(consumer.name, lane, marker),
507
+ head: marker
508
+ };
509
+ return tx(() => {
510
+ const head = headOf(sessionId);
511
+ const key = wakeLogAt({
512
+ sessionId,
513
+ consumer: consumer.name,
514
+ log: consumer.log,
515
+ base: head,
516
+ clearIndex: clearIndexOf(sessionId)
517
+ });
518
+ const cursor = selectCursor.get(sessionId, consumer.name, key);
519
+ return {
520
+ cursor: toCursor(cursor),
521
+ head
522
+ };
523
+ });
524
+ });
525
+ },
526
+ async cursors(sessionId) {
527
+ return wrap(() => {
528
+ const clearIndex = clearIndexOf(sessionId);
529
+ return db.prepare(`select * from a2_cursors
530
+ where session_id = ?
531
+ and (
532
+ kind = 'log'
533
+ or head > ?
534
+ or (kind = 'lane' and head is null and position > ?)
535
+ )
536
+ order by consumer, lane is null, lane, head`).all(sessionId, clearIndex, clearIndex).map(toCursor);
537
+ });
538
+ },
359
539
  async read(sessionId, opts) {
360
540
  return wrap(() => {
361
541
  const conditions = ["session_id = ?", "idx >= coalesce((select clear_index from a2_clears where session_id = ?), 0)"];
@@ -371,24 +551,40 @@ function sqlite(options = {}) {
371
551
  return db.prepare(`select * from a2_events where ${conditions.join(" and ")} order by idx`).all(...params).map(toStored);
372
552
  });
373
553
  },
374
- async claimAvailable({ sessionId, holder, ttlMs, expiresAtMs, excludeIndexes = [], maxFailures }) {
554
+ async claimAvailable({ sessionId, holder, ttlMs, expiresAtMs, exclude = [], consumers, maxFailures, through }) {
375
555
  return wrap(() => tx(() => {
556
+ refreshLogTypes(sessionId, consumers);
376
557
  const now = clock.now().getTime();
377
558
  const expiresAt = expiresAtMs ?? now + ttlMs;
378
559
  const clearIndex = clearIndexOf(sessionId);
379
- const exclusion = excludeIndexes.length === 0 ? "" : `and event.idx not in (${excludeIndexes.map(() => "?").join(", ")})`;
380
- const eligible = db.prepare(`select event.idx, event.claim_holder, event.failure_count
381
- from a2_events event
382
- where event.session_id = ?
383
- and event.processed_at is null
384
- and event.failed_at is null
385
- and event.lane_ready = 1
386
- and event.idx > ?
387
- and (event.claim_expires_at is null or event.claim_expires_at <= ?)
388
- ${exclusion}
389
- order by event.idx`).all(sessionId, clearIndex, now, ...excludeIndexes);
560
+ const served = servedClause(consumers);
561
+ const excludedKeys = exclude.map((ref) => `${ref.consumer}\u0000${cursorKeyOf(ref.lane, ref.index)}`);
562
+ const exclusion = excludedKeys.length === 0 ? "" : `and cursor.consumer || char(0) || cursor.cursor_key
563
+ not in (${placeholders(excludedKeys.length)})`;
564
+ const filter = `
565
+ where cursor.session_id = ?
566
+ and cursor.head > ?
567
+ and cursor.failed_at is null
568
+ ${through === void 0 ? "" : "and cursor.head <= ?"}
569
+ ${served.sql}
570
+ ${exclusion}`;
571
+ const params = [
572
+ sessionId,
573
+ clearIndex,
574
+ ...through === void 0 ? [] : [through],
575
+ ...served.params,
576
+ ...excludedKeys,
577
+ now
578
+ ];
579
+ const eligible = db.prepare(`select cursor.consumer, cursor.cursor_key,
580
+ cursor.claim_holder, cursor.failure_count
581
+ from a2_cursors cursor
582
+ ${filter}
583
+ and (cursor.claim_expires_at is null
584
+ or cursor.claim_expires_at <= ?)
585
+ order by cursor.head, cursor.consumer`).all(...params);
390
586
  if (eligible.length > 0) {
391
- const update = db.prepare(`update a2_events set
587
+ const claimHead = db.prepare(`update a2_cursors set
392
588
  attempt_count = attempt_count + 1,
393
589
  failure_count = failure_count
394
590
  + case when claim_holder is null then 0 else 1 end,
@@ -396,37 +592,35 @@ function sqlite(options = {}) {
396
592
  last_claimed_at = ?,
397
593
  claim_holder = ?,
398
594
  claim_expires_at = ?
399
- where session_id = ? and idx = ?
400
- returning *`);
401
- const deadLetter = () => db.prepare(`update a2_events set
402
- attempt_count = attempt_count + 1,
403
- failure_count = failure_count + 1,
404
- failed_at = ?,
405
- claim_holder = null,
406
- claim_expires_at = null
407
- where session_id = ? and idx = ?
408
- returning *`);
595
+ where session_id = ? and consumer = ? and cursor_key = ?`);
596
+ const deadLetterHead = db.prepare(`update a2_cursors set
597
+ attempt_count = attempt_count + 1,
598
+ failure_count = failure_count + 1,
599
+ failed_at = ?,
600
+ claim_holder = null,
601
+ claim_expires_at = null
602
+ where session_id = ? and consumer = ? and cursor_key = ?`);
603
+ const selectHeadRow = db.prepare(`${HEAD_SELECT}
604
+ where cursor.session_id = ? and cursor.consumer = ?
605
+ and cursor.cursor_key = ?`);
409
606
  return {
410
607
  outcome: "claimed",
411
608
  events: eligible.map((row) => {
412
609
  const exhausted = row.claim_holder !== null && maxFailures !== void 0 && Number(row.failure_count) + 1 >= maxFailures;
413
- const claimed = exhausted ? deadLetter().get(now, sessionId, row.idx) : update.get(now, now, holder, expiresAt, sessionId, row.idx);
414
- if (exhausted) releasePins(sessionId, Number(row.idx));
415
- const stored = toStored(claimed);
416
- if (clearIndex > 0) stored.clearIndex = clearIndex;
417
- return stored;
418
- })
610
+ if (exhausted) deadLetterHead.run(now, sessionId, row.consumer, row.cursor_key);
611
+ else claimHead.run(now, now, holder, expiresAt, sessionId, row.consumer, row.cursor_key);
612
+ const claimed = toHead(selectHeadRow.get(sessionId, row.consumer, row.cursor_key));
613
+ if (exhausted) releasePins(sessionId, row.consumer, row.cursor_key, claimed.index);
614
+ if (clearIndex > 0) claimed.clearIndex = clearIndex;
615
+ return claimed;
616
+ }),
617
+ head: headOf(sessionId)
419
618
  };
420
619
  }
421
- const active = db.prepare(`select min(claim_expires_at) as retry_at
422
- from a2_events event
423
- where session_id = ?
424
- and processed_at is null
425
- and failed_at is null
426
- and lane_ready = 1
427
- and idx > ?
428
- and claim_expires_at > ?
429
- ${exclusion}`).get(sessionId, clearIndex, now, ...excludeIndexes);
620
+ const active = db.prepare(`select min(cursor.claim_expires_at) as retry_at
621
+ from a2_cursors cursor
622
+ ${filter}
623
+ and cursor.claim_expires_at > ?`).get(...params);
430
624
  return active.retry_at === null ? { outcome: "settled" } : {
431
625
  outcome: "busy",
432
626
  retryAt: new Date(Number(active.retry_at))
@@ -443,113 +637,137 @@ function sqlite(options = {}) {
443
637
  const expiresAt = expiresAtMs ?? now + ttlMs;
444
638
  const renewed = [];
445
639
  const superseded = [];
446
- const currentAttempt = db.prepare("select attempt_count from a2_events where session_id = ? and idx = ?");
447
- const extend = db.prepare(`update a2_events set claim_expires_at = ?
448
- where session_id = ?
449
- and idx = ?
640
+ const extend = db.prepare(`update a2_cursors set claim_expires_at = ?
641
+ where session_id = ? and consumer = ? and cursor_key = ?
642
+ and head = ?
450
643
  and attempt_count = ?
451
- and processed_at is null
452
644
  and failed_at is null
453
645
  and claim_holder = ?
454
646
  and claim_expires_at > ?`);
455
647
  const clearIndex = clearIndexOf(sessionId);
456
- for (const claim of claims) {
457
- if (claim.index <= clearIndex) {
458
- superseded.push(claim.index);
648
+ const ordered = claims.toSorted((a, b) => a.index - b.index || compareText(a.consumer, b.consumer));
649
+ for (const { attempt, ...ref } of ordered) {
650
+ if (ref.index <= clearIndex) {
651
+ superseded.push(ref);
459
652
  continue;
460
653
  }
461
- const row = currentAttempt.get(sessionId, claim.index);
462
- if (!row) continue;
463
- if (Number(row.attempt_count) > claim.attempt) {
464
- superseded.push(claim.index);
654
+ const cursor = cursorOf(sessionId, ref);
655
+ if (!cursor) continue;
656
+ const head = toNumber(cursor.head);
657
+ if (head !== ref.index && Number(cursor.position) < ref.index) continue;
658
+ if (head !== ref.index || Number(cursor.attempt_count) > attempt) {
659
+ superseded.push(ref);
465
660
  continue;
466
661
  }
467
- const updated = extend.run(expiresAt, sessionId, claim.index, claim.attempt, holder, now);
468
- if (Number(updated.changes) === 1) renewed.push(claim.index);
662
+ const updated = extend.run(expiresAt, sessionId, ref.consumer, cursor.cursor_key, ref.index, attempt, holder, now);
663
+ if (Number(updated.changes) === 1) renewed.push(ref);
469
664
  }
470
665
  return {
471
- renewed: renewed.toSorted((a, b) => a - b),
472
- superseded: superseded.toSorted((a, b) => a - b)
666
+ renewed,
667
+ superseded
473
668
  };
474
669
  }));
475
670
  },
476
- async completeAttempt({ sessionId, index, attempt, events }) {
671
+ async completeAttempt({ sessionId, consumer, lane, index, attempt, events, consumers }) {
477
672
  return wrap(() => tx(() => {
478
673
  const clearIndex = clearIndexOf(sessionId);
479
674
  if (index <= clearIndex) return { outcome: "superseded" };
480
- const parent = db.prepare("select * from a2_events where session_id = ? and idx = ?").get(sessionId, index);
481
- if (!parent) throw new TypeError(`no event at index ${index} in session '${sessionId}'`);
675
+ const cursor = cursorOf(sessionId, {
676
+ consumer,
677
+ lane,
678
+ index
679
+ });
680
+ if (!cursor) {
681
+ if (!selectEvent.get(sessionId, index)) throw new TypeError(`no event at index ${index} in session '${sessionId}'`);
682
+ return { outcome: "superseded" };
683
+ }
684
+ refreshLogTypes(sessionId, consumers);
482
685
  const ids = events.map((event) => event.id);
483
686
  if (new Set(ids).size !== ids.length) throw new A2Error("PARTIAL_DUPLICATE_BATCH", "returned event batch contains the same event id more than once");
484
- if (parent.processed_at !== null) {
485
- if (Number(parent.processed_by_attempt) !== attempt) return { outcome: "superseded" };
486
- const returnedIds = parent.returned_event_ids === null ? null : decodeReturnedEventIds(parent.returned_event_ids);
687
+ if (toNumber(cursor.head) !== index) {
688
+ let completion;
689
+ if (cursor.kind === "lane") {
690
+ const row = selectLaneEvent.get(sessionId, consumer, lane, index);
691
+ completion = row && {
692
+ attempt: toNumber(row.completed_attempt),
693
+ ids: row.returned_event_ids
694
+ };
695
+ } else if (toNumber(cursor.completed_idx) === index) completion = {
696
+ attempt: toNumber(cursor.completed_attempt),
697
+ ids: cursor.returned_event_ids
698
+ };
699
+ if (!completion || completion.attempt !== attempt) return { outcome: "superseded" };
700
+ const returnedIds = completion.ids === null ? null : decodeReturnedEventIds(completion.ids);
487
701
  if (returnedIds === null || returnedIds.length !== ids.length || returnedIds.some((id, offset) => id !== ids[offset])) throw new A2Error("PARTIAL_DUPLICATE_BATCH", "completed attempt does not match the returned event batch");
488
- const existing = ids.length === 0 ? [] : selectByIds(ids.length).all(sessionId, clearIndex, ...ids);
702
+ const existing = ids.length === 0 ? [] : selectByIds(ids.length).all(sessionId, ...ids);
489
703
  const byId = new Map(existing.map((row) => [row.event_id, row]));
490
704
  const ordered = ids.map((id) => byId.get(id));
491
- if (ordered.some((row) => !row || row.session_id !== sessionId || row.cause === null || toCause(row.cause)?.index !== index || toCause(row.cause)?.attempt !== attempt)) throw new A2Error("PARTIAL_DUPLICATE_BATCH", "returned event retry does not match the committed batch");
705
+ if (ordered.some((row) => {
706
+ const cause = row ? decodeCause(row.cause) : null;
707
+ return !row || cause?.consumer !== consumer || cause.lane !== lane || cause.index !== index || cause.attempt !== attempt;
708
+ })) throw new A2Error("PARTIAL_DUPLICATE_BATCH", "returned event retry does not match the committed batch");
492
709
  return {
493
710
  outcome: "completed",
494
- events: idempotentReplay(events, ordered.map(toStored))
711
+ events: events.length === 0 ? [] : idempotentReplay(events, ordered.map(toStored))
495
712
  };
496
713
  }
497
- if (Number(parent.attempt_count) !== attempt || parent.claim_holder === null || parent.failed_at !== null) return { outcome: "superseded" };
714
+ if (Number(cursor.attempt_count) !== attempt || cursor.claim_holder === null || cursor.failed_at !== null) return { outcome: "superseded" };
498
715
  if (liveOwnersOf(sessionId, clearIndex, ids).length > 0) throw new A2Error("PARTIAL_DUPLICATE_BATCH", "returned event batch contains an already-appended event id");
499
- const status = appendStatus.get(sessionId, sessionId, clearIndex);
500
- const now = clock.now().getTime();
501
- db.prepare(`update a2_events set
502
- processed_at = ?,
503
- processed_by_attempt = ?,
504
- returned_event_ids = ?,
505
- lane_ready = 0,
506
- claim_holder = null,
507
- claim_expires_at = null
508
- where session_id = ? and idx = ?`).run(now, attempt, JSON.stringify(ids), sessionId, index);
509
- db.prepare("delete from a2_snapshot_pins where session_id = ? and event_index = ?").run(sessionId, index);
510
- db.prepare(`delete from a2_snapshot_history
511
- where session_id = ?
512
- and not exists (
513
- select 1 from a2_snapshot_pins pin
514
- where pin.session_id = a2_snapshot_history.session_id
515
- and pin.reducer_name = a2_snapshot_history.reducer_name
516
- and pin.up_to_index = a2_snapshot_history.up_to_index
517
- )`).run(sessionId);
518
- if (parent.lane !== null) promoteLaneHead.run(sessionId, sessionId, parent.lane, clearIndex);
716
+ const inserted = insertBatch({
717
+ sessionId,
718
+ events,
719
+ clearIndex,
720
+ consumers,
721
+ cause: {
722
+ consumer,
723
+ lane,
724
+ index,
725
+ attempt
726
+ }
727
+ });
728
+ const returned = JSON.stringify(ids);
729
+ if (cursor.kind === "lane") finishLaneEvent.run(attempt, returned, sessionId, consumer, lane, index);
730
+ else recordCompletion.run(index, attempt, returned, sessionId, consumer, cursor.cursor_key);
731
+ advance(sessionId, cursor);
519
732
  return {
520
733
  outcome: "completed",
521
- events: events.map((event, offset) => {
522
- const id = event.id;
523
- const childIndex = Number(status.max) + offset + 1;
524
- const cause = {
525
- index,
526
- attempt
527
- };
528
- insertEvent.run(sessionId, childIndex, event.type, JSON.stringify(event.payload) ?? "null", id, now, JSON.stringify(cause), event.lane ?? null, event.lane ?? null, sessionId, event.lane ?? null, clearIndex, event.settled ? now : null);
529
- return toStored(db.prepare("select * from a2_events where session_id = ? and idx = ?").get(sessionId, childIndex));
530
- })
734
+ events: inserted
531
735
  };
532
736
  }));
533
737
  },
534
- async failAttempt({ sessionId, index, attempt, error, maxFailures, retryAfterMs }) {
738
+ async failAttempt({ sessionId, consumer, lane, index, attempt, error, maxFailures, retryAfterMs }) {
535
739
  return wrap(() => tx(() => {
536
740
  if (index <= clearIndexOf(sessionId)) return {
537
741
  outcome: "superseded",
538
742
  failureCount: 0
539
743
  };
540
- const current = db.prepare("select * from a2_events where session_id = ? and idx = ?").get(sessionId, index);
541
- if (!current) throw new TypeError(`no event at index ${index} in session '${sessionId}'`);
744
+ const current = cursorOf(sessionId, {
745
+ consumer,
746
+ lane,
747
+ index
748
+ });
749
+ if (!current) {
750
+ if (!selectEvent.get(sessionId, index)) throw new TypeError(`no event at index ${index} in session '${sessionId}'`);
751
+ return {
752
+ outcome: "superseded",
753
+ failureCount: 0
754
+ };
755
+ }
756
+ if (toNumber(current.head) !== index) return {
757
+ outcome: "superseded",
758
+ failureCount: 0
759
+ };
542
760
  const failureCount = Number(current.failure_count);
543
- if (current.processed_at !== null || Number(current.attempt_count) !== attempt) return {
761
+ if (Number(current.attempt_count) !== attempt) return {
544
762
  outcome: "superseded",
545
763
  failureCount
546
764
  };
547
- if (current.failed_at !== null) return {
548
- outcome: "dead_lettered",
765
+ if (current.claim_holder === null && current.last_failed_attempt !== null && Number(current.last_failed_attempt) === attempt) return {
766
+ outcome: current.failed_at === null ? "failed" : "dead_lettered",
549
767
  failureCount
550
768
  };
551
- if (current.claim_holder === null && current.last_failed_attempt !== null && Number(current.last_failed_attempt) === attempt) return {
552
- outcome: "failed",
769
+ if (current.failed_at !== null) return {
770
+ outcome: "dead_lettered",
553
771
  failureCount
554
772
  };
555
773
  if (current.claim_holder === null) return {
@@ -559,7 +777,7 @@ function sqlite(options = {}) {
559
777
  const now = clock.now().getTime();
560
778
  const nextFailureCount = failureCount + 1;
561
779
  const deadLettered = nextFailureCount >= maxFailures;
562
- db.prepare(`update a2_events set
780
+ db.prepare(`update a2_cursors set
563
781
  failure_count = ?,
564
782
  last_error = ?,
565
783
  last_failed_at = ?,
@@ -567,8 +785,8 @@ function sqlite(options = {}) {
567
785
  failed_at = ?,
568
786
  claim_holder = null,
569
787
  claim_expires_at = ?
570
- where session_id = ? and idx = ?`).run(nextFailureCount, error, now, attempt, deadLettered ? now : null, deadLettered || retryAfterMs === void 0 ? null : now + retryAfterMs, sessionId, index);
571
- if (deadLettered) releasePins(sessionId, index);
788
+ where session_id = ? and consumer = ? and cursor_key = ?`).run(nextFailureCount, error, now, attempt, deadLettered ? now : null, deadLettered || retryAfterMs === void 0 ? null : now + retryAfterMs, sessionId, consumer, current.cursor_key);
789
+ if (deadLettered) releasePins(sessionId, consumer, current.cursor_key, index);
572
790
  return {
573
791
  outcome: deadLettered ? "dead_lettered" : "failed",
574
792
  failureCount: nextFailureCount
@@ -577,31 +795,32 @@ function sqlite(options = {}) {
577
795
  },
578
796
  async deadLetters(sessionId) {
579
797
  return wrap(() => {
580
- return db.prepare(`select * from a2_events
581
- where session_id = ?
582
- and processed_at is null
583
- and failed_at is not null
584
- and idx > coalesce(
798
+ return db.prepare(`${HEAD_SELECT}
799
+ where cursor.session_id = ?
800
+ and cursor.failed_at is not null
801
+ and cursor.head > coalesce(
585
802
  (select clear_index from a2_clears where session_id = ?), 0
586
803
  )
587
- order by idx`).all(sessionId, sessionId).map(toStored);
804
+ order by cursor.head, cursor.consumer`).all(sessionId, sessionId).map(toHead);
588
805
  });
589
806
  },
590
- async redrive({ sessionId, index, action }) {
807
+ async redrive({ sessionId, consumer, index, action, consumers }) {
591
808
  return wrap(() => tx(() => {
592
809
  const clearIndex = clearIndexOf(sessionId);
593
- const row = index > clearIndex ? db.prepare("select lane, processed_at, failed_at from a2_events where session_id = ? and idx = ?").get(sessionId, index) : void 0;
594
- const redriven = row !== void 0 && row.processed_at === null && row.failed_at !== null;
595
- if (redriven && action === "skip") {
596
- db.prepare(`update a2_events set processed_at = ?, lane_ready = 0
597
- where session_id = ? and idx = ?`).run(clock.now().getTime(), sessionId, index);
598
- if (row.lane !== null) promoteLaneHead.run(sessionId, sessionId, row.lane, clearIndex);
599
- } else if (redriven) db.prepare(`update a2_events set failed_at = null, failure_count = 0
600
- where session_id = ? and idx = ?`).run(sessionId, index);
601
- const status = appendStatus.get(sessionId, sessionId, clearIndex);
810
+ const cursor = index > clearIndex ? db.prepare(`select * from a2_cursors
811
+ where session_id = ? and consumer = ? and head = ?
812
+ and failed_at is not null`).get(sessionId, consumer, index) : void 0;
813
+ if (cursor) {
814
+ refreshLogTypes(sessionId, consumers);
815
+ if (action === "skip") {
816
+ if (cursor.kind === "lane") finishLaneEvent.run(null, null, sessionId, consumer, cursor.lane, index);
817
+ advance(sessionId, cursor);
818
+ } else db.prepare(`update a2_cursors set failed_at = null, failure_count = 0
819
+ where session_id = ? and consumer = ? and cursor_key = ?`).run(sessionId, consumer, cursor.cursor_key);
820
+ }
602
821
  return {
603
- redriven,
604
- hasPending: Number(status.has_pending) === 1
822
+ redriven: cursor !== void 0,
823
+ hasPending: hasPending(sessionId, clearIndex, consumers)
605
824
  };
606
825
  }));
607
826
  },
@@ -637,21 +856,15 @@ function sqlite(options = {}) {
637
856
  (select state from snapshot) as snapshot_state,
638
857
  null as session_id, null as idx, null as event_type,
639
858
  null as payload, null as event_id, null as created_at,
640
- null as cause, null as lane, null as lane_ready,
641
- null as processed_at,
642
- null as processed_by_attempt, null as returned_event_ids,
643
- null as first_claimed_at,
644
- null as last_claimed_at, null as attempt_count,
645
- null as failure_count, null as last_failed_at,
646
- null as last_failed_attempt, null as last_error,
647
- null as failed_at, null as claim_holder,
648
- null as claim_expires_at
859
+ null as cause
649
860
  union all
650
861
  select 1 as row_order, 'event' as row_kind,
651
862
  null as clear_index,
652
863
  (select up_to_index from head) as head_index,
653
864
  null as snapshot_index, null as snapshot_state,
654
- event.*
865
+ event.session_id, event.idx, event.event_type,
866
+ event.payload, event.event_id, event.created_at,
867
+ event.cause
655
868
  from a2_events as event
656
869
  where event.session_id = ?
657
870
  and event.idx > max(
@@ -675,14 +888,14 @@ function sqlite(options = {}) {
675
888
  },
676
889
  async putSnapshots(sessionId, reducerName, writes) {
677
890
  await wrap(() => tx(() => {
678
- const selectHead = db.prepare(`select up_to_index, state, updated_at from a2_snapshots
891
+ const selectSnapshotHead = db.prepare(`select up_to_index, state, updated_at from a2_snapshots
679
892
  where session_id = ? and reducer_name = ?`);
680
- const unfinished = db.prepare(`select 1 from a2_events
681
- where session_id = ? and idx = ? and idx > ?
682
- and processed_at is null and failed_at is null`);
893
+ const unfinished = db.prepare(`select 1 from a2_cursors
894
+ where session_id = ? and consumer = ? and cursor_key = ?
895
+ and head = ? and head > ? and failed_at is null`);
683
896
  const insertPin = db.prepare(`insert or ignore into a2_snapshot_pins
684
- (session_id, event_index, reducer_name, up_to_index)
685
- values (?, ?, ?, ?)`);
897
+ (session_id, consumer, cursor_key, event_index, reducer_name, up_to_index)
898
+ values (?, ?, ?, ?, ?, ?)`);
686
899
  const hasPin = db.prepare(`select 1 from a2_snapshot_pins
687
900
  where session_id = ? and reducer_name = ? and up_to_index = ?
688
901
  limit 1`);
@@ -704,8 +917,11 @@ function sqlite(options = {}) {
704
917
  const clearIndex = clearIndexOf(sessionId);
705
918
  for (const write of writes.toSorted((a, b) => a.index - b.index)) {
706
919
  if (write.index < clearIndex) continue;
707
- for (const eventIndex of write.pinEventIndexes ?? []) if (unfinished.get(sessionId, eventIndex, clearIndex)) insertPin.run(sessionId, eventIndex, reducerName, write.index);
708
- const head = selectHead.get(sessionId, reducerName);
920
+ for (const pin of write.pins ?? []) {
921
+ const cursorKey = cursorKeyOf(pin.lane, pin.index);
922
+ if (unfinished.get(sessionId, pin.consumer, cursorKey, pin.index, clearIndex)) insertPin.run(sessionId, pin.consumer, cursorKey, pin.index, reducerName, write.index);
923
+ }
924
+ const head = selectSnapshotHead.get(sessionId, reducerName);
709
925
  const now = clock.now().getTime();
710
926
  if (!head || write.index >= Number(head.up_to_index)) {
711
927
  if (head && write.index > Number(head.up_to_index) && Number(head.up_to_index) >= clearIndex && hasPin.get(sessionId, reducerName, Number(head.up_to_index))) putHistory.run(sessionId, reducerName, Number(head.up_to_index), head.state, Number(head.updated_at));
@@ -721,9 +937,9 @@ function sqlite(options = {}) {
721
937
  if (entries.length === 0) return;
722
938
  wrap(() => tx(() => {
723
939
  const atMs = meta.at.getTime();
724
- const expiresAtMs = clock.now().getTime() + meta.ttlMs;
725
- for (const [field, value] of entries) if (value === null) deletePresence.run(ns, participant, field, atMs);
726
- else upsertPresence.run(ns, participant, field, JSON.stringify(value) ?? "null", meta.seen, atMs, expiresAtMs);
940
+ const nowMs = clock.now().getTime();
941
+ const expiresAtMs = nowMs + meta.ttlMs;
942
+ for (const [field, value] of entries) upsertPresence.run(ns, participant, field, JSON.stringify(value) ?? "null", meta.seen, atMs, expiresAtMs, nowMs);
727
943
  }));
728
944
  },
729
945
  async read(ns) {
@@ -758,7 +974,7 @@ function sqlite(options = {}) {
758
974
  cause,
759
975
  clearIndex
760
976
  });
761
- const head = Number(appendStatus.get(sessionId, sessionId, clearIndex).max);
977
+ const head = headOf(sessionId);
762
978
  if (ifLastIndex !== void 0 && clearIndex === ifLastIndex + 1) return {
763
979
  cleared: true,
764
980
  index: head,
@@ -775,10 +991,15 @@ function sqlite(options = {}) {
775
991
  clearIndex
776
992
  };
777
993
  const marker = head + 1;
778
- const now = clock.now().getTime();
779
- insertEvent.run(sessionId, marker, CLEAR_EVENT_TYPE, "null", generateId(), now, null, null, null, sessionId, null, clearIndex, now);
994
+ insertEvent.run(sessionId, marker, CLEAR_EVENT_TYPE, "null", generateId(), clock.now().getTime(), null);
780
995
  db.prepare(`insert into a2_clears (session_id, clear_index) values (?, ?)
781
996
  on conflict (session_id) do update set clear_index = excluded.clear_index`).run(sessionId, marker);
997
+ db.prepare(`update a2_cursors
998
+ set position = ?, head = null, ${FRESH_DISPATCH},
999
+ completed_idx = null, completed_attempt = null,
1000
+ returned_event_ids = null
1001
+ where session_id = ? and kind = 'log'`).run(marker, sessionId);
1002
+ db.prepare("delete from a2_snapshot_pins where session_id = ? and event_index < ?").run(sessionId, marker);
782
1003
  return {
783
1004
  cleared: true,
784
1005
  index: marker,
@@ -802,14 +1023,21 @@ function sqlite(options = {}) {
802
1023
  if (last.last !== null) {
803
1024
  const through = Number(last.last);
804
1025
  deleted = Number(db.prepare("delete from a2_events where session_id = ? and idx <= ?").run(sessionId, through).changes);
805
- db.prepare("delete from a2_snapshot_pins where session_id = ? and event_index <= ?").run(sessionId, through);
1026
+ db.prepare("delete from a2_lane_events where session_id = ? and idx <= ?").run(sessionId, through);
1027
+ db.prepare(`delete from a2_cursors
1028
+ where session_id = ? and kind = 'event' and position < ?`).run(sessionId, through);
806
1029
  }
807
1030
  const done = db.prepare("select 1 from a2_events where session_id = ? and idx < ? limit 1").get(sessionId, clearIndex) === void 0;
808
- if (done) for (const table of [
809
- "a2_snapshot_pins",
810
- "a2_snapshot_history",
811
- "a2_snapshots"
812
- ]) db.prepare(`delete from ${table} where session_id = ? and up_to_index < ?`).run(sessionId, clearIndex);
1031
+ if (done) {
1032
+ db.prepare(`delete from a2_cursors
1033
+ where session_id = ? and kind != 'log'
1034
+ and (head is null or head <= ?) and position <= ?`).run(sessionId, clearIndex, clearIndex);
1035
+ for (const table of [
1036
+ "a2_snapshot_pins",
1037
+ "a2_snapshot_history",
1038
+ "a2_snapshots"
1039
+ ]) db.prepare(`delete from ${table} where session_id = ? and up_to_index < ?`).run(sessionId, clearIndex);
1040
+ }
813
1041
  return {
814
1042
  deleted,
815
1043
  done