experimental-a2 0.20.0 → 0.21.1

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