defmodule Mob.ScreenState do @moduledoc """ Persistent store for screen assigns. Backed by the app's configured Ecto Repo (via `config :mob, :repo, MyApp.Repo`). All functions are no-ops when no Repo is configured, so screens compiled with `vsn:` enabled don't crash in environments without a database. ## Setup Generated projects include the required table migration and config automatically. For existing projects, add to `config/config.exs`: config :mob, :repo, MyApp.Repo And run the migration (see `priv/repo/migrations/*_create_mob_screen_states.exs` generated by `mix mob.new`). ## Storage State is keyed by screen module name by default. Screens with per-user or parameterised state should implement `screen_key/1`: def screen_key(assigns), do: "\#{__MODULE__}:\#{assigns.user_id}" ## Serialisation Values are encoded with `:erlang.term_to_binary/1` after stripping non-serialisable terms (PIDs, references, ports, functions). The `:safe` flag is used on decode to prevent atom-table pollution from untrusted data. """ @select_sql "SELECT vsn, data FROM mob_screen_states WHERE key = ?" @upsert_sql """ INSERT OR REPLACE INTO mob_screen_states (key, vsn, data, updated_at) VALUES (?, ?, ?, ?) """ @delete_sql "DELETE FROM mob_screen_states WHERE key = ?" @doc """ Persist the current assigns of `socket` for `module`. Calls `module.dump_state/1`, strips non-serialisable values, and writes to the configured Repo. Returns `:ok` regardless — persistence failures are silent so a missing or misconfigured Repo never crashes a screen. """ @spec dump(module(), Mob.Socket.t()) :: :ok def dump(module, socket) do with repo when not is_nil(repo) <- repo(), key <- screen_key(module, socket), vsn <- module.__mob_vsn__(), raw <- module.dump_state(socket.assigns), {:ok, data} <- safe_encode(raw) do ts = System.system_time(:second) apply(repo, :query!, [@upsert_sql, [key, vsn, data, ts]]) end :ok end @doc """ Load previously persisted assigns for `module`. Returns `{:ok, stored_vsn, raw_map}` when a record exists, `:not_found` otherwise (including when no Repo is configured or the data cannot be decoded). """ @spec load(module(), Mob.Socket.t()) :: {:ok, non_neg_integer(), map()} | :not_found def load(module, socket) do with repo when not is_nil(repo) <- repo(), key <- screen_key(module, socket) do case apply(repo, :query!, [@select_sql, [key]]) do %{rows: [[stored_vsn, blob]]} when is_binary(blob) -> case safe_decode(blob) do {:ok, raw} -> {:ok, stored_vsn, raw} :error -> :not_found end _ -> :not_found end else _ -> :not_found end end @doc """ Delete the persisted state for `module`. Used when resetting or logging out. """ @spec delete(module(), Mob.Socket.t()) :: :ok def delete(module, socket) do with repo when not is_nil(repo) <- repo(), key <- screen_key(module, socket) do apply(repo, :query!, [@delete_sql, [key]]) end :ok end # ── Private ──────────────────────────────────────────────────────────────── defp repo, do: Application.get_env(:mob, :repo) defp screen_key(module, socket) do if function_exported?(module, :screen_key, 1), do: module.screen_key(socket.assigns), else: to_string(module) end defp safe_encode(term) do {:ok, :erlang.term_to_binary(strip(term))} rescue _ -> :error end defp safe_decode(blob) do {:ok, :erlang.binary_to_term(blob, [:safe])} rescue _ -> :error end defp strip(map) when is_map(map) and not is_struct(map) do map |> Enum.reject(fn {_k, v} -> ephemeral?(v) end) |> Map.new(fn {k, v} -> {k, strip(v)} end) end defp strip(list) when is_list(list) do list |> Enum.reject(&ephemeral?/1) |> Enum.map(&strip/1) end defp strip(v), do: v defp ephemeral?(v), do: is_pid(v) or is_reference(v) or is_port(v) or is_function(v) end