Current section

19 Versions

Jump to

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…