Persistence pattern (application-owned)

Copy Markdown View Source

Musubi does not ship an Musubi.Persistence module, behaviour, or adapter. This is a deliberate scope decision recorded in spec/backlog.md: persistence is an application concern, not a runtime primitive. Hook stages and the socket extension points already give applications everything they need.

This document is the reference for that pattern: load on mount/1, save on attach_hook(:persist, :after_command, …).

Why hooks, not a built-in adapter

  • Topology varies. Some stores save full snapshots; some append events; some cache and let a background worker write through. A built-in adapter forces a single shape.
  • Authorization is application-specific. Who can persist what for whom is policy, not transport.
  • Hooks already have the right surface. :after_command runs after handle_command/3 returns. The store's resolved post-command socket is in scope. That is the natural save point.
  • mount/1 is the natural load point. It already runs once per page mount before the first render and can read from any store the application controls.

Save: attach_hook(:persist, :after_command, …)

:after_command hooks are arity 4, called as (command_name, payload, reply, socket). Return shape is {:cont, socket} or {:halt, socket}. use Musubi.Store exposes assign/2,3, attach_hook/4, and the other LV-style helpers bare; the fully-qualified Musubi.Socket.* and Musubi.Lifecycle.* forms remain available when preferred.

defmodule MyApp.Stores.CartStore do
  use Musubi.Store

  state do
    field :items, list(CartItemState.t())
  end

  command :add_item do
    payload do
      field :sku, String.t()
    end
  end

  def mount(socket) do
    items = MyApp.Storage.load_cart(socket.assigns.cart_id) || []

    socket =
      socket
      |> assign(:items, items)
      |> attach_hook(:persist, :after_command, &persist/4)

    {:ok, socket}
  end

  defp persist(_command_name, _payload, _reply, socket) do
    MyApp.Storage.save_cart(socket.assigns.cart_id, socket.assigns.items)
    {:cont, socket}
  end

  def handle_command(:add_item, %{"sku" => sku}, socket) do
    {:noreply, update(socket, :items, &[CartItemState.new(sku) | &1])}
  end

  def render(socket), do: %{items: socket.assigns.items}
end

Selective save

Hooks see the command name, so the application can opt out of persistence for read-only commands or for commands that already wrote to storage themselves.

defp persist(:refresh, _payload, _reply, socket), do: {:cont, socket}
defp persist(_command, _payload, _reply, socket) do
  MyApp.Storage.save_cart(socket.assigns.cart_id, socket.assigns.items)
  {:cont, socket}
end

Failure handling

If persistence fails and the application wants the user-visible error to surface, raise inside the hook — the page server will crash per BDR-0003 let-it-crash. If the application wants to swallow and retry asynchronously, log inside the hook and continue. There is no built-in retry.

require Logger

defp persist(_command, _payload, _reply, socket) do
  case MyApp.Storage.save_cart(socket.assigns.cart_id, socket.assigns.items) do
    :ok -> {:cont, socket}
    {:error, reason} ->
      Logger.error("cart persist failed: #{inspect(reason)}")
      {:cont, socket}
  end
end

Load: inside mount/1

mount/1 runs once per page mount on the root store and once per child mount; both have full access to socket.assigns. Load there:

def mount(socket) do
  case MyApp.Storage.load_cart(socket.assigns.cart_id) do
    {:ok, items} -> {:ok, assign(socket, :items, items)}
    :error -> {:ok, assign(socket, :items, [])}
  end
end

If the load is slow, prefer assign_async/3:

def mount(socket) do
  cart_id = socket.assigns.cart_id

  socket =
    socket
    |> assign(:items, [])
    |> assign_async(:loaded, fn -> {:ok, MyApp.Storage.load_cart(cart_id)} end)

  {:ok, socket}
end

Stream slot reload

For a stream slot, refresh in-session via stream(socket, :messages, items, reset: true) or stream_async(socket, :messages, fun, reset: true). The persistence pattern still applies — load fresh items inside mount/1 (or whichever handler triggers a refresh) and emit them through the stream API. The runtime forgets stream values after flush; only the per-stream slot config (item_key fn, limit, ref counter) is retained on the socket. The client owns the materialized list.

Snapshot vs append-only

Both shapes work with the same primitives:

  • Snapshot — overwrite on every :after_command write. Simple. Higher write cost. Easy reload (single read at mount).
  • Append-only — write each command + payload to an event log; rebuild state at mount by replaying. More flexible. Reload cost grows with history. Use when audit/event sourcing is already part of the application.

Musubi doesn't pick. Both compose with the same hook stages.

Reconnect = fresh mount

Per BDR-0003, reconnect rebuilds the page from scratch. There is no in-memory checkpoint that survives a transport drop. The load path is the only restoration mechanism. This is intentional: the runtime never tries to be a durable store; durability is delegated entirely to the application's persistence layer.

What you do not need to do

  • Do not call into the runtime to persist on its behalf — there is no API.
  • Do not write to socket.private for app-level persistence state. That namespace is reserved (hook table, async ref tracking, pending stream ops). Use a dedicated assign.
  • Do not introduce a :persist hook stage. The six public stages (:before_command, :after_command, :handle_async, :handle_info, :after_render, :after_serialize) are stable per BDR-0004; persistence rides on :after_command.