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
@@ -11,6 +11,7 @@ import { A2Error, supersededAttemptError } from './errors.ts'
11
11
  import { idempotentReplay } from './idempotent-replay.ts'
12
12
  import { nullProtoRecord } from './internal.ts'
13
13
  import { CLEAR_EVENT_TYPE, sessionClearedError } from './session-clear.ts'
14
+ import { idleLogCursor } from './store-codec.ts'
14
15
  import {
15
16
  RANDOM_IDS,
16
17
  SYSTEM_CLOCK,
@@ -19,8 +20,13 @@ import {
19
20
  type Clock,
20
21
  type Event,
21
22
  type EventCause,
23
+ type HeadDispatch,
24
+ type HeadEvent,
25
+ type HeadRef,
22
26
  type IdSource,
23
27
  type PresenceRow,
28
+ type StoreConsumer,
29
+ type StoredCursor,
24
30
  type StoredEvent,
25
31
  type StoreStateRead,
26
32
  } from './store.ts'
@@ -40,20 +46,32 @@ type Row = {
40
46
  sessionId: string
41
47
  createdAt: Date
42
48
  cause: EventCause | null
49
+ }
50
+
51
+ type Dispatch = Omit<HeadDispatch, 'consumer' | 'lane'>
52
+
53
+ type Completion = { attempt: number; returnedEventIds: string[] }
54
+
55
+ /**
56
+ * One (consumer, lane) record. A log cursor reads the whole log by type, a
57
+ * lane cursor queues its lane's events, and a concurrent cursor (lane null)
58
+ * covers one event.
59
+ */
60
+ type Cursor = {
61
+ consumer: string
43
62
  lane: string | null
44
- processedAt: Date | null
45
- processedByAttempt: number | null
46
- returnedEventIds: string[] | null
47
- firstClaimedAt: Date | null
48
- lastClaimedAt: Date | null
49
- attemptCount: number
50
- claimHolder: string | null
51
- claimExpiresAt: Date | null
52
- failureCount: number
53
- lastFailedAt: Date | null
54
- lastFailedAttempt: number | null
55
- lastError: string | null
56
- failedAt: Date | null
63
+ kind: 'log' | 'lane' | 'event'
64
+ position: number
65
+ head: number | null
66
+ dispatch: Dispatch
67
+ /** Lane cursors: the lane's event indexes in log order. */
68
+ queue: number[]
69
+ /** Lane cursors: where the head sits in `queue`. */
70
+ queued: number
71
+ /** Completions by head index; a log cursor keeps only its latest. */
72
+ completions: Map<number, Completion>
73
+ /** Log cursors: the types it takes, null for every type. */
74
+ types: readonly string[] | null
57
75
  }
58
76
 
59
77
  type PresenceEntry = {
@@ -63,12 +81,6 @@ type PresenceEntry = {
63
81
  expiresAt: Date
64
82
  }
65
83
 
66
- type LaneQueue = { rows: Row[]; head: number }
67
- type SessionDispatch = {
68
- ready: Map<number, Row>
69
- lanes: Map<string, LaneQueue>
70
- }
71
-
72
84
  /** Contiguous rows; `reclaim` removes from the front below `clearIndex`. */
73
85
  type SessionLog = {
74
86
  rows: Row[]
@@ -83,6 +95,22 @@ const RECLAIM_BATCH = 256
83
95
  const headOf = (log: SessionLog | undefined): number =>
84
96
  log ? log.offset + log.rows.length : 0
85
97
 
98
+ const freshDispatch = (): Dispatch => ({
99
+ firstClaimedAt: null,
100
+ lastClaimedAt: null,
101
+ attemptCount: 0,
102
+ claimHolder: null,
103
+ claimExpiresAt: null,
104
+ failureCount: 0,
105
+ lastFailedAt: null,
106
+ lastFailedAttempt: null,
107
+ lastError: null,
108
+ failedAt: null,
109
+ })
110
+
111
+ const date = (value: Date | null): Date | null =>
112
+ value ? new Date(value) : null
113
+
86
114
  const toStored = (row: Row): StoredEvent => ({
87
115
  id: row.id,
88
116
  type: row.type,
@@ -91,20 +119,6 @@ const toStored = (row: Row): StoredEvent => ({
91
119
  sessionId: row.sessionId,
92
120
  createdAt: new Date(row.createdAt),
93
121
  cause: row.cause ? { ...row.cause } : null,
94
- lane: row.lane,
95
- processedAt: row.processedAt ? new Date(row.processedAt) : null,
96
- processedByAttempt: row.processedByAttempt,
97
- returnedEventIds: row.returnedEventIds ? [...row.returnedEventIds] : null,
98
- firstClaimedAt: row.firstClaimedAt ? new Date(row.firstClaimedAt) : null,
99
- lastClaimedAt: row.lastClaimedAt ? new Date(row.lastClaimedAt) : null,
100
- attemptCount: row.attemptCount,
101
- claimHolder: row.claimHolder,
102
- claimExpiresAt: row.claimExpiresAt ? new Date(row.claimExpiresAt) : null,
103
- failureCount: row.failureCount,
104
- lastFailedAt: row.lastFailedAt ? new Date(row.lastFailedAt) : null,
105
- lastFailedAttempt: row.lastFailedAttempt,
106
- lastError: row.lastError,
107
- failedAt: row.failedAt ? new Date(row.failedAt) : null,
108
122
  })
109
123
 
110
124
  const toEvent = (row: Row): Event => ({
@@ -116,18 +130,95 @@ const toEvent = (row: Row): Event => ({
116
130
  createdAt: new Date(row.createdAt),
117
131
  })
118
132
 
133
+ const toHead = (cursor: Cursor, row: Row): HeadEvent => ({
134
+ ...toStored(row),
135
+ consumer: cursor.consumer,
136
+ lane: cursor.lane,
137
+ firstClaimedAt: date(cursor.dispatch.firstClaimedAt),
138
+ lastClaimedAt: date(cursor.dispatch.lastClaimedAt),
139
+ attemptCount: cursor.dispatch.attemptCount,
140
+ claimHolder: cursor.dispatch.claimHolder,
141
+ claimExpiresAt: date(cursor.dispatch.claimExpiresAt),
142
+ failureCount: cursor.dispatch.failureCount,
143
+ lastFailedAt: date(cursor.dispatch.lastFailedAt),
144
+ lastFailedAttempt: cursor.dispatch.lastFailedAttempt,
145
+ lastError: cursor.dispatch.lastError,
146
+ failedAt: date(cursor.dispatch.failedAt),
147
+ })
148
+
149
+ const toCursor = (cursor: Cursor): StoredCursor => ({
150
+ consumer: cursor.consumer,
151
+ lane: cursor.lane,
152
+ position: cursor.position,
153
+ head: cursor.head,
154
+ firstClaimedAt: date(cursor.dispatch.firstClaimedAt),
155
+ lastClaimedAt: date(cursor.dispatch.lastClaimedAt),
156
+ attemptCount: cursor.dispatch.attemptCount,
157
+ claimHolder: cursor.dispatch.claimHolder,
158
+ claimExpiresAt: date(cursor.dispatch.claimExpiresAt),
159
+ failureCount: cursor.dispatch.failureCount,
160
+ lastFailedAt: date(cursor.dispatch.lastFailedAt),
161
+ lastFailedAttempt: cursor.dispatch.lastFailedAttempt,
162
+ lastError: cursor.dispatch.lastError,
163
+ failedAt: date(cursor.dispatch.failedAt),
164
+ })
165
+
166
+ const compareText = (a: string, b: string): number =>
167
+ a < b ? -1 : a > b ? 1 : 0
168
+
169
+ /** Consumer, then lane, with concurrent cursors last by head. */
170
+ const byCursor = (a: StoredCursor, b: StoredCursor): number =>
171
+ compareText(a.consumer, b.consumer) ||
172
+ (a.lane === null
173
+ ? b.lane === null
174
+ ? (a.head ?? 0) - (b.head ?? 0)
175
+ : 1
176
+ : b.lane === null
177
+ ? -1
178
+ : compareText(a.lane, b.lane))
179
+
180
+ const cursorKey = (
181
+ consumer: string,
182
+ lane: string | null,
183
+ index: number,
184
+ ): string =>
185
+ lane === null ? `${consumer}\u0000#${index}` : `${consumer}\u0000L${lane}`
186
+
187
+ const takes = (types: readonly string[] | null, type: string): boolean =>
188
+ type !== CLEAR_EVENT_TYPE && (types === null || types.includes(type))
189
+
190
+ const byHead = (a: HeadEvent, b: HeadEvent): number =>
191
+ a.index - b.index || compareText(a.consumer, b.consumer)
192
+
119
193
  const snapshotKey = (sessionId: string, reducerName: string): string =>
120
194
  `${sessionId}\u0000${reducerName}`
121
195
 
122
- const eventPinKey = (sessionId: string, index: number): string =>
123
- `${sessionId}\u0000${index}`
196
+ const headPinKey = (sessionId: string, ref: HeadRef): string =>
197
+ `${sessionId}\u0000${cursorKey(ref.consumer, ref.lane, ref.index)}\u0000${ref.index}`
198
+
199
+ /** Whether a cursor belongs to a consumer the caller serves; omitted, every one. */
200
+ const servedBy = (
201
+ consumers: readonly StoreConsumer[] | undefined,
202
+ ): ((cursor: Cursor) => boolean) => {
203
+ if (!consumers) return () => true
204
+ const names = new Set(consumers.map((consumer) => consumer.name))
205
+ return (cursor) => names.has(cursor.consumer)
206
+ }
207
+
208
+ /** A cursor's current head. */
209
+ const refOf = (cursor: Cursor): HeadRef => ({
210
+ consumer: cursor.consumer,
211
+ lane: cursor.lane,
212
+ index: cursor.head!,
213
+ })
124
214
 
125
215
  export function memory(options: MemoryStoreOptions = {}): A2Store {
126
216
  const clock = options.clock ?? SYSTEM_CLOCK
127
217
  const generateId = options.ids ?? RANDOM_IDS
128
218
 
129
219
  const sessions = new Map<string, SessionLog>()
130
- const dispatch = new Map<string, SessionDispatch>()
220
+ /** Session id → cursor key → cursor. */
221
+ const cursors = new Map<string, Map<string, Cursor>>()
131
222
  /** Global unique index on event ids, like the SQL schema's. */
132
223
  const byEventId = new Map<string, Row>()
133
224
  /** Keyed by `${sessionId}\u0000${reducerName}` — a pure cache. */
@@ -187,14 +278,26 @@ export function memory(options: MemoryStoreOptions = {}): A2Store {
187
278
  : row
188
279
  }
189
280
 
281
+ const cursorsOf = (sessionId: string): Map<string, Cursor> => {
282
+ let map = cursors.get(sessionId)
283
+ if (!map) {
284
+ map = new Map()
285
+ cursors.set(sessionId, map)
286
+ }
287
+ return map
288
+ }
289
+
290
+ const cursorAt = (sessionId: string, ref: HeadRef): Cursor | undefined =>
291
+ cursors.get(sessionId)?.get(cursorKey(ref.consumer, ref.lane, ref.index))
292
+
190
293
  const checkpointKey = (
191
294
  sessionId: string,
192
295
  reducerName: string,
193
296
  index: number,
194
297
  ): string => `${snapshotKey(sessionId, reducerName)}\u0000${index}`
195
298
 
196
- const releaseSnapshotPins = (sessionId: string, eventIndex: number): void => {
197
- const pinKey = eventPinKey(sessionId, eventIndex)
299
+ const releaseSnapshotPins = (sessionId: string, ref: HeadRef): void => {
300
+ const pinKey = headPinKey(sessionId, ref)
198
301
  const checkpoints = snapshotPins.get(pinKey)
199
302
  if (!checkpoints) return
200
303
  for (const key of checkpoints) {
@@ -209,114 +312,114 @@ export function memory(options: MemoryStoreOptions = {}): A2Store {
209
312
  snapshotPins.delete(pinKey)
210
313
  }
211
314
 
212
- const dispatchOf = (sessionId: string): SessionDispatch => {
213
- let state = dispatch.get(sessionId)
214
- if (!state) {
215
- state = { ready: new Map(), lanes: new Map() }
216
- dispatch.set(sessionId, state)
217
- }
218
- return state
219
- }
220
-
221
- const enqueue = (sessionId: string, row: Row): void => {
222
- if (row.processedAt !== null) return
223
- const state = dispatchOf(sessionId)
224
- if (row.lane === null) {
225
- state.ready.set(row.index, row)
226
- return
227
- }
228
- let queue = state.lanes.get(row.lane)
229
- if (!queue) {
230
- queue = { rows: [], head: 0 }
231
- state.lanes.set(row.lane, queue)
315
+ /** The next event after `after` that a log cursor takes, or null. */
316
+ const nextLogHead = (
317
+ sessionId: string,
318
+ after: number,
319
+ types: readonly string[] | null,
320
+ ): number | null => {
321
+ const log = sessions.get(sessionId)
322
+ if (!log) return null
323
+ for (
324
+ let offset = Math.max(0, after - log.offset);
325
+ offset < log.rows.length;
326
+ offset += 1
327
+ ) {
328
+ const row = log.rows[offset]!
329
+ if (takes(types, row.type)) return row.index
232
330
  }
233
- queue.rows.push(row)
234
- if (queue.rows.length === 1) state.ready.set(row.index, row)
331
+ return null
235
332
  }
236
333
 
237
- const settle = (sessionId: string, row: Row): void => {
238
- const state = dispatchOf(sessionId)
239
- state.ready.delete(row.index)
240
- if (row.lane === null) return
241
- const queue = state.lanes.get(row.lane)!
242
- if (queue.rows[queue.head] !== row) return
243
- queue.head += 1
244
- if (queue.head === queue.rows.length) {
245
- state.lanes.delete(row.lane)
246
- return
334
+ /** Moves a cursor past its head: the next head starts with fresh bookkeeping. */
335
+ const advance = (sessionId: string, cursor: Cursor): void => {
336
+ const done = cursor.head!
337
+ releaseSnapshotPins(sessionId, refOf(cursor))
338
+ cursor.position = done
339
+ cursor.dispatch = freshDispatch()
340
+ if (cursor.kind === 'event') {
341
+ cursor.head = null
342
+ } else if (cursor.kind === 'lane') {
343
+ cursor.queued += 1
344
+ cursor.head = cursor.queue[cursor.queued] ?? null
345
+ } else {
346
+ cursor.head = nextLogHead(sessionId, done, cursor.types)
247
347
  }
248
- const next = queue.rows[queue.head]!
249
- if (next.failedAt === null) state.ready.set(next.index, next)
250
348
  }
251
349
 
252
- const find = (sessionId: string, index: number): Row => {
253
- const row = rowAt(sessionId, index)
254
- if (!row) {
255
- throw new TypeError(
256
- `no event at index ${index} in session '${sessionId}'`,
257
- )
350
+ const hasPending = (
351
+ sessionId: string,
352
+ consumers: readonly StoreConsumer[] | undefined,
353
+ ): boolean => {
354
+ const served = servedBy(consumers)
355
+ for (const cursor of cursors.get(sessionId)?.values() ?? []) {
356
+ if (
357
+ cursor.head !== null &&
358
+ cursor.dispatch.failedAt === null &&
359
+ served(cursor)
360
+ ) {
361
+ return true
362
+ }
258
363
  }
259
- return row
364
+ return false
260
365
  }
261
366
 
262
- const hasPending = (sessionId: string): boolean => {
263
- const log = sessions.get(sessionId)
264
- return (log?.rows ?? []).some(
265
- (row) => row.index > log!.clearIndex && row.processedAt === null,
266
- )
267
- }
268
-
269
- const appendResult = (sessionId: string, events: StoredEvent[]) => ({
270
- events,
271
- hasPending: hasPending(sessionId),
272
- })
273
-
274
- /** The attempt-currency fence shared by handler appends and clears. */
367
+ /** The attempt-currency fence shared by consumer appends and clears. */
275
368
  const assertCurrentCause = (sessionId: string, cause: EventCause): void => {
276
369
  if (cause.index <= clearIndexOf(sessionId)) {
277
370
  throw supersededAttemptError(sessionId, cause)
278
371
  }
279
- const parent = find(sessionId, cause.index)
280
- if (parent.attemptCount !== cause.attempt || parent.failedAt !== null) {
372
+ const cursor = cursorAt(sessionId, cause)
373
+ if (
374
+ !cursor ||
375
+ cursor.head !== cause.index ||
376
+ cursor.dispatch.attemptCount !== cause.attempt ||
377
+ cursor.dispatch.failedAt !== null
378
+ ) {
281
379
  throw supersededAttemptError(sessionId, cause)
282
380
  }
283
381
  }
284
382
 
383
+ const deadLetter = (sessionId: string, cursor: Cursor, now: Date): void => {
384
+ cursor.dispatch.failedAt = new Date(now)
385
+ releaseSnapshotPins(sessionId, refOf(cursor))
386
+ }
387
+
285
388
  const claim = ({
286
389
  sessionId,
287
- row,
390
+ cursor,
288
391
  holder,
289
392
  now,
290
393
  expiresAt,
291
394
  maxFailures,
292
395
  }: {
293
396
  sessionId: string
294
- row: Row
397
+ cursor: Cursor
295
398
  holder: string
296
399
  now: Date
297
400
  expiresAt: Date
298
401
  maxFailures: number | undefined
299
- }): StoredEvent => {
402
+ }): HeadEvent => {
403
+ const dispatch = cursor.dispatch
404
+ const row = rowAt(sessionId, cursor.head!)!
300
405
  // A claim still held here lapsed: its attempt never finished.
301
- if (row.claimHolder !== null) {
302
- row.failureCount += 1
303
- if (maxFailures !== undefined && row.failureCount >= maxFailures) {
406
+ if (dispatch.claimHolder !== null) {
407
+ dispatch.failureCount += 1
408
+ if (maxFailures !== undefined && dispatch.failureCount >= maxFailures) {
304
409
  // Advancing the attempt fences the lapsed one; nothing runs.
305
- row.attemptCount += 1
306
- row.failedAt = new Date(now)
307
- row.claimHolder = null
308
- row.claimExpiresAt = null
309
- dispatchOf(sessionId).ready.delete(row.index)
310
- releaseSnapshotPins(sessionId, row.index)
311
- return toStored(row)
410
+ dispatch.attemptCount += 1
411
+ dispatch.claimHolder = null
412
+ dispatch.claimExpiresAt = null
413
+ deadLetter(sessionId, cursor, now)
414
+ return toHead(cursor, row)
312
415
  }
313
416
  }
314
- row.attemptCount += 1
315
- row.firstClaimedAt ??= new Date(now)
316
- row.lastClaimedAt = new Date(now)
317
- row.claimHolder = holder
318
- row.claimExpiresAt = new Date(expiresAt)
319
- return toStored(row)
417
+ dispatch.attemptCount += 1
418
+ dispatch.firstClaimedAt ??= new Date(now)
419
+ dispatch.lastClaimedAt = new Date(now)
420
+ dispatch.claimHolder = holder
421
+ dispatch.claimExpiresAt = new Date(expiresAt)
422
+ return toHead(cursor, row)
320
423
  }
321
424
 
322
425
  const checkBatchIds = (
@@ -347,10 +450,105 @@ export function memory(options: MemoryStoreOptions = {}): A2Store {
347
450
  return existing
348
451
  }
349
452
 
453
+ /** Records the types a declared log consumer takes on its cursor. */
454
+ const refreshLogTypes = (
455
+ sessionId: string,
456
+ consumers: readonly StoreConsumer[] | undefined,
457
+ ): void => {
458
+ const map = cursors.get(sessionId)
459
+ if (!map) return
460
+ for (const consumer of consumers ?? []) {
461
+ if (!consumer.log) continue
462
+ const cursor = map.get(cursorKey(consumer.name, consumer.log.lane, 0))
463
+ if (cursor?.kind === 'log') cursor.types = consumer.log.types
464
+ }
465
+ }
466
+
467
+ /** Starts the cursor work an inserted batch carries. */
468
+ const enqueue = (
469
+ sessionId: string,
470
+ rows: readonly Row[],
471
+ events: readonly AppendEvent[],
472
+ base: number,
473
+ consumers: readonly StoreConsumer[] | undefined,
474
+ ): void => {
475
+ const map = cursorsOf(sessionId)
476
+ rows.forEach((row, offset) => {
477
+ for (const target of events[offset]!.targets ?? []) {
478
+ const key = cursorKey(target.consumer, target.lane, row.index)
479
+ if (target.lane === null) {
480
+ map.set(key, {
481
+ consumer: target.consumer,
482
+ lane: null,
483
+ kind: 'event',
484
+ position: row.index - 1,
485
+ head: row.index,
486
+ dispatch: freshDispatch(),
487
+ queue: [],
488
+ queued: 0,
489
+ completions: new Map(),
490
+ types: null,
491
+ })
492
+ continue
493
+ }
494
+ let cursor = map.get(key)
495
+ if (!cursor) {
496
+ cursor = {
497
+ consumer: target.consumer,
498
+ lane: target.lane,
499
+ kind: 'lane',
500
+ position: clearIndexOf(sessionId),
501
+ head: null,
502
+ dispatch: freshDispatch(),
503
+ queue: [],
504
+ queued: 0,
505
+ completions: new Map(),
506
+ types: null,
507
+ }
508
+ map.set(key, cursor)
509
+ }
510
+ cursor.queue.push(row.index)
511
+ cursor.head ??= row.index
512
+ }
513
+ })
514
+ const clearIndex = clearIndexOf(sessionId)
515
+ for (const consumer of consumers ?? []) {
516
+ const log = consumer.log
517
+ if (!log) continue
518
+ const first = rows.find((row) => takes(log.types, row.type))
519
+ if (!first) continue
520
+ const key = cursorKey(consumer.name, log.lane, 0)
521
+ const cursor = map.get(key)
522
+ if (!cursor) {
523
+ const position = log.backfill ? clearIndex : base
524
+ map.set(key, {
525
+ consumer: consumer.name,
526
+ lane: log.lane,
527
+ kind: 'log',
528
+ position,
529
+ head: log.backfill
530
+ ? nextLogHead(sessionId, position, log.types)
531
+ : first.index,
532
+ dispatch: freshDispatch(),
533
+ queue: [],
534
+ queued: 0,
535
+ completions: new Map(),
536
+ types: log.types,
537
+ })
538
+ } else {
539
+ cursor.types = log.types
540
+ // Not `first`: a deployment that doesn't serve this consumer may
541
+ // have appended events it takes since it went idle.
542
+ cursor.head ??= nextLogHead(sessionId, cursor.position, log.types)
543
+ }
544
+ }
545
+ }
546
+
350
547
  const insert = (
351
548
  sessionId: string,
352
549
  events: readonly AppendEvent[],
353
550
  now: Date,
551
+ consumers: readonly StoreConsumer[] | undefined,
354
552
  cause?: EventCause,
355
553
  ): Row[] => {
356
554
  const log = logOf(sessionId)
@@ -367,20 +565,6 @@ export function memory(options: MemoryStoreOptions = {}): A2Store {
367
565
  : candidate.cause
368
566
  ? { ...candidate.cause }
369
567
  : null,
370
- lane: candidate.lane ?? null,
371
- processedAt: candidate.settled ? new Date(now) : null,
372
- processedByAttempt: null,
373
- returnedEventIds: null,
374
- firstClaimedAt: null,
375
- lastClaimedAt: null,
376
- attemptCount: 0,
377
- claimHolder: null,
378
- claimExpiresAt: null,
379
- failureCount: 0,
380
- lastFailedAt: null,
381
- lastFailedAttempt: null,
382
- lastError: null,
383
- failedAt: null,
384
568
  }))
385
569
  for (const row of inserted) {
386
570
  if (ownerOf(sessionId, row.id)) {
@@ -393,8 +577,8 @@ export function memory(options: MemoryStoreOptions = {}): A2Store {
393
577
  for (const row of inserted) {
394
578
  log.rows.push(row)
395
579
  byEventId.set(row.id, row)
396
- enqueue(sessionId, row)
397
580
  }
581
+ enqueue(sessionId, inserted, events, base, consumers)
398
582
  return inserted
399
583
  }
400
584
 
@@ -462,9 +646,31 @@ export function memory(options: MemoryStoreOptions = {}): A2Store {
462
646
  }
463
647
  }
464
648
 
649
+ /** The dead-lettered head a redrive names, or undefined. */
650
+ const deadHeadOf = (
651
+ sessionId: string,
652
+ consumer: string,
653
+ index: number,
654
+ ): Cursor | undefined => {
655
+ if (index <= clearIndexOf(sessionId)) return undefined
656
+ for (const cursor of cursors.get(sessionId)?.values() ?? []) {
657
+ if (
658
+ cursor.consumer === consumer &&
659
+ cursor.head === index &&
660
+ cursor.dispatch.failedAt !== null
661
+ ) {
662
+ return cursor
663
+ }
664
+ }
665
+ return undefined
666
+ }
667
+
465
668
  return {
466
669
  async append(sessionId, events, appendOptions) {
467
- if (events.length === 0) return appendResult(sessionId, [])
670
+ const consumers = appendOptions?.consumers
671
+ if (events.length === 0) {
672
+ return { events: [], hasPending: hasPending(sessionId, consumers) }
673
+ }
468
674
 
469
675
  // Batch-scoped idempotency (a2-implementation.md §3): all ids
470
676
  // already present → lost-ack retry, return the original rows;
@@ -472,10 +678,10 @@ export function memory(options: MemoryStoreOptions = {}): A2Store {
472
678
  const existing = checkBatchIds(sessionId, events)
473
679
  if (existing.length > 0) {
474
680
  if (existing.length === events.length) {
475
- return appendResult(
476
- sessionId,
477
- idempotentReplay(events, existing.map(toStored)),
478
- )
681
+ return {
682
+ events: idempotentReplay(events, existing.map(toStored)),
683
+ hasPending: hasPending(sessionId, consumers),
684
+ }
479
685
  }
480
686
  throw new A2Error(
481
687
  'PARTIAL_DUPLICATE_BATCH',
@@ -489,16 +695,18 @@ export function memory(options: MemoryStoreOptions = {}): A2Store {
489
695
  `scheduled work from event ${originIndex} was cleared in session '${sessionId}'`,
490
696
  )
491
697
  }
492
- // Attempt-currency fence: a fresh handler append commits only while
493
- // its causal attempt is still the parent's latest (store.ts `append`).
698
+ // Attempt-currency fence: a fresh consumer append commits only while
699
+ // its causal attempt still owns the cursor's head (store.ts `append`).
494
700
  for (const event of events) {
495
701
  if (event.cause) assertCurrentCause(sessionId, event.cause)
496
702
  }
497
703
 
498
- const now = clock.now()
499
- const inserted = insert(sessionId, events, now)
704
+ const inserted = insert(sessionId, events, clock.now(), consumers)
500
705
  notifyStreams(sessionId, inserted)
501
- return appendResult(sessionId, inserted.map(toStored))
706
+ return {
707
+ events: inserted.map(toStored),
708
+ hasPending: hasPending(sessionId, consumers),
709
+ }
502
710
  },
503
711
 
504
712
  async clear({ sessionId, ifLastIndex, cause }) {
@@ -517,12 +725,25 @@ export function memory(options: MemoryStoreOptions = {}): A2Store {
517
725
  }
518
726
  const [marker] = insert(
519
727
  sessionId,
520
- [{ type: CLEAR_EVENT_TYPE, payload: null, settled: true }],
728
+ [{ type: CLEAR_EVENT_TYPE, payload: null }],
521
729
  clock.now(),
730
+ undefined,
522
731
  )
523
732
  logOf(sessionId).clearIndex = marker!.index
524
- // Every row is at or below the marker, so no dispatch state survives.
525
- dispatch.delete(sessionId)
733
+ // Every event is at or below the marker: no head survives, and log
734
+ // cursors count from the marker.
735
+ const map = cursors.get(sessionId)
736
+ for (const [key, cursor] of map ?? []) {
737
+ if (cursor.head !== null) releaseSnapshotPins(sessionId, refOf(cursor))
738
+ if (cursor.kind !== 'log') {
739
+ map!.delete(key)
740
+ continue
741
+ }
742
+ cursor.position = marker!.index
743
+ cursor.head = null
744
+ cursor.dispatch = freshDispatch()
745
+ cursor.completions.clear()
746
+ }
526
747
  notifyStreams(sessionId, [marker!])
527
748
  return { cleared: true, index: marker!.index, clearIndex: marker!.index }
528
749
  },
@@ -540,7 +761,6 @@ export function memory(options: MemoryStoreOptions = {}): A2Store {
540
761
  for (const row of removed) {
541
762
  // A reused id belongs to its new row now.
542
763
  if (byEventId.get(row.id) === row) byEventId.delete(row.id)
543
- releaseSnapshotPins(sessionId, row.index)
544
764
  }
545
765
  const done = log.offset === log.clearIndex - 1
546
766
  // Writes below the marker are dropped, so one sweep at the end suffices.
@@ -548,6 +768,49 @@ export function memory(options: MemoryStoreOptions = {}): A2Store {
548
768
  return { deleted: removed.length, done }
549
769
  },
550
770
 
771
+ async wakeLog({ sessionId, consumer }) {
772
+ const { log } = consumer
773
+ const key = cursorKey(consumer.name, log.lane, 0)
774
+ const head = headOf(sessions.get(sessionId))
775
+ let cursor = cursors.get(sessionId)?.get(key)
776
+ if (!cursor) {
777
+ const marker = clearIndexOf(sessionId)
778
+ if (head === marker) {
779
+ return {
780
+ cursor: idleLogCursor(consumer.name, log.lane, marker),
781
+ head,
782
+ }
783
+ }
784
+ const position = log.backfill ? marker : head
785
+ cursor = {
786
+ consumer: consumer.name,
787
+ lane: log.lane,
788
+ kind: 'log',
789
+ position,
790
+ head: log.backfill
791
+ ? nextLogHead(sessionId, position, log.types)
792
+ : null,
793
+ dispatch: freshDispatch(),
794
+ queue: [],
795
+ queued: 0,
796
+ completions: new Map(),
797
+ types: log.types,
798
+ }
799
+ cursorsOf(sessionId).set(key, cursor)
800
+ } else {
801
+ cursor.types = log.types
802
+ cursor.head ??= nextLogHead(sessionId, cursor.position, log.types)
803
+ }
804
+ return { cursor: toCursor(cursor), head }
805
+ },
806
+
807
+ async cursors(sessionId) {
808
+ return [...(cursors.get(sessionId)?.values() ?? [])]
809
+ .filter((cursor) => cursor.kind !== 'event' || cursor.head !== null)
810
+ .map(toCursor)
811
+ .toSorted(byCursor)
812
+ },
813
+
551
814
  async read(sessionId, opts) {
552
815
  const log = sessions.get(sessionId)
553
816
  if (!log) return []
@@ -569,46 +832,64 @@ export function memory(options: MemoryStoreOptions = {}): A2Store {
569
832
  holder,
570
833
  ttlMs,
571
834
  expiresAtMs,
572
- excludeIndexes,
835
+ exclude,
836
+ consumers,
573
837
  maxFailures,
838
+ through,
574
839
  }) {
840
+ refreshLogTypes(sessionId, consumers)
575
841
  const claimedAt = clock.now()
576
842
  const now = claimedAt.getTime()
577
843
  const expiresAt = new Date(expiresAtMs ?? now + ttlMs)
578
- const excluded = new Set(excludeIndexes ?? [])
579
- const eligible: Row[] = []
844
+ const excluded = new Set(
845
+ (exclude ?? []).map((ref) =>
846
+ cursorKey(ref.consumer, ref.lane, ref.index),
847
+ ),
848
+ )
849
+ const served = servedBy(consumers)
850
+ const eligible: Cursor[] = []
580
851
  let retryAt: Date | null = null
581
852
 
582
- for (const row of dispatchOf(sessionId).ready.values()) {
583
- if (row.failedAt !== null || excluded.has(row.index)) continue
584
- if (row.claimExpiresAt && row.claimExpiresAt.getTime() > now) {
585
- if (!retryAt || row.claimExpiresAt < retryAt) {
586
- retryAt = new Date(row.claimExpiresAt)
587
- }
853
+ for (const [key, cursor] of cursors.get(sessionId) ?? []) {
854
+ if (
855
+ cursor.head === null ||
856
+ (through !== undefined && cursor.head > through) ||
857
+ cursor.dispatch.failedAt !== null ||
858
+ !served(cursor) ||
859
+ excluded.has(key)
860
+ ) {
861
+ continue
862
+ }
863
+ const expiry = cursor.dispatch.claimExpiresAt
864
+ if (expiry && expiry.getTime() > now) {
865
+ if (!retryAt || expiry < retryAt) retryAt = new Date(expiry)
588
866
  continue
589
867
  }
590
- eligible.push(row)
868
+ eligible.push(cursor)
591
869
  }
592
870
 
593
871
  if (eligible.length === 0) {
594
872
  return retryAt ? { outcome: 'busy', retryAt } : { outcome: 'settled' }
595
873
  }
596
- eligible.sort((a, b) => a.index - b.index)
597
874
  const clearIndex = clearIndexOf(sessionId)
598
- return {
599
- outcome: 'claimed',
600
- events: eligible.map((row) => {
601
- const claimed = claim({
875
+ const claimed = eligible
876
+ .map((cursor) => {
877
+ const head = claim({
602
878
  sessionId,
603
- row,
879
+ cursor,
604
880
  holder,
605
881
  now: claimedAt,
606
882
  expiresAt,
607
883
  maxFailures,
608
884
  })
609
- if (clearIndex > 0) claimed.clearIndex = clearIndex
610
- return claimed
611
- }),
885
+ if (clearIndex > 0) head.clearIndex = clearIndex
886
+ return head
887
+ })
888
+ .toSorted(byHead)
889
+ return {
890
+ outcome: 'claimed',
891
+ events: claimed,
892
+ head: headOf(sessions.get(sessionId)),
612
893
  }
613
894
  },
614
895
 
@@ -616,48 +897,69 @@ export function memory(options: MemoryStoreOptions = {}): A2Store {
616
897
  const now = clock.now()
617
898
  const expiresAt = new Date(expiresAtMs ?? now.getTime() + ttlMs)
618
899
  const clearIndex = clearIndexOf(sessionId)
619
- const renewed: number[] = []
620
- const superseded: number[] = []
621
- for (const { index, attempt } of claims.toSorted(
622
- (a, b) => a.index - b.index,
623
- )) {
624
- if (index <= clearIndex) {
625
- superseded.push(index)
900
+ const renewed: HeadRef[] = []
901
+ const superseded: HeadRef[] = []
902
+ const ordered = claims.toSorted(
903
+ (a, b) => a.index - b.index || compareText(a.consumer, b.consumer),
904
+ )
905
+ for (const { attempt, ...ref } of ordered) {
906
+ if (ref.index <= clearIndex) {
907
+ superseded.push(ref)
626
908
  continue
627
909
  }
628
- const row = rowAt(sessionId, index)
629
- if (!row) continue
630
- if (row.attemptCount > attempt) {
631
- superseded.push(row.index)
910
+ const cursor = cursorAt(sessionId, ref)
911
+ if (!cursor) continue
912
+ const dispatch = cursor.dispatch
913
+ // An event the cursor has not reached yet holds no claim to renew.
914
+ if (cursor.head !== ref.index && cursor.position < ref.index) continue
915
+ if (cursor.head !== ref.index || dispatch.attemptCount > attempt) {
916
+ superseded.push(ref)
632
917
  continue
633
918
  }
634
919
  if (
635
- row.processedAt === null &&
636
- row.failedAt === null &&
637
- row.claimHolder === holder &&
638
- row.claimExpiresAt !== null &&
639
- row.claimExpiresAt > now
920
+ dispatch.failedAt === null &&
921
+ dispatch.claimHolder === holder &&
922
+ dispatch.claimExpiresAt !== null &&
923
+ dispatch.claimExpiresAt > now
640
924
  ) {
641
- row.claimExpiresAt = new Date(expiresAt)
642
- renewed.push(row.index)
925
+ dispatch.claimExpiresAt = new Date(expiresAt)
926
+ renewed.push(ref)
643
927
  }
644
928
  }
645
929
  return { renewed, superseded }
646
930
  },
647
931
 
648
- async completeAttempt({ sessionId, index, attempt, events }) {
932
+ async completeAttempt({
933
+ sessionId,
934
+ consumer,
935
+ lane,
936
+ index,
937
+ attempt,
938
+ events,
939
+ consumers,
940
+ }) {
649
941
  if (index <= clearIndexOf(sessionId)) return { outcome: 'superseded' }
650
- const parent = find(sessionId, index)
651
- const cause = { index, attempt }
652
- if (parent.processedAt !== null) {
653
- if (parent.processedByAttempt !== attempt) {
942
+ const ref = { consumer, lane, index }
943
+ const cursor = cursorAt(sessionId, ref)
944
+ if (!cursor) {
945
+ if (!rowAt(sessionId, index)) {
946
+ throw new TypeError(
947
+ `no event at index ${index} in session '${sessionId}'`,
948
+ )
949
+ }
950
+ return { outcome: 'superseded' }
951
+ }
952
+ refreshLogTypes(sessionId, consumers)
953
+
954
+ if (cursor.head !== index) {
955
+ const completion = cursor.completions.get(index)
956
+ if (!completion || completion.attempt !== attempt) {
654
957
  return { outcome: 'superseded' }
655
958
  }
656
959
  const requestedIds = events.map((event) => event.id)
657
960
  if (
658
- parent.returnedEventIds === null ||
659
- parent.returnedEventIds.length !== requestedIds.length ||
660
- parent.returnedEventIds.some(
961
+ completion.returnedEventIds.length !== requestedIds.length ||
962
+ completion.returnedEventIds.some(
661
963
  (id, offset) => id !== requestedIds[offset],
662
964
  )
663
965
  ) {
@@ -671,7 +973,10 @@ export function memory(options: MemoryStoreOptions = {}): A2Store {
671
973
  existing.length !== events.length ||
672
974
  existing.some(
673
975
  (row) =>
674
- row.cause?.index !== index || row.cause.attempt !== attempt,
976
+ row.cause?.consumer !== consumer ||
977
+ row.cause.lane !== lane ||
978
+ row.cause.index !== index ||
979
+ row.cause.attempt !== attempt,
675
980
  )
676
981
  ) {
677
982
  throw new A2Error(
@@ -681,13 +986,17 @@ export function memory(options: MemoryStoreOptions = {}): A2Store {
681
986
  }
682
987
  return {
683
988
  outcome: 'completed',
684
- events: idempotentReplay(events, existing.map(toStored)),
989
+ events:
990
+ events.length === 0
991
+ ? []
992
+ : idempotentReplay(events, existing.map(toStored)),
685
993
  }
686
994
  }
995
+ const dispatch = cursor.dispatch
687
996
  if (
688
- parent.attemptCount !== attempt ||
689
- parent.failedAt !== null ||
690
- parent.claimHolder === null
997
+ dispatch.attemptCount !== attempt ||
998
+ dispatch.failedAt !== null ||
999
+ dispatch.claimHolder === null
691
1000
  ) {
692
1001
  return { outcome: 'superseded' }
693
1002
  }
@@ -699,21 +1008,26 @@ export function memory(options: MemoryStoreOptions = {}): A2Store {
699
1008
  'returned event batch contains already-appended ids',
700
1009
  )
701
1010
  }
702
- const now = clock.now()
703
- const inserted = insert(sessionId, events, now, cause)
704
- parent.processedAt = new Date(now)
705
- parent.processedByAttempt = attempt
706
- parent.returnedEventIds = events.map((event) => event.id)
707
- parent.claimHolder = null
708
- parent.claimExpiresAt = null
709
- settle(sessionId, parent)
710
- releaseSnapshotPins(sessionId, index)
1011
+ const inserted = insert(sessionId, events, clock.now(), consumers, {
1012
+ consumer,
1013
+ lane,
1014
+ index,
1015
+ attempt,
1016
+ })
1017
+ if (cursor.kind === 'log') cursor.completions.clear()
1018
+ cursor.completions.set(index, {
1019
+ attempt,
1020
+ returnedEventIds: events.map((event) => event.id),
1021
+ })
1022
+ advance(sessionId, cursor)
711
1023
  notifyStreams(sessionId, inserted)
712
1024
  return { outcome: 'completed', events: inserted.map(toStored) }
713
1025
  },
714
1026
 
715
1027
  async failAttempt({
716
1028
  sessionId,
1029
+ consumer,
1030
+ lane,
717
1031
  index,
718
1032
  attempt,
719
1033
  error,
@@ -723,76 +1037,88 @@ export function memory(options: MemoryStoreOptions = {}): A2Store {
723
1037
  if (index <= clearIndexOf(sessionId)) {
724
1038
  return { outcome: 'superseded', failureCount: 0 }
725
1039
  }
726
- const row = find(sessionId, index)
1040
+ const cursor = cursorAt(sessionId, { consumer, lane, index })
1041
+ if (!cursor) {
1042
+ if (!rowAt(sessionId, index)) {
1043
+ throw new TypeError(
1044
+ `no event at index ${index} in session '${sessionId}'`,
1045
+ )
1046
+ }
1047
+ return { outcome: 'superseded', failureCount: 0 }
1048
+ }
1049
+ const dispatch = cursor.dispatch
727
1050
  if (
728
- row.processedAt ||
729
- row.attemptCount !== attempt ||
730
- (row.claimHolder === null && row.lastFailedAttempt !== attempt)
1051
+ cursor.head !== index ||
1052
+ dispatch.attemptCount !== attempt ||
1053
+ (dispatch.claimHolder === null &&
1054
+ dispatch.lastFailedAttempt !== attempt)
731
1055
  ) {
732
- return { outcome: 'superseded', failureCount: row.failureCount }
1056
+ return {
1057
+ outcome: 'superseded',
1058
+ failureCount: cursor.head === index ? dispatch.failureCount : 0,
1059
+ }
733
1060
  }
734
- if (row.lastFailedAttempt === attempt && row.claimHolder === null) {
1061
+ if (
1062
+ dispatch.lastFailedAttempt === attempt &&
1063
+ dispatch.claimHolder === null
1064
+ ) {
735
1065
  return {
736
- outcome: row.failedAt ? 'dead_lettered' : 'failed',
737
- failureCount: row.failureCount,
1066
+ outcome: dispatch.failedAt ? 'dead_lettered' : 'failed',
1067
+ failureCount: dispatch.failureCount,
738
1068
  }
739
1069
  }
740
- if (row.failedAt) {
741
- return { outcome: 'dead_lettered', failureCount: row.failureCount }
1070
+ if (dispatch.failedAt) {
1071
+ return { outcome: 'dead_lettered', failureCount: dispatch.failureCount }
742
1072
  }
743
- row.failureCount += 1
744
- row.lastError = error
1073
+ dispatch.failureCount += 1
1074
+ dispatch.lastError = error
745
1075
  const failedAt = clock.now()
746
- row.lastFailedAt = new Date(failedAt)
747
- row.lastFailedAttempt = attempt
748
- row.claimHolder = null
749
- row.claimExpiresAt =
750
- retryAfterMs === undefined || row.failureCount >= maxFailures
1076
+ dispatch.lastFailedAt = new Date(failedAt)
1077
+ dispatch.lastFailedAttempt = attempt
1078
+ dispatch.claimHolder = null
1079
+ dispatch.claimExpiresAt =
1080
+ retryAfterMs === undefined || dispatch.failureCount >= maxFailures
751
1081
  ? null
752
1082
  : new Date(failedAt.getTime() + retryAfterMs)
753
- if (row.failureCount >= maxFailures) {
754
- row.failedAt = new Date(failedAt)
755
- dispatchOf(sessionId).ready.delete(row.index)
756
- releaseSnapshotPins(sessionId, index)
1083
+ if (dispatch.failureCount >= maxFailures) {
1084
+ deadLetter(sessionId, cursor, failedAt)
757
1085
  return {
758
1086
  outcome: 'dead_lettered',
759
- failureCount: row.failureCount,
1087
+ failureCount: dispatch.failureCount,
760
1088
  }
761
1089
  }
762
- return { outcome: 'failed', failureCount: row.failureCount }
1090
+ return { outcome: 'failed', failureCount: dispatch.failureCount }
763
1091
  },
764
1092
 
765
1093
  async deadLetters(sessionId) {
766
- const log = sessions.get(sessionId)
767
- return (log?.rows ?? [])
768
- .filter(
769
- (row) =>
770
- row.index > log!.clearIndex &&
771
- row.processedAt === null &&
772
- row.failedAt !== null,
773
- )
774
- .map(toStored)
1094
+ const clearIndex = clearIndexOf(sessionId)
1095
+ const dead: HeadEvent[] = []
1096
+ for (const cursor of cursors.get(sessionId)?.values() ?? []) {
1097
+ if (
1098
+ cursor.head === null ||
1099
+ cursor.head <= clearIndex ||
1100
+ cursor.dispatch.failedAt === null
1101
+ ) {
1102
+ continue
1103
+ }
1104
+ dead.push(toHead(cursor, rowAt(sessionId, cursor.head)!))
1105
+ }
1106
+ return dead.toSorted(byHead)
775
1107
  },
776
1108
 
777
- async redrive({ sessionId, index, action }) {
778
- const row =
779
- index > clearIndexOf(sessionId) ? rowAt(sessionId, index) : undefined
780
- if (!row || row.processedAt !== null || row.failedAt === null) {
781
- return { redriven: false, hasPending: hasPending(sessionId) }
1109
+ async redrive({ sessionId, consumer, index, action, consumers }) {
1110
+ const cursor = deadHeadOf(sessionId, consumer, index)
1111
+ if (!cursor) {
1112
+ return { redriven: false, hasPending: hasPending(sessionId, consumers) }
782
1113
  }
1114
+ refreshLogTypes(sessionId, consumers)
783
1115
  if (action === 'skip') {
784
- row.processedAt = clock.now()
785
- settle(sessionId, row)
1116
+ advance(sessionId, cursor)
786
1117
  } else {
787
- row.failedAt = null
788
- row.failureCount = 0
789
- const state = dispatchOf(sessionId)
790
- const queue = row.lane === null ? undefined : state.lanes.get(row.lane)
791
- if (row.lane === null || queue?.rows[queue.head] === row) {
792
- state.ready.set(row.index, row)
793
- }
1118
+ cursor.dispatch.failedAt = null
1119
+ cursor.dispatch.failureCount = 0
794
1120
  }
795
- return { redriven: true, hasPending: hasPending(sessionId) }
1121
+ return { redriven: true, hasPending: hasPending(sessionId, consumers) }
796
1122
  },
797
1123
 
798
1124
  async readState(sessionId, reducerName, stateOptions) {
@@ -821,17 +1147,17 @@ export function memory(options: MemoryStoreOptions = {}): A2Store {
821
1147
  for (const write of writes.toSorted((a, b) => a.index - b.index)) {
822
1148
  if (write.index < clearIndex) continue
823
1149
  const checkpoint = checkpointKey(sessionId, reducerName, write.index)
824
- for (const eventIndex of write.pinEventIndexes ?? []) {
825
- const event = rowAt(sessionId, eventIndex)
1150
+ for (const pin of write.pins ?? []) {
1151
+ const cursor = cursorAt(sessionId, pin)
826
1152
  if (
827
- !event ||
828
- event.index <= clearIndex ||
829
- event.processedAt !== null ||
830
- event.failedAt !== null
1153
+ !cursor ||
1154
+ pin.index <= clearIndex ||
1155
+ cursor.head !== pin.index ||
1156
+ cursor.dispatch.failedAt !== null
831
1157
  ) {
832
1158
  continue
833
1159
  }
834
- const pinKey = eventPinKey(sessionId, eventIndex)
1160
+ const pinKey = headPinKey(sessionId, pin)
835
1161
  let pinned = snapshotPins.get(pinKey)
836
1162
  if (!pinned) {
837
1163
  pinned = new Set()