experimental-a2 0.20.0 → 0.22.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (309) hide show
  1. package/CHANGELOG.md +55 -0
  2. package/README.md +1 -1
  3. package/dist/{actor-C7b-1arn.d.ts → actor-CqibLgjQ.d.ts} +15 -7
  4. package/dist/actor-CqibLgjQ.d.ts.map +1 -0
  5. package/dist/actor-client.d.ts +1 -1
  6. package/dist/actor-client.js +2 -2
  7. package/dist/actor-react.d.ts +3 -3
  8. package/dist/actor-react.js +5 -5
  9. package/dist/{actor-shared-t7w8xIKP.js → actor-shared-DFET5q16.js} +3 -3
  10. package/dist/actor-shared-DFET5q16.js.map +1 -0
  11. package/dist/actor.d.ts +1 -1
  12. package/dist/actor.js +54 -43
  13. package/dist/actor.js.map +1 -1
  14. package/dist/{ai-Cz3hqVhk.d.ts → ai-DAu77CDr.d.ts} +8 -8
  15. package/dist/{ai-Cz3hqVhk.d.ts.map → ai-DAu77CDr.d.ts.map} +1 -1
  16. package/dist/{ai-mMiWul1T.js → ai-EEfKyc-z.js} +9 -9
  17. package/dist/ai-EEfKyc-z.js.map +1 -0
  18. package/dist/ai-client.d.ts +2 -2
  19. package/dist/ai-client.js +2 -2
  20. package/dist/ai-fx.d.ts +4 -2
  21. package/dist/ai-fx.d.ts.map +1 -1
  22. package/dist/ai-fx.js +214 -67
  23. package/dist/ai-fx.js.map +1 -1
  24. package/dist/{ai-harness-CV6rXbB3.d.ts → ai-harness-DTjkAr2T.d.ts} +2 -2
  25. package/dist/{ai-harness-CV6rXbB3.d.ts.map → ai-harness-DTjkAr2T.d.ts.map} +1 -1
  26. package/dist/{ai-harness-Clbg5ei7.js → ai-harness-dQeJaR1t.js} +7 -3
  27. package/dist/ai-harness-dQeJaR1t.js.map +1 -0
  28. package/dist/ai-pi.d.ts +1 -1
  29. package/dist/ai-pi.js +3 -3
  30. package/dist/ai-pi.js.map +1 -1
  31. package/dist/ai-react.d.ts +5 -5
  32. package/dist/ai-react.js +2 -2
  33. package/dist/ai-server.d.ts +14 -17
  34. package/dist/ai-server.d.ts.map +1 -1
  35. package/dist/ai-server.js +86 -73
  36. package/dist/ai-server.js.map +1 -1
  37. package/dist/ai.d.ts +2 -2
  38. package/dist/ai.js +2 -2
  39. package/dist/{client-BhHLoNKF.js → client-BQS4i_V9.js} +29 -6
  40. package/dist/client-BQS4i_V9.js.map +1 -0
  41. package/dist/{client-h_DKFAjj.d.ts → client-CUMbS_38.d.ts} +3 -3
  42. package/dist/{client-h_DKFAjj.d.ts.map → client-CUMbS_38.d.ts.map} +1 -1
  43. package/dist/client.d.ts +3 -3
  44. package/dist/client.js +1 -1
  45. package/dist/{contract-BYKASlCV.js → contract-CWukt01d.js} +2 -2
  46. package/dist/contract-CWukt01d.js.map +1 -0
  47. package/dist/index.d.ts +5 -5
  48. package/dist/index.js +3 -3
  49. package/dist/{initial-snapshot-Bx-c0pDP.d.ts → initial-snapshot-D3Lve43o.d.ts} +4 -4
  50. package/dist/initial-snapshot-D3Lve43o.d.ts.map +1 -0
  51. package/dist/{internal-CscSY7K_.js → internal-CvsrprnU.js} +40 -16
  52. package/dist/internal-CvsrprnU.js.map +1 -0
  53. package/dist/otel.d.ts +1 -1
  54. package/dist/otel.js +1 -1
  55. package/dist/otel.js.map +1 -1
  56. package/dist/{react-BBXgHWFc.d.ts → react-BoHvC3v9.d.ts} +4 -4
  57. package/dist/{react-BBXgHWFc.d.ts.map → react-BoHvC3v9.d.ts.map} +1 -1
  58. package/dist/react.d.ts +1 -1
  59. package/dist/react.js +1 -1
  60. package/dist/{reducer-CqA59Q6U.d.ts → reducer-CYPHsSmd.d.ts} +2 -2
  61. package/dist/{reducer-CqA59Q6U.d.ts.map → reducer-CYPHsSmd.d.ts.map} +1 -1
  62. package/dist/{reducer-BAXc8JyF.js → reducer-CjmvZSAb.js} +2 -2
  63. package/dist/{reducer-BAXc8JyF.js.map → reducer-CjmvZSAb.js.map} +1 -1
  64. package/dist/runtime-consumer-CIeL33wj.js +12 -0
  65. package/dist/runtime-consumer-CIeL33wj.js.map +1 -0
  66. package/dist/scheduler-qstash.d.ts +2 -2
  67. package/dist/scheduler-qstash.js +3 -3
  68. package/dist/scheduler-task-X1SToDjP.js.map +1 -1
  69. package/dist/scheduler-vercel.d.ts +2 -2
  70. package/dist/scheduler-vercel.js +3 -3
  71. package/dist/scheduler-vercel.js.map +1 -1
  72. package/dist/{server-qyb5aJJG.js → server-DtFrWxi3.js} +412 -116
  73. package/dist/server-DtFrWxi3.js.map +1 -0
  74. package/dist/{server-DYAATFuT.d.ts → server-qgez0Xny.d.ts} +108 -41
  75. package/dist/server-qgez0Xny.d.ts.map +1 -0
  76. package/dist/server.browser.js +1 -1
  77. package/dist/server.browser.js.map +1 -1
  78. package/dist/server.d.ts +4 -4
  79. package/dist/server.js +2 -2
  80. package/dist/{session-clear-C-QwTXV1.d.ts → session-clear-D1PFe7xl.d.ts} +2 -2
  81. package/dist/session-clear-D1PFe7xl.d.ts.map +1 -0
  82. package/dist/{session-clear-DGI4jTQ5.js → session-clear-wHd5GCpZ.js} +5 -4
  83. package/dist/session-clear-wHd5GCpZ.js.map +1 -0
  84. package/dist/store-D0MhBvg6.d.ts +532 -0
  85. package/dist/store-D0MhBvg6.d.ts.map +1 -0
  86. package/dist/store-N8PXxDAS.js.map +1 -1
  87. package/dist/store-codec-DwrWlgQx.js +65 -0
  88. package/dist/store-codec-DwrWlgQx.js.map +1 -0
  89. package/dist/store-memory.d.ts +1 -1
  90. package/dist/store-memory.d.ts.map +1 -1
  91. package/dist/store-memory.js +406 -225
  92. package/dist/store-memory.js.map +1 -1
  93. package/dist/{store-polling-7WJV0YWP.js → store-polling-BZt2Rf3D.js} +2 -2
  94. package/dist/{store-polling-7WJV0YWP.js.map → store-polling-BZt2Rf3D.js.map} +1 -1
  95. package/dist/store-postgres.d.ts +1 -1
  96. package/dist/store-postgres.d.ts.map +1 -1
  97. package/dist/store-postgres.js +1598 -587
  98. package/dist/store-postgres.js.map +1 -1
  99. package/dist/store-presence-polling-JuAy2Ck2.js +111 -0
  100. package/dist/store-presence-polling-JuAy2Ck2.js.map +1 -0
  101. package/dist/store-redis-http.d.ts +1 -1
  102. package/dist/store-redis-http.js +4 -4
  103. package/dist/store-redis-notify-CsJw_hfD.js +3060 -0
  104. package/dist/store-redis-notify-CsJw_hfD.js.map +1 -0
  105. package/dist/store-redis.d.ts +1 -1
  106. package/dist/store-redis.d.ts.map +1 -1
  107. package/dist/store-redis.js +4 -5
  108. package/dist/store-redis.js.map +1 -1
  109. package/dist/store-sqlite.d.ts +1 -1
  110. package/dist/store-sqlite.d.ts.map +1 -1
  111. package/dist/store-sqlite.js +527 -299
  112. package/dist/store-sqlite.js.map +1 -1
  113. package/dist/stream-buffer-DrTyiIuo.js +57 -0
  114. package/dist/stream-buffer-DrTyiIuo.js.map +1 -0
  115. package/dist/{telemetry-Cyfqo6Qt.d.ts → telemetry-CWwsh-Gx.d.ts} +3 -3
  116. package/dist/telemetry-CWwsh-Gx.d.ts.map +1 -0
  117. package/dist/{wire-BKxH1vbP.js → wire-CV-Z7wz-.js} +6 -5
  118. package/dist/wire-CV-Z7wz-.js.map +1 -0
  119. package/docs/01-quickstart.mdx +21 -15
  120. package/docs/02-examples.mdx +1 -1
  121. package/docs/actors/01-introduction.mdx +13 -2
  122. package/docs/actors/02-concurrency.mdx +1 -1
  123. package/docs/agents/01-introduction.mdx +1 -1
  124. package/docs/agents/02-tools.mdx +12 -11
  125. package/docs/agents/03-conversations.mdx +36 -25
  126. package/docs/agents/04-harnesses.mdx +14 -7
  127. package/docs/client/03-presence.mdx +18 -14
  128. package/docs/contract/01-events.mdx +2 -2
  129. package/docs/contract/02-state.mdx +18 -14
  130. package/docs/contract/03-sessions.mdx +32 -25
  131. package/docs/index.mdx +29 -24
  132. package/docs/reference/01-core.mdx +7 -6
  133. package/docs/reference/02-server.mdx +250 -191
  134. package/docs/reference/04-ai.mdx +33 -23
  135. package/docs/reference/05-telemetry.mdx +27 -13
  136. package/docs/reference/06-errors.mdx +25 -19
  137. package/docs/reference/meta.ts +1 -1
  138. package/docs/schedulers/01-overview.mdx +8 -8
  139. package/docs/schedulers/02-vercel-queues.mdx +2 -2
  140. package/docs/schedulers/03-qstash.mdx +4 -4
  141. package/docs/server/01-consumers.mdx +418 -0
  142. package/docs/server/02-timers.mdx +31 -21
  143. package/docs/server/03-cancellation.mdx +52 -44
  144. package/docs/server/04-routes.mdx +4 -1
  145. package/docs/server/05-sql-projections.mdx +391 -0
  146. package/docs/server/06-guarantees.mdx +61 -51
  147. package/docs/stores/01-overview.mdx +3 -3
  148. package/docs/stores/02-redis.mdx +24 -10
  149. package/docs/stores/03-redis-http.mdx +8 -6
  150. package/docs/stores/04-postgres.mdx +65 -14
  151. package/docs/stores/05-sqlite.mdx +5 -3
  152. package/docs/upgrades/01-overview.mdx +31 -0
  153. package/docs/upgrades/78-0-22.mdx +115 -0
  154. package/docs/upgrades/79-0-21.mdx +165 -0
  155. package/docs/upgrades/80-0-20.mdx +43 -0
  156. package/docs/upgrades/81-0-19.mdx +33 -0
  157. package/docs/upgrades/82-0-18.mdx +33 -0
  158. package/docs/upgrades/83-0-17.mdx +69 -0
  159. package/docs/upgrades/meta.ts +3 -0
  160. package/examples/playground/DEPLOY.md +39 -7
  161. package/examples/playground/app/a0/chat.test.tsx +2 -2
  162. package/examples/playground/app/a0/sandbox-log.ts +17 -15
  163. package/examples/playground/app/a0/sandbox.ts +58 -13
  164. package/examples/playground/app/a0/server.ts +4 -4
  165. package/examples/playground/app/a2slack/a2slack.css +1067 -0
  166. package/examples/playground/app/a2slack/c/[channel]/page.tsx +20 -0
  167. package/examples/playground/app/a2slack/consumers/agent.ts +63 -0
  168. package/examples/playground/app/a2slack/consumers/feed.ts +164 -0
  169. package/examples/playground/app/a2slack/consumers/notify.ts +116 -0
  170. package/examples/playground/app/a2slack/consumers/search.ts +37 -0
  171. package/examples/playground/app/a2slack/consumers/triage.ts +31 -0
  172. package/examples/playground/app/a2slack/consumers/welcome.ts +25 -0
  173. package/examples/playground/app/a2slack/db/a2.ts +209 -0
  174. package/examples/playground/app/a2slack/db/index.ts +73 -0
  175. package/examples/playground/app/a2slack/db/migrations/0000_extensions.sql +4 -0
  176. package/examples/playground/app/a2slack/db/migrations/0001_workspace.sql +74 -0
  177. package/examples/playground/app/a2slack/db/migrations/0002_a2.sql +215 -0
  178. package/examples/playground/app/a2slack/db/migrations/0003_a2_batch.sql +67 -0
  179. package/examples/playground/app/a2slack/db/migrations/0004_a2_session_event_ids.sql +2 -0
  180. package/examples/playground/app/a2slack/db/migrations/meta/0000_snapshot.json +18 -0
  181. package/examples/playground/app/a2slack/db/migrations/meta/0001_snapshot.json +500 -0
  182. package/examples/playground/app/a2slack/db/migrations/meta/0002_snapshot.json +1159 -0
  183. package/examples/playground/app/a2slack/db/migrations/meta/0003_snapshot.json +1159 -0
  184. package/examples/playground/app/a2slack/db/migrations/meta/0004_snapshot.json +1165 -0
  185. package/examples/playground/app/a2slack/db/migrations/meta/_journal.json +41 -0
  186. package/examples/playground/app/a2slack/db/queries.ts +263 -0
  187. package/examples/playground/app/a2slack/db/schema.ts +145 -0
  188. package/examples/playground/app/a2slack/drills/a2slack.drill.ts +708 -0
  189. package/examples/playground/app/a2slack/drills/harness.ts +244 -0
  190. package/examples/playground/app/a2slack/events/route.ts +36 -0
  191. package/examples/playground/app/a2slack/identity.ts +44 -0
  192. package/examples/playground/app/a2slack/inbox/route.ts +8 -0
  193. package/examples/playground/app/a2slack/lag/data/route.ts +8 -0
  194. package/examples/playground/app/a2slack/lag/page.tsx +101 -0
  195. package/examples/playground/app/a2slack/lag.ts +73 -0
  196. package/examples/playground/app/a2slack/layout.tsx +43 -0
  197. package/examples/playground/app/a2slack/messages/route.ts +17 -0
  198. package/examples/playground/app/a2slack/model.test.ts +208 -0
  199. package/examples/playground/app/a2slack/model.ts +369 -0
  200. package/examples/playground/app/a2slack/models.test.ts +72 -0
  201. package/examples/playground/app/a2slack/page.tsx +10 -0
  202. package/examples/playground/app/a2slack/scheduler/route.ts +5 -0
  203. package/examples/playground/app/a2slack/search/route.ts +40 -0
  204. package/examples/playground/app/a2slack/search-index.ts +108 -0
  205. package/examples/playground/app/a2slack/server.test.ts +315 -0
  206. package/examples/playground/app/a2slack/server.ts +55 -0
  207. package/examples/playground/app/a2slack/sessions.ts +50 -0
  208. package/examples/playground/app/a2slack/thread/route.ts +8 -0
  209. package/examples/playground/app/a2slack/ui/ChannelView.tsx +87 -0
  210. package/examples/playground/app/a2slack/ui/Composer.tsx +117 -0
  211. package/examples/playground/app/a2slack/ui/DotsShadow.tsx +42 -0
  212. package/examples/playground/app/a2slack/ui/Feed.tsx +157 -0
  213. package/examples/playground/app/a2slack/ui/InboxContext.tsx +63 -0
  214. package/examples/playground/app/a2slack/ui/InboxView.tsx +82 -0
  215. package/examples/playground/app/a2slack/ui/Menu.tsx +128 -0
  216. package/examples/playground/app/a2slack/ui/MessageRow.tsx +314 -0
  217. package/examples/playground/app/a2slack/ui/NameGate.tsx +35 -0
  218. package/examples/playground/app/a2slack/ui/SearchContext.tsx +146 -0
  219. package/examples/playground/app/a2slack/ui/SearchResults.tsx +97 -0
  220. package/examples/playground/app/a2slack/ui/Shell.tsx +34 -0
  221. package/examples/playground/app/a2slack/ui/Sidebar.tsx +199 -0
  222. package/examples/playground/app/a2slack/ui/ThreadPanel.tsx +134 -0
  223. package/examples/playground/app/a2slack/ui/useInbox.ts +71 -0
  224. package/examples/playground/app/agent/server.ts +3 -3
  225. package/examples/playground/app/chat/[chatId]/chat-client.tsx +1 -1
  226. package/examples/playground/app/chat/server.ts +45 -35
  227. package/examples/playground/app/components/demo-icons.tsx +7 -0
  228. package/examples/playground/app/components/demos.tsx +7 -0
  229. package/examples/playground/app/orders/[orderId]/order-client.tsx +2 -2
  230. package/examples/playground/app/orders/page.tsx +1 -1
  231. package/examples/playground/app/orders/server.ts +49 -30
  232. package/examples/playground/app/recovery/[recoveryId]/recovery-client.tsx +14 -15
  233. package/examples/playground/app/recovery/page.tsx +4 -4
  234. package/examples/playground/app/recovery/server.ts +23 -32
  235. package/examples/playground/drizzle.config.ts +14 -0
  236. package/examples/playground/lib/store.ts +1 -1
  237. package/examples/playground/next.config.ts +6 -1
  238. package/examples/playground/package.json +12 -1
  239. package/examples/playground/scripts/migrate.mjs +31 -0
  240. package/examples/playground/vercel.json +9 -0
  241. package/examples/playground/vitest.drills.config.ts +32 -0
  242. package/package.json +5 -2
  243. package/sql/postgres/drizzle-schema.ts +209 -0
  244. package/sql/postgres/schema.sql +227 -67
  245. package/src/actor-shared.ts +10 -2
  246. package/src/actor.ts +101 -71
  247. package/src/ai-agent.ts +162 -111
  248. package/src/ai-control-server.ts +8 -8
  249. package/src/ai-fx.ts +324 -82
  250. package/src/ai-harness.ts +17 -2
  251. package/src/ai-pi.ts +2 -2
  252. package/src/ai-server.ts +2 -2
  253. package/src/ai.ts +7 -7
  254. package/src/client.ts +19 -2
  255. package/src/contract.ts +1 -1
  256. package/src/deterministic-id.ts +27 -4
  257. package/src/errors.ts +8 -5
  258. package/src/idempotent-replay.ts +0 -1
  259. package/src/index.ts +1 -1
  260. package/src/internal.ts +57 -16
  261. package/src/otel.ts +1 -1
  262. package/src/postgres-schema.ts +98 -57
  263. package/src/presence-recovery.ts +18 -11
  264. package/src/presence-tombstones.ts +52 -0
  265. package/src/push-envelope.ts +20 -0
  266. package/src/runtime-consumer.ts +10 -0
  267. package/src/scheduler-task.ts +2 -2
  268. package/src/scheduler-vercel.ts +1 -1
  269. package/src/server.browser.ts +1 -1
  270. package/src/server.ts +819 -218
  271. package/src/session-socket.ts +1 -1
  272. package/src/store-codec.ts +66 -0
  273. package/src/store-memory.ts +604 -286
  274. package/src/store-postgres.ts +2006 -857
  275. package/src/store-presence-polling.ts +59 -34
  276. package/src/store-redis-core.ts +1961 -1333
  277. package/src/store-redis-notify.ts +76 -26
  278. package/src/store-redis.ts +2 -3
  279. package/src/store-sqlite.ts +915 -516
  280. package/src/store.ts +241 -102
  281. package/src/telemetry.ts +2 -1
  282. package/src/wire.ts +1 -0
  283. package/dist/actor-C7b-1arn.d.ts.map +0 -1
  284. package/dist/actor-shared-t7w8xIKP.js.map +0 -1
  285. package/dist/ai-harness-Clbg5ei7.js.map +0 -1
  286. package/dist/ai-mMiWul1T.js.map +0 -1
  287. package/dist/client-BhHLoNKF.js.map +0 -1
  288. package/dist/contract-BYKASlCV.js.map +0 -1
  289. package/dist/idempotent-replay-JdgMQRRm.js +0 -21
  290. package/dist/idempotent-replay-JdgMQRRm.js.map +0 -1
  291. package/dist/initial-snapshot-Bx-c0pDP.d.ts.map +0 -1
  292. package/dist/internal-CscSY7K_.js.map +0 -1
  293. package/dist/server-DYAATFuT.d.ts.map +0 -1
  294. package/dist/server-qyb5aJJG.js.map +0 -1
  295. package/dist/session-clear-C-QwTXV1.d.ts.map +0 -1
  296. package/dist/session-clear-DGI4jTQ5.js.map +0 -1
  297. package/dist/store-CyLUy86v.d.ts +0 -404
  298. package/dist/store-CyLUy86v.d.ts.map +0 -1
  299. package/dist/store-codec-DTG0Ftek.js +0 -10
  300. package/dist/store-codec-DTG0Ftek.js.map +0 -1
  301. package/dist/store-presence-polling-B1ixeqIS.js +0 -94
  302. package/dist/store-presence-polling-B1ixeqIS.js.map +0 -1
  303. package/dist/store-redis-notify-CM0AfLY0.js +0 -2544
  304. package/dist/store-redis-notify-CM0AfLY0.js.map +0 -1
  305. package/dist/stream-buffer-Cx7AAPGq.js +0 -23
  306. package/dist/stream-buffer-Cx7AAPGq.js.map +0 -1
  307. package/dist/telemetry-Cyfqo6Qt.d.ts.map +0 -1
  308. package/dist/wire-BKxH1vbP.js.map +0 -1
  309. package/docs/server/01-handlers.mdx +0 -227
