experimental-a2 0.16.1 → 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 (333) hide show
  1. package/CHANGELOG.md +352 -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-DgOBltJ_.js → ai-UrWfKeuv.js} +621 -216
  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 +432 -258
  32. package/dist/ai-server.js.map +1 -1
  33. package/dist/{ai-CrEf6p_W.d.ts → ai-vpGtcaCB.d.ts} +114 -100
  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 +671 -581
  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 +656 -194
  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 +6 -3
  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 +92 -72
  256. package/src/ai-coordinator.ts +23 -22
  257. package/src/ai-message-base.ts +209 -0
  258. package/src/ai-message-projection.ts +262 -64
  259. package/src/ai-progress-batches.ts +26 -0
  260. package/src/ai-projector.ts +95 -14
  261. package/src/ai-react.ts +146 -0
  262. package/src/ai-server.browser.ts +2 -2
  263. package/src/ai-server.ts +20 -1964
  264. package/src/ai-stored-state.ts +48 -163
  265. package/src/ai.ts +339 -155
  266. package/src/bootstrap-stream.ts +72 -0
  267. package/src/bounded-cache.ts +55 -0
  268. package/src/client-render-session.ts +49 -0
  269. package/src/client.ts +688 -151
  270. package/src/contract.ts +92 -42
  271. package/src/deterministic-id.ts +37 -0
  272. package/src/equal-json.ts +32 -0
  273. package/src/errors.ts +13 -1
  274. package/src/index.ts +5 -1
  275. package/src/initial-snapshot.ts +39 -0
  276. package/src/internal.ts +45 -0
  277. package/src/postgres-notification-scope.ts +14 -3
  278. package/src/react.ts +171 -206
  279. package/src/redis-http-reply.ts +256 -0
  280. package/src/redis-http-stream.ts +73 -0
  281. package/src/redis-http-subscriptions.ts +13 -5
  282. package/src/reducer-runtime.ts +52 -0
  283. package/src/reducer.ts +92 -10
  284. package/src/scheduler-qstash.ts +49 -20
  285. package/src/scheduler-task.ts +34 -2
  286. package/src/scheduler-vercel.ts +32 -84
  287. package/src/server-fetch.ts +139 -35
  288. package/src/server.ts +957 -237
  289. package/src/session-clear.ts +23 -0
  290. package/src/session-socket.ts +154 -49
  291. package/src/sse.ts +71 -4
  292. package/src/store-memory.ts +220 -78
  293. package/src/store-polling.ts +18 -4
  294. package/src/store-postgres.ts +461 -114
  295. package/src/store-redis-core.ts +1260 -434
  296. package/src/store-redis-http.ts +18 -2
  297. package/src/store-redis-notify.ts +260 -85
  298. package/src/store-redis.ts +19 -9
  299. package/src/store-sqlite.ts +259 -45
  300. package/src/store.ts +46 -1
  301. package/src/stream-buffer.ts +43 -0
  302. package/src/telemetry.ts +13 -1
  303. package/src/wire.ts +241 -13
  304. package/dist/actor-ohPC-81x.d.ts.map +0 -1
  305. package/dist/actor-shared-B5tJfzt-.js.map +0 -1
  306. package/dist/ai-CrEf6p_W.d.ts.map +0 -1
  307. package/dist/ai-DgOBltJ_.js.map +0 -1
  308. package/dist/client-BYzHjkwU.js.map +0 -1
  309. package/dist/client-CzyacQpJ.d.ts.map +0 -1
  310. package/dist/contract-CKRg_E4q.js.map +0 -1
  311. package/dist/errors-DCk6ch5n.js.map +0 -1
  312. package/dist/errors-DvhSXnxk.d.ts.map +0 -1
  313. package/dist/internal-Dq2qYxou.js.map +0 -1
  314. package/dist/react.d.ts.map +0 -1
  315. package/dist/reducer-DEMjEY_O.js +0 -29
  316. package/dist/reducer-DEMjEY_O.js.map +0 -1
  317. package/dist/reducer-otzHjuJj.d.ts.map +0 -1
  318. package/dist/scheduler-task-BpzhPnRS.js.map +0 -1
  319. package/dist/server-BD5ckxZb.js.map +0 -1
  320. package/dist/server-DbD7IJVK.d.ts.map +0 -1
  321. package/dist/store-DyZM6fS5.d.ts.map +0 -1
  322. package/dist/store-polling-CmxUbV93.js.map +0 -1
  323. package/dist/store-redis-notify-BVLUfI3j.js +0 -1583
  324. package/dist/store-redis-notify-BVLUfI3j.js.map +0 -1
  325. package/dist/telemetry-B5jzpy6y.d.ts.map +0 -1
  326. package/dist/validate-XKT4FSNn.js +0 -30
  327. package/dist/validate-XKT4FSNn.js.map +0 -1
  328. package/dist/wire-BO5wWCb1.js.map +0 -1
  329. package/examples/playground/app/agent/[agentId]/compaction/route.ts +0 -14
  330. package/examples/playground/app/agent/compaction-settings.test.ts +0 -144
  331. package/examples/playground/app/agent/compaction-settings.ts +0 -49
  332. package/examples/playground/app/agent/compaction-timeline.test.ts +0 -377
  333. package/examples/playground/app/agent/compaction-timeline.ts +0 -208
