experimental-a2 0.16.1 → 0.18.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 (406) hide show
  1. package/CHANGELOG.md +373 -0
  2. package/README.md +1 -1
  3. package/dist/{actor-ohPC-81x.d.ts → actor-Bjce9_5U.d.ts} +21 -11
  4. package/dist/actor-Bjce9_5U.d.ts.map +1 -0
  5. package/dist/actor-client.d.ts +2 -5
  6. package/dist/actor-client.d.ts.map +1 -1
  7. package/dist/actor-client.js +13 -4
  8. package/dist/actor-client.js.map +1 -1
  9. package/dist/actor-react.d.ts +5 -3
  10. package/dist/actor-react.d.ts.map +1 -1
  11. package/dist/actor-react.js +20 -10
  12. package/dist/actor-react.js.map +1 -1
  13. package/dist/{actor-shared-B5tJfzt-.js → actor-shared-t7w8xIKP.js} +12 -7
  14. package/dist/actor-shared-t7w8xIKP.js.map +1 -0
  15. package/dist/actor.d.ts +1 -1
  16. package/dist/actor.js +42 -23
  17. package/dist/actor.js.map +1 -1
  18. package/dist/{ai-DgOBltJ_.js → ai-BpOGm-zB.js} +621 -216
  19. package/dist/ai-BpOGm-zB.js.map +1 -0
  20. package/dist/{ai-CrEf6p_W.d.ts → ai-DrUaMyRt.d.ts} +114 -100
  21. package/dist/ai-DrUaMyRt.d.ts.map +1 -0
  22. package/dist/ai-client.d.ts +27 -0
  23. package/dist/ai-client.d.ts.map +1 -0
  24. package/dist/ai-client.js +29 -0
  25. package/dist/ai-client.js.map +1 -0
  26. package/dist/ai-fx.browser.d.ts +1 -0
  27. package/dist/ai-fx.browser.js +6 -0
  28. package/dist/ai-fx.browser.js.map +1 -0
  29. package/dist/ai-fx.d.ts +10 -0
  30. package/dist/ai-fx.d.ts.map +1 -0
  31. package/dist/ai-fx.js +634 -0
  32. package/dist/ai-fx.js.map +1 -0
  33. package/dist/ai-harness-BuLh_EYi.js +273 -0
  34. package/dist/ai-harness-BuLh_EYi.js.map +1 -0
  35. package/dist/ai-harness-DvsPeJeB.d.ts +26 -0
  36. package/dist/ai-harness-DvsPeJeB.d.ts.map +1 -0
  37. package/dist/ai-pi.browser.d.ts +1 -0
  38. package/dist/ai-pi.browser.js +6 -0
  39. package/dist/ai-pi.browser.js.map +1 -0
  40. package/dist/ai-pi.d.ts +17 -0
  41. package/dist/ai-pi.d.ts.map +1 -0
  42. package/dist/ai-pi.js +904 -0
  43. package/dist/ai-pi.js.map +1 -0
  44. package/dist/ai-react.d.ts +96 -0
  45. package/dist/ai-react.d.ts.map +1 -0
  46. package/dist/ai-react.js +142 -0
  47. package/dist/ai-react.js.map +1 -0
  48. package/dist/ai-server.browser.js +2 -2
  49. package/dist/ai-server.browser.js.map +1 -1
  50. package/dist/ai-server.d.ts +109 -43
  51. package/dist/ai-server.d.ts.map +1 -1
  52. package/dist/ai-server.js +526 -283
  53. package/dist/ai-server.js.map +1 -1
  54. package/dist/ai.d.ts +2 -2
  55. package/dist/ai.js +2 -2
  56. package/dist/bounded-cache-CPHWF-8h.js +41 -0
  57. package/dist/bounded-cache-CPHWF-8h.js.map +1 -0
  58. package/dist/{client-BYzHjkwU.js → client-BReKqwZC.js} +447 -80
  59. package/dist/client-BReKqwZC.js.map +1 -0
  60. package/dist/{client-CzyacQpJ.d.ts → client-h_DKFAjj.d.ts} +14 -13
  61. package/dist/client-h_DKFAjj.d.ts.map +1 -0
  62. package/dist/client.d.ts +4 -2
  63. package/dist/client.js +1 -1
  64. package/dist/{contract-CKRg_E4q.js → contract-BYKASlCV.js} +12 -21
  65. package/dist/contract-BYKASlCV.js.map +1 -0
  66. package/dist/{idempotent-replay-DVOlyYbx.js → idempotent-replay-JdgMQRRm.js} +2 -2
  67. package/dist/{idempotent-replay-DVOlyYbx.js.map → idempotent-replay-JdgMQRRm.js.map} +1 -1
  68. package/dist/index.d.ts +6 -5
  69. package/dist/index.d.ts.map +1 -1
  70. package/dist/index.js +4 -3
  71. package/dist/{errors-DvhSXnxk.d.ts → initial-snapshot-Bx-c0pDP.d.ts} +7 -3
  72. package/dist/initial-snapshot-Bx-c0pDP.d.ts.map +1 -0
  73. package/dist/{internal-Dq2qYxou.js → internal-B2T3U4Rq.js} +29 -2
  74. package/dist/internal-B2T3U4Rq.js.map +1 -0
  75. package/dist/otel.d.ts +1 -1
  76. package/dist/react-BBXgHWFc.d.ts +86 -0
  77. package/dist/react-BBXgHWFc.d.ts.map +1 -0
  78. package/dist/react.d.ts +2 -122
  79. package/dist/react.js +107 -100
  80. package/dist/react.js.map +1 -1
  81. package/dist/reducer-BAXc8JyF.js +87 -0
  82. package/dist/reducer-BAXc8JyF.js.map +1 -0
  83. package/dist/{reducer-otzHjuJj.d.ts → reducer-CqA59Q6U.d.ts} +46 -30
  84. package/dist/reducer-CqA59Q6U.d.ts.map +1 -0
  85. package/dist/reducer-runtime-DQwz_J59.js +28 -0
  86. package/dist/reducer-runtime-DQwz_J59.js.map +1 -0
  87. package/dist/scheduler-qstash.d.ts +3 -3
  88. package/dist/scheduler-qstash.d.ts.map +1 -1
  89. package/dist/scheduler-qstash.js +33 -10
  90. package/dist/scheduler-qstash.js.map +1 -1
  91. package/dist/{scheduler-task-BpzhPnRS.js → scheduler-task-X1SToDjP.js} +18 -3
  92. package/dist/scheduler-task-X1SToDjP.js.map +1 -0
  93. package/dist/scheduler-vercel.d.ts +4 -9
  94. package/dist/scheduler-vercel.d.ts.map +1 -1
  95. package/dist/scheduler-vercel.js +25 -44
  96. package/dist/scheduler-vercel.js.map +1 -1
  97. package/dist/{server-BD5ckxZb.js → server-Dw_k_7kO.js} +904 -219
  98. package/dist/server-Dw_k_7kO.js.map +1 -0
  99. package/dist/{server-DbD7IJVK.d.ts → server-En7cbi3v.d.ts} +116 -26
  100. package/dist/server-En7cbi3v.d.ts.map +1 -0
  101. package/dist/server.d.ts +4 -4
  102. package/dist/server.js +2 -2
  103. package/dist/session-clear-Bo-XTFEy.d.ts +12 -0
  104. package/dist/session-clear-Bo-XTFEy.d.ts.map +1 -0
  105. package/dist/{errors-DCk6ch5n.js → session-clear-DGI4jTQ5.js} +17 -2
  106. package/dist/session-clear-DGI4jTQ5.js.map +1 -0
  107. package/dist/{store-DyZM6fS5.d.ts → store-Djdv0bnZ.d.ts} +73 -3
  108. package/dist/store-Djdv0bnZ.d.ts.map +1 -0
  109. package/dist/store-N8PXxDAS.js.map +1 -1
  110. package/dist/store-memory.d.ts +1 -1
  111. package/dist/store-memory.d.ts.map +1 -1
  112. package/dist/store-memory.js +205 -66
  113. package/dist/store-memory.js.map +1 -1
  114. package/dist/{store-polling-CmxUbV93.js → store-polling-B5FcwJfo.js} +21 -4
  115. package/dist/store-polling-B5FcwJfo.js.map +1 -0
  116. package/dist/store-postgres.d.ts +1 -1
  117. package/dist/store-postgres.d.ts.map +1 -1
  118. package/dist/store-postgres.js +450 -108
  119. package/dist/store-postgres.js.map +1 -1
  120. package/dist/{store-presence-polling-C7-XZyW9.js → store-presence-polling-BAwS8RWn.js} +3 -3
  121. package/dist/{store-presence-polling-C7-XZyW9.js.map → store-presence-polling-BAwS8RWn.js.map} +1 -1
  122. package/dist/store-redis-http.d.ts +1 -1
  123. package/dist/store-redis-http.d.ts.map +1 -1
  124. package/dist/store-redis-http.js +293 -11
  125. package/dist/store-redis-http.js.map +1 -1
  126. package/dist/store-redis-notify-BRITti_o.js +2493 -0
  127. package/dist/store-redis-notify-BRITti_o.js.map +1 -0
  128. package/dist/store-redis.d.ts +1 -1
  129. package/dist/store-redis.d.ts.map +1 -1
  130. package/dist/store-redis.js +11 -11
  131. package/dist/store-redis.js.map +1 -1
  132. package/dist/store-sqlite.d.ts +1 -1
  133. package/dist/store-sqlite.d.ts.map +1 -1
  134. package/dist/store-sqlite.js +197 -33
  135. package/dist/store-sqlite.js.map +1 -1
  136. package/dist/stream-buffer-Cx7AAPGq.js +23 -0
  137. package/dist/stream-buffer-Cx7AAPGq.js.map +1 -0
  138. package/dist/{telemetry-B5jzpy6y.d.ts → telemetry-Cyfqo6Qt.d.ts} +2 -2
  139. package/dist/telemetry-Cyfqo6Qt.d.ts.map +1 -0
  140. package/dist/{wire-BO5wWCb1.js → wire-rSUglYo8.js} +132 -10
  141. package/dist/wire-rSUglYo8.js.map +1 -0
  142. package/docs/01-quickstart.mdx +50 -84
  143. package/docs/{guides/07-examples.mdx → 02-examples.mdx} +10 -20
  144. package/docs/actors/01-introduction.mdx +48 -82
  145. package/docs/actors/02-concurrency.mdx +43 -70
  146. package/docs/actors/03-timers.mdx +28 -42
  147. package/docs/actors/04-routes.mdx +110 -137
  148. package/docs/actors/meta.ts +1 -1
  149. package/docs/agents/01-introduction.mdx +257 -0
  150. package/docs/agents/02-tools.mdx +247 -0
  151. package/docs/agents/03-conversations.mdx +538 -0
  152. package/docs/agents/04-harnesses.mdx +136 -0
  153. package/docs/agents/meta.ts +1 -0
  154. package/docs/client/01-react.mdx +294 -0
  155. package/docs/client/02-transports.mdx +86 -0
  156. package/docs/client/03-presence.mdx +224 -0
  157. package/docs/client/meta.ts +1 -0
  158. package/docs/contract/01-events.mdx +57 -0
  159. package/docs/contract/02-state.mdx +142 -0
  160. package/docs/contract/03-sessions.mdx +175 -0
  161. package/docs/index.mdx +112 -156
  162. package/docs/reference/01-core.mdx +139 -0
  163. package/docs/reference/02-server.mdx +855 -0
  164. package/docs/reference/03-client.mdx +301 -0
  165. package/docs/reference/04-ai.mdx +722 -0
  166. package/docs/reference/05-adapters.mdx +351 -0
  167. package/docs/reference/06-errors.mdx +138 -0
  168. package/docs/reference/meta.ts +1 -1
  169. package/docs/server/01-handlers.mdx +200 -0
  170. package/docs/server/02-timers.mdx +130 -0
  171. package/docs/server/03-cancellation.mdx +141 -0
  172. package/docs/server/04-routes.mdx +243 -0
  173. package/docs/server/05-production.mdx +322 -0
  174. package/docs/server/06-guarantees.mdx +123 -0
  175. package/examples/playground/DEPLOY.md +53 -7
  176. package/examples/playground/app/a0/[appId]/loading.tsx +16 -0
  177. package/examples/playground/app/a0/[appId]/page.tsx +53 -0
  178. package/examples/playground/app/a0/a0.css +818 -0
  179. package/examples/playground/app/a0/app-list.tsx +218 -0
  180. package/examples/playground/app/a0/app-view.tsx +63 -0
  181. package/examples/playground/app/a0/apps/[appId]/preview/route.ts +27 -0
  182. package/examples/playground/app/a0/apps/[appId]/route.ts +28 -0
  183. package/examples/playground/app/a0/apps.test.ts +128 -0
  184. package/examples/playground/app/a0/apps.ts +102 -0
  185. package/examples/playground/app/a0/chat.tsx +410 -0
  186. package/examples/playground/app/a0/columns.tsx +109 -0
  187. package/examples/playground/app/a0/context-menu.tsx +116 -0
  188. package/examples/playground/app/a0/cookies.ts +46 -0
  189. package/examples/playground/app/a0/crash-on-request.ts +72 -0
  190. package/examples/playground/app/a0/crashes/route.ts +3 -0
  191. package/examples/playground/app/a0/crashes.test.ts +33 -0
  192. package/examples/playground/app/a0/crashes.ts +28 -0
  193. package/examples/playground/app/a0/devtools.tsx +164 -0
  194. package/examples/playground/app/a0/events/route.ts +6 -0
  195. package/examples/playground/app/a0/flags.ts +30 -0
  196. package/examples/playground/app/a0/fx/events/route.ts +9 -0
  197. package/examples/playground/app/a0/fx/scheduler/route.ts +7 -0
  198. package/examples/playground/app/a0/fx/short/events/route.ts +11 -0
  199. package/examples/playground/app/a0/layout.tsx +38 -0
  200. package/examples/playground/app/a0/list/route.ts +10 -0
  201. package/examples/playground/app/a0/model.test.ts +73 -0
  202. package/examples/playground/app/a0/model.ts +95 -0
  203. package/examples/playground/app/a0/no-photon.cjs +4 -0
  204. package/examples/playground/app/a0/no-quickjs.cjs +3 -0
  205. package/examples/playground/app/a0/page.tsx +14 -0
  206. package/examples/playground/app/a0/pi/events/route.ts +9 -0
  207. package/examples/playground/app/a0/pi/scheduler/route.ts +7 -0
  208. package/examples/playground/app/a0/pi/short/events/route.ts +11 -0
  209. package/examples/playground/app/a0/preview-pane.tsx +145 -0
  210. package/examples/playground/app/a0/preview.test.ts +53 -0
  211. package/examples/playground/app/a0/preview.ts +26 -0
  212. package/examples/playground/app/a0/sandbox-log.ts +75 -0
  213. package/examples/playground/app/a0/sandbox-session.ts +152 -0
  214. package/examples/playground/app/a0/sandbox.ts +191 -0
  215. package/examples/playground/app/a0/sandboxes/scheduler/route.ts +6 -0
  216. package/examples/playground/app/a0/scheduler/route.ts +7 -0
  217. package/examples/playground/app/a0/server.ts +213 -0
  218. package/examples/playground/app/a0/session.ts +59 -0
  219. package/examples/playground/app/a0/shell.tsx +158 -0
  220. package/examples/playground/app/a0/short/events/route.ts +9 -0
  221. package/examples/playground/app/a0/splitter.tsx +120 -0
  222. package/examples/playground/app/agent/[agentId]/agent-client.tsx +144 -174
  223. package/examples/playground/app/agent/[agentId]/command-receipt.test.tsx +65 -0
  224. package/examples/playground/app/agent/[agentId]/command-receipt.tsx +27 -0
  225. package/examples/playground/app/agent/[agentId]/compaction-event.tsx +25 -24
  226. package/examples/playground/app/agent/[agentId]/compaction-panel.test.tsx +119 -0
  227. package/examples/playground/app/agent/[agentId]/compaction-panel.tsx +208 -165
  228. package/examples/playground/app/agent/[agentId]/page.tsx +2 -16
  229. package/examples/playground/app/agent/events/route.ts +5 -2
  230. package/examples/playground/app/agent/ingress.test.ts +101 -0
  231. package/examples/playground/app/agent/model-input.test.tsx +58 -0
  232. package/examples/playground/app/agent/model-input.tsx +115 -0
  233. package/examples/playground/app/agent/model.test.ts +73 -0
  234. package/examples/playground/app/agent/model.ts +58 -26
  235. package/examples/playground/app/agent/new-agent-session.tsx +37 -19
  236. package/examples/playground/app/agent/page.tsx +4 -4
  237. package/examples/playground/app/agent/server.ts +19 -15
  238. package/examples/playground/app/agent/session.ts +5 -6
  239. package/examples/playground/app/agent/telemetry.test.ts +189 -0
  240. package/examples/playground/app/agent/telemetry.ts +30 -0
  241. package/examples/playground/app/agent/trace.test.ts +23 -0
  242. package/examples/playground/app/agent/trace.ts +18 -0
  243. package/examples/playground/app/canvas/[canvasId]/canvas-client.tsx +12 -12
  244. package/examples/playground/app/canvas/[canvasId]/page.tsx +1 -2
  245. package/examples/playground/app/canvas/[canvasId]/session.ts +2 -2
  246. package/examples/playground/app/canvas/model.ts +53 -53
  247. package/examples/playground/app/chat/[chatId]/agent-state.test.ts +176 -0
  248. package/examples/playground/app/chat/[chatId]/agent-stream-drawer.tsx +16 -1
  249. package/examples/playground/app/chat/[chatId]/chat-client.tsx +55 -77
  250. package/examples/playground/app/chat/[chatId]/chat-view.ts +37 -3
  251. package/examples/playground/app/chat/[chatId]/page.tsx +7 -15
  252. package/examples/playground/app/chat/[chatId]/session.ts +2 -19
  253. package/examples/playground/app/chat/agent/route.ts +9 -0
  254. package/examples/playground/app/chat/model.test.ts +46 -119
  255. package/examples/playground/app/chat/model.ts +82 -128
  256. package/examples/playground/app/chat/scheduler/route.ts +2 -2
  257. package/examples/playground/app/chat/server.test.ts +112 -0
  258. package/examples/playground/app/chat/server.ts +113 -117
  259. package/examples/playground/app/components/activity-feed.tsx +30 -1
  260. package/examples/playground/app/components/activity-history.test.tsx +35 -0
  261. package/examples/playground/app/components/demo-icons.tsx +89 -0
  262. package/examples/playground/app/components/demos.tsx +33 -0
  263. package/examples/playground/app/components/fast-link.test.tsx +58 -0
  264. package/examples/playground/app/components/fast-link.tsx +55 -0
  265. package/examples/playground/app/components/site-header.tsx +96 -0
  266. package/examples/playground/app/counter/counter-client.tsx +3 -3
  267. package/examples/playground/app/counter/model.test.ts +3 -3
  268. package/examples/playground/app/counter/model.ts +11 -21
  269. package/examples/playground/app/counter/page.tsx +2 -15
  270. package/examples/playground/app/counter/session.ts +2 -2
  271. package/examples/playground/app/documents/[documentId]/code-editor.tsx +12 -1
  272. package/examples/playground/app/documents/[documentId]/document-client.tsx +10 -11
  273. package/examples/playground/app/documents/[documentId]/page.tsx +1 -2
  274. package/examples/playground/app/documents/[documentId]/session.ts +2 -2
  275. package/examples/playground/app/documents/model.ts +27 -27
  276. package/examples/playground/app/globals.css +420 -207
  277. package/examples/playground/app/initial-state.test.tsx +54 -0
  278. package/examples/playground/app/layout.tsx +7 -23
  279. package/examples/playground/app/orders/[orderId]/page.tsx +1 -2
  280. package/examples/playground/app/orders/[orderId]/session.ts +2 -2
  281. package/examples/playground/app/orders/model.ts +49 -49
  282. package/examples/playground/app/page.tsx +13 -102
  283. package/examples/playground/app/recovery/[recoveryId]/page.tsx +1 -2
  284. package/examples/playground/app/recovery/[recoveryId]/session.ts +2 -2
  285. package/examples/playground/app/recovery/model.ts +30 -30
  286. package/examples/playground/instrumentation.ts +5 -0
  287. package/examples/playground/next.config.ts +17 -5
  288. package/examples/playground/package.json +13 -2
  289. package/examples/playground/scripts/a0-template.ts +77 -0
  290. package/examples/playground/vercel.json +36 -0
  291. package/package.json +29 -3
  292. package/src/actor-client.ts +16 -4
  293. package/src/actor-react.ts +25 -11
  294. package/src/actor-shared.ts +21 -6
  295. package/src/actor.ts +76 -31
  296. package/src/ai-agent-actions.ts +219 -0
  297. package/src/ai-agent.ts +2517 -0
  298. package/src/ai-app-state.ts +91 -0
  299. package/src/ai-client-state.ts +6 -3
  300. package/src/ai-client.ts +62 -0
  301. package/src/ai-context-schema.ts +25 -5
  302. package/src/ai-context.ts +90 -40
  303. package/src/ai-control-server.ts +92 -72
  304. package/src/ai-coordinator.ts +23 -22
  305. package/src/ai-fx.browser.ts +5 -0
  306. package/src/ai-fx.ts +833 -0
  307. package/src/ai-harness.ts +518 -0
  308. package/src/ai-message-base.ts +209 -0
  309. package/src/ai-message-projection.ts +262 -64
  310. package/src/ai-model-metadata.ts +11 -6
  311. package/src/ai-pi.browser.ts +5 -0
  312. package/src/ai-pi.ts +1263 -0
  313. package/src/ai-progress-batches.ts +26 -0
  314. package/src/ai-projector.ts +95 -14
  315. package/src/ai-react.ts +146 -0
  316. package/src/ai-sdk-step.ts +2 -2
  317. package/src/ai-server.browser.ts +2 -2
  318. package/src/ai-server.ts +23 -1965
  319. package/src/ai-stored-state.ts +48 -163
  320. package/src/ai.ts +339 -155
  321. package/src/bootstrap-stream.ts +72 -0
  322. package/src/bounded-cache.ts +55 -0
  323. package/src/client-render-session.ts +49 -0
  324. package/src/client.ts +688 -151
  325. package/src/contract.ts +92 -42
  326. package/src/deterministic-id.ts +37 -0
  327. package/src/equal-json.ts +32 -0
  328. package/src/errors.ts +13 -1
  329. package/src/index.ts +5 -1
  330. package/src/initial-snapshot.ts +39 -0
  331. package/src/internal.ts +52 -0
  332. package/src/libfx.d.ts +34 -0
  333. package/src/postgres-notification-scope.ts +14 -3
  334. package/src/react.ts +171 -206
  335. package/src/redis-http-reply.ts +256 -0
  336. package/src/redis-http-stream.ts +73 -0
  337. package/src/redis-http-subscriptions.ts +13 -5
  338. package/src/reducer-runtime.ts +52 -0
  339. package/src/reducer.ts +93 -11
  340. package/src/scheduler-qstash.ts +49 -20
  341. package/src/scheduler-task.ts +34 -2
  342. package/src/scheduler-vercel.ts +32 -84
  343. package/src/server-fetch.ts +139 -35
  344. package/src/server.ts +1133 -255
  345. package/src/session-clear.ts +23 -0
  346. package/src/session-socket.ts +154 -49
  347. package/src/sse.ts +71 -4
  348. package/src/store-memory.ts +252 -75
  349. package/src/store-polling.ts +18 -4
  350. package/src/store-postgres.ts +541 -114
  351. package/src/store-redis-core.ts +1450 -493
  352. package/src/store-redis-http.ts +18 -2
  353. package/src/store-redis-notify.ts +262 -85
  354. package/src/store-redis.ts +19 -9
  355. package/src/store-sqlite.ts +319 -45
  356. package/src/store.ts +79 -1
  357. package/src/stream-buffer.ts +43 -0
  358. package/src/telemetry.ts +14 -1
  359. package/src/wire.ts +241 -13
  360. package/dist/actor-ohPC-81x.d.ts.map +0 -1
  361. package/dist/actor-shared-B5tJfzt-.js.map +0 -1
  362. package/dist/ai-CrEf6p_W.d.ts.map +0 -1
  363. package/dist/ai-DgOBltJ_.js.map +0 -1
  364. package/dist/client-BYzHjkwU.js.map +0 -1
  365. package/dist/client-CzyacQpJ.d.ts.map +0 -1
  366. package/dist/contract-CKRg_E4q.js.map +0 -1
  367. package/dist/errors-DCk6ch5n.js.map +0 -1
  368. package/dist/errors-DvhSXnxk.d.ts.map +0 -1
  369. package/dist/internal-Dq2qYxou.js.map +0 -1
  370. package/dist/react.d.ts.map +0 -1
  371. package/dist/reducer-DEMjEY_O.js +0 -29
  372. package/dist/reducer-DEMjEY_O.js.map +0 -1
  373. package/dist/reducer-otzHjuJj.d.ts.map +0 -1
  374. package/dist/scheduler-task-BpzhPnRS.js.map +0 -1
  375. package/dist/server-BD5ckxZb.js.map +0 -1
  376. package/dist/server-DbD7IJVK.d.ts.map +0 -1
  377. package/dist/store-DyZM6fS5.d.ts.map +0 -1
  378. package/dist/store-polling-CmxUbV93.js.map +0 -1
  379. package/dist/store-redis-notify-BVLUfI3j.js +0 -1583
  380. package/dist/store-redis-notify-BVLUfI3j.js.map +0 -1
  381. package/dist/telemetry-B5jzpy6y.d.ts.map +0 -1
  382. package/dist/validate-XKT4FSNn.js +0 -30
  383. package/dist/validate-XKT4FSNn.js.map +0 -1
  384. package/dist/wire-BO5wWCb1.js.map +0 -1
  385. package/docs/concepts/01-contracts.mdx +0 -142
  386. package/docs/concepts/02-handlers.mdx +0 -284
  387. package/docs/concepts/03-durability.mdx +0 -314
  388. package/docs/concepts/04-state.mdx +0 -209
  389. package/docs/guides/01-timers.mdx +0 -185
  390. package/docs/guides/02-cancellation.mdx +0 -161
  391. package/docs/guides/03-react.mdx +0 -384
  392. package/docs/guides/04-local-first.mdx +0 -88
  393. package/docs/guides/05-production.mdx +0 -521
  394. package/docs/guides/06-ai-agents.mdx +0 -1318
  395. package/docs/guides/08-application-data.mdx +0 -113
  396. package/docs/guides/09-presence.mdx +0 -266
  397. package/docs/guides/10-transports.mdx +0 -221
  398. package/docs/reference/01-api.mdx +0 -1896
  399. package/docs/reference/02-errors.mdx +0 -149
  400. package/examples/playground/app/agent/[agentId]/compaction/route.ts +0 -14
  401. package/examples/playground/app/agent/compaction-settings.test.ts +0 -144
  402. package/examples/playground/app/agent/compaction-settings.ts +0 -49
  403. package/examples/playground/app/agent/compaction-timeline.test.ts +0 -377
  404. package/examples/playground/app/agent/compaction-timeline.ts +0 -208
  405. /package/docs/{concepts → contract}/meta.ts +0 -0
  406. /package/docs/{guides → server}/meta.ts +0 -0
