Current section
Files
Jump to
Current section
Files
lib/dala/platform/state.ex
defmodule Dala.Platform.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
- **`Dala.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.
Dala.State.put(:theme, :citrus)
Dala.State.put(:onboarded, true)
Dala.State.put(:last_position, %{lat: 43.7, lng: -79.4})
# Read back on next launch — returns `default` if not yet set.
Dala.State.get(:theme, :obsidian) #=> :citrus
Dala.State.get(:missing_key, 0) #=> 0
# Remove a key.
Dala.State.delete(:last_position)
## Lifecycle
Started automatically by `Dala.App.start/0` — no setup required.
The backing file is stored at `dala_DATA_DIR/dala_state.dets` on device,
or `priv/repo/dala_state.dets` in local dev (same directory as the SQLite DB).
"""
use GenServer
@table :dala_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> Dala.State.get(:theme, :obsidian)
:obsidian
iex> Dala.State.put(:theme, :citrus)
iex> Dala.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).
Dala.State.put(:theme, :citrus)
Dala.State.put(:onboarded, true)
Dala.State.put({:last_seen, :home}, DateTime.utc_now())
Dala.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.
Dala.State.delete(:theme)
Dala.State.get(:theme, :obsidian) #=> :obsidian
"""
@spec delete(term()) :: :ok
def delete(key) do
GenServer.call(__MODULE__, {:delete, key})
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
data_dir =
System.get_env("dala_DATA_DIR") ||
System.get_env("HOME") ||
Path.join(File.cwd!(), "priv/repo")
File.mkdir_p!(data_dir)
Path.join(data_dir, "dala_state.dets")
end
end