experimental-a2 0.16.0 → 0.17.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 (334) hide show
  1. package/CHANGELOG.md +360 -0
  2. package/dist/{actor-ohPC-81x.d.ts → actor-DBbe5xeE.d.ts} +21 -11
  3. package/dist/actor-DBbe5xeE.d.ts.map +1 -0
  4. package/dist/actor-client.d.ts +2 -5
  5. package/dist/actor-client.d.ts.map +1 -1
  6. package/dist/actor-client.js +13 -4
  7. package/dist/actor-client.js.map +1 -1
  8. package/dist/actor-react.d.ts +5 -3
  9. package/dist/actor-react.d.ts.map +1 -1
  10. package/dist/actor-react.js +20 -10
  11. package/dist/actor-react.js.map +1 -1
  12. package/dist/{actor-shared-B5tJfzt-.js → actor-shared-t7w8xIKP.js} +12 -7
  13. package/dist/actor-shared-t7w8xIKP.js.map +1 -0
  14. package/dist/actor.d.ts +1 -1
  15. package/dist/actor.js +42 -23
  16. package/dist/actor.js.map +1 -1
  17. package/dist/{ai-CrLYwNEx.js → ai-UrWfKeuv.js} +966 -541
  18. package/dist/ai-UrWfKeuv.js.map +1 -0
  19. package/dist/ai-client.d.ts +27 -0
  20. package/dist/ai-client.d.ts.map +1 -0
  21. package/dist/ai-client.js +29 -0
  22. package/dist/ai-client.js.map +1 -0
  23. package/dist/ai-react.d.ts +96 -0
  24. package/dist/ai-react.d.ts.map +1 -0
  25. package/dist/ai-react.js +142 -0
  26. package/dist/ai-react.js.map +1 -0
  27. package/dist/ai-server.browser.js +2 -2
  28. package/dist/ai-server.browser.js.map +1 -1
  29. package/dist/ai-server.d.ts +80 -36
  30. package/dist/ai-server.d.ts.map +1 -1
  31. package/dist/ai-server.js +466 -265
  32. package/dist/ai-server.js.map +1 -1
  33. package/dist/{ai-HJ9fHfYI.d.ts → ai-vpGtcaCB.d.ts} +115 -101
  34. package/dist/ai-vpGtcaCB.d.ts.map +1 -0
  35. package/dist/ai.d.ts +2 -2
  36. package/dist/ai.js +2 -2
  37. package/dist/{client-BYzHjkwU.js → client-COaiaDub.js} +447 -80
  38. package/dist/client-COaiaDub.js.map +1 -0
  39. package/dist/{client-CzyacQpJ.d.ts → client-qRnRUzBS.d.ts} +14 -13
  40. package/dist/client-qRnRUzBS.d.ts.map +1 -0
  41. package/dist/client.d.ts +4 -2
  42. package/dist/client.js +1 -1
  43. package/dist/{contract-CKRg_E4q.js → contract-BYKASlCV.js} +12 -21
  44. package/dist/contract-BYKASlCV.js.map +1 -0
  45. package/dist/{idempotent-replay-DVOlyYbx.js → idempotent-replay-JdgMQRRm.js} +2 -2
  46. package/dist/{idempotent-replay-DVOlyYbx.js.map → idempotent-replay-JdgMQRRm.js.map} +1 -1
  47. package/dist/index.d.ts +6 -5
  48. package/dist/index.d.ts.map +1 -1
  49. package/dist/index.js +4 -3
  50. package/dist/{errors-DvhSXnxk.d.ts → initial-snapshot-BrLso5x_.d.ts} +7 -3
  51. package/dist/initial-snapshot-BrLso5x_.d.ts.map +1 -0
  52. package/dist/{internal-Dq2qYxou.js → internal-CbVvEwad.js} +23 -2
  53. package/dist/internal-CbVvEwad.js.map +1 -0
  54. package/dist/otel.d.ts +1 -1
  55. package/dist/react-BbJ-g6Uu.d.ts +86 -0
  56. package/dist/react-BbJ-g6Uu.d.ts.map +1 -0
  57. package/dist/react.d.ts +2 -122
  58. package/dist/react.js +107 -100
  59. package/dist/react.js.map +1 -1
  60. package/dist/reducer-BAXc8JyF.js +87 -0
  61. package/dist/reducer-BAXc8JyF.js.map +1 -0
  62. package/dist/{reducer-otzHjuJj.d.ts → reducer-BYu6DT7h.d.ts} +45 -29
  63. package/dist/reducer-BYu6DT7h.d.ts.map +1 -0
  64. package/dist/reducer-runtime-DQwz_J59.js +28 -0
  65. package/dist/reducer-runtime-DQwz_J59.js.map +1 -0
  66. package/dist/scheduler-qstash.d.ts +3 -3
  67. package/dist/scheduler-qstash.d.ts.map +1 -1
  68. package/dist/scheduler-qstash.js +33 -10
  69. package/dist/scheduler-qstash.js.map +1 -1
  70. package/dist/{scheduler-task-BpzhPnRS.js → scheduler-task-X1SToDjP.js} +18 -3
  71. package/dist/scheduler-task-X1SToDjP.js.map +1 -0
  72. package/dist/scheduler-vercel.d.ts +4 -9
  73. package/dist/scheduler-vercel.d.ts.map +1 -1
  74. package/dist/scheduler-vercel.js +25 -44
  75. package/dist/scheduler-vercel.js.map +1 -1
  76. package/dist/{server-DbD7IJVK.d.ts → server-CGM8WzRG.d.ts} +87 -26
  77. package/dist/server-CGM8WzRG.d.ts.map +1 -0
  78. package/dist/{server-BD5ckxZb.js → server-Rj64fNvX.js} +854 -202
  79. package/dist/server-Rj64fNvX.js.map +1 -0
  80. package/dist/server.d.ts +4 -4
  81. package/dist/server.js +2 -2
  82. package/dist/{errors-DCk6ch5n.js → session-clear-DGI4jTQ5.js} +17 -2
  83. package/dist/session-clear-DGI4jTQ5.js.map +1 -0
  84. package/dist/session-clear-Dshpuhff.d.ts +12 -0
  85. package/dist/session-clear-Dshpuhff.d.ts.map +1 -0
  86. package/dist/{store-DyZM6fS5.d.ts → store-Gpkq7WBo.d.ts} +44 -3
  87. package/dist/store-Gpkq7WBo.d.ts.map +1 -0
  88. package/dist/store-N8PXxDAS.js.map +1 -1
  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 +182 -69
  92. package/dist/store-memory.js.map +1 -1
  93. package/dist/{store-polling-CmxUbV93.js → store-polling-4_y8lETm.js} +21 -4
  94. package/dist/store-polling-4_y8lETm.js.map +1 -0
  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 +399 -108
  98. package/dist/store-postgres.js.map +1 -1
  99. package/dist/{store-presence-polling-C7-XZyW9.js → store-presence-polling-Q1C-k2dd.js} +3 -3
  100. package/dist/{store-presence-polling-C7-XZyW9.js.map → store-presence-polling-Q1C-k2dd.js.map} +1 -1
  101. package/dist/store-redis-http.d.ts +1 -1
  102. package/dist/store-redis-http.d.ts.map +1 -1
  103. package/dist/store-redis-http.js +293 -11
  104. package/dist/store-redis-http.js.map +1 -1
  105. package/dist/store-redis-notify-tqWNnMRu.js +2374 -0
  106. package/dist/store-redis-notify-tqWNnMRu.js.map +1 -0
  107. package/dist/store-redis.d.ts +1 -1
  108. package/dist/store-redis.d.ts.map +1 -1
  109. package/dist/store-redis.js +11 -11
  110. package/dist/store-redis.js.map +1 -1
  111. package/dist/store-sqlite.d.ts +1 -1
  112. package/dist/store-sqlite.d.ts.map +1 -1
  113. package/dist/store-sqlite.js +166 -32
  114. package/dist/store-sqlite.js.map +1 -1
  115. package/dist/stream-buffer-Cx7AAPGq.js +23 -0
  116. package/dist/stream-buffer-Cx7AAPGq.js.map +1 -0
  117. package/dist/{telemetry-B5jzpy6y.d.ts → telemetry-cZH-cibj.d.ts} +2 -2
  118. package/dist/telemetry-cZH-cibj.d.ts.map +1 -0
  119. package/dist/{wire-BO5wWCb1.js → wire-DjfHOvdW.js} +132 -10
  120. package/dist/wire-DjfHOvdW.js.map +1 -0
  121. package/docs/01-quickstart.mdx +2 -2
  122. package/docs/actors/01-introduction.mdx +4 -0
  123. package/docs/actors/04-routes.mdx +14 -1
  124. package/docs/concepts/01-contracts.mdx +95 -0
  125. package/docs/concepts/04-state.mdx +3 -3
  126. package/docs/guides/01-timers.mdx +22 -14
  127. package/docs/guides/02-cancellation.mdx +1 -1
  128. package/docs/guides/03-react.mdx +268 -328
  129. package/docs/guides/04-local-first.mdx +32 -73
  130. package/docs/guides/05-production.mdx +40 -27
  131. package/docs/guides/06-ai-agents.mdx +680 -580
  132. package/docs/guides/09-presence.mdx +3 -7
  133. package/docs/guides/10-transports.mdx +5 -5
  134. package/docs/index.mdx +10 -18
  135. package/docs/reference/01-api.mdx +662 -195
  136. package/docs/reference/02-errors.mdx +2 -1
  137. package/examples/playground/DEPLOY.md +52 -6
  138. package/examples/playground/app/a0/[appId]/loading.tsx +16 -0
  139. package/examples/playground/app/a0/[appId]/page.tsx +48 -0
  140. package/examples/playground/app/a0/a0.css +818 -0
  141. package/examples/playground/app/a0/app-list.tsx +211 -0
  142. package/examples/playground/app/a0/app-view.tsx +55 -0
  143. package/examples/playground/app/a0/apps/[appId]/preview/route.ts +27 -0
  144. package/examples/playground/app/a0/apps/[appId]/route.ts +23 -0
  145. package/examples/playground/app/a0/apps.test.ts +116 -0
  146. package/examples/playground/app/a0/apps.ts +87 -0
  147. package/examples/playground/app/a0/chat.tsx +364 -0
  148. package/examples/playground/app/a0/columns.tsx +109 -0
  149. package/examples/playground/app/a0/context-menu.tsx +116 -0
  150. package/examples/playground/app/a0/cookies.ts +46 -0
  151. package/examples/playground/app/a0/crash-on-request.ts +72 -0
  152. package/examples/playground/app/a0/crashes/route.ts +3 -0
  153. package/examples/playground/app/a0/crashes.test.ts +33 -0
  154. package/examples/playground/app/a0/crashes.ts +28 -0
  155. package/examples/playground/app/a0/devtools.tsx +164 -0
  156. package/examples/playground/app/a0/events/route.ts +6 -0
  157. package/examples/playground/app/a0/flags.ts +30 -0
  158. package/examples/playground/app/a0/layout.tsx +38 -0
  159. package/examples/playground/app/a0/list/route.ts +10 -0
  160. package/examples/playground/app/a0/model.test.ts +56 -0
  161. package/examples/playground/app/a0/model.ts +58 -0
  162. package/examples/playground/app/a0/page.tsx +14 -0
  163. package/examples/playground/app/a0/preview-pane.tsx +145 -0
  164. package/examples/playground/app/a0/preview.test.ts +53 -0
  165. package/examples/playground/app/a0/preview.ts +26 -0
  166. package/examples/playground/app/a0/sandbox-log.ts +69 -0
  167. package/examples/playground/app/a0/sandbox.ts +191 -0
  168. package/examples/playground/app/a0/sandboxes/scheduler/route.ts +6 -0
  169. package/examples/playground/app/a0/scheduler/route.ts +7 -0
  170. package/examples/playground/app/a0/server.ts +158 -0
  171. package/examples/playground/app/a0/session.ts +47 -0
  172. package/examples/playground/app/a0/shell.tsx +150 -0
  173. package/examples/playground/app/a0/short/events/route.ts +9 -0
  174. package/examples/playground/app/a0/splitter.tsx +120 -0
  175. package/examples/playground/app/agent/[agentId]/agent-client.tsx +144 -174
  176. package/examples/playground/app/agent/[agentId]/command-receipt.test.tsx +65 -0
  177. package/examples/playground/app/agent/[agentId]/command-receipt.tsx +27 -0
  178. package/examples/playground/app/agent/[agentId]/compaction-event.tsx +25 -24
  179. package/examples/playground/app/agent/[agentId]/compaction-panel.test.tsx +119 -0
  180. package/examples/playground/app/agent/[agentId]/compaction-panel.tsx +208 -165
  181. package/examples/playground/app/agent/[agentId]/page.tsx +2 -16
  182. package/examples/playground/app/agent/events/route.ts +5 -2
  183. package/examples/playground/app/agent/ingress.test.ts +98 -0
  184. package/examples/playground/app/agent/model-input.test.tsx +58 -0
  185. package/examples/playground/app/agent/model-input.tsx +115 -0
  186. package/examples/playground/app/agent/model.test.ts +73 -0
  187. package/examples/playground/app/agent/model.ts +58 -26
  188. package/examples/playground/app/agent/new-agent-session.tsx +37 -19
  189. package/examples/playground/app/agent/page.tsx +4 -4
  190. package/examples/playground/app/agent/server.ts +15 -14
  191. package/examples/playground/app/agent/session.ts +5 -6
  192. package/examples/playground/app/agent/telemetry.test.ts +168 -0
  193. package/examples/playground/app/agent/telemetry.ts +30 -0
  194. package/examples/playground/app/agent/trace.test.ts +23 -0
  195. package/examples/playground/app/agent/trace.ts +18 -0
  196. package/examples/playground/app/canvas/[canvasId]/canvas-client.tsx +11 -11
  197. package/examples/playground/app/canvas/[canvasId]/page.tsx +1 -2
  198. package/examples/playground/app/canvas/[canvasId]/session.ts +2 -2
  199. package/examples/playground/app/canvas/model.ts +53 -53
  200. package/examples/playground/app/chat/[chatId]/agent-state.test.ts +176 -0
  201. package/examples/playground/app/chat/[chatId]/agent-stream-drawer.tsx +16 -1
  202. package/examples/playground/app/chat/[chatId]/chat-client.tsx +55 -77
  203. package/examples/playground/app/chat/[chatId]/chat-view.ts +37 -3
  204. package/examples/playground/app/chat/[chatId]/page.tsx +7 -15
  205. package/examples/playground/app/chat/[chatId]/session.ts +2 -19
  206. package/examples/playground/app/chat/agent/route.ts +9 -0
  207. package/examples/playground/app/chat/model.test.ts +46 -119
  208. package/examples/playground/app/chat/model.ts +82 -128
  209. package/examples/playground/app/chat/scheduler/route.ts +2 -2
  210. package/examples/playground/app/chat/server.test.ts +108 -0
  211. package/examples/playground/app/chat/server.ts +69 -73
  212. package/examples/playground/app/components/activity-feed.tsx +30 -1
  213. package/examples/playground/app/components/activity-history.test.tsx +35 -0
  214. package/examples/playground/app/components/demo-icons.tsx +89 -0
  215. package/examples/playground/app/components/demos.tsx +33 -0
  216. package/examples/playground/app/components/fast-link.test.tsx +58 -0
  217. package/examples/playground/app/components/fast-link.tsx +55 -0
  218. package/examples/playground/app/components/site-header.tsx +96 -0
  219. package/examples/playground/app/counter/counter-client.tsx +3 -3
  220. package/examples/playground/app/counter/model.test.ts +3 -3
  221. package/examples/playground/app/counter/model.ts +11 -21
  222. package/examples/playground/app/counter/page.tsx +2 -15
  223. package/examples/playground/app/counter/session.ts +2 -2
  224. package/examples/playground/app/documents/[documentId]/code-editor.tsx +12 -1
  225. package/examples/playground/app/documents/[documentId]/document-client.tsx +10 -11
  226. package/examples/playground/app/documents/[documentId]/page.tsx +1 -2
  227. package/examples/playground/app/documents/[documentId]/session.ts +2 -2
  228. package/examples/playground/app/documents/model.ts +27 -27
  229. package/examples/playground/app/globals.css +420 -207
  230. package/examples/playground/app/initial-state.test.tsx +54 -0
  231. package/examples/playground/app/layout.tsx +7 -23
  232. package/examples/playground/app/orders/[orderId]/page.tsx +1 -2
  233. package/examples/playground/app/orders/[orderId]/session.ts +2 -2
  234. package/examples/playground/app/orders/model.ts +49 -49
  235. package/examples/playground/app/page.tsx +13 -102
  236. package/examples/playground/app/recovery/[recoveryId]/page.tsx +1 -2
  237. package/examples/playground/app/recovery/[recoveryId]/session.ts +2 -2
  238. package/examples/playground/app/recovery/model.ts +30 -30
  239. package/examples/playground/instrumentation.ts +5 -0
  240. package/examples/playground/package.json +10 -2
  241. package/examples/playground/scripts/a0-template.ts +77 -0
  242. package/examples/playground/vercel.json +18 -0
  243. package/package.json +3 -1
  244. package/src/actor-client.ts +16 -4
  245. package/src/actor-react.ts +25 -11
  246. package/src/actor-shared.ts +21 -6
  247. package/src/actor.ts +76 -31
  248. package/src/ai-agent-actions.ts +219 -0
  249. package/src/ai-agent.ts +2343 -0
  250. package/src/ai-app-state.ts +91 -0
  251. package/src/ai-client-state.ts +39 -2
  252. package/src/ai-client.ts +62 -0
  253. package/src/ai-context-schema.ts +25 -5
  254. package/src/ai-context.ts +90 -40
  255. package/src/ai-control-server.ts +94 -55
  256. package/src/ai-control.ts +2 -0
  257. package/src/ai-coordinator.ts +23 -22
  258. package/src/ai-message-base.ts +209 -0
  259. package/src/ai-message-projection.ts +382 -58
  260. package/src/ai-progress-batches.ts +26 -0
  261. package/src/ai-projector.ts +95 -14
  262. package/src/ai-react.ts +146 -0
  263. package/src/ai-server.browser.ts +2 -2
  264. package/src/ai-server.ts +20 -1951
  265. package/src/ai-stored-state.ts +48 -163
  266. package/src/ai.ts +343 -276
  267. package/src/bootstrap-stream.ts +72 -0
  268. package/src/bounded-cache.ts +55 -0
  269. package/src/client-render-session.ts +49 -0
  270. package/src/client.ts +688 -151
  271. package/src/contract.ts +92 -42
  272. package/src/deterministic-id.ts +37 -0
  273. package/src/equal-json.ts +32 -0
  274. package/src/errors.ts +13 -1
  275. package/src/index.ts +5 -1
  276. package/src/initial-snapshot.ts +39 -0
  277. package/src/internal.ts +45 -0
  278. package/src/postgres-notification-scope.ts +14 -3
  279. package/src/react.ts +171 -206
  280. package/src/redis-http-reply.ts +256 -0
  281. package/src/redis-http-stream.ts +73 -0
  282. package/src/redis-http-subscriptions.ts +13 -5
  283. package/src/reducer-runtime.ts +52 -0
  284. package/src/reducer.ts +92 -10
  285. package/src/scheduler-qstash.ts +49 -20
  286. package/src/scheduler-task.ts +34 -2
  287. package/src/scheduler-vercel.ts +32 -84
  288. package/src/server-fetch.ts +139 -35
  289. package/src/server.ts +957 -237
  290. package/src/session-clear.ts +23 -0
  291. package/src/session-socket.ts +154 -49
  292. package/src/sse.ts +71 -4
  293. package/src/store-memory.ts +220 -78
  294. package/src/store-polling.ts +18 -4
  295. package/src/store-postgres.ts +461 -114
  296. package/src/store-redis-core.ts +1260 -434
  297. package/src/store-redis-http.ts +18 -2
  298. package/src/store-redis-notify.ts +260 -85
  299. package/src/store-redis.ts +19 -9
  300. package/src/store-sqlite.ts +259 -45
  301. package/src/store.ts +46 -1
  302. package/src/stream-buffer.ts +43 -0
  303. package/src/telemetry.ts +13 -1
  304. package/src/wire.ts +241 -13
  305. package/dist/actor-ohPC-81x.d.ts.map +0 -1
  306. package/dist/actor-shared-B5tJfzt-.js.map +0 -1
  307. package/dist/ai-CrLYwNEx.js.map +0 -1
  308. package/dist/ai-HJ9fHfYI.d.ts.map +0 -1
  309. package/dist/client-BYzHjkwU.js.map +0 -1
  310. package/dist/client-CzyacQpJ.d.ts.map +0 -1
  311. package/dist/contract-CKRg_E4q.js.map +0 -1
  312. package/dist/errors-DCk6ch5n.js.map +0 -1
  313. package/dist/errors-DvhSXnxk.d.ts.map +0 -1
  314. package/dist/internal-Dq2qYxou.js.map +0 -1
  315. package/dist/react.d.ts.map +0 -1
  316. package/dist/reducer-DEMjEY_O.js +0 -29
  317. package/dist/reducer-DEMjEY_O.js.map +0 -1
  318. package/dist/reducer-otzHjuJj.d.ts.map +0 -1
  319. package/dist/scheduler-task-BpzhPnRS.js.map +0 -1
  320. package/dist/server-BD5ckxZb.js.map +0 -1
  321. package/dist/server-DbD7IJVK.d.ts.map +0 -1
  322. package/dist/store-DyZM6fS5.d.ts.map +0 -1
  323. package/dist/store-polling-CmxUbV93.js.map +0 -1
  324. package/dist/store-redis-notify-BVLUfI3j.js +0 -1583
  325. package/dist/store-redis-notify-BVLUfI3j.js.map +0 -1
  326. package/dist/telemetry-B5jzpy6y.d.ts.map +0 -1
  327. package/dist/validate-XKT4FSNn.js +0 -30
  328. package/dist/validate-XKT4FSNn.js.map +0 -1
  329. package/dist/wire-BO5wWCb1.js.map +0 -1
  330. package/examples/playground/app/agent/[agentId]/compaction/route.ts +0 -14
  331. package/examples/playground/app/agent/compaction-settings.test.ts +0 -144
  332. package/examples/playground/app/agent/compaction-settings.ts +0 -49
  333. package/examples/playground/app/agent/compaction-timeline.test.ts +0 -377
  334. package/examples/playground/app/agent/compaction-timeline.ts +0 -208