package/src/client.ts CHANGED
@@ -14,7 +14,14 @@
14
14
  */
15
15
 
16
16
  // oxlint-disable no-await-in-loop -- retry/backoff/stream loops are sequential by nature
17
+ import { decodeState, projectClientState } from './reducer-runtime.ts'
17
18
  import { A2ClientError, asClientError } from './client-errors.ts'
19
+ import {
20
+ isInitialPromise,
21
+ observeInitial,
22
+ type InitialSnapshot,
23
+ } from './initial-snapshot.ts'
24
+ import { registerRenderSessionFactory } from './client-render-session.ts'
18
25
  import {
19
26
  PRESENCE_TIMINGS,
20
27
  RESERVED_PARTICIPANT_IDS,
@@ -23,6 +30,7 @@ import {
23
30
  } from './internal.ts'
24
31
  import type {
25
32
  AppendInput,
33
+ Contract,
26
34
  ContractEvent,
27
35
  EventDefs,
28
36
  PresenceDefs,
@@ -30,11 +38,17 @@ import type {
30
38
  PresencePatch,
31
39
  WithPresence,
32
40
  } from './contract.ts'
33
- import { clientStateProjection, type Reducer } from './reducer.ts'
41
+ import { foldEvent, type Reducer, type StateSnapshot } from './reducer.ts'
42
+ import { isSessionCleared } from './session-clear.ts'
34
43
  import { validateSync } from './validate.ts'
35
44
  import {
45
+ isStateSnapshotFrame,
46
+ type StateSnapshotFrame,
47
+ type SubscriptionStart,
36
48
  errorFromWire,
49
+ eventFromCompactWire,
37
50
  eventFromWire,
51
+ isCompactWireEvent,
38
52
  isWireEvent,
39
53
  isWirePresencePatch,
40
54
  isWirePresenceSnapshot,
@@ -50,8 +64,11 @@ import {
50
64
  type WirePresencePatch,
51
65
  type WirePresenceSnapshot,
52
66
  } from './wire.ts'
67
+ import type { Event } from './store.ts'
53
68
 
54
69
  export { A2ClientError } from './client-errors.ts'
70
+ export type { StateSnapshot } from './reducer.ts'
71
+ export type { InitialSnapshot } from './initial-snapshot.ts'
55
72
  export type { A2ClientErrorOptions } from './client-errors.ts'
56
73
 
57
74
  export type ReconnectPolicy = (failure: {
@@ -129,6 +146,8 @@ export type SessionSnapshot<
129
146
  > & {
130
147
  /** The live view: server events folded, optimistic pushes applied. */
131
148
  state: S
149
+ /** A state/index snapshot has been applied; independent of connection status. */
150
+ hydrated: boolean
132
151
  /** The raw event feed: server truth — observed, seeded, or
133
152
  * backscrolled via `loadHistory` — plus pending optimistic events
134
153
  * (provisional indexes past the frontier). `state` never folds
@@ -164,11 +183,10 @@ export type SessionClient<
164
183
  D extends EventDefs,
165
184
  S,
166
185
  P extends PresenceDefs = Record<never, never>,
167
- V = S,
168
186
  > = WithPresence<P, SessionClientPresence<P>> & {
169
187
  readonly sessionId: string
170
188
  subscribe(listener: () => void): () => void
171
- getSnapshot(): SessionSnapshot<D, V, P>
189
+ getSnapshot(): SessionSnapshot<D, S, P>
172
190
  /**
173
191
  * Optimistic append: validates locally against the reducer's event
174
192
  * schemas (instant `INVALID_PAYLOAD`, no flicker), applies to the
@@ -212,25 +230,53 @@ export type SessionClient<
212
230
  }
213
231
 
214
232
  export type SessionOptions<D extends EventDefs, S> = {
215
- initialState?: S
216
- initialIndex?: number
217
- /** Server-rendered history through `initialIndex`. Seeds the event feed. */
233
+ initial?: InitialSnapshot<S>
234
+ /** Server-rendered history through `initial.index`. Seeds the event feed. */
218
235
  initialEvents?: ContractEvent<D>[]
219
236
  /** Overrides the client's `participant` for this session handle.
220
237
  * Bound once per handle. */
221
238
  participant?: string
222
239
  }
223
240
 
241
+ function assertSessionSeed(options: { initial?: unknown } | undefined): void {
242
+ const initial = options?.initial
243
+ if (initial == null || isInitialPromise(initial as InitialSnapshot<unknown>))
244
+ return
245
+ if (
246
+ initial === null ||
247
+ typeof initial !== 'object' ||
248
+ !Object.hasOwn(initial, 'state') ||
249
+ !Object.hasOwn(initial, 'index') ||
250
+ !('index' in initial) ||
251
+ typeof initial.index !== 'number' ||
252
+ !Number.isSafeInteger(initial.index) ||
253
+ initial.index < 0
254
+ )
255
+ throw new TypeError(
256
+ 'initial must contain state and a non-negative safe integer index',
257
+ )
258
+ const clearIndex = (initial as { clearIndex?: unknown }).clearIndex
259
+ if (
260
+ clearIndex !== undefined &&
261
+ (typeof clearIndex !== 'number' ||
262
+ !Number.isSafeInteger(clearIndex) ||
263
+ clearIndex < 0 ||
264
+ clearIndex > initial.index)
265
+ )
266
+ throw new TypeError(
267
+ 'initial.clearIndex must be a non-negative safe integer no greater than its index',
268
+ )
269
+ }
270
+
224
271
  export type A2Client<
225
272
  D extends EventDefs,
226
273
  S,
227
274
  P extends PresenceDefs = Record<never, never>,
228
- V = S,
229
275
  > = {
230
276
  session(
231
277
  sessionId: string,
232
278
  options?: SessionOptions<D, S>,
233
- ): SessionClient<D, S, P, V>
279
+ ): SessionClient<D, S, P>
234
280
  }
235
281
 
236
282
  /**
@@ -277,9 +323,8 @@ export type CreateClientOptions<
277
323
  D extends EventDefs,
278
324
  S,
279
325
  P extends PresenceDefs = Record<never, never>,
280
- V = S,
281
326
  > = {
282
- reducer: Reducer<D, S, P, V>
327
+ contract: Contract<D, P, Reducer<D, S, P>>
283
328
  /** The route (or routes) the client speaks — see `ClientApi`. */
284
329
  api: ClientApi
285
330
  /** Delay the next connection attempt in milliseconds, or pause with false. */
@@ -321,7 +366,9 @@ const DEFAULT_HISTORY_LIMIT = 50
321
366
  * discrimination today, the ws `kind` discriminant tomorrow. `ping`
322
367
  * carries no data — it exists so the watchdog sees liveness. */
323
368
  type TransportFrame =
369
+ | StateSnapshotFrame
324
370
  | { kind: 'event'; event: WireEvent }
371
+ | { kind: 'events'; events: Event[] }
325
372
  | { kind: 'presence'; patch: WirePresencePatch }
326
373
  | { kind: 'presence-snapshot'; snapshot: WirePresenceSnapshot }
327
374
  | { kind: 'activity'; idle: boolean }
@@ -351,7 +398,8 @@ type Transport = {
351
398
  */
352
399
  connect(opts: {
353
400
  sessionId: string
354
- startAfter: number
401
+ startAfter: number | undefined
402
+ reducer: string
355
403
  signal: AbortSignal
356
404
  }): AsyncIterable<TransportFrame>
357
405
  /** One attempt (the retry loop lives above the seam): resolves with
@@ -364,7 +412,7 @@ type Transport = {
364
412
  sessionId: string
365
413
  gte: number
366
414
  lte: number
367
- }): Promise<WireEvent[]>
415
+ }): Promise<{ events: WireEvent[]; clearIndex?: number }>
368
416
  /** Fire-and-forget; failures are dropped (presence repaints). */
369
417
  sendPresence(envelope: TransportPresenceEnvelope): void
370
418
  /**
@@ -409,11 +457,11 @@ const httpTransport = (
409
457
  // exactly like unknown named frames.
410
458
  presence: boolean,
411
459
  ): Transport => ({
412
- async *connect({ sessionId, startAfter, signal }) {
460
+ async *connect({ sessionId, startAfter, reducer: reducerName, signal }) {
413
461
  const streamUrl = sessionUrl(routes.stream, sessionId)
414
462
  const sep = streamUrl.includes('?') ? '&' : '?'
415
463
  const res = await fetchImpl(
416
- `${streamUrl}${sep}sessionId=${encodeURIComponent(sessionId)}&index=${startAfter}`,
464
+ `${streamUrl}${sep}${new URLSearchParams({ sessionId, reducer: reducerName, wire: '2', ...(startAfter === undefined ? { bootstrap: '1' } : { index: String(startAfter) }) })}`,
417
465
  { headers: { accept: 'text/event-stream' }, signal },
418
466
  )
419
467
  if (!res.ok) throw await httpError(res)
@@ -453,7 +501,30 @@ const httpTransport = (
453
501
  if (!data) continue
454
502
  if (eventName === undefined) {
455
503
  const parsed: unknown = JSON.parse(data)
456
- if (isWireEvent(parsed)) yield { kind: 'event', event: parsed }
504
+ // A version 2 server batches events; an older one sends one.
505
+ if (Array.isArray(parsed)) {
506
+ // Dropping a batch would leave a gap the frontier then skips past.
507
+ if (!parsed.every(isCompactWireEvent))
508
+ throw new A2ClientError(
509
+ 'INVALID_PAYLOAD',
510
+ 'invalid event batch',
511
+ )
512
+ yield {
513
+ kind: 'events',
514
+ events: parsed.map((row) =>
515
+ eventFromCompactWire(row, sessionId),
516
+ ),
517
+ }
518
+ } else if (isWireEvent(parsed))
519
+ yield { kind: 'event', event: parsed }
520
+ } else if (eventName === 'a2-snapshot') {
521
+ const parsed: unknown = JSON.parse(data)
522
+ if (!isStateSnapshotFrame(parsed))
523
+ throw new A2ClientError(
524
+ 'INVALID_PAYLOAD',
525
+ 'invalid bootstrap snapshot',
526
+ )
527
+ yield parsed
457
528
  } else if (presence && eventName === 'presence-snapshot') {
458
529
  const parsed: unknown = JSON.parse(data)
459
530
  if (isWirePresenceSnapshot(parsed)) {
@@ -515,7 +586,10 @@ const httpTransport = (
515
586
  'history response was not a list of events',
516
587
  )
517
588
  }
518
- return rows
589
+ const clearIndex = Number(res.headers.get('a2-clear-index') ?? Number.NaN)
590
+ return Number.isSafeInteger(clearIndex) && clearIndex > 0
591
+ ? { events: rows, clearIndex }
592
+ : { events: rows }
519
593
  },
520
594
 
521
595
  sendPresence(envelope) {
@@ -622,21 +696,32 @@ const sessionWsTransport = (
622
696
  const ready = socketReadyWaiters()
623
697
  let nextReq = 1
624
698
 
625
- const socketUrl = (sessionId: string, startAfter: number): string => {
699
+ const socketUrl = (
700
+ sessionId: string,
701
+ startAfter: number | undefined,
702
+ reducerName: string,
703
+ ): string => {
626
704
  const resolved = webSocketUrl(sessionUrl(url, sessionId))
627
705
  resolved.searchParams.set('sessionId', sessionId)
628
- resolved.searchParams.set('index', String(startAfter))
706
+ resolved.searchParams.delete('index')
707
+ resolved.searchParams.delete('bootstrap')
708
+ resolved.searchParams.set('reducer', reducerName)
709
+ resolved.searchParams.set('wire', '2')
710
+ if (startAfter === undefined) resolved.searchParams.set('bootstrap', '1')
711
+ else resolved.searchParams.set('index', String(startAfter))
629
712
  return resolved.href
630
713
  }
631
714
 
632
715
  return {
633
- async *connect({ sessionId, startAfter, signal }) {
716
+ async *connect({ sessionId, startAfter, reducer: reducerName, signal }) {
634
717
  if (webSocketImpl === undefined) {
635
718
  throw new TypeError(
636
719
  'no WebSocket implementation available — pass one via createClient({ webSocket }) where the global is missing',
637
720
  )
638
721
  }
639
- const socket = new webSocketImpl(socketUrl(sessionId, startAfter))
722
+ const socket = new webSocketImpl(
723
+ socketUrl(sessionId, startAfter, reducerName),
724
+ )
640
725
  const connection: SocketConnection = { socket, pending: new Map() }
641
726
  connections.set(sessionId, connection)
642
727
 
@@ -654,6 +739,7 @@ const sessionWsTransport = (
654
739
  failure = error
655
740
  if (connections.get(sessionId) === connection) {
656
741
  connections.delete(sessionId)
742
+ ready.notify(sessionId)
657
743
  }
658
744
  rejectPendingAcks(connection.pending)
659
745
  notify()
@@ -668,9 +754,18 @@ const sessionWsTransport = (
668
754
  socket.addEventListener('message', (event) => {
669
755
  if (connections.get(sessionId) !== connection || ended) return
670
756
  if (typeof event.data !== 'string') return
671
- const frame = parseSocketFrame(event.data)
757
+ let frame: SocketDownFrame | null
758
+ try {
759
+ frame = parseSocketFrame(event.data)
760
+ } catch (error) {
761
+ finish(asClientError(error))
762
+ return
763
+ }
672
764
  if (frame === null) return
673
765
  switch (frame.kind) {
766
+ case 'snapshot':
767
+ queue.push(frame)
768
+ break
674
769
  case 'ack': {
675
770
  const waiter = connection.pending.get(frame.req)
676
771
  if (waiter === undefined) return
@@ -696,6 +791,15 @@ const sessionWsTransport = (
696
791
  if (frame.event.sessionId !== sessionId) return
697
792
  queue.push({ kind: 'event', event: frame.event })
698
793
  break
794
+ case 'events':
795
+ if (frame.sessionId !== undefined) return
796
+ queue.push({
797
+ kind: 'events',
798
+ events: frame.events.map((row) =>
799
+ eventFromCompactWire(row, sessionId),
800
+ ),
801
+ })
802
+ break
699
803
  case 'presence':
700
804
  if (presence && frame.sessionId === undefined) {
701
805
  queue.push({ kind: 'presence', patch: frame.patch })
@@ -834,7 +938,12 @@ const sessionWsTransport = (
834
938
  * attempt, so `startAfter` is that attempt's resume frontier. */
835
939
  type WsChannel = {
836
940
  sessionId: string
837
- startAfter: number
941
+ /** Echoed on this lane's subscribe answers. An answer to an earlier
942
+ * attempt for the same session must not confirm or end this one;
943
+ * data frames from that attempt precede this attempt's confirmation. */
944
+ attempt: number
945
+ startAfter: number | undefined
946
+ reducer: string
838
947
  queue: TransportFrame[]
839
948
  /** The server confirmed the subscribe — pushes and presence ride. */
840
949
  live: boolean
@@ -850,9 +959,18 @@ const wakeChannel = (channel: WsChannel): void => {
850
959
  channel.wake = null
851
960
  }
852
961
 
853
- const subscribeFrame = (
854
- sessions: Array<{ id: string; index: number }>,
855
- ): string => {
962
+ const answersAttempt = (
963
+ frame: { attempt?: number },
964
+ channel: WsChannel,
965
+ ): boolean => frame.attempt === undefined || frame.attempt === channel.attempt
966
+
967
+ const subscriptionStart = (
968
+ index: number | undefined,
969
+ reducer: string,
970
+ ): SubscriptionStart =>
971
+ index === undefined ? { bootstrap: true, reducer } : { index, reducer }
972
+
973
+ const subscribeFrame = (sessions: SocketSubscribeFrame['sessions']): string => {
856
974
  const frame: SocketSubscribeFrame = { kind: 'subscribe', sessions }
857
975
  return JSON.stringify(frame)
858
976
  }
@@ -874,6 +992,7 @@ const multiplexedWsTransport = (
874
992
  let socketOpen = false
875
993
  let watchdog: ReturnType<typeof setTimeout> | undefined
876
994
  let nextReq = 1
995
+ let nextAttempt = 1
877
996
 
878
997
  const trySend = (data: string): void => {
879
998
  if (socket === null || !socketOpen) return
@@ -926,6 +1045,7 @@ const multiplexedWsTransport = (
926
1045
  const detach = (channel: WsChannel): void => {
927
1046
  if (channels.get(channel.sessionId) !== channel) return
928
1047
  channels.delete(channel.sessionId)
1048
+ ready.notify(channel.sessionId)
929
1049
  if (!channel.dropped) {
930
1050
  const frame: SocketUnsubscribeFrame = {
931
1051
  kind: 'unsubscribe',
@@ -964,6 +1084,11 @@ const multiplexedWsTransport = (
964
1084
 
965
1085
  const route = (frame: SocketDownFrame): void => {
966
1086
  switch (frame.kind) {
1087
+ case 'snapshot': {
1088
+ const channel = channels.get(frame.sessionId)
1089
+ if (channel && channel.live && !channel.ended) deliver(channel, frame)
1090
+ return
1091
+ }
967
1092
  case 'ping': {
968
1093
  // Global liveness — every live channel's stall watchdog feeds
969
1094
  // on it, the way each SSE stream feeds on its own heartbeat.
@@ -976,7 +1101,13 @@ const multiplexedWsTransport = (
976
1101
  }
977
1102
  case 'subscribed': {
978
1103
  const channel = channels.get(frame.sessionId)
979
- if (channel === undefined || channel.ended || channel.live) return
1104
+ if (
1105
+ channel === undefined ||
1106
+ channel.ended ||
1107
+ channel.live ||
1108
+ !answersAttempt(frame, channel)
1109
+ )
1110
+ return
980
1111
  channel.live = true
981
1112
  deliver(channel, { kind: 'ping' }) // the connected signal
982
1113
  ready.notify(frame.sessionId)
@@ -984,7 +1115,7 @@ const multiplexedWsTransport = (
984
1115
  }
985
1116
  case 'unsubscribed': {
986
1117
  const channel = channels.get(frame.sessionId)
987
- if (channel === undefined) return
1118
+ if (channel === undefined || !answersAttempt(frame, channel)) return
988
1119
  channel.dropped = true
989
1120
  settleChannel(
990
1121
  channel,
@@ -1013,6 +1144,20 @@ const multiplexedWsTransport = (
1013
1144
  }
1014
1145
  return
1015
1146
  }
1147
+ case 'events': {
1148
+ const { sessionId } = frame
1149
+ if (sessionId === undefined) return
1150
+ const channel = channels.get(sessionId)
1151
+ if (channel !== undefined && channel.live && !channel.ended) {
1152
+ deliver(channel, {
1153
+ kind: 'events',
1154
+ events: frame.events.map((row) =>
1155
+ eventFromCompactWire(row, sessionId),
1156
+ ),
1157
+ })
1158
+ }
1159
+ return
1160
+ }
1016
1161
  case 'presence': {
1017
1162
  // Contracts that declare no presence skip presence frames,
1018
1163
  // exactly like the http transport.
@@ -1044,7 +1189,9 @@ const multiplexedWsTransport = (
1044
1189
  'no WebSocket implementation available — pass one via createClient({ webSocket }) where the global is missing',
1045
1190
  )
1046
1191
  }
1047
- const created = new webSocketImpl(webSocketUrl(url).href)
1192
+ const target = webSocketUrl(url)
1193
+ target.searchParams.set('wire', '2')
1194
+ const created = new webSocketImpl(target.href)
1048
1195
  socket = created
1049
1196
  socketOpen = false
1050
1197
  armWatchdog(created)
@@ -1056,7 +1203,8 @@ const multiplexedWsTransport = (
1056
1203
  .filter((channel) => !channel.ended)
1057
1204
  .map((channel) => ({
1058
1205
  id: channel.sessionId,
1059
- index: channel.startAfter,
1206
+ attempt: channel.attempt,
1207
+ ...subscriptionStart(channel.startAfter, channel.reducer),
1060
1208
  }))
1061
1209
  if (sessions.length > 0) trySend(subscribeFrame(sessions))
1062
1210
  })
@@ -1064,7 +1212,13 @@ const multiplexedWsTransport = (
1064
1212
  if (socket !== created) return
1065
1213
  if (typeof event.data !== 'string') return
1066
1214
  armWatchdog(created)
1067
- const frame = parseSocketFrame(event.data)
1215
+ let frame: SocketDownFrame | null
1216
+ try {
1217
+ frame = parseSocketFrame(event.data)
1218
+ } catch (error) {
1219
+ failSocket(asClientError(error))
1220
+ return
1221
+ }
1068
1222
  if (frame === null) return
1069
1223
  route(frame)
1070
1224
  })
@@ -1087,10 +1241,12 @@ const multiplexedWsTransport = (
1087
1241
  }
1088
1242
 
1089
1243
  return {
1090
- async *connect({ sessionId, startAfter, signal }) {
1244
+ async *connect({ sessionId, startAfter, reducer: reducerName, signal }) {
1091
1245
  const channel: WsChannel = {
1092
1246
  sessionId,
1247
+ attempt: nextAttempt++,
1093
1248
  startAfter,
1249
+ reducer: reducerName,
1094
1250
  queue: [],
1095
1251
  live: false,
1096
1252
  dropped: false,
@@ -1102,7 +1258,15 @@ const multiplexedWsTransport = (
1102
1258
  try {
1103
1259
  ensureSocket()
1104
1260
  if (socketOpen) {
1105
- trySend(subscribeFrame([{ id: sessionId, index: startAfter }]))
1261
+ trySend(
1262
+ subscribeFrame([
1263
+ {
1264
+ id: sessionId,
1265
+ attempt: channel.attempt,
1266
+ ...subscriptionStart(startAfter, reducerName),
1267
+ },
1268
+ ]),
1269
+ )
1106
1270
  }
1107
1271
  } catch (error) {
1108
1272
  if (channels.get(sessionId) === channel) channels.delete(sessionId)
@@ -1261,20 +1425,40 @@ type Pending<D extends EventDefs> = {
1261
1425
  /** Set once the POST ack returns; the entry leaves the overlay when
1262
1426
  * the stream delivers its index. */
1263
1427
  acked?: ContractEvent<D>
1428
+ sent?: true
1429
+ /** Sent before the current baseline was adopted and not yet acked:
1430
+ * the baseline may already contain it, so only its ack index can
1431
+ * place it in the overlay. */
1432
+ heldUntilAck?: true
1433
+ }
1434
+
1435
+ type ResolvedSessionOptions<D extends EventDefs, S> = Omit<
1436
+ SessionOptions<D, S>,
1437
+ 'initial'
1438
+ > & {
1439
+ initial?: StateSnapshot<S> | null | undefined
1440
+ }
1441
+
1442
+ type PendingInitial<D extends EventDefs, S> = {
1443
+ source: Promise<StateSnapshot<S> | null | undefined>
1444
+ version: number
1445
+ settings: Omit<SessionOptions<D, S>, 'initial'>
1446
+ cancel(): void
1447
+ error?: A2ClientError
1264
1448
  }
1265
1449
 
1266
1450
  type SessionRuntime<
1267
1451
  D extends EventDefs,
1268
1452
  S,
1269
1453
  P extends PresenceDefs,
1270
- V,
1271
- > = SessionClient<D, S, P, V> & {
1454
+ > = SessionClient<D, S, P> & {
1272
1455
  hydrate(options?: SessionOptions<D, S>): void
1456
+ resolveInitial(options: { source: object; value: unknown }): void
1273
1457
  canEvict(): boolean
1274
1458
  }
1275
1459
 
1276
- type CachedSession<D extends EventDefs, S, P extends PresenceDefs, V> = {
1277
- runtime: SessionRuntime<D, S, P, V>
1460
+ type CachedSession<D extends EventDefs, S, P extends PresenceDefs> = {
1461
+ runtime: SessionRuntime<D, S, P>
1278
1462
  timer?: ReturnType<typeof setTimeout>
1279
1463
  }
1280
1464
 
@@ -1282,10 +1466,14 @@ export function createClient<
1282
1466
  D extends EventDefs,
1283
1467
  S,
1284
1468
  P extends PresenceDefs = Record<never, never>,
1285
- V = S,
1286
- >(options: CreateClientOptions<D, S, P, V>): A2Client<D, S, P, V> {
1287
- const { reducer, api } = options
1288
- const presenceDefs: Readonly<PresenceDefs> = reducer.presence
1469
+ >(options: CreateClientOptions<D, S, P>): A2Client<D, S, P> {
1470
+ const { contract, api } = options
1471
+ const reducer = contract?.reducer
1472
+ if (!reducer)
1473
+ throw new TypeError(
1474
+ 'createClient requires a contract with a public reducer',
1475
+ )
1476
+ const presenceDefs: Readonly<PresenceDefs> = contract.presence
1289
1477
  const declaresPresence = Object.keys(presenceDefs).length > 0
1290
1478
  const fetchImpl = options.fetch ?? globalThis.fetch.bind(globalThis)
1291
1479
  const clientFetch: typeof globalThis.fetch = async (input, init) => {
@@ -1308,7 +1496,7 @@ export function createClient<
1308
1496
  if (Number.isNaN(gcTime) || gcTime < 0) {
1309
1497
  throw new RangeError('gcTime must be a non-negative number or Infinity')
1310
1498
  }
1311
- const sessions = new Map<string, CachedSession<D, S, P, V>>()
1499
+ const sessions = new Map<string, CachedSession<D, S, P>>()
1312
1500
 
1313
1501
  const scheduleGc = (sessionId: string, delay = gcTime): void => {
1314
1502
  const entry = sessions.get(sessionId)
@@ -1319,6 +1507,8 @@ export function createClient<
1319
1507
  const current = sessions.get(sessionId)
1320
1508
  if (current !== entry) return
1321
1509
  if (entry.runtime.canEvict()) {
1510
+ entry.runtime.close()
1511
+ clearTimeout(entry.timer)
1322
1512
  sessions.delete(sessionId)
1323
1513
  return
1324
1514
  }
@@ -1336,21 +1526,26 @@ export function createClient<
1336
1526
  const makeSession = (
1337
1527
  sessionId: string,
1338
1528
  sessionOptions?: SessionOptions<D, S>,
1339
- ): SessionRuntime<D, S, P, V> => {
1529
+ ): SessionRuntime<D, S, P> => {
1530
+ assertSessionSeed(sessionOptions)
1340
1531
  // ── server truth ─────────────────────────────────────────────
1341
- let stateIndex = sessionOptions?.initialIndex ?? 0
1342
- let streamIndex =
1343
- sessionOptions?.initialState === undefined
1344
- ? sessionOptions?.initialIndex
1345
- : stateIndex
1346
- let foldedState: S =
1347
- sessionOptions?.initialState !== undefined
1348
- ? sessionOptions.initialState
1349
- : reducer.initialState
1532
+ const supplied = sessionOptions?.initial
1533
+ const initial = isInitialPromise(supplied) ? undefined : supplied
1534
+ let hydrated = initial != null
1535
+ let bootstrapGeneration: number | undefined
1536
+ let stateIndex = initial?.index ?? 0
1537
+ let streamIndex = initial?.index
1538
+ let foldedState: S = initial == null ? reducer.initialState : initial.state
1539
+ /** The latest clear marker this session has learned of, or 0. */
1540
+ let clearIndex = initial?.clearIndex ?? 0
1541
+ let initialVersion = 0
1542
+ const seenInitials = new WeakSet<object>()
1543
+ if (initial != null) seenInitials.add(initial)
1544
+ let preload: PendingInitial<D, S> | undefined
1350
1545
  const serverEvents: ContractEvent<D>[] = (
1351
1546
  sessionOptions?.initialEvents ?? []
1352
1547
  )
1353
- .filter((event) => event.index <= stateIndex)
1548
+ .filter((event) => event.index <= stateIndex && event.index > clearIndex)
1354
1549
  .toSorted((a, b) => a.index - b.index)
1355
1550
 
1356
1551
  // ── optimistic overlay ───────────────────────────────────────
@@ -1378,13 +1573,15 @@ export function createClient<
1378
1573
 
1379
1574
  // ── store plumbing ───────────────────────────────────────────
1380
1575
  const listeners = new Set<() => void>()
1381
- let snapshot: SessionSnapshot<D, V, P> | null = null
1576
+ let snapshot: SessionSnapshot<D, S, P> | null = null
1382
1577
  let status: ConnectionStatus = 'idle'
1383
1578
  let reconnects = 0
1384
1579
  let lastError: A2ClientError | null = null
1385
1580
  let inFlightPushes = 0
1386
1581
  let pushWireTail: Promise<void> = Promise.resolve()
1387
- let deferredHydration: SessionOptions<D, S> | undefined
1582
+ let deferredHydration:
1583
+ { settings: ResolvedSessionOptions<D, S>; version?: number } | undefined
1584
+ let resumeInitialError: A2ClientError | undefined
1388
1585
  let hydrationNotification: ReturnType<typeof setTimeout> | undefined
1389
1586
 
1390
1587
  const emit = (): void => {
@@ -1398,14 +1595,21 @@ export function createClient<
1398
1595
  emit()
1399
1596
  }
1400
1597
 
1401
- const notifyHydrated = (): void => {
1598
+ const notifyHydrated = (resume?: A2ClientError): void => {
1599
+ resumeInitialError ??= resume
1402
1600
  // `session(id, serverFold)` is also called while a destination
1403
1601
  // provider renders. Its own snapshot must advance synchronously,
1404
1602
  // but notifying an outgoing provider during that render would be a
1405
1603
  // React setState-in-render violation.
1406
1604
  snapshot = null
1407
1605
  clearTimeout(hydrationNotification)
1408
- hydrationNotification = setTimeout(emit, 0)
1606
+ hydrationNotification = setTimeout(() => {
1607
+ const failure = resumeInitialError
1608
+ resumeInitialError = undefined
1609
+ if (failure && active && status === 'paused' && lastError === failure)
1610
+ reconnectStream()
1611
+ else emit()
1612
+ }, 0)
1409
1613
  ;(hydrationNotification as { unref?: () => void }).unref?.()
1410
1614
  }
1411
1615
 
@@ -1417,7 +1621,8 @@ export function createClient<
1417
1621
  const byIndex = new Map(serverEvents.map((event) => [event.index, event]))
1418
1622
  let changed = false
1419
1623
  for (const event of events) {
1420
- if (event.index > throughIndex) continue
1624
+ // Also drops rows from responses that began before a newer clear.
1625
+ if (event.index > throughIndex || event.index <= clearIndex) continue
1421
1626
  const existing = byIndex.get(event.index)
1422
1627
  if (existing?.id === event.id) continue
1423
1628
  byIndex.set(event.index, event)
@@ -1432,6 +1637,22 @@ export function createClient<
1432
1637
  return true
1433
1638
  }
1434
1639
 
1640
+ /**
1641
+ * Raise the latest known clear marker (a2-implementation.md §17.4):
1642
+ * drop the served history it ended and every push acked below it.
1643
+ * The boundary never moves backward.
1644
+ */
1645
+ const applyClearBoundary = (index: number): boolean => {
1646
+ if (index <= clearIndex) return false
1647
+ clearIndex = index
1648
+ const kept = serverEvents.filter((event) => event.index > index)
1649
+ serverEvents.splice(0, serverEvents.length, ...kept)
1650
+ pending = pending.filter(
1651
+ (entry) => entry.acked === undefined || entry.acked.index > index,
1652
+ )
1653
+ return true
1654
+ }
1655
+
1435
1656
  const overlayEvents = (): ContractEvent<D>[] => {
1436
1657
  // Acked entries first (they have a server-assigned order), then
1437
1658
  // unacked in push order with provisional indexes.
@@ -1440,19 +1661,20 @@ export function createClient<
1440
1661
  .map((p) => p.acked!)
1441
1662
  .toSorted((a, b) => a.index - b.index)
1442
1663
  const maxKnown = Math.max(stateIndex, acked.at(-1)?.index ?? stateIndex)
1443
- const unacked = pending
1444
- .filter((p) => !p.acked)
1445
- .map(
1446
- (p, i) =>
1447
- ({
1448
- id: p.id,
1449
- type: p.type,
1450
- payload: p.payload,
1451
- index: maxKnown + 1 + i, // provisional
1452
- sessionId,
1453
- createdAt: p.createdAt,
1454
- }) as ContractEvent<D>,
1455
- )
1664
+ const waiting = pending.filter((p) => !p.acked)
1665
+ const held = waiting.findIndex((p) => p.heldUntilAck)
1666
+ // Later pushes may depend on a held one, so they stay hidden with it.
1667
+ const unacked = (held === -1 ? waiting : waiting.slice(0, held)).map(
1668
+ (p, i) =>
1669
+ ({
1670
+ id: p.id,
1671
+ type: p.type,
1672
+ payload: p.payload,
1673
+ index: maxKnown + 1 + i, // provisional
1674
+ sessionId,
1675
+ createdAt: p.createdAt,
1676
+ }) as ContractEvent<D>,
1677
+ )
1456
1678
  return [...acked, ...unacked]
1457
1679
  }
1458
1680
 
@@ -1474,17 +1696,20 @@ export function createClient<
1474
1696
  const oldestLoaded = serverEvents[0]?.index ?? null
1475
1697
  return {
1476
1698
  loading: historyLoads > 0,
1477
- // Index 1 loaded — or a state index of 0, an empty log as far as
1478
- // this client knows — means nothing older is left to fetch.
1479
- complete: oldestLoaded === 1 || stateIndex === 0,
1699
+ // The first event of the lifetime loaded — or a state index at
1700
+ // its start, an empty lifetime as far as this client knows —
1701
+ // means nothing older is left to fetch.
1702
+ complete:
1703
+ hydrated &&
1704
+ (oldestLoaded === clearIndex + 1 || stateIndex === clearIndex),
1480
1705
  oldestLoaded,
1481
1706
  }
1482
1707
  }
1483
1708
 
1484
- const buildSnapshot = (): SessionSnapshot<D, V, P> => {
1709
+ const buildSnapshot = (): SessionSnapshot<D, S, P> => {
1485
1710
  const overlay = overlayEvents()
1486
1711
  let state = foldedState
1487
- for (const event of overlay) {
1712
+ for (const event of hydrated ? overlay : []) {
1488
1713
  if (event.index > stateIndex) state = reducer.fold(state, event)
1489
1714
  }
1490
1715
  const displayedOverlay = overlay.filter((event) => {
@@ -1501,8 +1726,8 @@ export function createClient<
1501
1726
  return true
1502
1727
  })
1503
1728
  return {
1504
- state:
1505
- reducer[clientStateProjection]?.(state) ?? (state as unknown as V),
1729
+ state: projectClientState({ reducer, state }),
1730
+ hydrated,
1506
1731
  events: [...serverEvents, ...displayedOverlay],
1507
1732
  index: stateIndex,
1508
1733
  history: historyState(),
@@ -1510,7 +1735,7 @@ export function createClient<
1510
1735
  ...(declaresPresence
1511
1736
  ? { presence: presenceState as PresenceMap<P> }
1512
1737
  : {}),
1513
- } as SessionSnapshot<D, V, P>
1738
+ } as SessionSnapshot<D, S, P>
1514
1739
  }
1515
1740
 
1516
1741
  // ── presence ─────────────────────────────────────────────────
@@ -1696,28 +1921,93 @@ export function createClient<
1696
1921
  touch(sessionId)
1697
1922
  }
1698
1923
 
1699
- /** Pushes awaiting stream confirmation — resolved by `ingest` the
1700
- * moment the state index passes their batch. */
1701
- let confirmWatchers: Array<{ index: number; resolve: () => void }> = []
1924
+ /** Pushes awaiting stream confirmation — resolved the moment the
1925
+ * stream, by event or bootstrap snapshot, passes their batch. */
1926
+ let confirmWatchers: Array<{
1927
+ index: number
1928
+ resolve: () => void
1929
+ lease?: true
1930
+ }> = []
1931
+
1932
+ const resolveConfirmed = (): void => {
1933
+ const delivered = streamIndex ?? 0
1934
+ const due = confirmWatchers.filter(
1935
+ (watcher) => watcher.index <= delivered,
1936
+ )
1937
+ if (due.length === 0) return
1938
+ confirmWatchers = confirmWatchers.filter(
1939
+ (watcher) => watcher.index > delivered,
1940
+ )
1941
+ for (const watcher of due) watcher.resolve()
1942
+ }
1943
+
1944
+ // State hydration does not subsume delivery: unobserved ACKs still
1945
+ // keep an idle stream catching up even when their state is covered.
1946
+ const retireCoveredPending = (): void => {
1947
+ pending = pending.filter(
1948
+ (entry) =>
1949
+ !entry.acked ||
1950
+ entry.acked.index > Math.min(stateIndex, streamIndex ?? stateIndex),
1951
+ )
1952
+ }
1953
+
1954
+ const adoptBaseline = (baseline: StateSnapshot<S>): void => {
1955
+ hydrated = true
1956
+ stateIndex = baseline.index
1957
+ foldedState = baseline.state
1958
+ for (const entry of pending)
1959
+ if (entry.sent && !entry.acked) entry.heldUntilAck = true
1960
+ retireCoveredPending()
1961
+ }
1962
+
1963
+ const bootstrapState = (frame: StateSnapshotFrame): S => {
1964
+ let state: S
1965
+ try {
1966
+ state = decodeState({
1967
+ reducer,
1968
+ state: frame.stateUndefined ? undefined : frame.state,
1969
+ }) as S
1970
+ } catch (error) {
1971
+ throw asClientError(error, { code: 'INVALID_PAYLOAD' })
1972
+ }
1973
+ if (!reducer.stateSchema) return state
1974
+ const result = validateSync(
1975
+ reducer.stateSchema,
1976
+ state,
1977
+ 'bootstrap stateSchema',
1978
+ )
1979
+ if (result.issues)
1980
+ throw new A2ClientError(
1981
+ 'INVALID_PAYLOAD',
1982
+ 'bootstrap state failed stateSchema',
1983
+ { details: result.issues },
1984
+ )
1985
+ return result.value
1986
+ }
1702
1987
 
1703
1988
  /** The single ingest point: every server-confirmed event, in log
1704
1989
  * order, from the stream. */
1705
- const ingest = (event: ContractEvent<D>): void => {
1706
- if (event.index <= (streamIndex ?? 0)) return
1707
- streamIndex = event.index
1708
- if (event.index > stateIndex) {
1709
- stateIndex = event.index
1710
- serverEvents.push(event)
1711
- foldedState = reducer.fold(foldedState, event)
1712
- } else {
1713
- mergeServerEvents([event], stateIndex)
1714
- }
1715
- pending = pending.filter((p) => p.id !== event.id)
1716
- if (confirmWatchers.some((w) => w.index <= stateIndex)) {
1717
- const due = confirmWatchers.filter((w) => w.index <= stateIndex)
1718
- confirmWatchers = confirmWatchers.filter((w) => w.index > stateIndex)
1719
- for (const watcher of due) watcher.resolve()
1990
+ // A batch is folded whole before listeners hear about it, once.
1991
+ const ingest = (events: readonly ContractEvent<D>[]): void => {
1992
+ let delivered = false
1993
+ for (const event of events) {
1994
+ if (event.index <= (streamIndex ?? 0)) continue
1995
+ delivered = true
1996
+ streamIndex = event.index
1997
+ const cleared = isSessionCleared(event)
1998
+ // A marker at or below a newer hydrated fold is already reflected in it.
1999
+ if (event.index > stateIndex) {
2000
+ stateIndex = event.index
2001
+ if (!cleared) serverEvents.push(event)
2002
+ foldedState = foldEvent(reducer, foldedState, event)
2003
+ } else if (!cleared) {
2004
+ mergeServerEvents([event], stateIndex)
2005
+ }
2006
+ if (cleared) applyClearBoundary(event.index)
2007
+ pending = pending.filter((p) => p.id !== event.id)
1720
2008
  }
2009
+ if (!delivered) return
2010
+ resolveConfirmed()
1721
2011
  notify()
1722
2012
  touch(sessionId)
1723
2013
  }
@@ -1727,8 +2017,8 @@ export function createClient<
1727
2017
  events: AppendInput<D>[],
1728
2018
  ): Array<{ id: string; type: string; payload: unknown }> =>
1729
2019
  events.map((event) => {
1730
- const schema = Object.hasOwn(reducer.events, event.type)
1731
- ? reducer.events[event.type]
2020
+ const schema = Object.hasOwn(contract.events, event.type)
2021
+ ? contract.events[event.type]
1732
2022
  : undefined
1733
2023
  if (!schema) {
1734
2024
  throw new A2ClientError(
@@ -1804,7 +2094,7 @@ export function createClient<
1804
2094
  (acked) =>
1805
2095
  new Promise<ContractEvent<D>[]>((resolve) => {
1806
2096
  const last = acked.at(-1)?.index ?? 0
1807
- if (stateIndex >= last) {
2097
+ if ((streamIndex ?? 0) >= last) {
1808
2098
  resolve(acked)
1809
2099
  return
1810
2100
  }
@@ -1819,6 +2109,26 @@ export function createClient<
1819
2109
  })
1820
2110
  }
1821
2111
 
2112
+ // A cold socket opens only after a pending initial Promise settles.
2113
+ const initialSettled = (epoch: number): Promise<void> =>
2114
+ new Promise((resolve, reject) => {
2115
+ const check = (): void => {
2116
+ const failure =
2117
+ leaseEpoch !== epoch || status === 'closed'
2118
+ ? new DOMException('session closed', 'AbortError')
2119
+ : !hydrated && preload?.error
2120
+ if (failure) {
2121
+ listeners.delete(check)
2122
+ reject(failure)
2123
+ } else if (hydrated || !preload) {
2124
+ listeners.delete(check)
2125
+ resolve()
2126
+ }
2127
+ }
2128
+ listeners.add(check)
2129
+ check()
2130
+ })
2131
+
1822
2132
  const push = (...events: AppendInput<D>[]): PushResult<D> => {
1823
2133
  touch(sessionId)
1824
2134
  return withConfirmed(
@@ -1841,11 +2151,27 @@ export function createClient<
1841
2151
  })
1842
2152
  pending.push(...entries)
1843
2153
  inFlightPushes += 1
2154
+ let acknowledgedIndex: number | undefined
2155
+ const pushEpoch = leaseEpoch
2156
+ const releaseConnection =
2157
+ !hydrated || transport.awaitReady || active
2158
+ ? runtime.connect()
2159
+ : undefined
1844
2160
 
1845
2161
  try {
1846
2162
  refreshIdleStream()
1847
2163
  notify() // optimistic — the view updates before the network moves
1848
2164
  await waitForPriorPush
2165
+ if (transport.awaitReady) {
2166
+ await initialSettled(pushEpoch)
2167
+ await transport.awaitReady(
2168
+ sessionId,
2169
+ STREAM_TIMINGS.stallTimeoutMs,
2170
+ )
2171
+ }
2172
+ if (leaseEpoch !== pushEpoch)
2173
+ throw new DOMException('session closed', 'AbortError')
2174
+ for (const entry of entries) entry.sent = true
1849
2175
  const acked = await post({
1850
2176
  sessionId,
1851
2177
  events: entries.map(({ id, type, payload }) => ({
@@ -1854,16 +2180,12 @@ export function createClient<
1854
2180
  payload,
1855
2181
  })),
1856
2182
  })
2183
+ acknowledgedIndex = acked.at(-1)?.index
1857
2184
  for (const event of acked) {
1858
2185
  const entry = pending.find((p) => p.id === event.id)
1859
2186
  if (entry) entry.acked = event // stream retires it at its index
1860
2187
  }
1861
- pending = pending.filter(
1862
- (entry) =>
1863
- entry.acked === undefined ||
1864
- entry.acked.index >
1865
- Math.min(stateIndex, streamIndex ?? stateIndex),
1866
- )
2188
+ retireCoveredPending()
1867
2189
  if ((acked.at(-1)?.index ?? 0) > (streamIndex ?? stateIndex))
1868
2190
  refreshIdleStream()
1869
2191
  notify()
@@ -1875,12 +2197,25 @@ export function createClient<
1875
2197
  notify()
1876
2198
  throw err
1877
2199
  } finally {
2200
+ if (
2201
+ releaseConnection &&
2202
+ pushEpoch === leaseEpoch &&
2203
+ acknowledgedIndex !== undefined &&
2204
+ acknowledgedIndex > (streamIndex ?? 0)
2205
+ ) {
2206
+ confirmWatchers.push({
2207
+ index: acknowledgedIndex,
2208
+ resolve: releaseConnection,
2209
+ lease: true,
2210
+ })
2211
+ } else releaseConnection?.()
1878
2212
  releasePush()
1879
2213
  inFlightPushes -= 1
1880
2214
  if (inFlightPushes === 0 && deferredHydration) {
1881
2215
  const next = deferredHydration
1882
2216
  deferredHydration = undefined
1883
- runtime.hydrate(next)
2217
+ if (next.version === undefined || next.version === initialVersion)
2218
+ if (applyInitial(next)) notifyHydrated()
1884
2219
  }
1885
2220
  touch(sessionId)
1886
2221
  }
@@ -1944,16 +2279,20 @@ export function createClient<
1944
2279
  // everything above it, and the optimistic overlay must never
1945
2280
  // collide with a backscrolled row.
1946
2281
  const lte = Math.min(upper - 1, stateIndex)
1947
- const gte = Math.max(1, upper - (limit ?? DEFAULT_HISTORY_LIMIT))
2282
+ const gte = Math.max(
2283
+ clearIndex + 1,
2284
+ upper - (limit ?? DEFAULT_HISTORY_LIMIT),
2285
+ )
1948
2286
  if (gte > lte) return []
1949
2287
  const missing = missingBetween(gte, lte)
1950
2288
  if (missing === null) return eventsBetween(gte, lte)
1951
2289
  historyLoads += 1
1952
2290
  notify()
1953
2291
  try {
1954
- const rows = await readHistory({ sessionId, ...missing })
2292
+ const page = await readHistory({ sessionId, ...missing })
2293
+ if (page.clearIndex !== undefined) applyClearBoundary(page.clearIndex)
1955
2294
  mergeServerEvents(
1956
- rows.map((row) => eventFromWire(row) as ContractEvent<D>),
2295
+ page.events.map((row) => eventFromWire(row) as ContractEvent<D>),
1957
2296
  stateIndex,
1958
2297
  )
1959
2298
  return eventsBetween(gte, lte)
@@ -2031,9 +2370,26 @@ export function createClient<
2031
2370
  }
2032
2371
  let wasLive = false
2033
2372
  try {
2373
+ // oxlint-disable-next-line no-unmodified-loop-condition -- hydration changes while this connection waits
2374
+ while (!hydrated && preload && !preload.error)
2375
+ await waitForInitial(controller.signal)
2376
+ if (generation !== run || controller.signal.aborted) break
2377
+ if (!hydrated && preload?.error) {
2378
+ lastError = preload.error
2379
+ status = 'paused'
2380
+ notify()
2381
+ return
2382
+ }
2383
+ if (hydrated && streamIndex === undefined) streamIndex = stateIndex
2384
+ // Acks at or before this start are covered by the baseline and
2385
+ // are never streamed.
2386
+ resolveConfirmed()
2387
+ if (generation !== run) break
2388
+ bootstrapGeneration = streamIndex === undefined ? run : undefined
2034
2389
  const frames = transport.connect({
2035
2390
  sessionId,
2036
- startAfter: (streamIndex ??= stateIndex),
2391
+ startAfter: streamIndex,
2392
+ reducer: reducer.name,
2037
2393
  signal: controller.signal,
2038
2394
  })
2039
2395
  for await (const frame of frames) {
@@ -2041,6 +2397,41 @@ export function createClient<
2041
2397
  // Every frame counts — heartbeats included. Liveness is
2042
2398
  // about the connection, not about events.
2043
2399
  armStall()
2400
+ if (frame.kind === 'snapshot') {
2401
+ if (
2402
+ frame.sessionId !== sessionId ||
2403
+ frame.name !== reducer.name ||
2404
+ !isStateSnapshotFrame(frame)
2405
+ )
2406
+ throw new A2ClientError(
2407
+ 'INVALID_PAYLOAD',
2408
+ 'bootstrap snapshot belongs to another session or reducer',
2409
+ )
2410
+ if (bootstrapGeneration !== run)
2411
+ throw new A2ClientError(
2412
+ 'INVALID_PAYLOAD',
2413
+ 'unexpected bootstrap snapshot',
2414
+ )
2415
+ const baseline =
2416
+ !hydrated || frame.index >= stateIndex
2417
+ ? { state: bootstrapState(frame), index: frame.index }
2418
+ : undefined
2419
+ streamIndex = frame.index
2420
+ bootstrapGeneration = undefined
2421
+ applyClearBoundary(frame.clearIndex ?? 0)
2422
+ if (baseline) adoptBaseline(baseline)
2423
+ resolveConfirmed()
2424
+ notify()
2425
+ if (generation !== run) break
2426
+ }
2427
+ if (!hydrated || streamIndex === undefined) {
2428
+ if (frame.kind === 'event' || frame.kind === 'events')
2429
+ throw new A2ClientError(
2430
+ 'INVALID_PAYLOAD',
2431
+ 'received an event before the bootstrap snapshot',
2432
+ )
2433
+ continue
2434
+ }
2044
2435
  if (!wasLive) {
2045
2436
  // The first frame is the connection signal: the transport
2046
2437
  // yields it the moment the stream is established.
@@ -2059,7 +2450,10 @@ export function createClient<
2059
2450
  }
2060
2451
  if (frame.kind === 'event') {
2061
2452
  streamIdle = false
2062
- ingest(eventFromWire(frame.event) as ContractEvent<D>)
2453
+ ingest([eventFromWire(frame.event) as ContractEvent<D>])
2454
+ } else if (frame.kind === 'events') {
2455
+ streamIdle = false
2456
+ ingest(frame.events as ContractEvent<D>[])
2063
2457
  } else if (frame.kind === 'activity') {
2064
2458
  streamIdle = frame.idle
2065
2459
  if (
@@ -2091,11 +2485,15 @@ export function createClient<
2091
2485
  ? new A2ClientError(
2092
2486
  'TIMEOUT',
2093
2487
  `stream stalled: no data for ${STREAM_TIMINGS.stallTimeoutMs}ms`,
2094
- { phase: 'stream', timeoutMs: STREAM_TIMINGS.stallTimeoutMs },
2488
+ {
2489
+ phase: 'stream',
2490
+ timeoutMs: STREAM_TIMINGS.stallTimeoutMs,
2491
+ },
2095
2492
  )
2096
2493
  : asClientError(err)
2097
2494
  }
2098
2495
  } finally {
2496
+ if (bootstrapGeneration === run) bootstrapGeneration = undefined
2099
2497
  clearTimeout(stall)
2100
2498
  }
2101
2499
  if (generation !== run) break
@@ -2173,7 +2571,114 @@ export function createClient<
2173
2571
  previous?.abort()
2174
2572
  }
2175
2573
 
2176
- const runtime: SessionRuntime<D, S, P, V> = {
2574
+ const applyInitial = ({
2575
+ settings: next,
2576
+ version,
2577
+ }: {
2578
+ settings: ResolvedSessionOptions<D, S>
2579
+ version?: number
2580
+ }): boolean => {
2581
+ const nextIndex = next.initial?.index ?? 0
2582
+ if (hydrated && nextIndex > stateIndex && inFlightPushes > 0) {
2583
+ if (
2584
+ !deferredHydration ||
2585
+ nextIndex > (deferredHydration.settings.initial?.index ?? 0)
2586
+ ) {
2587
+ deferredHydration = {
2588
+ settings: next,
2589
+ ...(version === undefined ? {} : { version }),
2590
+ }
2591
+ }
2592
+ return false
2593
+ }
2594
+
2595
+ // The boundary applies even to an older fold: it only removes history.
2596
+ const boundaryChanged = applyClearBoundary(next.initial?.clearIndex ?? 0)
2597
+ const historyChanged =
2598
+ mergeServerEvents(
2599
+ next.initialEvents,
2600
+ Math.min(nextIndex, stateIndex),
2601
+ ) || boundaryChanged
2602
+ if (next.initial != null && bootstrapGeneration === undefined)
2603
+ streamIndex ??= nextIndex
2604
+ if (next.initial == null || (hydrated && nextIndex <= stateIndex)) {
2605
+ return historyChanged
2606
+ }
2607
+
2608
+ adoptBaseline(next.initial)
2609
+ mergeServerEvents(next.initialEvents, stateIndex)
2610
+ return true
2611
+ }
2612
+
2613
+ const invalidateInitial = (): void => {
2614
+ initialVersion++
2615
+ preload?.cancel()
2616
+ preload = undefined
2617
+ if (deferredHydration?.version !== undefined)
2618
+ deferredHydration = undefined
2619
+ }
2620
+
2621
+ const settleInitial = (
2622
+ result:
2623
+ { source: object; value: unknown } | { source: object; error: unknown },
2624
+ ): void => {
2625
+ const entry = preload
2626
+ if (
2627
+ !entry ||
2628
+ entry.source !== result.source ||
2629
+ entry.version !== initialVersion
2630
+ )
2631
+ return
2632
+ entry.cancel()
2633
+ const cold = !hydrated
2634
+ let changed = false
2635
+ if ('error' in result) {
2636
+ if (cold) entry.error = asClientError(result.error)
2637
+ else preload = undefined
2638
+ } else {
2639
+ try {
2640
+ assertSessionSeed({ initial: result.value })
2641
+ if (isInitialPromise(result.value as InitialSnapshot<unknown>))
2642
+ throw new TypeError('an initial Promise must resolve to a snapshot')
2643
+ preload = undefined
2644
+ changed = applyInitial({
2645
+ settings: {
2646
+ ...entry.settings,
2647
+ initial: result.value as StateSnapshot<S> | null | undefined,
2648
+ },
2649
+ version: entry.version,
2650
+ })
2651
+ } catch (error) {
2652
+ if (cold) {
2653
+ preload = entry
2654
+ entry.error = asClientError(error, { code: 'INVALID_PAYLOAD' })
2655
+ } else preload = undefined
2656
+ }
2657
+ }
2658
+ if (cold || changed) notifyHydrated()
2659
+ }
2660
+
2661
+ const waitForInitial = (signal: AbortSignal): Promise<void> =>
2662
+ new Promise((resolve, reject) => {
2663
+ let settled = false
2664
+ const finish = (): void => {
2665
+ if (
2666
+ settled ||
2667
+ (!signal.aborted && !hydrated && preload && !preload.error)
2668
+ )
2669
+ return
2670
+ settled = true
2671
+ listeners.delete(finish)
2672
+ signal.removeEventListener('abort', finish)
2673
+ if (signal.aborted) reject(signal.reason)
2674
+ else resolve()
2675
+ }
2676
+ listeners.add(finish)
2677
+ signal.addEventListener('abort', finish, { once: true })
2678
+ finish()
2679
+ })
2680
+
2681
+ const runtime: SessionRuntime<D, S, P> = {
2177
2682
  sessionId,
2178
2683
  // Only on presence-declaring contracts — the type surface
2179
2684
  // promises no runtime-inert members (a2-api.md §12).
@@ -2217,55 +2722,77 @@ export function createClient<
2217
2722
  }
2218
2723
  },
2219
2724
  close() {
2725
+ invalidateInitial()
2726
+ resumeInitialError = undefined
2220
2727
  leases = 0
2221
2728
  leaseEpoch += 1
2729
+ confirmWatchers = confirmWatchers.filter((watcher) => !watcher.lease)
2222
2730
  stopStream()
2223
2731
  },
2732
+ resolveInitial: settleInitial,
2224
2733
  hydrate(next) {
2734
+ assertSessionSeed(next)
2225
2735
  touch(sessionId)
2226
2736
  if (!next) return
2227
2737
  participant ??= next.participant
2228
- const nextIndex = next.initialIndex ?? 0
2229
- if (nextIndex > stateIndex && inFlightPushes > 0) {
2230
- if (
2231
- !deferredHydration ||
2232
- nextIndex > (deferredHydration.initialIndex ?? 0)
2233
- ) {
2234
- deferredHydration = next
2738
+ const input = next.initial
2739
+ const resume =
2740
+ !hydrated && status === 'paused' && preload?.error === lastError
2741
+ ? preload.error
2742
+ : undefined
2743
+ if (isInitialPromise(input)) {
2744
+ if (seenInitials.has(input)) {
2745
+ if (preload?.source === input && next.initialEvents !== undefined)
2746
+ preload.settings.initialEvents = next.initialEvents
2747
+ if (mergeServerEvents(next.initialEvents, stateIndex))
2748
+ notifyHydrated()
2749
+ return
2235
2750
  }
2751
+ invalidateInitial()
2752
+ seenInitials.add(input)
2753
+ const { initial: _initial, ...settings } = next
2754
+ const entry: PendingInitial<D, S> = {
2755
+ source: input,
2756
+ version: initialVersion,
2757
+ settings,
2758
+ cancel() {},
2759
+ }
2760
+ preload = entry
2761
+ entry.cancel = observeInitial({
2762
+ promise: input,
2763
+ settled: (result) => settleInitial({ source: input, ...result }),
2764
+ })
2765
+ if (resume) notifyHydrated(resume)
2236
2766
  return
2237
2767
  }
2238
-
2239
- const historyChanged = mergeServerEvents(
2240
- next.initialEvents,
2241
- Math.min(nextIndex, stateIndex),
2242
- )
2243
- if (next.initialState !== undefined) streamIndex ??= nextIndex
2244
- if (next.initialState === undefined || nextIndex <= stateIndex) {
2245
- if (historyChanged) notifyHydrated()
2246
- return
2768
+ if (input != null) {
2769
+ const repeated = seenInitials.has(input)
2770
+ seenInitials.add(input)
2771
+ if (repeated || (hydrated && input.index <= stateIndex)) {
2772
+ if (
2773
+ deferredHydration?.settings.initial === input &&
2774
+ next.initialEvents !== undefined
2775
+ )
2776
+ deferredHydration.settings.initialEvents = next.initialEvents
2777
+ const boundaryChanged = applyClearBoundary(input.clearIndex ?? 0)
2778
+ if (
2779
+ mergeServerEvents(
2780
+ next.initialEvents,
2781
+ Math.min(input.index, stateIndex),
2782
+ ) ||
2783
+ boundaryChanged
2784
+ )
2785
+ notifyHydrated()
2786
+ return
2787
+ }
2247
2788
  }
2248
-
2249
- // State hydration does not subsume delivery: unobserved ACKs still
2250
- // keep an idle stream catching up even when their state is covered.
2251
- stateIndex = nextIndex
2252
- foldedState = next.initialState
2253
- mergeServerEvents(next.initialEvents, stateIndex)
2254
- pending = pending.filter(
2255
- (entry) =>
2256
- !entry.acked ||
2257
- entry.acked.index > Math.min(stateIndex, streamIndex ?? stateIndex),
2789
+ if (input !== undefined) invalidateInitial()
2790
+ if (
2791
+ applyInitial({ settings: { ...next, initial: input } }) ||
2792
+ resume ||
2793
+ input === null
2258
2794
  )
2259
- if (confirmWatchers.some((watcher) => watcher.index <= stateIndex)) {
2260
- const due = confirmWatchers.filter(
2261
- (watcher) => watcher.index <= stateIndex,
2262
- )
2263
- confirmWatchers = confirmWatchers.filter(
2264
- (watcher) => watcher.index > stateIndex,
2265
- )
2266
- for (const watcher of due) watcher.resolve()
2267
- }
2268
- notifyHydrated()
2795
+ notifyHydrated(resume)
2269
2796
  },
2270
2797
  canEvict() {
2271
2798
  return (
@@ -2277,12 +2804,13 @@ export function createClient<
2277
2804
  presenceTimer === undefined
2278
2805
  )
2279
2806
  },
2280
- } as SessionRuntime<D, S, P, V>
2807
+ } as SessionRuntime<D, S, P>
2281
2808
 
2809
+ if (isInitialPromise(supplied)) runtime.hydrate(sessionOptions)
2282
2810
  return runtime
2283
2811
  }
2284
2812
 
2285
- return {
2813
+ const client: A2Client<D, S, P> = {
2286
2814
  session(sessionId, sessionOptions) {
2287
2815
  const existing = sessions.get(sessionId)
2288
2816
  if (existing) {
@@ -2297,4 +2825,13 @@ export function createClient<
2297
2825
  return runtime
2298
2826
  },
2299
2827
  }
2828
+ registerRenderSessionFactory({
2829
+ client,
2830
+ factory: (sessionId, settings) =>
2831
+ makeSession(sessionId, settings as SessionOptions<D, S>),
2832
+ resolveInitial: ({ session, source, value }) => {
2833
+ ;(session as SessionRuntime<D, S, P>).resolveInitial({ source, value })
2834
+ },
2835
+ })
2836
+ return client
2300
2837
  }