Current section
19 Versions
Jump to
Current section
19 Versions
Compare versions
11
files changed
+906
additions
-96
deletions
| @@ -11,6 +11,33 @@ not in lockstep yet; entries note which surface they affect. | |
| 11 11 | |
| 12 12 | ## [Unreleased] |
| 13 13 | |
| 14 | + ## [0.8.0] — 2026-06-13 |
| 15 | + |
| 16 | + ### Added |
| 17 | + |
| 18 | + - **`@musubi/client` / `@musubi/react`** — Opt-in, TanStack-Query-style |
| 19 | + stale-while-revalidate store cache. Mounting a store whose identity was |
| 20 | + seen before seeds last-known state immediately (`fromCache: true`) while the |
| 21 | + live mount revalidates in the background and swaps in fresh state when the |
| 22 | + server's initial patch lands. Enabled per call via `MountStoreOptions.cache` |
| 23 | + (`{ gcTime?, persister?, buster?, initialData? }`). Storage is pluggable |
| 24 | + through `MusubiCachePersister` — the default is a connection-scoped in-memory |
| 25 | + Map (cleared on `disconnect`); `createStorageCachePersister` adapts |
| 26 | + `localStorage` / `sessionStorage`. Accepted patches write through to the |
| 27 | + cache on a per-key trailing throttle and flush on teardown / disconnect. |
| 28 | + `gcTime` (default 5 min) is measured from the entry's last update and |
| 29 | + enforced at read so it survives reloads. A `buster` string discards stale |
| 30 | + data shapes across deploys, with a dev warning when a durable persister is |
| 31 | + used without one. Commands dispatched during the stale window are queued |
| 32 | + behind the live initial patch instead of rejecting. New surface: |
| 33 | + `MountedStore.{fromCache, isFetching, revalidated}`, |
| 34 | + `MusubiConnection.clearStoreCache(target?)`, and the |
| 35 | + `createMemoryPersister` / `createStorageCachePersister` / `storeCacheKey` |
| 36 | + exports. In `@musubi/react`, `useMusubiRoot`'s result gains `isFetching` and |
| 37 | + `revalidationError`, plus a `keepPreviousData` option that keeps the prior |
| 38 | + store visible across an `id` / `params` change until the new mount resolves. |
| 39 | + Non-cached mounts are unchanged (#74). |
| 40 | + |
| 14 41 | ## [0.7.2] — 2026-06-05 |
| 15 42 | |
| 16 43 | ### Fixed |
| @@ -251,7 +278,8 @@ Initial public release of the Musubi runtime (then `Arbor`): | |
| 251 278 | - TypeScript client and React adapter that materialize the diff stream |
| 252 279 | into immutable snapshots. |
| 253 280 | |
| 254 | - [Unreleased]: https://github.com/fahchen/musubi/compare/v0.7.2...HEAD |
| 281 | + [Unreleased]: https://github.com/fahchen/musubi/compare/v0.8.0...HEAD |
| 282 | + [0.8.0]: https://github.com/fahchen/musubi/compare/v0.7.2...v0.8.0 |
| 255 283 | [0.7.2]: https://github.com/fahchen/musubi/compare/v0.7.1...v0.7.2 |
| 256 284 | [0.7.1]: https://github.com/fahchen/musubi/compare/v0.7.0...v0.7.1 |
| 257 285 | [0.7.0]: https://github.com/fahchen/musubi/compare/v0.6.1...v0.7.0 |
| @@ -34,7 +34,7 @@ Add Musubi to your Phoenix application: | |
| 34 34 | ```elixir |
| 35 35 | def deps do |
| 36 36 | [ |
| 37 | - {:musubi, "~> 0.7.2"} |
| 37 | + {:musubi, "~> 0.8.0"} |
| 38 38 | ] |
| 39 39 | end |
| 40 40 | ``` |
| @@ -206,6 +206,113 @@ export function DashboardPage() { | |
| 206 206 | } |
| 207 207 | ``` |
| 208 208 | |
| 209 | + ## Stale-While-Revalidate Cache |
| 210 | + |
| 211 | + Mounting always needs a server round-trip to establish the live channel |
| 212 | + subscription, so a re-mount of a store you held moments ago (a route swap) |
| 213 | + flashes `loading` again. The opt-in cache removes that flash: when a valid |
| 214 | + entry for a store's identity exists, the mount **resolves immediately with the |
| 215 | + last-known state** (`fromCache: true`) while the live mount revalidates in the |
| 216 | + background and swaps in the server's initial patch when it lands. |
| 217 | + |
| 218 | + Because the channel is always live, there is no TanStack-style `staleTime` — a |
| 219 | + cached mount *always* revalidates; the cache only controls what is shown in the |
| 220 | + meantime. |
| 221 | + |
| 222 | + ### Enable it per mount |
| 223 | + |
| 224 | + Pass `cache` to `mountStore` (or `useMusubiRoot`). An empty object uses the |
| 225 | + defaults: a connection-scoped in-memory backend and a 5-minute `gcTime`. |
| 226 | + |
| 227 | + ```ts |
| 228 | + const { store, fromCache, isFetching, revalidated } = await connection.mountStore({ |
| 229 | + module: "MyApp.Stores.DashboardStore", |
| 230 | + id: "dashboard", |
| 231 | + cache: {}, |
| 232 | + }) |
| 233 | + |
| 234 | + // `revalidated` resolves when the live initial patch replaces the seed |
| 235 | + // (resolves immediately for a cold mount with no cached entry). |
| 236 | + await revalidated |
| 237 | + ``` |
| 238 | + |
| 239 | + Options: |
| 240 | + |
| 241 | + ```ts |
| 242 | + cache: { |
| 243 | + gcTime: 300_000, // eviction TTL measured from the last accepted patch |
| 244 | + buster: "v3", // data-shape version; a mismatch discards the entry |
| 245 | + persister: myPersister, // storage backend (default: connection in-memory Map) |
| 246 | + initialData: seedState, // seed the first mount when no entry exists |
| 247 | + } |
| 248 | + ``` |
| 249 | + |
| 250 | + ### Persist across reloads |
| 251 | + |
| 252 | + The default backend is in-memory and cleared on `disconnect`. For state that |
| 253 | + survives a page reload, use the Web Storage adapter. Always set `buster` for a |
| 254 | + durable backend so a deploy that changes the store's data shape discards stale |
| 255 | + entries (omitting it logs a dev warning). |
| 256 | + |
| 257 | + ```ts |
| 258 | + import { createStorageCachePersister } from "@musubi/client" |
| 259 | + |
| 260 | + const persister = createStorageCachePersister(localStorage) // or sessionStorage |
| 261 | + |
| 262 | + await connection.mountStore({ |
| 263 | + module: "MyApp.Stores.DashboardStore", |
| 264 | + id: "dashboard", |
| 265 | + cache: { persister, buster: "v3" }, |
| 266 | + }) |
| 267 | + ``` |
| 268 | + |
| 269 | + Cache I/O is best-effort: a throwing persister degrades to a cold mount rather |
| 270 | + than failing the mount, and malformed stored entries are discarded on read. |
| 271 | + |
| 272 | + ### In React |
| 273 | + |
| 274 | + `useMusubiRoot` forwards `cache` and adds two signals plus `keepPreviousData`: |
| 275 | + |
| 276 | + ```tsx |
| 277 | + const root = useMusubiRoot({ |
| 278 | + module: "MyApp.Stores.DashboardStore", |
| 279 | + id: "dashboard", |
| 280 | + cache: { persister, buster: "v3" }, |
| 281 | + keepPreviousData: true, // keep the prior store visible across an id/params |
| 282 | + }) // change instead of flashing loading |
| 283 | + |
| 284 | + if (root.status === "loading") return <Spinner /> |
| 285 | + if (root.status === "error") return <p>{root.error.message}</p> |
| 286 | + |
| 287 | + return ( |
| 288 | + <DashboardContent |
| 289 | + store={root.store} |
| 290 | + revalidating={root.isFetching} // showing cached data, refresh in flight |
| 291 | + staleError={root.revalidationError} // refresh failed; stale store still shown |
| 292 | + /> |
| 293 | + ) |
| 294 | + ``` |
| 295 | + |
| 296 | + `isFetching` is `true` while a cached (or kept-previous) store is shown pending |
| 297 | + revalidation, then settles to `false`. On a revalidation failure the stale |
| 298 | + store stays visible and the error surfaces on `revalidationError` rather than |
| 299 | + blanking the UI. The Suspense variant is unchanged — SWR does not map onto |
| 300 | + throwing a promise. |
| 301 | + |
| 302 | + ### Commands during the stale window |
| 303 | + |
| 304 | + A command dispatched while a cache-seeded mount is still revalidating (before |
| 305 | + the live initial patch lands) is **queued** behind the initial patch instead of |
| 306 | + rejecting "Store is not connected"; it dispatches once the store is live, or |
| 307 | + rejects if revalidation fails. |
| 308 | + |
| 309 | + ### Clear the cache |
| 310 | + |
| 311 | + ```ts |
| 312 | + await connection.clearStoreCache({ module, id, params }) // one entry |
| 313 | + await connection.clearStoreCache() // every entry |
| 314 | + ``` |
| 315 | + |
| 209 316 | ## Subscribe To Snapshots |
| 210 317 | |
| 211 318 | `useMusubiSnapshot` subscribes to proxy updates and returns an immutable |
| @@ -10,7 +10,7 @@ In the Phoenix app: | |
| 10 10 | ```elixir |
| 11 11 | def deps do |
| 12 12 | [ |
| 13 | - {:musubi, "~> 0.7.2"} |
| 13 | + {:musubi, "~> 0.8.0"} |
| 14 14 | ] |
| 15 15 | end |
| 16 16 | ``` |
| @@ -1,67 +1,66 @@ | |
| 1 1 | {<<"links">>,[{<<"GitHub">>,<<"https://github.com/fahchen/musubi">>}]}. |
| 2 2 | {<<"name">>,<<"musubi">>}. |
| 3 | - {<<"version">>,<<"0.7.2">>}. |
| 3 | + {<<"version">>,<<"0.8.0">>}. |
| 4 4 | {<<"description">>, |
| 5 5 | <<"Server-authoritative, page-scoped runtime library for Elixir/Phoenix applications.">>}. |
| 6 6 | {<<"elixir">>,<<"~> 1.18">>}. |
| 7 7 | {<<"app">>,<<"musubi">>}. |
| 8 8 | {<<"licenses">>,[<<"MIT">>]}. |
| 9 9 | {<<"files">>, |
| 10 | - [<<"lib">>,<<"lib/musubi">>,<<"lib/musubi/resolver.ex">>, |
| 11 | - <<"lib/musubi/input.ex">>,<<"lib/musubi/reconciler.ex">>, |
| 12 | - <<"lib/musubi/stream">>,<<"lib/musubi/stream/marker.ex">>, |
| 13 | - <<"lib/musubi/stream/slot.ex">>, |
| 14 | - <<"lib/musubi/stream/async_placeholder.ex">>, |
| 15 | - <<"lib/musubi/stream/placeholder.ex">>,<<"lib/musubi/diff.ex">>, |
| 16 | - <<"lib/musubi/testing.ex">>,<<"lib/musubi/dsl">>, |
| 17 | - <<"lib/musubi/dsl/input.ex">>,<<"lib/musubi/dsl/upload.ex">>, |
| 18 | - <<"lib/musubi/dsl/attr.ex">>,<<"lib/musubi/dsl/command.ex">>, |
| 19 | - <<"lib/musubi/dsl/state.ex">>,<<"lib/musubi/dsl/field.ex">>, |
| 20 | - <<"lib/musubi/dsl/render.ex">>,<<"lib/musubi/dsl/schema.ex">>, |
| 21 | - <<"lib/musubi/plugin">>,<<"lib/musubi/plugin/type_spec.ex">>, |
| 22 | - <<"lib/musubi/plugin/reflection.ex">>, |
| 23 | - <<"lib/musubi/plugin/type_script.ex">>, |
| 24 | - <<"lib/musubi/plugin/state_field.ex">>,<<"lib/musubi/plugin/normalize.ex">>, |
| 25 | - <<"lib/musubi/plugin/definer.ex">>,<<"lib/musubi/async">>, |
| 26 | - <<"lib/musubi/async/telemetry.ex">>,<<"lib/musubi/upload.ex">>, |
| 27 | - <<"lib/musubi/child.ex">>,<<"lib/musubi/wire.ex">>, |
| 28 | - <<"lib/musubi/store.ex">>,<<"lib/musubi/upload">>, |
| 29 | - <<"lib/musubi/upload/error.ex">>,<<"lib/musubi/upload/marker.ex">>, |
| 10 | + [<<"lib">>,<<"lib/musubi">>,<<"lib/musubi/upload">>, |
| 11 | + <<"lib/musubi/upload/token.ex">>,<<"lib/musubi/upload/error.ex">>, |
| 30 12 | <<"lib/musubi/upload/config.ex">>,<<"lib/musubi/upload/preflight.ex">>, |
| 31 | - <<"lib/musubi/upload/entry.ex">>,<<"lib/musubi/upload/token.ex">>, |
| 32 | - <<"lib/musubi/page">>,<<"lib/musubi/page/store_table.ex">>, |
| 33 | - <<"lib/musubi/page/server.ex">>,<<"lib/musubi/page/server">>, |
| 34 | - <<"lib/musubi/page/server/state.ex">>,<<"lib/musubi/page/store_table">>, |
| 35 | - <<"lib/musubi/page/store_table/entry.ex">>, |
| 36 | - <<"lib/musubi/page/patch_envelope.ex">>,<<"lib/musubi/state.ex">>, |
| 37 | - <<"lib/musubi/transport">>,<<"lib/musubi/transport/upload_channel.ex">>, |
| 38 | - <<"lib/musubi/transport/connection_channel.ex">>, |
| 39 | - <<"lib/musubi/transport/socket.ex">>,<<"lib/musubi/transport/channel.ex">>, |
| 40 | - <<"lib/musubi/socket.ex">>,<<"lib/musubi/hooks">>, |
| 13 | + <<"lib/musubi/upload/marker.ex">>,<<"lib/musubi/upload/entry.ex">>, |
| 14 | + <<"lib/musubi/async">>,<<"lib/musubi/async/telemetry.ex">>, |
| 15 | + <<"lib/musubi/stream">>,<<"lib/musubi/stream/slot.ex">>, |
| 16 | + <<"lib/musubi/stream/marker.ex">>,<<"lib/musubi/stream/placeholder.ex">>, |
| 17 | + <<"lib/musubi/stream/async_placeholder.ex">>,<<"lib/musubi/telemetry.ex">>, |
| 18 | + <<"lib/musubi/application.ex">>,<<"lib/musubi/diff.ex">>, |
| 19 | + <<"lib/musubi/testing.ex">>,<<"lib/musubi/child.ex">>,<<"lib/musubi/page">>, |
| 20 | + <<"lib/musubi/page/store_table.ex">>,<<"lib/musubi/page/server.ex">>, |
| 21 | + <<"lib/musubi/page/store_table">>, |
| 22 | + <<"lib/musubi/page/store_table/entry.ex">>,<<"lib/musubi/page/server">>, |
| 23 | + <<"lib/musubi/page/server/state.ex">>, |
| 24 | + <<"lib/musubi/page/patch_envelope.ex">>,<<"lib/musubi/codegen">>, |
| 25 | + <<"lib/musubi/codegen/type_script.ex">>, |
| 26 | + <<"lib/musubi/codegen/type_script">>, |
| 27 | + <<"lib/musubi/codegen/type_script/manifest.ex">>, |
| 28 | + <<"lib/musubi/codegen/type_script/type_renderer.ex">>, |
| 29 | + <<"lib/musubi/upload.ex">>,<<"lib/musubi/input.ex">>, |
| 30 | + <<"lib/musubi/type.ex">>,<<"lib/musubi/wire.ex">>,<<"lib/musubi/store.ex">>, |
| 31 | + <<"lib/musubi/lifecycle.ex">>,<<"lib/musubi/async_supervisor.ex">>, |
| 32 | + <<"lib/musubi/stream.ex">>,<<"lib/musubi/resolver.ex">>, |
| 33 | + <<"lib/musubi/socket.ex">>,<<"lib/musubi/dsl">>, |
| 34 | + <<"lib/musubi/dsl/upload.ex">>,<<"lib/musubi/dsl/input.ex">>, |
| 35 | + <<"lib/musubi/dsl/render.ex">>,<<"lib/musubi/dsl/field.ex">>, |
| 36 | + <<"lib/musubi/dsl/command.ex">>,<<"lib/musubi/dsl/attr.ex">>, |
| 37 | + <<"lib/musubi/dsl/state.ex">>,<<"lib/musubi/dsl/schema.ex">>, |
| 38 | + <<"lib/musubi/async.ex">>,<<"lib/musubi/hooks">>, |
| 41 39 | <<"lib/musubi/hooks/validate_reply_schema.ex">>, |
| 42 40 | <<"lib/musubi/hooks/validate_render.ex">>, |
| 43 41 | <<"lib/musubi/hooks/validate_command_schema.ex">>, |
| 44 | - <<"lib/musubi/async_result.ex">>,<<"lib/musubi/lifecycle.ex">>, |
| 45 | - <<"lib/musubi/type.ex">>,<<"lib/musubi/async_supervisor.ex">>, |
| 46 | - <<"lib/musubi/stream.ex">>,<<"lib/musubi/application.ex">>, |
| 47 | - <<"lib/musubi/async.ex">>,<<"lib/musubi/telemetry.ex">>, |
| 48 | - <<"lib/musubi/codegen">>,<<"lib/musubi/codegen/type_script.ex">>, |
| 49 | - <<"lib/musubi/codegen/type_script">>, |
| 50 | - <<"lib/musubi/codegen/type_script/manifest.ex">>, |
| 51 | - <<"lib/musubi/codegen/type_script/type_renderer.ex">>,<<"lib/mix">>, |
| 52 | - <<"lib/mix/tasks">>,<<"lib/mix/tasks/compile">>, |
| 53 | - <<"lib/mix/tasks/compile/musubi_ts.ex">>,<<"lib/musubi.ex">>,<<"mix.exs">>, |
| 54 | - <<"README.md">>,<<"CHANGELOG.md">>,<<"guides">>,<<"guides/uploads.md">>, |
| 55 | - <<"guides/testing.md">>,<<"guides/getting-started.md">>, |
| 56 | - <<"guides/client-and-react.md">>,<<"guides/phoenix-setup.md">>, |
| 57 | - <<"packages/client/src">>,<<"packages/client/src/runtime.ts">>, |
| 58 | - <<"packages/client/src/index.ts">>,<<"packages/client/src/uploads.ts">>, |
| 59 | - <<"packages/client/src/keyof.ts">>,<<"packages/client/src/patch.ts">>, |
| 60 | - <<"packages/client/src/streams.ts">>,<<"packages/client/src/proxy.ts">>, |
| 61 | - <<"packages/client/src/error.ts">>,<<"packages/client/src/types.ts">>, |
| 62 | - <<"packages/client/src/connect.ts">>,<<"packages/client/package.json">>, |
| 63 | - <<"packages/react/src">>,<<"packages/react/src/index.tsx">>, |
| 64 | - <<"packages/react/src/shallow.ts">>,<<"packages/react/package.json">>]}. |
| 42 | + <<"lib/musubi/reconciler.ex">>,<<"lib/musubi/transport">>, |
| 43 | + <<"lib/musubi/transport/upload_channel.ex">>, |
| 44 | + <<"lib/musubi/transport/connection_channel.ex">>, |
| 45 | + <<"lib/musubi/transport/socket.ex">>,<<"lib/musubi/transport/channel.ex">>, |
| 46 | + <<"lib/musubi/async_result.ex">>,<<"lib/musubi/plugin">>, |
| 47 | + <<"lib/musubi/plugin/type_spec.ex">>,<<"lib/musubi/plugin/type_script.ex">>, |
| 48 | + <<"lib/musubi/plugin/state_field.ex">>,<<"lib/musubi/plugin/definer.ex">>, |
| 49 | + <<"lib/musubi/plugin/reflection.ex">>,<<"lib/musubi/plugin/normalize.ex">>, |
| 50 | + <<"lib/musubi/state.ex">>,<<"lib/mix">>,<<"lib/mix/tasks">>, |
| 51 | + <<"lib/mix/tasks/compile">>,<<"lib/mix/tasks/compile/musubi_ts.ex">>, |
| 52 | + <<"lib/musubi.ex">>,<<"mix.exs">>,<<"README.md">>,<<"CHANGELOG.md">>, |
| 53 | + <<"guides">>,<<"guides/testing.md">>,<<"guides/getting-started.md">>, |
| 54 | + <<"guides/uploads.md">>,<<"guides/phoenix-setup.md">>, |
| 55 | + <<"guides/client-and-react.md">>,<<"packages/client/src">>, |
| 56 | + <<"packages/client/src/proxy.ts">>,<<"packages/client/src/types.ts">>, |
| 57 | + <<"packages/client/src/keyof.ts">>,<<"packages/client/src/runtime.ts">>, |
| 58 | + <<"packages/client/src/uploads.ts">>,<<"packages/client/src/index.ts">>, |
| 59 | + <<"packages/client/src/connect.ts">>,<<"packages/client/src/cache.ts">>, |
| 60 | + <<"packages/client/src/patch.ts">>,<<"packages/client/src/streams.ts">>, |
| 61 | + <<"packages/client/src/error.ts">>,<<"packages/client/package.json">>, |
| 62 | + <<"packages/react/src">>,<<"packages/react/src/shallow.ts">>, |
| 63 | + <<"packages/react/src/index.tsx">>,<<"packages/react/package.json">>]}. |
| 65 64 | {<<"requirements">>, |
| 66 65 | [[{<<"name">>,<<"telemetry">>}, |
| 67 66 | {<<"app">>,<<"telemetry">>}, |
Loading more files…