Amarula.Storage behaviour (amarula v0.1.0)

View Source

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 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.

Summary

Types

Adapter-specific state, returned by the adapter's new/1.

A key within a namespace. :creds uses :self; the rest use strings.

A storage namespace. See the moduledoc.

The connection identity (its :profile) used to scope storage.

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).

Callbacks

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}.

Delete {profile, namespace, key}. Deleting a missing key is :ok.

Fetch the value at {profile, namespace, key}. :error on miss/corruption.

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}.

Initialise adapter state from opts. Returns the value carried in the scope and passed back to every other callback. Called once per connection.

Store value at {profile, namespace, key}, overwriting any prior value.

Functions

Wipe all data for profile (wipe_credentials). {:error, :not_supported} if the adapter can't.

Delete {profile, namespace, key}.

Like get/4, returning default (nil) instead of :error on a miss.

Fetch the value at {profile, namespace, key}. :error on miss/corruption.

List every profile with data in this store. {:error, :not_supported} if the adapter can't enumerate profiles.

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.

Store value at {profile, namespace, key}.

Build a 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.

Types

adapter_state()

@type adapter_state() :: term()

Adapter-specific state, returned by the adapter's new/1.

key()

@type key() :: :self | String.t()

A key within a namespace. :creds uses :self; the rest use strings.

namespace()

@type namespace() ::
  :creds
  | :session
  | :sender_key
  | :lid_mapping
  | :device_list
  | :app_state_sync_key
  | :app_state_version

A storage namespace. See the moduledoc.

profile()

@type profile() :: atom() | String.t()

The connection identity (its :profile) used to scope storage.

profile_info()

@type profile_info() :: %{
  profile: profile(),
  jid: String.t() | nil,
  lid: String.t() | nil,
  name: String.t() | nil
}

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).

Callbacks

clear(adapter_state, profile)

(optional)
@callback clear(adapter_state(), profile()) :: :ok | {:error, term()}

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}.

delete(adapter_state, profile, namespace, key)

@callback delete(adapter_state(), profile(), namespace(), key()) :: :ok | {:error, term()}

Delete {profile, namespace, key}. Deleting a missing key is :ok.

get(adapter_state, profile, namespace, key)

@callback get(adapter_state(), profile(), namespace(), key()) :: {:ok, term()} | :error

Fetch the value at {profile, namespace, key}. :error on miss/corruption.

list_profiles(adapter_state)

(optional)
@callback list_profiles(adapter_state()) :: {:ok, [profile()]} | {:error, term()}

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}.

new(opts)

@callback new(opts :: keyword()) :: adapter_state()

Initialise adapter state from opts. Returns the value carried in the scope and passed back to every other callback. Called once per connection.

put(adapter_state, profile, namespace, key, value)

@callback put(adapter_state(), profile(), namespace(), key(), value :: term()) ::
  :ok | {:error, term()}

Store value at {profile, namespace, key}, overwriting any prior value.

Functions

clear(scope, profile)

@spec clear(Amarula.Storage.Scope.t(), profile()) :: :ok | {:error, term()}

Wipe all data for profile (wipe_credentials). {:error, :not_supported} if the adapter can't.

default_adapter()

@spec default_adapter() :: module()

delete(scope, profile, namespace, key)

@spec delete(Amarula.Storage.Scope.t(), profile(), namespace(), key()) ::
  :ok | {:error, term()}

Delete {profile, namespace, key}.

fetch(scope, profile, namespace, key, default \\ nil)

@spec fetch(Amarula.Storage.Scope.t(), profile(), namespace(), key(), term()) ::
  term()

Like get/4, returning default (nil) instead of :error on a miss.

get(scope, profile, namespace, key)

@spec get(Amarula.Storage.Scope.t(), profile(), namespace(), key()) ::
  {:ok, term()} | :error

Fetch the value at {profile, namespace, key}. :error on miss/corruption.

list_profiles(scope)

@spec list_profiles(Amarula.Storage.Scope.t()) ::
  {:ok, [profile()]} | {:error, term()}

List every profile with data in this store. {:error, :not_supported} if the adapter can't enumerate profiles.

list_profiles_with_metadata(scope)

@spec list_profiles_with_metadata(Amarula.Storage.Scope.t()) ::
  {:ok, [profile_info()]} | {:error, term()}

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.

put(scope, profile, namespace, key, value)

@spec put(Amarula.Storage.Scope.t(), profile(), namespace(), key(), term()) ::
  :ok | {:error, term()}

Store value at {profile, namespace, key}.

scope(scope)

Build a 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