@@ -1,384 +1,324 @@
1
1
  ---
2
2
  title: Live UI with React
3
- description: "Synchronize one session from server to browser: render its state, stream new events, and push writes optimistically through one reducer."
3
+ description: "Start from a snapshot, fold new events, and push optimistic writes through one shared session."
4
4
  ---
5
5
 
6
- ## The model
6
+ ## One session, one state
7
7
 
8
- A session can be read live from the browser. The server renders the first
9
- paint, a stream keeps it fresh, and writes apply instantly. One reducer
10
- produces every one of those views; server and client fold the same log
11
- with the same function, so they can't disagree.
8
+ A contract can publish one reducer. The client starts from a snapshot of
9
+ that state and folds subsequent events locally. Initial data from a server
10
+ component is optional. Without it, the server sends a snapshot as the first
11
+ data frame of the subscription, then streams events after its index.
12
12
 
13
- That's the trick, really. The client receives one state snapshot and its log
14
- position, then syncs later events and folds them locally. It does not keep
15
- synchronizing state. An append-only log with positions is simpler.
13
+ The same connection carries bootstrap and live events, over SSE or WebSocket.
14
+ Events appended while the snapshot is read remain in the durable log and
15
+ are delivered after its index. Reconnecting resumes after the last delivered
16
+ position without fetching another snapshot.
16
17
 
17
- This is session sync. The client follows one known session id; it does not
18
- subscribe to database tables or queries across many sessions. Keep those
19
- collection views in your [application database](/guides/application-data).
18
+ This is session sync. A client follows a known session ID. Keep collection
19
+ queries across sessions in your [application database](/guides/application-data).
20
20
 
21
- The pieces: two route handlers (read and write), a factory that binds your
22
- reducer, and the provider + hook it returns.
21
+ ## Publish the state
23
22
 
24
- ## The API route
23
+ The reducer lives with the event vocabulary, in a shared module:
25
24
 
26
- `server.fetch` exposes a session over HTTP. It is bound, so the route can
27
- export it directly: `GET` streams events and serves history slices;
28
- `POST` appends.
25
+ ```ts model.ts
26
+ import { contract } from 'experimental-a2'
27
+ import { z } from 'zod'
29
28
 
30
- ```ts app/api/order-events/route.ts
31
- import { ordersServer } from '@/server/orders'
29
+ export const counter = contract({
30
+ name: 'counter',
31
+ events: { added: z.number() },
32
+ reducer: {
33
+ name: 'count.v1',
34
+ initialState: 0,
35
+ fold: (state, event) => state + event.payload,
36
+ },
37
+ })
38
+ ```
39
+
40
+ The reducer name identifies its snapshot format. Change it when the fold's
41
+ meaning or stored shape changes. The server and client must use the same
42
+ version. A fold returning a discriminated union may need `as const` on a
43
+ returned literal or an explicit return type. The standalone `reducer`
44
+ builder fixes the state type before its `.fold()` call.
45
+
46
+ Additional server-side reducers can be read with `session.state(reducer)`;
47
+ those reads do not publish another client state.
48
+
49
+ ```ts server/counter.ts
50
+ import { createServer } from 'experimental-a2/server'
51
+ import { counter } from '../model'
32
52
 
