Current section

Files

Jump to
mob lib mob screen_state.ex
Raw

lib/mob/screen_state.ex

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