Skip to main content

lib/amarula/storage.ex

defmodule Amarula.Storage do
  @moduledoc """
  Pluggable, connection-scoped persistence for a connection's protocol state.

  Everything Amarula must remember across a send/receive — credentials, 1:1
  Signal sessions, group sender keys, LID↔PN mappings, the device-list cache —
  is a key/value entry in one of a handful of *namespaces*. This behaviour is the
  seam between the protocol code (which only ever says "save this session", "load
  that mapping") and *where* those bytes actually live.

  ## Scoping by profile

  Storage is a plugin: implement the callbacks below and pass `{YourAdapter,
  opts}` as a connection's `:storage` config. Every call also receives the
  connection's **profile** (its identity, e.g. `:primary`), so one adapter
  instance can serve many connections, each isolated. The adapter decides *how*
  to isolate by that profile — `Amarula.Storage.File` uses a per-profile
  subfolder; a database adapter would use it as a tenant key. There is exactly
  one profile (the connection's); the storage layer never invents its own.

  The scope threaded through the protocol layer is `t:Amarula.Storage.Scope.t/0`
  (adapter + its state), built once at connect; the profile is carried alongside
  on the `Amarula.Conn` and passed to each call.

  ## Namespaces

    * `:creds`       — the auth-creds map. Singleton; key is `:self`.
    * `:session`     — 1:1 Signal `SessionRecord`, keyed by signal address.
    * `:sender_key`  — group `SenderKeyRecord`, keyed by sender-key-name string.
    * `:lid_mapping` — LID↔PN user mapping, keyed by the user string.
    * `:device_list` — cached device list, keyed by user string.

  The retry cache is deliberately *not* here — it is ephemeral, bounded state
  with different needs (eviction, low latency), handled by the separate
  pluggable `Amarula.RetryCache` (its own behaviour + adapters).

  ## Contract

  Values are arbitrary Elixir terms. `get/4` returns `{:ok, value}` on a hit and
  `:error` on a miss (a corrupt/unreadable entry is treated as a miss). `put/5`
  and `delete/4` return `:ok` or `{:error, reason}`. Adapters must be safe to
  call concurrently from multiple processes for the same scope/name.
  """

  alias Amarula.Storage.Scope

  @typedoc "Adapter-specific state, returned by the adapter's `new/1`."
  @type adapter_state :: term()

  @typedoc "The connection identity (its `:profile`) used to scope storage."
  @type profile :: atom() | String.t()

  @typedoc "A storage namespace. See the moduledoc."
  @type namespace ::
          :creds
          | :session
          | :sender_key
          | :lid_mapping
          | :device_list
          | :app_state_sync_key
          | :app_state_version

  @typedoc "A key within a namespace. `:creds` uses `:self`; the rest use strings."
  @type key :: :self | String.t()

  @doc """
  Initialise adapter state from `opts`. Returns the value carried in the scope and
  passed back to every other callback. Called once per connection.
  """
  @callback new(opts :: keyword()) :: adapter_state()

  @doc "Fetch the value at `{profile, namespace, key}`. `:error` on miss/corruption."
  @callback get(adapter_state(), profile(), namespace(), key()) :: {:ok, term()} | :error

  @doc "Store `value` at `{profile, namespace, key}`, overwriting any prior value."
  @callback put(adapter_state(), profile(), namespace(), key(), value :: term()) ::
              :ok | {:error, term()}

  @doc "Delete `{profile, namespace, key}`. Deleting a missing key is `:ok`."
  @callback delete(adapter_state(), profile(), namespace(), key()) :: :ok | {:error, term()}

  @doc """
  Wipe ALL stored data for `profile` (every namespace) — used by `wipe_credentials`.
  A filesystem adapter removes the profile directory; a DB adapter drops the
  tenant's rows. Optional: adapters that don't implement it report `{:error,
  :not_supported}`.
  """
  @callback clear(adapter_state(), profile()) :: :ok | {:error, term()}

  @doc """
  List every `profile` that has data in this store (one entry per profile that has
  ever been persisted to, e.g. each paired credential). Order is unspecified.
  Optional: adapters that don't implement it report `{:error, :not_supported}`.
  """
  @callback list_profiles(adapter_state()) :: {:ok, [profile()]} | {:error, term()}
  @optional_callbacks clear: 2, list_profiles: 1

  # The adapter used when config gives bare opts / no :storage. Configurable so
  # the core privileges no concrete backend:
  #   config :amarula, default_storage_adapter: Amarula.Storage.DETS
  @spec default_adapter() :: module()
  def default_adapter do
    Application.get_env(:amarula, :default_storage_adapter, Amarula.Storage.File)
  end

  @doc """
  Build a `t:Amarula.Storage.Scope.t/0` from a `:storage` config value. Accepts a
  `{adapter, opts}` spec, a bare opts list (→ `default_adapter/0`), or a prebuilt
  `Scope`. The adapter's `new/1` runs now.

      Storage.scope({Amarula.Storage.File, root: "./data"})
      Storage.scope(root: "./data")        # default adapter
  """
  @spec scope(Scope.t() | {module(), keyword()} | keyword()) :: Scope.t()
  def scope(%Scope{} = scope), do: scope
  def scope({adapter, opts}) when is_atom(adapter) and is_list(opts), do: build(adapter, opts)
  def scope(opts) when is_list(opts), do: build(default_adapter(), opts)

  defp build(adapter, opts), do: %Scope{adapter: adapter, state: adapter.new(opts)}

  @doc "Fetch the value at `{profile, namespace, key}`. `:error` on miss/corruption."
  @spec get(Scope.t(), profile(), namespace(), key()) :: {:ok, term()} | :error
  def get(%Scope{adapter: a, state: s}, profile, namespace, key),
    do: a.get(s, profile, namespace, key)

  @doc "Like `get/4`, returning `default` (nil) instead of `:error` on a miss."
  @spec fetch(Scope.t(), profile(), namespace(), key(), term()) :: term()
  def fetch(scope, profile, namespace, key, default \\ nil) do
    case get(scope, profile, namespace, key) do
      {:ok, value} -> value
      :error -> default
    end
  end

  @doc "Store `value` at `{profile, namespace, key}`."
  @spec put(Scope.t(), profile(), namespace(), key(), term()) :: :ok | {:error, term()}
  def put(%Scope{adapter: a, state: s}, profile, namespace, key, value),
    do: a.put(s, profile, namespace, key, value)

  @doc "Delete `{profile, namespace, key}`."
  @spec delete(Scope.t(), profile(), namespace(), key()) :: :ok | {:error, term()}
  def delete(%Scope{adapter: a, state: s}, profile, namespace, key),
    do: a.delete(s, profile, namespace, key)

  @doc "Wipe all data for `profile` (`wipe_credentials`). `{:error, :not_supported}` if the adapter can't."
  @spec clear(Scope.t(), profile()) :: :ok | {:error, term()}
  def clear(%Scope{adapter: a, state: s}, profile) do
    if function_exported?(a, :clear, 2), do: a.clear(s, profile), else: {:error, :not_supported}
  end

  @doc """
  List every profile with data in this store. `{:error, :not_supported}` if the
  adapter can't enumerate profiles.
  """
  @spec list_profiles(Scope.t()) :: {:ok, [profile()]} | {:error, term()}
  def list_profiles(%Scope{adapter: a, state: s}) do
    if function_exported?(a, :list_profiles, 1),
      do: a.list_profiles(s),
      else: {:error, :not_supported}
  end

  @typedoc """
  A profile plus a friendly summary read from its `:creds` — the logged-in
  identity (`jid`/`lid`) and display `name`, for UIs that pick between profiles.
  Fields are `nil` if the profile has no usable creds yet (e.g. mid-pairing).
  """
  @type profile_info :: %{
          profile: profile(),
          jid: String.t() | nil,
          lid: String.t() | nil,
          name: String.t() | nil
        }

  @doc """
  Like `list_profiles/1`, but enriches each profile with its `:creds` identity
  (`jid`/`lid`/`name`) for a friendlier picker. One extra `get/4` per profile.

  This is the one place the storage layer peeks inside a value (`creds.me`); every
  other call treats values as opaque. `{:error, :not_supported}` if the adapter
  can't enumerate profiles.
  """
  @spec list_profiles_with_metadata(Scope.t()) :: {:ok, [profile_info()]} | {:error, term()}
  def list_profiles_with_metadata(%Scope{} = scope) do
    with {:ok, profiles} <- list_profiles(scope) do
      {:ok, Enum.map(profiles, &profile_info(scope, &1))}
    end
  end

  defp profile_info(scope, profile) do
    me =
      case get(scope, profile, :creds, :self) do
        {:ok, %{me: me}} when is_map(me) -> me
        _ -> %{}
      end

    %{profile: profile, jid: me[:id], lid: me[:lid], name: me[:name]}
  end
end