33
- export const GET = ordersServer.fetch
34
- export const POST = ordersServer.fetch
53
+ export const counterServer = createServer({ contract: counter })
35
54
  ```
36
55
 
37
- `GET` is the read path. A plain `GET` is the live stream: a server-sent
38
- events response of one session's events, resumed after the `index` query
39
- parameter, so clients pick up exactly where they left off after a first
40
- paint or a dropped connection. A `GET` carrying `gte`/`lte` query
41
- parameters is a history slice instead: the bounded log range as JSON,
42
- the cold read [`loadHistory`](#the-client-component) rides.
43
-
44
- `POST` is the write path. The push envelope is validated, then `append` does the
45
- rest. The response is the appended events: an ack, not a stream. Thrown
46
- [`A2Error`s](/reference/errors#over-the-wire) serialize onto the wire so
47
- the client can branch on the same codes.
48
-
49
- Wrap `server.fetch` when the route needs authentication, per-operation
50
- authorization, or response decoration. `authorize` receives the parsed
51
- A2 operation after envelope parsing and before schema validation or I/O.
52
- Return `false` to answer `FORBIDDEN`. A thrown error is treated as an
53
- infrastructure failure.
54
-
55
- ```ts
56
- // app/api/order-events/route.ts, now with response decoration:
57
- import { ordersServer } from '@/server/orders'
58
-
59
- export async function GET(request: Request): Promise<Response> {
60
- const response = await ordersServer.fetch(request)
61
- if (response.headers.get('a2-history-covered') === 'true') {
62
- response.headers.set('cache-control', 'private, max-age=31536000')
63
- }
64
- return response
65
- }
56
+ ```ts app/api/counter/route.ts
57
+ import { counterServer } from '../../../server/counter'
66
58
 
67
- export const POST = ordersServer.fetch
59
+ export const GET = counterServer.fetch
60
+ export const POST = counterServer.fetch
68
61
  ```
69
62
 
70
- ## The session module
63
+ `GET` serves the subscription and bounded history reads. `POST` appends
64
+ writes. Wrap `server.fetch` with its `authorize` option for application
65
+ checks. Bootstrap is authorized as a stream operation with `bootstrap: true`
66
+ before reading state.
67
+
68
+ ## Bind React once
71
69
 
72
- Bind your reducer once, in a `'use client'` file, and export the pair:
70
+ The factory binds the client's types into the Provider and hook. It does
71
+ not create a separate connection pool.
73
72
 
74
- ```tsx app/orders/[orderId]/session.ts
73
+ ```tsx app/counter/session.ts
75
74
  'use client'
76
75
  import { createClient } from 'experimental-a2/client'
77
76
  import { createReact } from 'experimental-a2/react'
78
- import { ordersReducer } from '@/reducer'
77
+ import { counter } from '../../model'
79
78
 
