Current section

19 Versions

Jump to

Compare versions

9 files changed
+153 additions
-6 deletions
  @@ -11,6 +11,23 @@ not in lockstep yet; entries note which surface they affect.
11 11
12 12 ## [Unreleased]
13 13
14 + ## [0.9.0] — 2026-06-17
15 +
16 + ### Added
17 +
18 + - **`musubi`**`Musubi.send_update/2,3`, aligned with
19 + `Phoenix.LiveView.send_update`, lets the server target one mounted child store
20 + by `store_id` with new assigns. The map is delivered to the store's `update/2`;
21 + only that subtree re-renders and one scoped JSON Patch envelope ships (the
22 + clean root short-circuits its own `render/1`). It is the intra-page last hop
23 + for cross-connection fan-out coordinated over `Phoenix.PubSub` — Musubi owns
24 + the targeting, the application owns the broadcast (no built-in PubSub
25 + abstraction). The two-arity form sends to `self()` (call it from the root's
26 + `handle_info/2`); the three-arity form targets an explicit page pid.
27 + Addressing the root (`[]`) is allowed. A `store_id` that no longer resolves to
28 + a mounted store is a no-op and emits `[:musubi, :send_update, :no_target]`
29 + telemetry (BDR-0030, #76).
30 +
14 31 ## [0.8.0] — 2026-06-13
15 32
16 33 ### Added
  @@ -278,7 +295,8 @@ Initial public release of the Musubi runtime (then `Arbor`):
278 295 - TypeScript client and React adapter that materialize the diff stream
279 296 into immutable snapshots.
280 297
281 - [Unreleased]: https://github.com/fahchen/musubi/compare/v0.8.0...HEAD
298 + [Unreleased]: https://github.com/fahchen/musubi/compare/v0.9.0...HEAD
299 + [0.9.0]: https://github.com/fahchen/musubi/compare/v0.8.0...v0.9.0
282 300 [0.8.0]: https://github.com/fahchen/musubi/compare/v0.7.2...v0.8.0
283 301 [0.7.2]: https://github.com/fahchen/musubi/compare/v0.7.1...v0.7.2
284 302 [0.7.1]: https://github.com/fahchen/musubi/compare/v0.7.0...v0.7.1
  @@ -34,7 +34,7 @@ Add Musubi to your Phoenix application:
34 34 ```elixir
35 35 def deps do
36 36 [
37 - {:musubi, "~> 0.8.0"}
37 + {:musubi, "~> 0.9.0"}
38 38 ]
39 39 end
40 40 ```
  @@ -183,6 +183,25 @@ set — `MusubiProvider` (accepts `connection` or `socket`),
183 183 `{ dispatch, isPending, error, data, reset }`). Use `keyOf(proxy)` for
184 184 stable React list keys over child proxies.
185 185
186 + ## Server-driven child refresh
187 +
188 + To push new assigns to one specific mounted child store from the server, call
189 + `Musubi.send_update/2,3` — aligned with `Phoenix.LiveView.send_update`:
190 +
191 + ```elixir
192 + # Inside the root store's handle_info/2 (self() is the page process):
193 + def handle_info({:comments_changed, file_id}, socket) do
194 + Musubi.send_update(["files", file_id, "comments"], %{reload_token: make_ref()})
195 + {:noreply, socket}
196 + end
197 + ```
198 +
199 + The assigns map is delivered to the addressed store's `update/2`; only that
200 + subtree re-renders and one scoped JSON Patch envelope ships. This is the
201 + intra-page last hop for cross-connection fan-out: broadcast over
202 + `Phoenix.PubSub`, and each page's root `handle_info/2` calls `send_update`.
203 + Musubi has no built-in PubSub abstraction — the application owns the broadcast.
204 +
186 205 ## Documentation
187 206
188 207 - [Getting Started](guides/getting-started.md)
  @@ -10,7 +10,7 @@ In the Phoenix app:
10 10 ```elixir
11 11 def deps do
12 12 [
13 - {:musubi, "~> 0.8.0"}
13 + {:musubi, "~> 0.9.0"}
14 14 ]
15 15 end
16 16 ```
  @@ -1,6 +1,6 @@
1 1 {<<"links">>,[{<<"GitHub">>,<<"https://github.com/fahchen/musubi">>}]}.
2 2 {<<"name">>,<<"musubi">>}.
3 - {<<"version">>,<<"0.8.0">>}.
3 + {<<"version">>,<<"0.9.0">>}.
4 4 {<<"description">>,
5 5 <<"Server-authoritative, page-scoped runtime library for Elixir/Phoenix applications.">>}.
6 6 {<<"elixir">>,<<"~> 1.18">>}.
  @@ -44,4 +44,70 @@ defmodule Musubi do
44 44
45 45 See `docs/PRD.md` and `spec/decisions/` for the design rationale.
46 46 """
47 +
48 + @type store_id() :: Musubi.Socket.store_id()
49 +
50 + @doc """
51 + Targets a mounted child store with new assigns, aligned with
52 + `Phoenix.LiveView.send_update/2`.
53 +
54 + The `assigns` map is delivered to the addressed store's `update/2`
55 + callback (or merged directly when the store does not export it). The
56 + store's socket goes dirty and only that subtree re-renders; a clean
57 + root short-circuits its own `render/1` (BDR-0023). One coalesced patch
58 + envelope ships for the cycle. A `store_id` that no longer resolves to a
59 + mounted store is a no-op (LV-aligned) and emits
60 + `[:musubi, :send_update, :no_target]` telemetry.
61 +
62 + This two-arity form sends to `self()` — call it from inside the root
63 + store's `handle_info/2`, where `self()` is the page process. It is the
64 + intra-page last hop for cross-connection fan-out coordinated over
65 + `Phoenix.PubSub` (BDR-0005 / BDR-0030); Musubi owns the targeting, the
66 + application owns the broadcast.
67 +
68 + `store_id` may address any mounted store, including the root (`[]`).
69 +
70 + Push keys the **child owns**, not keys the parent passes it — e.g. an
71 + internal trigger like `%{reload_token: ref}` the child's `update/2`
72 + reacts to by reloading. A key the parent also supplies is reconciled
73 + back to the parent's value on the very next resolve (parent props win,
74 + LiveView-aligned) and re-invokes `update/2`, so it has no net effect.
75 +
76 + ## Examples
77 +
78 + iex> me = self()
79 + iex> Musubi.send_update(["comments"], %{reload_token: :ref})
80 + :ok
81 + iex> receive do {:musubi_send_update, _, _} = msg -> msg end
82 + {:musubi_send_update, ["comments"], %{reload_token: :ref}}
83 + iex> me == self()
84 + true
85 + """
86 + @spec send_update(store_id(), map()) :: :ok
87 + def send_update(store_id, assigns) when is_list(store_id) and is_map(assigns) do
88 + send(self(), {:musubi_send_update, store_id, assigns})
89 + :ok
90 + end
91 +
92 + @doc """
93 + Targets a mounted child store on `page_pid` with new assigns, aligned
94 + with `Phoenix.LiveView.send_update/3`.
95 +
96 + Same semantics as `send_update/2` but addressed at an explicit page
97 + process — for an in-node caller holding the page pid (e.g. a release
98 + `rpc` task) rather than running inside the page's own `handle_info/2`.
99 +
100 + ## Examples
101 +
102 + iex> Musubi.send_update(self(), ["comments"], %{reload_token: :ref})
103 + :ok
104 + iex> receive do {:musubi_send_update, _, _} = msg -> msg end
105 + {:musubi_send_update, ["comments"], %{reload_token: :ref}}
106 + """
107 + @spec send_update(pid(), store_id(), map()) :: :ok
108 + def send_update(page_pid, store_id, assigns)
109 + when is_pid(page_pid) and is_list(store_id) and is_map(assigns) do
110 + send(page_pid, {:musubi_send_update, store_id, assigns})
111 + :ok
112 + end
47 113 end
Loading more files…