@@ -1,6 +1,11 @@
1
1
  import { A2Error } from './errors.ts'
2
2
  import { NOTIFY_TIMINGS } from './internal.ts'
3
3
  import { redisHttpSubscriptions } from './redis-http-subscriptions.ts'
4
+ import {
5
+ readRedisStreamPage,
6
+ REDIS_HTTP_STREAM_READ_COUNT,
7
+ } from './redis-http-stream.ts'
8
+ import type { RedisStreamRead } from './store-redis-core.ts'
4
9
  import {
5
10
  createRedisNotifyStore,
6
11
  type RedisConnection,
@@ -74,6 +79,14 @@ export function redisHttp(options: RedisHttpStoreOptions): RedisHttpStore {
74
79
  return body.result
75
80
  }
76
81
 
82
+ const readStream: RedisStreamRead = (read) =>
83
+ readRedisStreamPage({
84
+ ...read,
85
+ url,
86
+ token,
87
+ count: REDIS_HTTP_STREAM_READ_COUNT,
88
+ })
89
+
77
90
  let subscriber: RedisConnection | undefined
78
91
  let onReconnect: ((channel: string) => void) | undefined
79
92
  const client: RedisConnection = {
@@ -85,7 +98,7 @@ export function redisHttp(options: RedisHttpStoreOptions): RedisHttpStore {
85
98
  onReconnect: (channel) => onReconnect?.(channel),
86
99
  fallback: {
87
100
  enabled: () => polling,
88
- enable() {
101
+ checkCommands() {
89
102
  fallbackCheck ??= call('PING')
90
103
  .then((result) => {
91
104
  if (result !== 'PONG')
@@ -93,7 +106,6 @@ export function redisHttp(options: RedisHttpStoreOptions): RedisHttpStore {
93
106
  'STORE_UNAVAILABLE',
94
107
  'redis rest capability check failed',
95
108
  )
96
- polling = true
97
109
  return undefined
98
110
  })
99
111
  .catch((error: unknown) => {
@@ -102,6 +114,9 @@ export function redisHttp(options: RedisHttpStoreOptions): RedisHttpStore {
102
114
  })
103
115
  return fallbackCheck
104
116
  },
117
+ enable() {
118
+ polling = true
119
+ },
105
120
  },
106
121
  })),
107
122
  on() {},
@@ -117,5 +132,6 @@ export function redisHttp(options: RedisHttpStoreOptions): RedisHttpStore {
117
132
  clock: options.clock ?? SYSTEM_CLOCK,
118
133
  ids: options.ids ?? RANDOM_IDS,
119
134
  keyPrefix: options.keyPrefix ?? 'a2',
135
+ readStream,
120
136
  })
121
137
  }