80
- export const ordersClient = createClient({
81
- reducer: ordersReducer,
82
- api: '/api/order-events',
79
+ export const counterClient = createClient({
80
+ contract: counter,
81
+ api: '/api/counter',
82
+ })
83
+
84
+ export const { SessionProvider, useSession } = createReact({
85
+ client: counterClient,
83
86
  })
84
- export const { SessionProvider, useSession } = createReact({ client: ordersClient })
85
87
  ```
86
88
 
87
- This is the whole client/server seam. `createReact` is a factory (like
88
- `createContext`): everything it returns is typed by the reducer value;
89
- the state from its fold, the pushable events from its vocabulary. No type
90
- arguments anywhere, and nothing in this file touches the server module.
91
- The client also gives every session a stable in-memory identity. Calling
92
- `ordersClient.session(orderId)` before navigation and mounting the provider
93
- after navigation reaches the same optimistic state, with no handoff store.
89
+ ```tsx app/counter/counter.tsx
90
+ 'use client'
91
+ import { useSession } from './session'
94
92
 
95
- ## The server component
93
+ export function Counter() {
94
+ const { state, connection, push } = useSession()
95
+ const error = 'error' in connection ? connection.error : null
96
96
 
97
- The first paint costs no client JavaScript: fold on the server, pass the
98
- result down.
97
+ return (
98
+ <>
99
+ <button onClick={() => void push({ type: 'added', payload: 1 })}>
100
+ Count: {state}
101
+ </button>
102
+ {error && <p role="alert">{error.message}</p>}
103
+ </>
104
+ )
105
+ }
106
+ ```
99
107
 
100
- ```tsx app/orders/[orderId]/page.tsx
101
- import { ordersServer } from '@/server/orders'
102
- import { ordersReducer } from '@/reducer'
108
+ `state` has the reducer's client state type. A supplied snapshot seeds the
109
+ first render; otherwise the client displays `reducer.initialState` (through
110
+ the reducer's client projection when present). The connection snapshot
111
+ replaces that seed atomically, then new events update it. The seed can look
112
+ empty while an existing session loads. Supply a server snapshot to render
113
+ its current state immediately.
114
+
115
+ `connection` stays `connecting` until bootstrap completes. Failures use its
116
+ existing error and retry policy. A slow snapshot can keep loading while the
117
+ connection sends heartbeats. The ordinary stream stall watchdog handles a
118
+ silent connection. A reconnect preserves the last state.
119
+
120
+ A push made during bootstrap is sent right away; it does not wait for the
121
+ snapshot. Its optimistic update appears once the snapshot is applied. If
122
+ the push was already sent when the snapshot arrives and has no
123
+ acknowledgement yet, it stays hidden until the acknowledgement arrives,
124
+ because the snapshot may already contain it. Pushes queued behind it stay
125
+ hidden with it, so the view never skips a write. As usual, the push promise
126
+ resolves on the server acknowledgement; `.confirmed` waits for delivery
127
+ through the stream, and the bootstrap snapshot counts as delivery through
128
+ its index.
129
+
130
+ ## Without server rendering
131
+
132
+ A Provider only needs the session ID. It supplies scope and optional data;
133
+ mounted hooks own connection leases.
134
+
135
+ ```tsx app/counter/page.tsx
136
+ import { Counter } from './counter'
103
137
  import { SessionProvider } from './session'
104
- import { OrderClient } from './order-client'
105
-
106
- export default async function OrderPage({
107
- params,
108
- }: {
109
- params: Promise<{ orderId: string }>
110
- }) {
111
- const { orderId } = await params
112
- const session = ordersServer.session(orderId)
113
- const { state, index } = await session.state(ordersReducer)
114
138
 
139
+ export default function Page() {
115
140
  return (
116
- <SessionProvider
117
- sessionId={orderId}
118
- initialState={state}
119
- initialIndex={index}
120
- >
121
- <OrderClient />
141
+ <SessionProvider sessionId="shared">
142
+ <Counter />
122
143
  </SessionProvider>
123
144
  )
124
145
  }
125
146
  ```
126
147
 
127
- Two props do the heavy lifting. `initialState` is the fold. `initialIndex` is
128
- its frontier, the log position the fold reflects. The client opens its stream
129
- after that position. The stream catches up any events appended after the server
130
- fold, then stays live. No history read is needed to hydrate state.
131
- (No `reducer` or `api` prop: the provider got both from the shared client.)
148
+ You can also omit the Provider. The bound hook already knows its client:
132
149
 
133
- ## The client component
134
-
135
- ```tsx app/orders/[orderId]/order-client.tsx
150
+ ```tsx app/counter/standalone.tsx
136
151
  'use client'
137
152
  import { useSession } from './session'
138
153
 
139
- export function OrderClient() {
140
- const { state, push } = useSession()
141
-
154
+ export function StandaloneCounter({ sessionId }: { sessionId: string }) {
155
+ const { state, push } = useSession({ sessionId })
142
156
  return (
143
- <button onClick={() => push({ type: 'shop.started', payload: {} })}>
144
- Start
157
+ <button
158
+ onClick={() => void push({ type: 'added', payload: 1 })}
159
+ >
160
+ {state}
145
161
  </button>
146
162
  )
147
163
  }
148
164
  ```
149
165
 
150
- What the hook gives you:
151
-
152
- - **`state`**: the live view, folded through the shared reducer. It
153
- updates three ways: optimistically when you push, corrected when the
154
- server acks, and continuously as the stream delivers events, yours and
155
- everyone else's.
156
- - **`push`**: typed against the contract's vocabulary (via the reducer). Payloads are
157
- validated locally before anything leaves the browser, apply instantly,
158
- swap in the real event when the `POST` acks, and refold without it if
159
- the server says no. Rejections carry the same
160
- [`A2Error` codes](/reference/errors) the server throws, so both sides
161
- branch on `code`. Awaiting `push(...)` gives the server ack; the
162
- result also carries **`.confirmed`**, a promise for the moment the
163
- live stream delivers the batch back and the view shows server truth.
164
- `await push(...).confirmed` is "continue once this is real"; two
165
- `performance.now()` calls around the two awaits are a complete
166
- push→ack→stream latency meter. Overlapping calls on the same
167
- session still apply immediately, then enter the transport in call
168
- order. Controls do not need to wait or disable themselves to preserve
169
- that order.
170
- - **`events`**: the raw events this client has observed or was explicitly
171
- seeded with. With only the server snapshot, it begins after `initialIndex`.
172
- Use it for UI that wants the log itself: an activity feed, a debug panel.
173
- Earlier events are not needed to hydrate `state`. Later hydration can
174
- advance state ahead of this feed; the stream keeps its own resume position
175
- and adds delayed events without folding them again.
176
- - **`loadHistory`**: backscroll. `loadHistory({ before?, limit? })`
177
- fetches a bounded slice of the log from below the frontier (the same
178
- route, `gte`/`lte` query parameters) and merges it into `events`:
179
- deduped, ordered, shared across every handle of the session. By
180
- default each call walks backward 50 events at a time from the oldest
181
- one loaded, including after a newer snapshot hydrates the session.
182
- Pass an explicit `before` to read another range. It never touches `state`
183
- or the optimistic overlay; backscrolled events are display data.
184
- Calls serialize per session, so
185
- a double-tap never fetches the same range twice. The `ws` api has no
186
- history lane; `loadHistory` throws a `TypeError` there.
187
- - **`history`**: backscroll progress, `{ loading, complete,
188
- oldestLoaded }`. `complete` means the feed reaches index 1 (or the
189
- log is empty): nothing older is left, hide the "load older" button.
190
- - **`index`**: the latest server-confirmed state index, advanced by
191
- snapshots and streamed events. This is the `lastSeenIndex` that makes
192
- [cancellation](/guides/cancellation) exact.
193
- - **`connection`**: a discriminated union of `{ status: 'idle' }`,
194
- `{ status: 'connecting', reconnects, error }`,
195
- `{ status: 'paused', reconnects, error }`,
196
- `{ status: 'live', reconnects }`, or `{ status: 'closed' }`. A status
197
- dot is one ternary away; "reconnecting…" is
198
- `status === 'connecting' && reconnects > 0`; `error` (why the last
199
- connection ended) only exists while disconnected, so impossible
200
- states don't compile. It's honest about silence, too: the server
201
- heartbeats the stream (`: ping` every 15s), and a client that hears
202
- nothing for two beats treats the connection as dead and reconnects, so
203
- `live` means bytes are flowing, not "the socket hasn't errored yet".
204
-
205
- The provider opens the stream on mount, closes it on unmount, and
206
- reconnects with backoff, resuming from `index`, when the connection
207
- drops. Components that only write don't need the hook; `POST` to the
208
- route directly, or call `ordersClient.session(id).push(...)` so a later
209
- provider sees the same optimistic session.
210
-
211
- Polling streams start fast and back off to a two-second interval when idle.
212
- Idle is a polling state; the connection remains live and heartbeats continue.
213
- When you call `push()` on an idle SSE stream, the client refreshes that stream
214
- immediately, without reconnect backoff. It resumes from its existing `index`,
215
- so concurrent events before the write's acknowledged index are still read.
216
- Overlapping pushes share the refreshed stream. If a slow push is still pending
217
- when the stream idles again, or its acknowledged events have not arrived, the
218
- client refreshes again. WebSockets wake the session's attached read loop
219
- without replacing the socket. This is internal and requires no component
220
- configuration.
221
-
222
- Use `reconnect()` after a custom REST request commits a change outside
223
- `push()`. It is available on the session client and the `useSession()` result.
224
- It immediately restarts the live subscription from its current `index`, even
225
- when the stream is active. State, optimistic writes, and connection leases
226
- are preserved. Without an active lease it does nothing.
227
-
228
- `reconnect()` returns `void`; observe `connection` and `index` for progress.
229
- SSE opens a fresh GET. A single-session WebSocket opens a fresh socket; a
230
- multiplexed WebSocket re-subscribes only this session on its existing socket.
231
- Other subscribed sessions keep running. Any outstanding write interrupted by
232
- a socket replacement uses the normal idempotent retry path.
233
-
234
- The server stops the old session lane before authorizing its replacement.
235
- That lane stays closed if authorization denies access or fails. Successful
236
- authorization catches up from the cursor. Already accepted writes can finish,
237
- and failed subscription attempts follow the normal reconnect retry policy.
238
-
239
- Configure `createClient({ reconnect })` on a shared client to choose each
240
- retry's delay or return `false` to pause automatic reconnection. The callback
241
- receives `{ sessionId, error, attempt }`; `error` is an `A2ClientError`, or
242
- `null` when the stream ended cleanly. `attempt` starts at 1 and resets when
243
- the connection becomes live or you call `reconnect()` explicitly.
244
-
245
- A paused connection keeps the hydrated session, its writer, and its existing
246
- state. Observe `connection.status === 'paused'` and use the hook's
247
- `reconnect()` to resume when your application is ready. New consumers and
248
- pushes do not automatically resume it. Configure the policy where you create
249
- the client, without changing the hook's `hydrate` input.
250
-
251
- That identity is an in-memory L1, not another source of truth. Repeated
252
- `session(id)` calls reuse it while active and for five idle minutes by
253
- default. A newer server fold advances it, pending pushes stay overlaid,
254
- and a stale server render cannot rewind it. The optional IndexedDB cache
255
- is the L2: it survives reloads; the memory runtime does not. The
256
- stream is shared the same way: `connect()` takes a lease and returns
257
- its release, so however many hooks, providers, or plain calls hold one
258
- session open, the first lease connects and the last release closes
259
- (`close()` is the hard stop).
260
-
261
- One detail worth knowing: every push carries a client-generated event id.
262
- That id is how the ack finds its optimistic entry, and it makes retrying
263
- a failed `POST` idempotent. `push` retries `STORE_UNAVAILABLE` and client
264
- transport failures, with a three-attempt budget.
265
-
266
- Optimistic pushes are also what make [cancellation](/guides/cancellation)
267
- feel instant: the `cancelled` event folds locally before the server ever
268
- sees it.
269
-
270
- Sessions can outlive the tab, too: hand `createReact` a `cache` and
271
- revisits paint from the local copy, the stream resumes from the cached
272
- frontier, and offline pushes queue and replay. See
273
- [Local-first](/guides/local-first).
274
-
275
- ## Client-first sessions
276
-
277
- Everything above assumes a server component hands the first fold down.
278
- A client-rendered app often has nothing to hand: a sidebar showing ten
279
- channel sessions, a user session read from deep inside a menu, panes
280
- that mount from client-side navigation. No server fold, and no natural
281
- provider boundary for components that reach sessions ad hoc.
282
-
283
- For that shape, `experimental-a2/react` exports a standalone hook. Any
284
- component can mount any session, no provider needed:
285
-
286
- ```tsx app/orders/[orderId]/order-badge.tsx
287
- 'use client'
288
- import { useSession } from 'experimental-a2/react'
289
- import { ordersClient } from './session'
290
-
291
- export function OrderBadge({ orderId }: { orderId: string }) {
292
- // your fetch (SWR, a router loader, a parent's payload): anything
293
- // that lands the { state, index } your route returns (below), e.g.
294
- // const { data } = useSWR(`/api/orders/${orderId}/state`)
295
- const data = undefined // still loading
296
-
297
- const { state } = useSession(ordersClient, orderId, { hydrate: data })
298
- if (!data) return <span>…</span> // your loading state IS the pending state
299
- return <span>{state.status}</span>
166
+ The direct `useSession(client, sessionId, options?)` export is available
167
+ when you do not use the factory. It has the same bootstrap and connection
168
+ ownership behavior.
169
+
170
+ ## Seed the first render
171
+
172
+ Server rendering can skip the bootstrap read. Supply the state and its
173
+ index together:
174
+
175
+ ```tsx app/seeded-counter/page.tsx
176
+ import { counterServer } from '../../server/counter'
177
+ import { Counter } from '../counter/counter'
178
+ import { SessionProvider } from '../counter/session'
179
+
180
+ export default async function Page() {
181
+ const initial = await counterServer.session('shared').state()
182
+ return (
183
+ <SessionProvider
184
+ sessionId="shared"
185
+ initial={initial}
186
+ >
187
+ <Counter />
188
+ </SessionProvider>
189
+ )
300
190
  }
301
191
  ```
302
192
 
303
- The fold comes from your own route, and the route is one line of A2:
193
+ Index 10 means this state already includes events through 10. The stream
194
+ starts after 10. Index 0 is valid when paired with the reducer's initial
195
+ state; that explicitly requests replay from the beginning. An index by
196
+ itself cannot describe a reducer state.
197
+
198
+ A standalone hook can receive the same pair as `initial`. Omitting
199
+ it enables automatic bootstrap. For an asynchronous server read, pass its
200
+ Promise as described below.
201
+
202
+ SSR uses render-local snapshots. One request's seed does not modify another
203
+ request's state, and browser hydration first renders the supplied snapshot
204
+ even when the shared browser cache has already advanced.
205
+
206
+ `hydrated` distinguishes the reducer seed from a known state/index snapshot.
207
+ It is `false` until a supplied snapshot or subscription bootstrap initializes
208
+ the session, including while a cold preload is pending or rejected. A snapshot
209
+ at index 0 counts. Once initialized, it stays `true` through disconnections,
210
+ reconnects, and disabled readers while the session retains its state. A new
211
+ session without a seed starts at `false` again.
212
+
213
+ Use `hydrated` when a decision needs initialized state, such as distinguishing
214
+ an empty queue from one that has not loaded. `connection` describes the
215
+ transport independently. A hydrated snapshot may be stale and does not imply
216
+ permission to write. Imperative code reads the same flag from
217
+ `client.session(id).getSnapshot().hydrated`.
218
+
219
+ ## Start a preload on the server
220
+
221
+ `initial` also accepts a stable Promise of a snapshot. A pending preload
222
+ blocks only cold startup: A2 waits before opening its subscription, displaying
223
+ `reducer.initialState` without suspending the UI by default. A snapshot result
224
+ replaces that seed and skips the
225
+ bootstrap read. A `null` or `undefined` result uses ordinary subscription
226
+ bootstrap. No additional request is started to resolve the Promise.
227
+
228
+ ```tsx app/preloaded-counter/page.tsx
229
+ import { Suspense } from 'react'
230
+ import { counterServer } from '../../server/counter'
231
+ import { SessionProvider } from '../counter/session'
232
+ import { PreloadedCounter } from './counter'
233
+
234
+ export default function Page() {
235
+ const initial = counterServer.session('shared').state()
236
+ return (
237
+ <SessionProvider sessionId="shared" initial={initial}>
238
+ <h1>Counter</h1>
239
+ <Suspense fallback={<p>Loading counter…</p>}>
240
+ <PreloadedCounter />
241
+ </Suspense>
242
+ </SessionProvider>
243
+ )
244
+ }
245
+ ```
304
246
 
305
- ```ts app/api/orders/[orderId]/state/route.ts
306
- import { ordersServer } from '@/server/orders'
307
- import { ordersReducer } from '@/reducer'
247
+ ```tsx app/preloaded-counter/counter.tsx
248
+ 'use client'
249
+ import { useSession } from '../counter/session'
308
250
 
309
- export async function GET(
310
- _req: Request,
311
- { params }: { params: Promise<{ orderId: string }> },
312
- ) {
313
- const { orderId } = await params
314
- // here's where you'd do auth, or any other checks
315
- return Response.json(
316
- await ordersServer.session(orderId).state(ordersReducer),
251
+ export function PreloadedCounter() {
252
+ const { state, push } = useSession({ suspend: true })
253
+ return (
254
+ <button
255
+ onClick={() => void push({ type: 'added', payload: 1 })}
256
+ >
257
+ {state}
258
+ </button>
317
259
  )
318
260
  }
319
261
  ```
320
262
 
321
- Note what is not on the wire: a reducer name. The route calls
322
- `session.state(reducer)` with the reducer by reference, the same
323
- module your client bundle folds with. That matters because renaming a
324
- reducer is the [snapshot invalidation knob](/concepts/state): if the
325
- name traveled on the wire, every rename would open a version-skew
326
- window between deployed clients and servers. By reference, the knob
327
- stays free. And unlike a history slice, which is immutable once read
328
- and earns a standard wire shape, a state read is a moving snapshot;
329
- there is nothing for the library to standardize, so the read stays
330
- behind your route, under your auth and caching policy.
331
-
332
- `hydrate` is one atomic option: the `{ state, index }` pair
333
- `session.state(reducer)` returns. State and its frontier travel
334
- together or not at all; a half-present handoff cannot compile. While
335
- it is `undefined` (your fetch has not landed), the hook holds the
336
- stream and `state` is the reducer's `initialState`. There is no
337
- pending flag on the result: whether the fold has been handed over is
338
- your own input, so your data layer's loading state is the pending
339
- state. When the value arrives, the session hydrates
340
- and the stream connects at that frontier. The hold is deliberate:
341
- connecting without a fold means replaying the whole log through the
342
- browser, and the library never picks the expensive path silently. When
343
- a full replay is what you want (a short log, a debug view), hand the
344
- fold's true starting point:
345
- `hydrate: { state: ordersReducer.initialState, index: 0 }`. State at
346
- index 0 is the reducer's seed by definition, so the explicit replay
347
- needs no special vocabulary; the stream then delivers every event
348
- live.
349
-
350
- The rest behaves like the provider. The handle is identity-mapped
351
- (every hook and provider mounting the same session shares one runtime)
352
- and each mount holds a lease on the shared stream (see
353
- [the client identity note](#the-client-component)). A later, further
354
- fold handed to the same session
355
- advances it and a stale one is ignored, the same never-move-backward
356
- rule as everywhere else. For presence, identity comes from the
357
- client's `participant` (set once on `createClient`) or the hook's
358
- `participant` option, which overrides it. The
359
- result is exactly the shape the bound hook returns. There is no
360
- loading or error member: the
361
- library performs no fetch here, so your data layer's states are the
362
- states, retried however your data layer retries.
363
-
364
- `SessionProvider` keeps its required `initialState`/`initialIndex`.
365
- The provider is the server-handoff tool; the hook is the client-first
366
- tool.
367
-
368
- ## Keep the backend out of the bundle
369
-
370
- The split is structural, not disciplinary. Core `experimental-a2` (the contract,
371
- reducers, errors) imports no backend, ever; only `experimental-a2/server` can reach
372
- one, and its exports map resolves to a loud error under the browser
373
- condition. The layout above rides that:
374
-
375
- 1. `contracts.ts` and `reducer.ts` import nothing but schemas and `experimental-a2`,
376
- isomorphic by construction.
377
- 2. The session module imports the reducer, never `experimental-a2/server`. Client
378
- components don't need the server's *type*, even: the factory carries
379
- all the typing.
380
-
381
- Belt and suspenders: put `import 'server-only'` at the top of your
382
- server module and enable `verbatimModuleSyntax` in your tsconfig; an
383
- accidental value import then fails your build too, with your own error
384
- message.
263
+ A rejected cold preload pauses startup and reports its error through
264
+ `connection`. A2 cannot retry an externally created Promise: supply a new
265
+ Promise, a snapshot, or `null` to continue. The caller can catch a preload
266
+ failure and return `null` when bootstrap fallback is appropriate.
267
+
268
+ A warm session keeps its state and connection while another initial Promise
269
+ is pending. A newer successful snapshot follows the normal hydration rules;
270
+ a rejected late preload does not disrupt a working session. Replaced sources
271
+ and hard close invalidate pending results, including deferred hydration.
272
+ Unmounting releases that hook's connection lease; it does not cancel the
273
+ caller's fetch. Within a cached session, repeating the same Promise does not
274
+ restart cancelled work.
275
+
276
+ Each hook can opt into React Suspense with `useSession({ suspend: true })`.
277
+ It reads the supplied Promise with React `use()`. The nearest Suspense and
278
+ Error boundaries handle its pending and rejected states. The Provider itself
279
+ does not suspend. Without a Promise, or when it resolves to no snapshot,
280
+ ordinary subscription bootstrap displays the reducer seed without suspending.
281
+ `enabled` continues to control connection ownership separately.
282
+
283
+ A non-suspending preload renders the reducer seed on the server. Await the
284
+ snapshot upstream or use the suspending hook within an appropriate boundary
285
+ to render the loaded session in the server response. Keep
286
+ Promise identity stable across client renders; do not create it inside the
287
+ component that consumes it.
288
+
289
+ ## Connections and activation
290
+
291
+ Hooks using the same client instance and session ID share one runtime,
292
+ connection, state, and optimistic queue. Providers do not determine deduplication.
293
+ Another client instance is isolated, even when its URL matches.
294
+
295
+ `useSession({ enabled: false })` observes cached or initial data without
296
+ acquiring an automatic connection lease. The default is `true`. Disabling
297
+ or unmounting a hook releases only its own lease. Another enabled consumer
298
+ continues normally. Explicit pushes still work and acquire a connection
299
+ when needed; disabling is not an authorization boundary.
300
+
301
+ The bound hook accepts `sessionId`, `client`, `participant`, `initial`,
302
+ `enabled`, and `suspend` options. Initial data and participant defaults are inherited
303
+ only when the resolved client and session ID match the Provider's scope.
304
+
305
+ ## Events and history
306
+
307
+ `useSession()` returns `state`, `events`, `index`, `connection`, `push`,
308
+ `reconnect`, `loadHistory`, and `history`. Presence contracts also return
309
+ `presence` and `setPresence`.
310
+
311
+ `events` contains supplied history, loaded history, received events, and
312
+ pending optimistic events. A bootstrap snapshot does not invent historical
313
+ rows. Without `initialEvents`, the array starts empty and receives events
314
+ after the snapshot's index.
315
+
316
+ `loadHistory({ before, limit })` reads older log entries into that array.
317
+ Omit the options to load up to 50 events before the oldest loaded event.
318
+ History reads do not change state or rewind the live cursor. `history`
319
+ reports loading, the oldest loaded index, and whether the beginning is
320
+ loaded. History reads currently require the HTTP transport.
321
+
322
+ The array is an in-memory collection, not a paginated projection. Long-lived
323
+ sessions can accumulate many events; a state snapshot bounds replay work,
324
+ not the size of state or retained history.