Packages
mob
0.6.19
0.7.20
0.7.19
0.7.18
0.7.17
0.7.16
0.7.15
0.7.14
0.7.13
0.7.12
0.7.11
0.7.10
0.7.9
0.7.8
0.7.7
0.7.6
0.7.5
0.7.4
0.7.3
0.7.2
0.7.1
0.7.0
0.6.26
0.6.25
0.6.24
0.6.23
0.6.22
0.6.21
0.6.20
0.6.19
0.6.18
0.6.17
0.6.16
0.6.15
0.6.14
0.6.13
0.6.12
0.6.11
0.6.10
0.6.9
0.6.8
0.6.7
0.6.6
0.6.5
0.6.2
0.6.1
0.6.0
0.5.18
0.5.17
0.5.16
0.5.15
0.5.14
0.5.11
0.5.10
0.5.7
0.5.6
0.5.5
0.5.4
0.5.3
0.5.2
0.5.1
0.5.0
0.4.0
0.3.10
0.3.9
0.3.8
0.3.7
0.3.6
0.3.5
0.3.4
0.3.3
0.3.2
0.3.1
0.3.0
0.2.0
0.1.0
BEAM-on-device mobile framework for Elixir
Current section
Files
Jump to
Current section
Files
lib/mob/state.ex
defmodule Mob.State do
@moduledoc """
Persistent key-value store for app state.
Backed by `:dets` — Erlang's disk-based term storage, part of OTP stdlib.
State survives app kills and restarts. Any Elixir term can be stored as a
value; no serialisation step required.
## When to use this vs. Ecto
- **`Mob.State`** — app preferences, UI choices, small per-user settings.
Think: selected theme, onboarding complete flag, last-opened tab, cached
user ID. Designed for O(dozens) of keys, not O(thousands) of rows.
- **Your Ecto Repo** — user records, structured data, anything you'd query,
filter, or paginate. Use migrations and schemas for that.
## Usage
# Store anything — atoms, maps, lists, nested structures.
Mob.State.put(:theme, :citrus)
Mob.State.put(:onboarded, true)
Mob.State.put(:last_position, %{lat: 43.7, lng: -79.4})
# Read back on next launch — returns `default` if not yet set.
Mob.State.get(:theme, :obsidian) #=> :citrus
Mob.State.get(:missing_key, 0) #=> 0
# Remove a key.
Mob.State.delete(:last_position)
## Lifecycle
Started automatically by `Mob.App.start/0` — no setup required.
The backing file is stored at `MOB_DATA_DIR/mob_state.dets` on device,
or `priv/repo/mob_state.dets` in local dev (same directory as the SQLite DB).
"""
use GenServer
@table :mob_state
# ── Public API ─────────────────────────────────────────────────────────────
@doc false
@spec start_link(keyword()) :: GenServer.on_start()
def start_link(opts \\ []) do
GenServer.start_link(__MODULE__, opts, name: __MODULE__)
end
@doc """
Read a persisted value.
Returns `default` if the key has never been written. `default` is `nil`
unless explicitly provided.
iex> Mob.State.get(:theme, :obsidian)
:obsidian
iex> Mob.State.put(:theme, :citrus)
iex> Mob.State.get(:theme, :obsidian)
:citrus
Reads go directly to the underlying `:dets` table without going through
the GenServer, so they never queue behind in-flight writes.
"""
@spec get(term(), term()) :: term()
def get(key, default \\ nil) do
case :dets.lookup(@table, key) do
[{^key, value}] -> value
[] -> default
end
end
@doc """
Persist a key-value pair to disk.
Any Elixir term is accepted for both key and value. Backed by `:dets`
(Erlang's disk-based term storage) with a `sync` after each write, so the
value is on disk before this function returns and survives an immediate
`SIGKILL` (Android OOM kill, iOS termination under memory pressure).
Mob.State.put(:theme, :citrus)
Mob.State.put(:onboarded, true)
Mob.State.put({:last_seen, :home}, DateTime.utc_now())
Mob.State.put(:prefs, %{font_size: 16, notifications: true})
Calling `put/2` with an existing key overwrites the previous value.
"""
@spec put(term(), term()) :: :ok
def put(key, value) do
GenServer.call(__MODULE__, {:put, key, value})
end
@doc """
Delete a key.
No-op if the key is absent. The deletion is synchronised to disk before
this function returns.
Mob.State.delete(:theme)
Mob.State.get(:theme, :obsidian) #=> :obsidian
"""
@spec delete(term()) :: :ok
def delete(key) do
GenServer.call(__MODULE__, {:delete, key})
end
@doc """
Return all `{key, value}` pairs whose key matches `pattern`.
`pattern` is a key-shape with `:_` placeholders for parts that vary —
e.g. `{:peer_profile, :_}` to fetch every peer profile that an app has
stored under `{:peer_profile, <pubkey>}` keys. Useful when an app uses
a tagged-tuple key convention to namespace records and wants to walk
the namespace.
# All peer profiles
Mob.State.match({:peer_profile, :_})
#=> [{{:peer_profile, <<...>>}, %{...}}, ...]
Reads directly from DETS without going through the GenServer call
loop — fine for occasional listings (UI mounts, tests). Don't call it
in a tight loop.
"""
@spec match(term()) :: [{term(), term()}]
def match(pattern) do
case :dets.match_object(@table, {pattern, :_}) do
list when is_list(list) -> list
_ -> []
end
rescue
ArgumentError -> []
end
# ── GenServer ──────────────────────────────────────────────────────────────
@impl true
def init(_opts) do
path = state_path() |> String.to_charlist()
case :dets.open_file(@table, file: path, type: :set) do
{:ok, _} -> {:ok, %{}}
{:error, r} -> {:stop, {:dets_open_failed, r}}
end
end
@impl true
def handle_call({:put, key, value}, _from, state) do
:ok = :dets.insert(@table, {key, value})
:ok = :dets.sync(@table)
{:reply, :ok, state}
end
@impl true
def handle_call({:delete, key}, _from, state) do
:ok = :dets.delete(@table, key)
:ok = :dets.sync(@table)
{:reply, :ok, state}
end
@impl true
def terminate(_reason, _state) do
:dets.close(@table)
end
# ── Private ────────────────────────────────────────────────────────────────
defp state_path do
Path.join(Mob.data_dir(), "mob_state.dets")
end
end