@@ -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,22 +46,35 @@ 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
 
77
+ /** A null `value` is a tombstone: it keeps a delete's `at` until it expires. */
59
78
  type PresenceEntry = {
60
79
  value: unknown
61
80
  seen: number
@@ -63,12 +82,6 @@ type PresenceEntry = {
63
82
  expiresAt: Date
64
83
  }
65
84
 
66
- type LaneQueue = { rows: Row[]; head: number }
67
- type SessionDispatch = {
68
- ready: Map<number, Row>
69
- lanes: Map<string, LaneQueue>
70
- }
71
-
72
85
  /** Contiguous rows; `reclaim` removes from the front below `clearIndex`. */
73
86
  type SessionLog = {
74
87
  rows: Row[]
@@ -76,6 +89,8 @@ type SessionLog = {
76
89
  offset: number
77
90
  /** Latest clear marker, or 0. */
78
91
  clearIndex: number
92
+ /** The session's unique index on event ids, like the SQL schemas'. */
93
+ byEventId: Map<string, Row>
79
94
  }
80
95
 
81
96
  const RECLAIM_BATCH = 256
@@ -83,6 +98,22 @@ const RECLAIM_BATCH = 256
83
98
  const headOf = (log: SessionLog | undefined): number =>
84
99
  log ? log.offset + log.rows.length : 0
85
100
 
101
+ const freshDispatch = (): Dispatch => ({
102
+ firstClaimedAt: null,
103
+ lastClaimedAt: null,
104
+ attemptCount: 0,
105
+ claimHolder: null,
106
+ claimExpiresAt: null,
107
+ failureCount: 0,
108
+ lastFailedAt: null,
109
+ lastFailedAttempt: null,
110
+ lastError: null,
111
+ failedAt: null,
112
+ })
113
+
114
+ const date = (value: Date | null): Date | null =>
115
+ value ? new Date(value) : null
116
+
86
117
  const toStored = (row: Row): StoredEvent => ({
87
118
  id: row.id,
88
119
  type: row.type,
@@ -91,20 +122,6 @@ const toStored = (row: Row): StoredEvent => ({
91
122
  sessionId: row.sessionId,
92
123
  createdAt: new Date(row.createdAt),
93
124
  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
125
  })
109
126
 
110
127
  const toEvent = (row: Row): Event => ({
@@ -116,20 +133,95 @@ const toEvent = (row: Row): Event => ({
116
133
  createdAt: new Date(row.createdAt),
117
134
  })
118
135
 
136
+ const toHead = (cursor: Cursor, row: Row): HeadEvent => ({
137
+ ...toStored(row),
138
+ consumer: cursor.consumer,
139
+ lane: cursor.lane,
140
+ firstClaimedAt: date(cursor.dispatch.firstClaimedAt),
141
+ lastClaimedAt: date(cursor.dispatch.lastClaimedAt),
142
+ attemptCount: cursor.dispatch.attemptCount,
143
+ claimHolder: cursor.dispatch.claimHolder,
144
+ claimExpiresAt: date(cursor.dispatch.claimExpiresAt),
145
+ failureCount: cursor.dispatch.failureCount,
146
+ lastFailedAt: date(cursor.dispatch.lastFailedAt),
147
+ lastFailedAttempt: cursor.dispatch.lastFailedAttempt,
148
+ lastError: cursor.dispatch.lastError,
149
+ failedAt: date(cursor.dispatch.failedAt),
150
+ })
151
+
152
+ const toCursor = (cursor: Cursor): StoredCursor => ({
153
+ consumer: cursor.consumer,
154
+ lane: cursor.lane,
155
+ position: cursor.position,
156
+ head: cursor.head,
157
+ firstClaimedAt: date(cursor.dispatch.firstClaimedAt),
158
+ lastClaimedAt: date(cursor.dispatch.lastClaimedAt),
159
+ attemptCount: cursor.dispatch.attemptCount,
160
+ claimHolder: cursor.dispatch.claimHolder,
161
+ claimExpiresAt: date(cursor.dispatch.claimExpiresAt),
162
+ failureCount: cursor.dispatch.failureCount,
163
+ lastFailedAt: date(cursor.dispatch.lastFailedAt),
164
+ lastFailedAttempt: cursor.dispatch.lastFailedAttempt,
165
+ lastError: cursor.dispatch.lastError,
166
+ failedAt: date(cursor.dispatch.failedAt),
167
+ })
168
+
169
+ const compareText = (a: string, b: string): number =>
170
+ a < b ? -1 : a > b ? 1 : 0
171
+
172
+ /** Consumer, then lane, with concurrent cursors last by head. */
173
+ const byCursor = (a: StoredCursor, b: StoredCursor): number =>
174
+ compareText(a.consumer, b.consumer) ||
175
+ (a.lane === null
176
+ ? b.lane === null
177
+ ? (a.head ?? 0) - (b.head ?? 0)
178
+ : 1
179
+ : b.lane === null
180
+ ? -1
181
+ : compareText(a.lane, b.lane))
182
+
183
+ const cursorKey = (
184
+ consumer: string,
185
+ lane: string | null,
186
+ index: number,
187
+ ): string =>
188
+ lane === null ? `${consumer}\u0000#${index}` : `${consumer}\u0000L${lane}`
189
+
190
+ const takes = (types: readonly string[] | null, type: string): boolean =>
191
+ type !== CLEAR_EVENT_TYPE && (types === null || types.includes(type))
192
+
193
+ const byHead = (a: HeadEvent, b: HeadEvent): number =>
194
+ a.index - b.index || compareText(a.consumer, b.consumer)
195
+
119
196
  const snapshotKey = (sessionId: string, reducerName: string): string =>
120
197
  `${sessionId}\u0000${reducerName}`
121
198
 
122
- const eventPinKey = (sessionId: string, index: number): string =>
123
- `${sessionId}\u0000${index}`
199
+ const headPinKey = (sessionId: string, ref: HeadRef): string =>
200
+ `${sessionId}\u0000${cursorKey(ref.consumer, ref.lane, ref.index)}\u0000${ref.index}`
201
+
202
+ /** Whether a cursor belongs to a consumer the caller serves; omitted, every one. */
203
+ const servedBy = (
204
+ consumers: readonly StoreConsumer[] | undefined,
205
+ ): ((cursor: Cursor) => boolean) => {
206
+ if (!consumers) return () => true
207
+ const names = new Set(consumers.map((consumer) => consumer.name))
208
+ return (cursor) => names.has(cursor.consumer)
209
+ }
210
+
211
+ /** A cursor's current head. */
212
+ const refOf = (cursor: Cursor): HeadRef => ({
213
+ consumer: cursor.consumer,
214
+ lane: cursor.lane,
215
+ index: cursor.head!,
216
+ })
124
217
 
125
218
  export function memory(options: MemoryStoreOptions = {}): A2Store {
126
219
  const clock = options.clock ?? SYSTEM_CLOCK
127
220
  const generateId = options.ids ?? RANDOM_IDS
128
221
 
129
222
  const sessions = new Map<string, SessionLog>()
130
- const dispatch = new Map<string, SessionDispatch>()
131
- /** Global unique index on event ids, like the SQL schema's. */
132
- const byEventId = new Map<string, Row>()
223
+ /** Session id → cursor key → cursor. */
224
+ const cursors = new Map<string, Map<string, Cursor>>()
133
225
  /** Keyed by `${sessionId}\u0000${reducerName}` — a pure cache. */
134
226
  const snapshots = new Map<
135
227
  string,
@@ -163,7 +255,7 @@ export function memory(options: MemoryStoreOptions = {}): A2Store {
163
255
  const logOf = (sessionId: string): SessionLog => {
164
256
  let log = sessions.get(sessionId)
165
257
  if (!log) {
166
- log = { rows: [], offset: 0, clearIndex: 0 }
258
+ log = { rows: [], offset: 0, clearIndex: 0, byEventId: new Map() }
167
259
  sessions.set(sessionId, log)
168
260
  }
169
261
  return log
@@ -179,22 +271,33 @@ export function memory(options: MemoryStoreOptions = {}): A2Store {
179
271
  const clearIndexOf = (sessionId: string): number =>
180
272
  sessions.get(sessionId)?.clearIndex ?? 0
181
273
 
182
- /** The row owning `id`, ignoring this session's rows below its clear. */
274
+ /** The session's row owning `id`, ignoring rows below its clear. */
183
275
  const ownerOf = (sessionId: string, id: string): Row | undefined => {
184
- const row = byEventId.get(id)
185
- return row?.sessionId === sessionId && row.index < clearIndexOf(sessionId)
186
- ? undefined
187
- : row
276
+ const log = sessions.get(sessionId)
277
+ const row = log?.byEventId.get(id)
278
+ return log && row && row.index >= log.clearIndex ? row : undefined
279
+ }
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
188
288
  }
189
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 = (
@@ -334,23 +437,110 @@ export function memory(options: MemoryStoreOptions = {}): A2Store {
334
437
  'batch contains the same event id more than once',
335
438
  )
336
439
  }
337
- const existing = supplied
440
+ return supplied
338
441
  .map((candidate) => ownerOf(sessionId, candidate.id))
339
442
  .filter((row): row is Row => row !== undefined)
340
- const foreign = existing.find((row) => row.sessionId !== sessionId)
341
- if (foreign) {
342
- throw new A2Error(
343
- 'PARTIAL_DUPLICATE_BATCH',
344
- `event id '${foreign.id}' already exists in another session`,
345
- )
443
+ }
444
+
445
+ /** Records the types a declared log consumer takes on its cursor. */
446
+ const refreshLogTypes = (
447
+ sessionId: string,
448
+ consumers: readonly StoreConsumer[] | undefined,
449
+ ): void => {
450
+ const map = cursors.get(sessionId)
451
+ if (!map) return
452
+ for (const consumer of consumers ?? []) {
453
+ if (!consumer.log) continue
454
+ const cursor = map.get(cursorKey(consumer.name, consumer.log.lane, 0))
455
+ if (cursor?.kind === 'log') cursor.types = consumer.log.types
456
+ }
457
+ }
458
+
459
+ /** Starts the cursor work an inserted batch carries. */
460
+ const enqueue = (
461
+ sessionId: string,
462
+ rows: readonly Row[],
463
+ events: readonly AppendEvent[],
464
+ base: number,
465
+ consumers: readonly StoreConsumer[] | undefined,
466
+ ): void => {
467
+ const map = cursorsOf(sessionId)
468
+ rows.forEach((row, offset) => {
469
+ for (const target of events[offset]!.targets ?? []) {
470
+ const key = cursorKey(target.consumer, target.lane, row.index)
471
+ if (target.lane === null) {
472
+ map.set(key, {
473
+ consumer: target.consumer,
474
+ lane: null,
475
+ kind: 'event',
476
+ position: row.index - 1,
477
+ head: row.index,
478
+ dispatch: freshDispatch(),
479
+ queue: [],
480
+ queued: 0,
481
+ completions: new Map(),
482
+ types: null,
483
+ })
484
+ continue
485
+ }
486
+ let cursor = map.get(key)
487
+ if (!cursor) {
488
+ cursor = {
489
+ consumer: target.consumer,
490
+ lane: target.lane,
491
+ kind: 'lane',
492
+ position: clearIndexOf(sessionId),
493
+ head: null,
494
+ dispatch: freshDispatch(),
495
+ queue: [],
496
+ queued: 0,
497
+ completions: new Map(),
498
+ types: null,
499
+ }
500
+ map.set(key, cursor)
501
+ }
502
+ cursor.queue.push(row.index)
503
+ cursor.head ??= row.index
504
+ }
505
+ })
506
+ const clearIndex = clearIndexOf(sessionId)
507
+ for (const consumer of consumers ?? []) {
508
+ const log = consumer.log
509
+ if (!log) continue
510
+ const first = rows.find((row) => takes(log.types, row.type))
511
+ if (!first) continue
512
+ const key = cursorKey(consumer.name, log.lane, 0)
513
+ const cursor = map.get(key)
514
+ if (!cursor) {
515
+ const position = log.backfill ? clearIndex : base
516
+ map.set(key, {
517
+ consumer: consumer.name,
518
+ lane: log.lane,
519
+ kind: 'log',
520
+ position,
521
+ head: log.backfill
522
+ ? nextLogHead(sessionId, position, log.types)
523
+ : first.index,
524
+ dispatch: freshDispatch(),
525
+ queue: [],
526
+ queued: 0,
527
+ completions: new Map(),
528
+ types: log.types,
529
+ })
530
+ } else {
531
+ cursor.types = log.types
532
+ // Not `first`: a deployment that doesn't serve this consumer may
533
+ // have appended events it takes since it went idle.
534
+ cursor.head ??= nextLogHead(sessionId, cursor.position, log.types)
535
+ }
346
536
  }
347
- return existing
348
537
  }
349
538
 
350
539
  const insert = (
351
540
  sessionId: string,
352
541
  events: readonly AppendEvent[],
353
542
  now: Date,
543
+ consumers: readonly StoreConsumer[] | undefined,
354
544
  cause?: EventCause,
355
545
  ): Row[] => {
356
546
  const log = logOf(sessionId)
@@ -367,20 +557,6 @@ export function memory(options: MemoryStoreOptions = {}): A2Store {
367
557
  : candidate.cause
368
558
  ? { ...candidate.cause }
369
559
  : 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
560
  }))
385
561
  for (const row of inserted) {
386
562
  if (ownerOf(sessionId, row.id)) {
@@ -392,9 +568,9 @@ export function memory(options: MemoryStoreOptions = {}): A2Store {
392
568
  }
393
569
  for (const row of inserted) {
394
570
  log.rows.push(row)
395
- byEventId.set(row.id, row)
396
- enqueue(sessionId, row)
571
+ log.byEventId.set(row.id, row)
397
572
  }
573
+ enqueue(sessionId, inserted, events, base, consumers)
398
574
  return inserted
399
575
  }
400
576
 
@@ -462,9 +638,31 @@ export function memory(options: MemoryStoreOptions = {}): A2Store {
462
638
  }
463
639
  }
464
640
 
641
+ /** The dead-lettered head a redrive names, or undefined. */
642
+ const deadHeadOf = (
643
+ sessionId: string,
644
+ consumer: string,
645
+ index: number,
646
+ ): Cursor | undefined => {
647
+ if (index <= clearIndexOf(sessionId)) return undefined
648
+ for (const cursor of cursors.get(sessionId)?.values() ?? []) {
649
+ if (
650
+ cursor.consumer === consumer &&
651
+ cursor.head === index &&
652
+ cursor.dispatch.failedAt !== null
653
+ ) {
654
+ return cursor
655
+ }
656
+ }
657
+ return undefined
658
+ }
659
+
465
660
  return {
466
661
  async append(sessionId, events, appendOptions) {
467
- if (events.length === 0) return appendResult(sessionId, [])
662
+ const consumers = appendOptions?.consumers
663
+ if (events.length === 0) {
664
+ return { events: [], hasPending: hasPending(sessionId, consumers) }
665
+ }
468
666
 
469
667
  // Batch-scoped idempotency (a2-implementation.md §3): all ids
470
668
  // already present → lost-ack retry, return the original rows;
@@ -472,10 +670,10 @@ export function memory(options: MemoryStoreOptions = {}): A2Store {
472
670
  const existing = checkBatchIds(sessionId, events)
473
671
  if (existing.length > 0) {
474
672
  if (existing.length === events.length) {
475
- return appendResult(
476
- sessionId,
477
- idempotentReplay(events, existing.map(toStored)),
478
- )
673
+ return {
674
+ events: idempotentReplay(events, existing.map(toStored)),
675
+ hasPending: hasPending(sessionId, consumers),
676
+ }
479
677
  }
480
678
  throw new A2Error(
481
679
  'PARTIAL_DUPLICATE_BATCH',
@@ -489,16 +687,18 @@ export function memory(options: MemoryStoreOptions = {}): A2Store {
489
687
  `scheduled work from event ${originIndex} was cleared in session '${sessionId}'`,
490
688
  )
491
689
  }
492
- // Attempt-currency fence: a fresh handler append commits only while
493
- // its causal attempt is still the parent's latest (store.ts `append`).
690
+ // Attempt-currency fence: a fresh consumer append commits only while
691
+ // its causal attempt still owns the cursor's head (store.ts `append`).
494
692
  for (const event of events) {
495
693
  if (event.cause) assertCurrentCause(sessionId, event.cause)
496
694
  }
497
695
 
498
- const now = clock.now()
499
- const inserted = insert(sessionId, events, now)
696
+ const inserted = insert(sessionId, events, clock.now(), consumers)
500
697
  notifyStreams(sessionId, inserted)
501
- return appendResult(sessionId, inserted.map(toStored))
698
+ return {
699
+ events: inserted.map(toStored),
700
+ hasPending: hasPending(sessionId, consumers),
701
+ }
502
702
  },
503
703
 
504
704
  async clear({ sessionId, ifLastIndex, cause }) {
@@ -517,12 +717,25 @@ export function memory(options: MemoryStoreOptions = {}): A2Store {
517
717
  }
518
718
  const [marker] = insert(
519
719
  sessionId,
520
- [{ type: CLEAR_EVENT_TYPE, payload: null, settled: true }],
720
+ [{ type: CLEAR_EVENT_TYPE, payload: null }],
521
721
  clock.now(),
722
+ undefined,
522
723
  )
523
724
  logOf(sessionId).clearIndex = marker!.index
524
- // Every row is at or below the marker, so no dispatch state survives.
525
- dispatch.delete(sessionId)
725
+ // Every event is at or below the marker: no head survives, and log
726
+ // cursors count from the marker.
727
+ const map = cursors.get(sessionId)
728
+ for (const [key, cursor] of map ?? []) {
729
+ if (cursor.head !== null) releaseSnapshotPins(sessionId, refOf(cursor))
730
+ if (cursor.kind !== 'log') {
731
+ map!.delete(key)
732
+ continue
733
+ }
734
+ cursor.position = marker!.index
735
+ cursor.head = null
736
+ cursor.dispatch = freshDispatch()
737
+ cursor.completions.clear()
738
+ }
526
739
  notifyStreams(sessionId, [marker!])
527
740
  return { cleared: true, index: marker!.index, clearIndex: marker!.index }
528
741
  },
@@ -539,8 +752,7 @@ export function memory(options: MemoryStoreOptions = {}): A2Store {
539
752
  log.offset += removed.length
540
753
  for (const row of removed) {
541
754
  // A reused id belongs to its new row now.
542
- if (byEventId.get(row.id) === row) byEventId.delete(row.id)
543
- releaseSnapshotPins(sessionId, row.index)
755
+ if (log.byEventId.get(row.id) === row) log.byEventId.delete(row.id)
544
756
  }
545
757
  const done = log.offset === log.clearIndex - 1
546
758
  // Writes below the marker are dropped, so one sweep at the end suffices.
@@ -548,6 +760,49 @@ export function memory(options: MemoryStoreOptions = {}): A2Store {
548
760
  return { deleted: removed.length, done }
549
761
  },
550
762
 
763
+ async wakeLog({ sessionId, consumer }) {
764
+ const { log } = consumer
765
+ const key = cursorKey(consumer.name, log.lane, 0)
766
+ const head = headOf(sessions.get(sessionId))
767
+ let cursor = cursors.get(sessionId)?.get(key)
768
+ if (!cursor) {
769
+ const marker = clearIndexOf(sessionId)
770
+ if (head === marker) {
771
+ return {
772
+ cursor: idleLogCursor(consumer.name, log.lane, marker),
773
+ head,
774
+ }
775
+ }
776
+ const position = log.backfill ? marker : head
777
+ cursor = {
778
+ consumer: consumer.name,
779
+ lane: log.lane,
780
+ kind: 'log',
781
+ position,
782
+ head: log.backfill
783
+ ? nextLogHead(sessionId, position, log.types)
784
+ : null,
785
+ dispatch: freshDispatch(),
786
+ queue: [],
787
+ queued: 0,
788
+ completions: new Map(),
789
+ types: log.types,
790
+ }
791
+ cursorsOf(sessionId).set(key, cursor)
792
+ } else {
793
+ cursor.types = log.types
794
+ cursor.head ??= nextLogHead(sessionId, cursor.position, log.types)
795
+ }
796
+ return { cursor: toCursor(cursor), head }
797
+ },
798
+
799
+ async cursors(sessionId) {
800
+ return [...(cursors.get(sessionId)?.values() ?? [])]
801
+ .filter((cursor) => cursor.kind !== 'event' || cursor.head !== null)
802
+ .map(toCursor)
803
+ .toSorted(byCursor)
804
+ },
805
+
551
806
  async read(sessionId, opts) {
552
807
  const log = sessions.get(sessionId)
553
808
  if (!log) return []
@@ -569,46 +824,64 @@ export function memory(options: MemoryStoreOptions = {}): A2Store {
569
824
  holder,
570
825
  ttlMs,
571
826
  expiresAtMs,
572
- excludeIndexes,
827
+ exclude,
828
+ consumers,
573
829
  maxFailures,
830
+ through,
574
831
  }) {
832
+ refreshLogTypes(sessionId, consumers)
575
833
  const claimedAt = clock.now()
576
834
  const now = claimedAt.getTime()
577
835
  const expiresAt = new Date(expiresAtMs ?? now + ttlMs)
578
- const excluded = new Set(excludeIndexes ?? [])
579
- const eligible: Row[] = []
836
+ const excluded = new Set(
837
+ (exclude ?? []).map((ref) =>
838
+ cursorKey(ref.consumer, ref.lane, ref.index),
839
+ ),
840
+ )
841
+ const served = servedBy(consumers)
842
+ const eligible: Cursor[] = []
580
843
  let retryAt: Date | null = null
581
844
 
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
- }
845
+ for (const [key, cursor] of cursors.get(sessionId) ?? []) {
846
+ if (
847
+ cursor.head === null ||
848
+ (through !== undefined && cursor.head > through) ||
849
+ cursor.dispatch.failedAt !== null ||
850
+ !served(cursor) ||
851
+ excluded.has(key)
852
+ ) {
853
+ continue
854
+ }
855
+ const expiry = cursor.dispatch.claimExpiresAt
856
+ if (expiry && expiry.getTime() > now) {
857
+ if (!retryAt || expiry < retryAt) retryAt = new Date(expiry)
588
858
  continue
589
859
  }
590
- eligible.push(row)
860
+ eligible.push(cursor)
591
861
  }
592
862
 
593
863
  if (eligible.length === 0) {
594
864
  return retryAt ? { outcome: 'busy', retryAt } : { outcome: 'settled' }
595
865
  }
596
- eligible.sort((a, b) => a.index - b.index)
597
866
  const clearIndex = clearIndexOf(sessionId)
598
- return {
599
- outcome: 'claimed',
600
- events: eligible.map((row) => {
601
- const claimed = claim({
867
+ const claimed = eligible
868
+ .map((cursor) => {
869
+ const head = claim({
602
870
  sessionId,
603
- row,
871
+ cursor,
604
872
  holder,
605
873
  now: claimedAt,
606
874
  expiresAt,
607
875
  maxFailures,
608
876
  })
609
- if (clearIndex > 0) claimed.clearIndex = clearIndex
610
- return claimed
611
- }),
877
+ if (clearIndex > 0) head.clearIndex = clearIndex
878
+ return head
879
+ })
880
+ .toSorted(byHead)
881
+ return {
882
+ outcome: 'claimed',
883
+ events: claimed,
884
+ head: headOf(sessions.get(sessionId)),
612
885
  }
613
886
  },
614
887
 
@@ -616,48 +889,69 @@ export function memory(options: MemoryStoreOptions = {}): A2Store {
616
889
  const now = clock.now()
617
890
  const expiresAt = new Date(expiresAtMs ?? now.getTime() + ttlMs)
618
891
  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)
892
+ const renewed: HeadRef[] = []
893
+ const superseded: HeadRef[] = []
894
+ const ordered = claims.toSorted(
895
+ (a, b) => a.index - b.index || compareText(a.consumer, b.consumer),
896
+ )
897
+ for (const { attempt, ...ref } of ordered) {
898
+ if (ref.index <= clearIndex) {
899
+ superseded.push(ref)
626
900
  continue
627
901
  }
628
- const row = rowAt(sessionId, index)
629
- if (!row) continue
630
- if (row.attemptCount > attempt) {
631
- superseded.push(row.index)
902
+ const cursor = cursorAt(sessionId, ref)
903
+ if (!cursor) continue
904
+ const dispatch = cursor.dispatch
905
+ // An event the cursor has not reached yet holds no claim to renew.
906
+ if (cursor.head !== ref.index && cursor.position < ref.index) continue
907
+ if (cursor.head !== ref.index || dispatch.attemptCount > attempt) {
908
+ superseded.push(ref)
632
909
  continue
633
910
  }
634
911
  if (
635
- row.processedAt === null &&
636
- row.failedAt === null &&
637
- row.claimHolder === holder &&
638
- row.claimExpiresAt !== null &&
639
- row.claimExpiresAt > now
912
+ dispatch.failedAt === null &&
913
+ dispatch.claimHolder === holder &&
914
+ dispatch.claimExpiresAt !== null &&
915
+ dispatch.claimExpiresAt > now
640
916
  ) {
641
- row.claimExpiresAt = new Date(expiresAt)
642
- renewed.push(row.index)
917
+ dispatch.claimExpiresAt = new Date(expiresAt)
918
+ renewed.push(ref)
643
919
  }
644
920
  }
645
921
  return { renewed, superseded }
646
922
  },
647
923
 
648
- async completeAttempt({ sessionId, index, attempt, events }) {
924
+ async completeAttempt({
925
+ sessionId,
926
+ consumer,
927
+ lane,
928
+ index,
929
+ attempt,
930
+ events,
931
+ consumers,
932
+ }) {
649
933
  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) {
934
+ const ref = { consumer, lane, index }
935
+ const cursor = cursorAt(sessionId, ref)
936
+ if (!cursor) {
937
+ if (!rowAt(sessionId, index)) {
938
+ throw new TypeError(
939
+ `no event at index ${index} in session '${sessionId}'`,
940
+ )
941
+ }
942
+ return { outcome: 'superseded' }
943
+ }
944
+ refreshLogTypes(sessionId, consumers)
945
+
946
+ if (cursor.head !== index) {
947
+ const completion = cursor.completions.get(index)
948
+ if (!completion || completion.attempt !== attempt) {
654
949
  return { outcome: 'superseded' }
655
950
  }
656
951
  const requestedIds = events.map((event) => event.id)
657
952
  if (
658
- parent.returnedEventIds === null ||
659
- parent.returnedEventIds.length !== requestedIds.length ||
660
- parent.returnedEventIds.some(
953
+ completion.returnedEventIds.length !== requestedIds.length ||
954
+ completion.returnedEventIds.some(
661
955
  (id, offset) => id !== requestedIds[offset],
662
956
  )
663
957
  ) {
@@ -671,7 +965,10 @@ export function memory(options: MemoryStoreOptions = {}): A2Store {
671
965
  existing.length !== events.length ||
672
966
  existing.some(
673
967
  (row) =>
674
- row.cause?.index !== index || row.cause.attempt !== attempt,
968
+ row.cause?.consumer !== consumer ||
969
+ row.cause.lane !== lane ||
970
+ row.cause.index !== index ||
971
+ row.cause.attempt !== attempt,
675
972
  )
676
973
  ) {
677
974
  throw new A2Error(
@@ -681,13 +978,17 @@ export function memory(options: MemoryStoreOptions = {}): A2Store {
681
978
  }
682
979
  return {
683
980
  outcome: 'completed',
684
- events: idempotentReplay(events, existing.map(toStored)),
981
+ events:
982
+ events.length === 0
983
+ ? []
984
+ : idempotentReplay(events, existing.map(toStored)),
685
985
  }
686
986
  }
987
+ const dispatch = cursor.dispatch
687
988
  if (
688
- parent.attemptCount !== attempt ||
689
- parent.failedAt !== null ||
690
- parent.claimHolder === null
989
+ dispatch.attemptCount !== attempt ||
990
+ dispatch.failedAt !== null ||
991
+ dispatch.claimHolder === null
691
992
  ) {
692
993
  return { outcome: 'superseded' }
693
994
  }
@@ -699,21 +1000,26 @@ export function memory(options: MemoryStoreOptions = {}): A2Store {
699
1000
  'returned event batch contains already-appended ids',
700
1001
  )
701
1002
  }
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)
1003
+ const inserted = insert(sessionId, events, clock.now(), consumers, {
1004
+ consumer,
1005
+ lane,
1006
+ index,
1007
+ attempt,
1008
+ })
1009
+ if (cursor.kind === 'log') cursor.completions.clear()
1010
+ cursor.completions.set(index, {
1011
+ attempt,
1012
+ returnedEventIds: events.map((event) => event.id),
1013
+ })
1014
+ advance(sessionId, cursor)
711
1015
  notifyStreams(sessionId, inserted)
712
1016
  return { outcome: 'completed', events: inserted.map(toStored) }
713
1017
  },
714
1018
 
715
1019
  async failAttempt({
716
1020
  sessionId,
1021
+ consumer,
1022
+ lane,
717
1023
  index,
718
1024
  attempt,
719
1025
  error,
@@ -723,76 +1029,88 @@ export function memory(options: MemoryStoreOptions = {}): A2Store {
723
1029
  if (index <= clearIndexOf(sessionId)) {
724
1030
  return { outcome: 'superseded', failureCount: 0 }
725
1031
  }
726
- const row = find(sessionId, index)
1032
+ const cursor = cursorAt(sessionId, { consumer, lane, index })
1033
+ if (!cursor) {
1034
+ if (!rowAt(sessionId, index)) {
1035
+ throw new TypeError(
1036
+ `no event at index ${index} in session '${sessionId}'`,
1037
+ )
1038
+ }
1039
+ return { outcome: 'superseded', failureCount: 0 }
1040
+ }
1041
+ const dispatch = cursor.dispatch
727
1042
  if (
728
- row.processedAt ||
729
- row.attemptCount !== attempt ||
730
- (row.claimHolder === null && row.lastFailedAttempt !== attempt)
1043
+ cursor.head !== index ||
1044
+ dispatch.attemptCount !== attempt ||
1045
+ (dispatch.claimHolder === null &&
1046
+ dispatch.lastFailedAttempt !== attempt)
731
1047
  ) {
732
- return { outcome: 'superseded', failureCount: row.failureCount }
1048
+ return {
1049
+ outcome: 'superseded',
1050
+ failureCount: cursor.head === index ? dispatch.failureCount : 0,
1051
+ }
733
1052
  }
734
- if (row.lastFailedAttempt === attempt && row.claimHolder === null) {
1053
+ if (
1054
+ dispatch.lastFailedAttempt === attempt &&
1055
+ dispatch.claimHolder === null
1056
+ ) {
735
1057
  return {
736
- outcome: row.failedAt ? 'dead_lettered' : 'failed',
737
- failureCount: row.failureCount,
1058
+ outcome: dispatch.failedAt ? 'dead_lettered' : 'failed',
1059
+ failureCount: dispatch.failureCount,
738
1060
  }
739
1061
  }
740
- if (row.failedAt) {
741
- return { outcome: 'dead_lettered', failureCount: row.failureCount }
1062
+ if (dispatch.failedAt) {
1063
+ return { outcome: 'dead_lettered', failureCount: dispatch.failureCount }
742
1064
  }
743
- row.failureCount += 1
744
- row.lastError = error
1065
+ dispatch.failureCount += 1
1066
+ dispatch.lastError = error
745
1067
  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
1068
+ dispatch.lastFailedAt = new Date(failedAt)
1069
+ dispatch.lastFailedAttempt = attempt
1070
+ dispatch.claimHolder = null
1071
+ dispatch.claimExpiresAt =
1072
+ retryAfterMs === undefined || dispatch.failureCount >= maxFailures
751
1073
  ? null
752
1074
  : 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)
1075
+ if (dispatch.failureCount >= maxFailures) {
1076
+ deadLetter(sessionId, cursor, failedAt)
757
1077
  return {
758
1078
  outcome: 'dead_lettered',
759
- failureCount: row.failureCount,
1079
+ failureCount: dispatch.failureCount,
760
1080
  }
761
1081
  }
762
- return { outcome: 'failed', failureCount: row.failureCount }
1082
+ return { outcome: 'failed', failureCount: dispatch.failureCount }
763
1083
  },
764
1084
 
765
1085
  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)
1086
+ const clearIndex = clearIndexOf(sessionId)
1087
+ const dead: HeadEvent[] = []
1088
+ for (const cursor of cursors.get(sessionId)?.values() ?? []) {
1089
+ if (
1090
+ cursor.head === null ||
1091
+ cursor.head <= clearIndex ||
1092
+ cursor.dispatch.failedAt === null
1093
+ ) {
1094
+ continue
1095
+ }
1096
+ dead.push(toHead(cursor, rowAt(sessionId, cursor.head)!))
1097
+ }
1098
+ return dead.toSorted(byHead)
775
1099
  },
776
1100
 
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) }
1101
+ async redrive({ sessionId, consumer, index, action, consumers }) {
1102
+ const cursor = deadHeadOf(sessionId, consumer, index)
1103
+ if (!cursor) {
1104
+ return { redriven: false, hasPending: hasPending(sessionId, consumers) }
782
1105
  }
1106
+ refreshLogTypes(sessionId, consumers)
783
1107
  if (action === 'skip') {
784
- row.processedAt = clock.now()
785
- settle(sessionId, row)
1108
+ advance(sessionId, cursor)
786
1109
  } 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
- }
1110
+ cursor.dispatch.failedAt = null
1111
+ cursor.dispatch.failureCount = 0
794
1112
  }
795
- return { redriven: true, hasPending: hasPending(sessionId) }
1113
+ return { redriven: true, hasPending: hasPending(sessionId, consumers) }
796
1114
  },
797
1115
 
798
1116
  async readState(sessionId, reducerName, stateOptions) {
@@ -821,17 +1139,17 @@ export function memory(options: MemoryStoreOptions = {}): A2Store {
821
1139
  for (const write of writes.toSorted((a, b) => a.index - b.index)) {
822
1140
  if (write.index < clearIndex) continue
823
1141
  const checkpoint = checkpointKey(sessionId, reducerName, write.index)
824
- for (const eventIndex of write.pinEventIndexes ?? []) {
825
- const event = rowAt(sessionId, eventIndex)
1142
+ for (const pin of write.pins ?? []) {
1143
+ const cursor = cursorAt(sessionId, pin)
826
1144
  if (
827
- !event ||
828
- event.index <= clearIndex ||
829
- event.processedAt !== null ||
830
- event.failedAt !== null
1145
+ !cursor ||
1146
+ pin.index <= clearIndex ||
1147
+ cursor.head !== pin.index ||
1148
+ cursor.dispatch.failedAt !== null
831
1149
  ) {
832
1150
  continue
833
1151
  }
834
- const pinKey = eventPinKey(sessionId, eventIndex)
1152
+ const pinKey = headPinKey(sessionId, pin)
835
1153
  let pinned = snapshotPins.get(pinKey)
836
1154
  if (!pinned) {
837
1155
  pinned = new Set()
@@ -900,26 +1218,25 @@ export function memory(options: MemoryStoreOptions = {}): A2Store {
900
1218
 
901
1219
  // Expiry anchors on the storage's own clock — the sender's
902
1220
  // `at` orders writes but never extends or shortens a lifetime.
903
- const expiresAtMs = clock.now().getTime() + meta.ttlMs
1221
+ const now = clock.now()
1222
+ const expiresAtMs = now.getTime() + meta.ttlMs
904
1223
  // Field-keyed and caller-named — null-prototype, like every
905
1224
  // presence map (see `nullProtoRecord`).
906
1225
  const applied: Record<string, unknown> = nullProtoRecord()
907
1226
  let appliedCount = 0
908
1227
  for (const [field, value] of Object.entries(values)) {
909
- // Field-wise LWW by `at`; ties go to the incoming write, so
1228
+ // Field-wise LWW by `at` against unexpired values and
1229
+ // tombstones alike; ties go to the incoming write, so
910
1230
  // same-stamp sets keep set-then-read intuition.
911
1231
  const existing = fields.get(field)
912
- if (existing && existing.at > meta.at) continue
913
- if (value === null) {
914
- fields.delete(field)
915
- } else {
916
- fields.set(field, {
917
- value: structuredClone(value),
918
- seen: meta.seen,
919
- at: new Date(meta.at),
920
- expiresAt: new Date(expiresAtMs),
921
- })
922
- }
1232
+ if (existing && existing.expiresAt > now && existing.at > meta.at)
1233
+ continue
1234
+ fields.set(field, {
1235
+ value: structuredClone(value),
1236
+ seen: meta.seen,
1237
+ at: new Date(meta.at),
1238
+ expiresAt: new Date(expiresAtMs),
1239
+ })
923
1240
  applied[field] = structuredClone(value)
924
1241
  appliedCount += 1
925
1242
  }
@@ -953,6 +1270,7 @@ export function memory(options: MemoryStoreOptions = {}): A2Store {
953
1270
  fields.delete(field)
954
1271
  continue
955
1272
  }
1273
+ if (entry.value === null) continue
956
1274
  rows.push({
957
1275
  participant,
958
1276
  field,