defmodule DurableStash do @moduledoc """ Keep Phoenix LiveView state alive across reconnects, crashes, and redeploys. A LiveView's assigns live in memory and vanish when the socket drops, the process crashes, or you deploy. `DurableStash` saves the assigns you pick to object storage and restores them the next time the LiveView mounts. The saved copy belongs to the browser session, so every LiveView the user opens shares it. Under the hood it plugs into [LiveStash](https://hex.pm/packages/live_stash) and stores state through [DurableServer](https://hex.pm/packages/durable_server). State dies with the browser session. ## Usage defmodule MyAppWeb.SomeLive do use MyAppWeb, :live_view use LiveStash, adapter: DurableStash, stored_keys: [:count, :username] def mount(_params, _session, socket) do socket = assign(socket, count: 0, username: nil) {_status, socket} = LiveStash.recover_state(socket) {:ok, socket} end end Call `LiveStash.recover_state/1` in `mount/3` *after* assigning defaults (recovered values overwrite them), and `LiveStash.stash/1` whenever a stored assign changes — or pass `auto_stash: true`. Unlike the stock ETS adapter, `DurableStash` recovers on **every** mount — fresh navigations included — and never deletes the stash on mount. State is keyed by the browser session, not by the socket. ## Setup 1. Register the adapter and configure the backend: config :live_stash, adapters: [DurableStash] config :durable_stash, backend: {DurableServer.Backends.ObjectStore, bucket: "...", ...}, prefix: "durable_stash/", secret: "some-stable-secret" With that config, `LiveStash.Application` starts the DurableServer supervisor automatically through `child_spec/1`. Alternatively, run your own `DurableServer.Supervisor` and point the adapter at it with the `:supervisor` option. 2. Put a session id into the cookie session, in the `:browser` pipeline after `plug :fetch_session`: plug :ensure_session_id defp ensure_session_id(conn, _opts) do if get_session(conn, "sid") do conn else sid = 16 |> :crypto.strong_rand_bytes() |> Base.url_encode64(padding: false) put_session(conn, "sid", sid) end end ## Scopes Not all state wants the same recovery policy. Each stored key declares a scope: use LiveStash, adapter: DurableStash, stored_keys: [ theme: :session, # recover on every mount (the default) draft: :reconnect # recover only on reconnects; cleared on fresh mounts ] * `:session` — recovered on every mount: live navigation, reconnects, crashes, redeploys. Right for settings the user expects to stick. * `:reconnect` — recovered only when the client *rejoins* an existing view (`_mounts > 0`): Wi-Fi drops, LiveView crashes, and redeploys — the browser stays on the page through all of these. A fresh navigation to the view clears the stored values, so starting a "new thing" starts blank. Right for in-progress form drafts. ## Options (via `use LiveStash, adapter: DurableStash, ...`) * `:stored_keys` (required) — assigns to persist. Bare atoms mean `:session` scope; see *Scopes* above for `:reconnect`. `:permanent` is reserved and raises for now. * `:vsn` (default `1`) — version of this view's stored shape. On recovery, a stored slice with a different vsn is discarded to defaults unless `:migrate` is given. * `:migrate` — 2-arity function `(old_vsn, data) :: data` receiving the stored string-keyed data map and returning the migrated one. The migrated set is written back under the new vsn. * `:supervisor` — DurableServer supervisor name (default `config :durable_stash, :supervisor_name`, falling back to `DurableStash.Supervisor`). * `:secret` — mixed into the storage-key hash (default `config :durable_stash, :secret`). * `:session_id_key` — cookie-session key holding the session id (default `"sid"`). ## What's storable JSON-safe values only: no structs, tuples, pids, or functions; maps come back with string keys. Offending values are skipped with a logged error, or raise when `config :durable_stash, on_invalid_value: :raise` is set (recommended for dev and test). Values are normalized through a JSON round-trip at stash time, so what you recover in dev is byte-for-byte what you'd recover after a redeploy in prod. """ @behaviour LiveStash.Adapter require Logger alias DurableStash.Session alias Phoenix.Component alias Phoenix.LiveView defmodule Context do @moduledoc false defstruct supervisor: nil, storage_key: nil, view: nil, stored_keys: [], vsn: 1, migrate: nil, fingerprints: %{}, reconnected?: false end @private_key :durable_stash @default_secret "durable_stash" @default_session_id_key "sid" ## LiveStash.Adapter callbacks @impl true def init_stash(socket, session, opts) do stored_keys = parse_stored_keys!(Keyword.fetch!(opts, :stored_keys)) vsn = Keyword.get(opts, :vsn, 1) migrate = parse_migrate!(Keyword.get(opts, :migrate)) supervisor = Keyword.get(opts, :supervisor) || Application.get_env(:durable_stash, :supervisor_name, DurableStash.Supervisor) secret = Keyword.get(opts, :secret) || Application.get_env(:durable_stash, :secret, @default_secret) session_id_key = Keyword.get(opts, :session_id_key, @default_session_id_key) context = %Context{ supervisor: supervisor, storage_key: derive_storage_key(session, session_id_key, secret), view: view_name(socket), stored_keys: stored_keys, vsn: vsn, migrate: migrate, reconnected?: reconnected?(socket) } clear_stale_reconnect_keys(socket, context) LiveView.put_private(socket, @private_key, context) end @impl true def stash(socket) do case operable_context(socket) do {:ok, context} -> do_stash(socket, context) :error -> socket end end @impl true def recover_state(socket) do case operable_context(socket) do {:ok, context} -> do_recover(socket, context) :error -> {:error, socket} end end @impl true def reset_stash(socket) do case operable_context(socket) do {:ok, context} -> do_reset(socket, context) :error -> socket end end @doc """ Starts the DurableServer supervisor from `:durable_stash` config when a `:backend` is configured; otherwise starts an empty, harmless supervisor. Invoked automatically by `LiveStash.Application` for registered adapters. """ @impl true def child_spec(_args) do %{ id: __MODULE__, start: {__MODULE__, :start_configured_supervisor, []}, type: :supervisor } end @doc false def start_configured_supervisor do children = case Application.get_env(:durable_stash, :backend) do nil -> [] backend -> supervisor_opts = Application.get_env(:durable_stash, :supervisor_opts, []) [ {DurableServer.Supervisor, [ name: Application.get_env(:durable_stash, :supervisor_name, DurableStash.Supervisor), prefix: Application.get_env(:durable_stash, :prefix, "durable_stash/"), backend: backend ] ++ supervisor_opts} ] end Supervisor.start_link(children, strategy: :one_for_one) end ## Stash defp do_stash(socket, %Context{} = context) do {changes, fingerprints} = changed_entries(socket.assigns, context) if changes == %{} do socket else case call_session(context, &Session.merge(&1, context.view, changes, context.vsn)) do :ok -> put_context(socket, %{context | fingerprints: fingerprints}) {:error, reason} -> Logger.error("[DurableStash] stash failed for #{context.view}: #{inspect(reason)}") socket end end end defp changed_entries(assigns, %Context{} = context) do context.stored_keys |> Enum.map(fn {key, _scope} -> key end) |> Enum.reduce({%{}, context.fingerprints}, fn key, {changes, fingerprints} -> with {:ok, value} <- Map.fetch(assigns, key), {:ok, encoded} <- encode_value(key, value) do name = Atom.to_string(key) fingerprint = fingerprint(encoded) if fingerprints[name] == fingerprint do {changes, fingerprints} else {Map.put(changes, name, JSON.decode!(encoded)), Map.put(fingerprints, name, fingerprint)} end else :error -> {changes, fingerprints} {:skip, _key} -> {changes, fingerprints} end end) end defp encode_value(key, value) do {:ok, JSON.encode!(value)} rescue error -> message = "[DurableStash] value for #{inspect(key)} is not JSON-safe " <> "(no structs, tuples, pids, or functions): #{Exception.message(error)}" case Application.get_env(:durable_stash, :on_invalid_value, :log) do :raise -> reraise ArgumentError.exception(message), __STACKTRACE__ _log -> Logger.error(message) {:skip, key} end end ## Recover defp do_recover(socket, %Context{} = context) do case call_session(context, &Session.fetch_view(&1, context.view)) do :not_found -> {:not_found, socket} {:ok, %{"vsn" => stored_vsn, "data" => data}} -> recover_slice(socket, context, stored_vsn, data) {:error, reason} -> Logger.error("[DurableStash] recover failed for #{context.view}: #{inspect(reason)}") {:error, socket} end end defp recover_slice(socket, %Context{vsn: vsn} = context, vsn, data) do apply_recovered(socket, context, data) end defp recover_slice(socket, %Context{migrate: nil}, _stored_vsn, _data) do # Version mismatch without a migration: UI-grade state, discard to # defaults. The next stash overwrites the slice under the current vsn. {:not_found, socket} end defp recover_slice(socket, %Context{} = context, stored_vsn, data) do migrated = context.migrate.(stored_vsn, data) case apply_recovered(socket, context, migrated) do {:recovered, recovered_socket} -> write_back_migrated(recovered_socket, migrated) other -> other end rescue error -> Logger.error( "[DurableStash] migrate from vsn #{stored_vsn} failed for #{context.view}: " <> Exception.message(error) ) {:error, socket} end # Persist the full migrated set under the new vsn so the slice can never # end up as a mix of old- and new-shape keys. defp write_back_migrated(socket, migrated) do context = socket.private[@private_key] restored = Map.take(migrated, Enum.map(recoverable_keys(context), &Atom.to_string/1)) case call_session(context, &Session.merge(&1, context.view, restored, context.vsn)) do :ok -> {:recovered, socket} {:error, reason} -> Logger.error( "[DurableStash] migrated write-back failed for #{context.view}: #{inspect(reason)}" ) {:recovered, socket} end end defp apply_recovered(socket, %Context{} = context, data) do {recovered, fingerprints} = context |> recoverable_keys() |> Enum.reduce({%{}, context.fingerprints}, fn key, {recovered, fingerprints} -> # Atom keys come from the declared whitelist — never String.to_atom # on stored input. case Map.fetch(data, Atom.to_string(key)) do {:ok, value} -> fingerprint = fingerprint(JSON.encode!(value)) {Map.put(recovered, key, value), Map.put(fingerprints, Atom.to_string(key), fingerprint)} :error -> {recovered, fingerprints} end end) if recovered == %{} do {:not_found, socket} else socket |> Component.assign(recovered) |> put_context(%{context | fingerprints: fingerprints}) |> then(&{:recovered, &1}) end end ## Reset defp do_reset(socket, %Context{} = context) do case call_session(context, &Session.reset_view(&1, context.view)) do :ok -> put_context(socket, %{context | fingerprints: %{}}) {:error, reason} -> Logger.error("[DurableStash] reset failed for #{context.view}: #{inspect(reason)}") socket end end ## Session access defp call_session(%Context{} = context, fun) do case DurableServer.Supervisor.ensure_started_child( context.supervisor, {Session, key: context.storage_key, initial_state: %{}} ) do {:ok, {pid, _meta}} -> fun.(pid) {:error, reason} -> {:error, reason} :ignore -> {:error, :ignore} end catch kind, reason -> {:error, {kind, reason}} end ## Context plumbing defp operable_context(socket) do case socket.private[@private_key] do %Context{} = context -> if operable?(context), do: {:ok, context}, else: :error nil -> Logger.error( "[DurableStash] no stash context on socket — did you `use LiveStash, adapter: DurableStash`?" ) :error end end defp operable?(%Context{storage_key: storage_key, view: view}) do is_binary(storage_key) and is_binary(view) end defp put_context(socket, %Context{} = context) do LiveView.put_private(socket, @private_key, context) end defp derive_storage_key(session, session_id_key, secret) do case session do %{^session_id_key => sid} when is_binary(sid) -> :sha256 |> :crypto.hash(sid <> secret) |> Base.url_encode64(padding: false) _session -> Logger.error( "[DurableStash] no #{inspect(session_id_key)} in the cookie session — " <> "add the ensure_session_id plug to your :browser pipeline (see the DurableStash docs)" ) nil end end defp view_name(%{view: view}) when is_atom(view) and not is_nil(view), do: Atom.to_string(view) defp view_name(_socket) do Logger.error("[DurableStash] socket has no view — cannot scope the stash") nil end defp keys_for_scope(%Context{stored_keys: stored_keys}, scope) do for {key, ^scope} <- stored_keys, do: key end defp recoverable_keys(%Context{reconnected?: true} = context) do keys_for_scope(context, :session) ++ keys_for_scope(context, :reconnect) end defp recoverable_keys(%Context{} = context) do keys_for_scope(context, :session) end # A reconnect (Wi-Fi drop, LiveView crash, deploy) rejoins the same client # view, so `_mounts` is positive. A fresh navigation mounts at zero. defp reconnected?(socket) do match?(%{"_mounts" => mounts} when is_integer(mounts) and mounts > 0, connect_params(socket)) end defp connect_params(socket) do if LiveView.connected?(socket) do LiveView.get_connect_params(socket) || %{} else %{} end rescue # get_connect_params is only available while mounting; treat anything # else as a fresh mount. _error -> %{} end # Stock LiveStash semantics for :reconnect keys: a fresh mount starts the # task over, so the stored values must go — otherwise a crash right after # navigating here would resurrect a stale draft. defp clear_stale_reconnect_keys(socket, %Context{} = context) do reconnect_keys = keys_for_scope(context, :reconnect) if reconnect_keys != [] and LiveView.connected?(socket) and not context.reconnected? and operable?(context) do keys = Enum.map(reconnect_keys, &Atom.to_string/1) case call_session(context, &Session.drop(&1, context.view, keys)) do :ok -> :ok {:error, reason} -> Logger.error( "[DurableStash] clearing reconnect keys failed for #{context.view}: #{inspect(reason)}" ) end end :ok end defp fingerprint(encoded) when is_binary(encoded) do :crypto.hash(:sha256, encoded) end ## Option parsing — config errors should be loud, so these raise. defp parse_stored_keys!(stored_keys) when is_list(stored_keys) do Enum.map(stored_keys, fn key when is_atom(key) -> {key, :session} {key, scope} when is_atom(key) and scope in [:session, :reconnect] -> {key, scope} {key, :permanent} when is_atom(key) -> raise ArgumentError, "[DurableStash] the :permanent scope is not yet supported (key #{inspect(key)})" other -> raise ArgumentError, "[DurableStash] invalid stored_keys entry: #{inspect(other)} — " <> "expected an atom, `{atom, :session}`, or `{atom, :reconnect}`" end) end defp parse_stored_keys!(other) do raise ArgumentError, "[DurableStash] stored_keys must be a list, got: #{inspect(other)}" end defp parse_migrate!(nil), do: nil defp parse_migrate!(migrate) when is_function(migrate, 2), do: migrate defp parse_migrate!(other) do raise ArgumentError, "[DurableStash] :migrate must be a 2-arity function (old_vsn, data), got: #{inspect(other)}" end end