Current section
19 Versions
Jump to
Current section
19 Versions
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…