@@ -1,11 +1,23 @@
1
1
  import type { PresencePatch } from './contract.ts'
2
2
  import { A2Error } from './errors.ts'
3
- import { NOTIFY_TIMINGS } from './internal.ts'
3
+ import { NOTIFY_TIMINGS, stateTailReaders } from './internal.ts'
4
4
  import { defaultSleep, pollingStream, type Sleeper } from './store-polling.ts'
5
5
  import { pollPresence } from './store-presence-polling.ts'
6
6
  import { presenceRecovery } from './presence-recovery.ts'
7
7
  import { createStreamActivity, streamActivities } from './stream-activity.ts'
8
- import { createRedisStoreCore } from './store-redis-core.ts'
8
+ import {
9
+ bufferedEvents,
10
+ isSerializedEvent,
11
+ parseSerializedEvent,
12
+ type HeldEvent,
13
+ type SerializedEvent,
14
+ } from './stream-buffer.ts'
15
+ import {
16
+ createRedisStoreCore,
17
+ FEED_PAGE_SIZE,
18
+ type RedisCall,
19
+ type RedisStreamRead,
20
+ } from './store-redis-core.ts'
9
21
  import type { A2Store, Clock, Event, IdSource } from './store.ts'
10
22
 
11
23
  export type RedisConnection = {
@@ -32,14 +44,72 @@ type PresenceSubscription = {
32
44
  recovery?: ReturnType<typeof presenceRecovery> | undefined
33
45
  }
34
46
 
47
+ /**
48
+ * One live event feed's view of its channel: wake-up messages received
49
+ * since it subscribed, whether it must catch up with XRANGE before trusting
50
+ * them, and the resolver of its park, if parked.
51
+ */
52
+ type Feed = {
53
+ inbox: string[]
54
+ bytes: number
55
+ dirty: boolean
56
+ wake: (() => void) | null
57
+ stopRead: () => Promise<void>
58
+ }
59
+
60
+ /** A feed that falls this far behind drops its inbox and catches up. */
61
+ const INBOX_BYTES = 512 * 1024
62
+
63
+ type WakeEntry = [index: number, id: string, type: string, payload: unknown]
64
+
65
+ /**
66
+ * Parses a wake-up message: `{"f":frontier}` or a bare frontier number
67
+ * announces events without carrying them; `{"f","t","e"}` carries them.
68
+ * Returns null for anything else, which callers treat as a hint to catch up.
69
+ */
70
+ const parseWake = (
71
+ message: string,
72
+ ): { frontier: number; createdAt: number; entries: WakeEntry[] } | null => {
73
+ let parsed: unknown
74
+ try {
75
+ parsed = JSON.parse(message)
76
+ } catch {
77
+ return null
78
+ }
79
+ if (typeof parsed === 'number') {
80
+ return Number.isSafeInteger(parsed)
81
+ ? { frontier: parsed, createdAt: 0, entries: [] }
82
+ : null
83
+ }
84
+ if (typeof parsed !== 'object' || parsed === null) return null
85
+ const { f, t, e } = parsed as { f?: unknown; t?: unknown; e?: unknown }
86
+ if (typeof f !== 'number' || !Number.isSafeInteger(f)) return null
87
+ if (e === undefined) return { frontier: f, createdAt: 0, entries: [] }
88
+ if (!Array.isArray(e) || typeof t !== 'number' || !Number.isFinite(t))
89
+ return null
90
+ for (const entry of e) {
91
+ if (
92
+ !Array.isArray(entry) ||
93
+ entry.length !== 4 ||
94
+ !Number.isSafeInteger(entry[0]) ||
95
+ typeof entry[1] !== 'string' ||
96
+ typeof entry[2] !== 'string'
97
+ )
98
+ return null
99
+ }
100
+ return { frontier: f, createdAt: t, entries: e as WakeEntry[] }
101
+ }
102
+
35
103
  export function createRedisNotifyStore(options: {
36
104
  client: () => Promise<RedisConnection>
105
+ callBytes?: RedisCall
37
106
  closeClient: () => Promise<void>
38
107
  clock: Clock
39
108
  ids: IdSource
40
109
  keyPrefix: string
41
110
  onReconnect?: (listener: (channel: string) => void) => void
42
111
  polling?: () => boolean
112
+ readStream?: RedisStreamRead
43
113
  }): A2Store & { close(): Promise<void> } {
44
114
  const { client, clock, ids, keyPrefix: prefix } = options
45
115
  const notifyChannel = (sessionId: string): string =>
@@ -56,31 +126,27 @@ export function createRedisNotifyStore(options: {
56
126
  ids,
57
127
  keyPrefix: prefix,
58
128
  presenceSource: instanceId,
129
+ ...(options.callBytes ? { callBytes: options.callBytes } : {}),
130
+ ...(options.readStream ? { readStream: options.readStream } : {}),
59
131
  })
60
132
 
61
133
  let severed = false
62
134
  let subscriber: Promise<RedisConnection> | null = null
63
- /** channel → parked-feed wake-ups, armed before each catch-up read. */
64
- const wakers = new Map<string, Set<() => void>>()
135
+ /** channel → live event feeds, registered before they subscribe. */
136
+ const feeds = new Map<string, Set<Feed>>()
65
137
  /** channel → refcounted subscription held for each live iterator. */
66
138
  const subscriptions = new Map<
67
139
  string,
68
140
  { refs: number; ready: Promise<unknown> }
69
141
  >()
70
- /**
71
- * channel → last presence-marker refresh, throttling the SET.
72
- * Deliberately real time (`Date.now()`), not the injected clock:
73
- * the marker's PX expiry runs on server real time, and the throttle
74
- * must tick with the TTL it refreshes or time-traveling tests would
75
- * desync the two. Process-local and never stored, so the injected
76
- * clock's every-stored-timestamp pledge is untouched.
77
- */
78
- const watched = new Map<string, { at: number; ready: Promise<void> }>()
79
142
  const presenceSubscriptions = new Map<string, PresenceSubscription>()
80
143
  const pollingStreams = new Set<AsyncIterator<Event>>()
81
144
 
82
145
  options.onReconnect?.((channel) => {
83
- for (const wake of wakers.get(channel) ?? []) wake()
146
+ for (const feed of feeds.get(channel) ?? []) {
147
+ feed.dirty = true
148
+ feed.wake?.()
149
+ }
84
150
  void presenceSubscriptions
85
151
  .get(channel)
86
152
  ?.recovery?.reconnect()
@@ -115,11 +181,16 @@ export function createRedisNotifyStore(options: {
115
181
  subscriber ??= (async () => {
116
182
  const conn = (await client()).duplicate()
117
183
  conn.on('message', (channel, message) => {
118
- // wake() only resolves a promise; removal happens later via
119
- // disarm(), so iterating the live set is safe.
120
- const set = wakers.get(channel)
121
- if (set) {
122
- for (const wake of set) wake()
184
+ for (const feed of feeds.get(channel) ?? []) {
185
+ if (feed.bytes + message.length <= INBOX_BYTES) {
186
+ feed.inbox.push(message)
187
+ feed.bytes += message.length
188
+ } else {
189
+ feed.inbox.length = 0
190
+ feed.bytes = 0
191
+ feed.dirty = true
192
+ }
193
+ feed.wake?.()
123
194
  }
124
195
  const subscription = presenceSubscriptions.get(channel)
125
196
  if (subscription) deliverRemotePresence({ subscription, message })
@@ -204,36 +275,12 @@ export function createRedisNotifyStore(options: {
204
275
  lease.refs -= 1
205
276
  if (lease.refs > 0) return
206
277
  subscriptions.delete(channel)
207
- watched.delete(channel)
208
278
  void gatedSubscriber()
209
279
  .then((conn) => conn.call('unsubscribe', channel))
210
280
  .catch(() => {})
211
281
  }
212
282
 
213
- const armWaker = (
214
- channel: string,
215
- ): { wakeup: Promise<void>; wake: () => void; disarm: () => void } => {
216
- let wake!: () => void
217
- const wakeup = new Promise<void>((resolve) => {
218
- wake = resolve // assigned synchronously by the executor
219
- })
220
- let set = wakers.get(channel)
221
- if (!set) {
222
- set = new Set()
223
- wakers.set(channel, set)
224
- }
225
- set.add(wake)
226
- return {
227
- wakeup,
228
- wake,
229
- disarm: () => {
230
- set.delete(wake)
231
- if (set.size === 0) wakers.delete(channel)
232
- },
233
- }
234
- }
235
-
236
- return {
283
+ const store: A2Store & { close(): Promise<void> } = {
237
284
  append: core.append,
238
285
  read: core.read,
239
286
  readHistory: core.readHistory!,
@@ -243,6 +290,8 @@ export function createRedisNotifyStore(options: {
243
290
  failAttempt: core.failAttempt,
244
291
  readState: core.readState,
245
292
  putSnapshots: core.putSnapshots,
293
+ clear: core.clear!,
294
+ reclaim: core.reclaim!,
246
295
 
247
296
  presence: {
248
297
  async set(ns, participant, values, meta) {
@@ -311,18 +360,68 @@ export function createRedisNotifyStore(options: {
311
360
  return {
312
361
  [Symbol.asyncIterator](): AsyncIterator<Event> {
313
362
  let last = startAfter
314
- let buffer: Event[] = []
363
+ let buffer: HeldEvent[] = []
315
364
  let closed = false
316
365
  let subscribed = false
317
366
  let pending: Sleeper | null = null
318
- let interrupt: (() => void) | null = null
319
367
  let poller: AsyncIterator<Event> | undefined
320
368
  let stopActivity: (() => void) | undefined
369
+ let catchup:
370
+ | {
371
+ controller: AbortController
372
+ iterator: AsyncGenerator<SerializedEvent[], void>
373
+ delivered: boolean
374
+ }
375
+ | undefined
376
+ let stoppingRead: Promise<void> | undefined
377
+ const stopRead = (): Promise<void> => {
378
+ const reading = catchup
379
+ catchup = undefined
380
+ if (reading) {
381
+ reading.controller.abort()
382
+ stoppingRead = reading.iterator
383
+ .return(undefined)
384
+ .then(() => undefined)
385
+ .catch(() => {})
386
+ }
387
+ return stoppingRead ?? Promise.resolve()
388
+ }
389
+ const nextRead = async (
390
+ after: number,
391
+ ): Promise<IteratorResult<SerializedEvent[], void>> => {
392
+ if (!catchup) {
393
+ const controller = new AbortController()
394
+ catchup = {
395
+ controller,
396
+ delivered: false,
397
+ iterator: core.streamEvents!({
398
+ sessionId,
399
+ afterIndex: after,
400
+ signal: controller.signal,
401
+ })[Symbol.asyncIterator](),
402
+ }
403
+ }
404
+ const reading = catchup
405
+ const result = await reading.iterator.next()
406
+ if (result.done && catchup === reading) {
407
+ catchup = undefined
408
+ if (reading.delivered) feed.dirty = true
409
+ } else if (!result.done) reading.delivered = true
410
+ return result
411
+ }
412
+ const feed: Feed = {
413
+ inbox: [],
414
+ bytes: 0,
415
+ dirty: true,
416
+ wake: null,
417
+ stopRead,
418
+ }
321
419
  const activity = createStreamActivity(() => {
322
420
  if (poller) streamActivities.get(poller)?.activate()
323
421
  })
324
422
 
325
423
  const release = (): void => {
424
+ void stopRead()
326
425
  stopActivity?.()
327
426
  if (poller) {
328
427
  void poller.return?.()
@@ -330,9 +429,46 @@ export function createRedisNotifyStore(options: {
330
429
  }
331
430
  if (!subscribed) return
332
431
  subscribed = false
432
+ const set = feeds.get(channel)
433
+ set?.delete(feed)
434
+ if (set?.size === 0) feeds.delete(channel)
333
435
  releaseChannel(channel)
334
436
  }
335
437
 
438
+ /**
439
+ * Moves carried events that continue this feed's sequence into
440
+ * the buffer. A gap, a message without events, or an unreadable
441
+ * message marks the feed dirty so it catches up with XRANGE.
442
+ */
443
+ const applyInbox = (): void => {
444
+ feed.bytes = 0
445
+ for (const message of feed.inbox.splice(0)) {
446
+ const wake = parseWake(message)
447
+ if (!wake) {
448
+ feed.dirty = true
449
+ continue
450
+ }
451
+ for (const [index, id, type, payload] of wake.entries) {
452
+ const expected = (buffer.at(-1)?.index ?? last) + 1
453
+ if (index < expected) continue
454
+ if (index > expected) {
455
+ feed.dirty = true
456
+ break
457
+ }
458
+ buffer.push({
459
+ id,
460
+ type,
461
+ payload,
462
+ index,
463
+ sessionId,
464
+ createdAt: new Date(wake.createdAt),
465
+ })
466
+ }
467
+ if (wake.frontier > (buffer.at(-1)?.index ?? last))
468
+ feed.dirty = true
469
+ }
470
+ }
471
+
336
472
  const iterator: AsyncIterator<Event> = {
337
473
  async next(): Promise<IteratorResult<Event>> {
338
474
  for (;;) {
@@ -342,21 +478,41 @@ export function createRedisNotifyStore(options: {
342
478
  }
343
479
  const row = buffer.shift()
344
480
  if (row) {
481
+ let event: Event
482
+ try {
483
+ event = isSerializedEvent(row)
484
+ ? parseSerializedEvent(row)
485
+ : row
486
+ } catch (cause) {
487
+ // A retry re-reads from the last delivered event.
488
+ buffer = []
489
+ feed.dirty = true
490
+ release()
491
+ throw new A2Error(
492
+ 'STORE_UNAVAILABLE',
493
+ 'redis stream read failed',
494
+ { cause },
495
+ )
496
+ }
345
497
  last = row.index
346
- return { value: row, done: false }
498
+ return { value: event, done: false }
347
499
  }
348
500
  if (severed) {
349
501
  release()
350
502
  throw new A2Error('STORE_UNAVAILABLE', 'the store was closed')
351
503
  }
352
- const waker = armWaker(channel)
353
- interrupt = waker.wake
354
504
  try {
355
505
  if (!subscribed) {
356
- // Subscribe before reading: anything appended
357
- // after the read lands as a wake-up, so nothing
358
- // can slip between catch-up and park.
506
+ // Register and subscribe before the first read:
507
+ // anything appended after it arrives in the inbox,
508
+ // so nothing slips between catch-up and park.
359
509
  subscribed = true
510
+ let set = feeds.get(channel)
511
+ if (!set) {
512
+ set = new Set()
513
+ feeds.set(channel, set)
514
+ }
515
+ set.add(feed)
360
516
  // oxlint-disable-next-line no-await-in-loop -- one-time setup
361
517
  await acquireChannel(channel)
362
518
  if (closed || severed) continue
@@ -385,37 +541,43 @@ export function createRedisNotifyStore(options: {
385
541
  )
386
542
  return result
387
543
  }
388
- // Mark watched before reading, for the same reason:
389
- // a write after this read sees the marker and
390
- // publishes. Throttled — one SET per safety period
391
- // per session per process, shared across feeds.
392
- let marker = watched.get(channel)
393
- if (
394
- !marker ||
395
- Date.now() - marker.at >= NOTIFY_TIMINGS.safetyReadMs
396
- ) {
397
- marker = {
398
- at: Date.now(),
399
- ready: core.markWatched(
400
- sessionId,
401
- NOTIFY_TIMINGS.safetyReadMs * 3,
402
- ),
403
- }
404
- watched.set(channel, marker)
544
+ if (catchup) {
545
+ // oxlint-disable-next-line no-await-in-loop -- decode the next catch-up batch on demand
546
+ const result = await nextRead(last)
547
+ if (closed || severed) continue
548
+ if (!result.done) buffer = result.value
549
+ continue
405
550
  }
406
- // Every viewer waits until writers can observe the marker.
407
- // oxlint-disable-next-line no-await-in-loop -- live-feed loop
408
- await marker.ready
409
- if (closed || severed) continue
410
- // oxlint-disable-next-line no-await-in-loop -- live-feed loop
411
- buffer = await core.readEvents(sessionId, last)
412
- if (buffer.length === 0 && !closed && !severed) {
413
- pending = defaultSleep(NOTIFY_TIMINGS.safetyReadMs)
551
+ applyInbox()
552
+ if (buffer.length > 0) continue
553
+ if (feed.dirty) {
554
+ feed.dirty = false
555
+ if (core.streamEvents) {
556
+ // oxlint-disable-next-line no-await-in-loop -- begin an incremental catch-up
557
+ const result = await nextRead(last)
558
+ if (closed || severed) continue
559
+ if (!result.done) buffer = result.value
560
+ continue
561
+ }
414
562
  // oxlint-disable-next-line no-await-in-loop -- live-feed loop
415
- await Promise.race([waker.wakeup, pending.promise])
416
- pending.cancel()
417
- pending = null
563
+ buffer = await core.readFeedPage(sessionId, last)
564
+ if (buffer.length >= FEED_PAGE_SIZE) feed.dirty = true
565
+ continue
418
566
  }
567
+ // Caught up: park until a message, a reconnect, or the
568
+ // safety read that heals a lost message.
569
+ const woken = new Promise<'wake'>((resolve) => {
570
+ feed.wake = () => resolve('wake')
571
+ })
572
+ pending = defaultSleep(NOTIFY_TIMINGS.safetyReadMs)
573
+ // oxlint-disable-next-line no-await-in-loop -- live-feed loop
574
+ const outcome = await Promise.race([
575
+ woken,
576
+ pending.promise.then(() => 'timer' as const),
577
+ ])
578
+ pending.cancel()
579
+ pending = null
580
+ if (outcome === 'timer') feed.dirty = true
419
581
  } catch (err) {
420
582
  release()
421
583
  if (closed) return { value: undefined, done: true }
@@ -426,20 +588,26 @@ export function createRedisNotifyStore(options: {
426
588
  { cause: err },
427
589
  )
428
590
  } finally {
429
- waker.disarm()
430
- interrupt = null
591
+ feed.wake = null
431
592
  }
432
593
  }
433
594
  },
434
595
  async return(): Promise<IteratorResult<Event>> {
435
596
  closed = true
436
597
  pending?.cancel()
437
- interrupt?.() // wakes an in-flight next() immediately
598
+ feed.wake?.() // wakes an in-flight next() immediately
438
599
  release()
600
+ await stopRead()
439
601
  return { value: undefined, done: true }
440
602
  },
441
603
  }
442
604
  streamActivities.set(iterator, activity)
605
+ bufferedEvents.set(iterator, () => {
606
+ if (closed) return undefined
607
+ const row = buffer.shift()
608
+ if (row) last = row.index
609
+ return row
610
+ })
443
611
  return iterator
444
612
  },
445
613
  }
@@ -452,9 +620,14 @@ export function createRedisNotifyStore(options: {
452
620
  presenceSubscriptions.clear()
453
621
  for (const poller of pollingStreams) void poller.return?.()
454
622
  pollingStreams.clear()
455
- for (const set of wakers.values()) {
456
- for (const wake of set) wake()
623
+ const reads: Promise<void>[] = []
624
+ for (const set of feeds.values()) {
625
+ for (const feed of set) {
626
+ feed.wake?.()
627
+ reads.push(feed.stopRead())
628
+ }
457
629
  }
630
+ await Promise.allSettled(reads)
458
631
  if (subscriber) {
459
632
  const sub = await subscriber.catch(() => null)
460
633
  sub?.disconnect()
@@ -462,4 +635,6 @@ export function createRedisNotifyStore(options: {
462
635
  await options.closeClient()
463
636
  },
464
637
  }
638
+ stateTailReaders.set(store, core.readTail)
639
+ return store
465
640
  }
@@ -4,15 +4,14 @@
4
4
  * with experimental-a2/store-redis-http); this module owns the connection and
5
5
  * the live feed.
6
6
  *
7
- * `stream()` is notify-driven: writes to watched sessions fire a
8
- * disposable PUBLISH wake-up (feeds hold a TTL'd presence marker the
9
- * write script checks, so unwatched sessions cost no wake-up), one
10
- * shared subscriber connection per backend serves every local feed,
11
- * and each wake triggers an `XRANGE` catch-up read. The notification
12
- * only decides when to read, never what — a lost one is healed by a
13
- * safety re-read (NOTIFY_TIMINGS), so delivery never depends on
14
- * pub/sub. Connections scale with processes (one command client plus
15
- * one subscriber), not with concurrent viewers.
7
+ * `stream()` is notify-driven: every write publishes a wake-up that
8
+ * carries the appended events, one shared subscriber connection per backend
9
+ * serves every local feed, and a caught-up feed applies carried events that
10
+ * continue its sequence without reading. A gap, an oversized batch, a
11
+ * reconnect, or the safety re-read (NOTIFY_TIMINGS) triggers an `XRANGE`
12
+ * catch-up, so a lost wake-up costs latency, never delivery. Connections
13
+ * scale with processes (one command client plus one subscriber), not with
14
+ * concurrent viewers.
16
15
  *
17
16
  * Works with any Redis-protocol server on a single instance or a
18
17
  * non-cluster provider (Upstash — durable by default — Redis, Valkey).
@@ -27,6 +26,7 @@ import {
27
26
  type RedisConnection,
28
27
  } from './store-redis-notify.ts'
29
28
  import { retryableLazy } from './retryable-lazy.ts'
29
+ import type { RedisCall } from './store-redis-core.ts'
30
30
  import {
31
31
  RANDOM_IDS,
32
32
  SYSTEM_CLOCK,
@@ -89,6 +89,16 @@ export function redis(options: RedisStoreOptions = {}): RedisStore {
89
89
 
90
90
  return createRedisNotifyStore({
91
91
  client,
92
+ // Only a client created from `url` is known to be ioredis, whose
93
+ // `callBuffer` returns bulk replies as raw bytes.
94
+ ...(options.client
95
+ ? {}
96
+ : {
97
+ callBytes: async (command, ...args) =>
98
+ (
99
+ (await client()) as RedisConnection & { callBuffer: RedisCall }
100
+ ).callBuffer(command, ...args),
101
+ }),
92
102
  clock,
93
103
  ids,
94
104
  keyPrefix: prefix,