Amarula.Storage behaviour (amarula v0.1.0)
View SourcePluggable, 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 SignalSessionRecord, keyed by signal address.:sender_key— groupSenderKeyRecord, 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
@type adapter_state() :: term()
Adapter-specific state, returned by the adapter's new/1.
@type key() :: :self | String.t()
A key within a namespace. :creds uses :self; the rest use strings.
@type namespace() ::
:creds
| :session
| :sender_key
| :lid_mapping
| :device_list
| :app_state_sync_key
| :app_state_version
A storage namespace. See the moduledoc.
The connection identity (its :profile) used to scope storage.
@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
@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}.
@callback delete(adapter_state(), profile(), namespace(), key()) :: :ok | {:error, term()}
Delete {profile, namespace, key}. Deleting a missing key is :ok.
@callback get(adapter_state(), profile(), namespace(), key()) :: {:ok, term()} | :error
Fetch the value at {profile, namespace, key}. :error on miss/corruption.
@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}.
@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.
@callback put(adapter_state(), profile(), namespace(), key(), value :: term()) :: :ok | {:error, term()}
Store value at {profile, namespace, key}, overwriting any prior value.
Functions
@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.
@spec default_adapter() :: module()
@spec delete(Amarula.Storage.Scope.t(), profile(), namespace(), key()) :: :ok | {:error, term()}
Delete {profile, namespace, key}.
Like get/4, returning default (nil) instead of :error on a miss.
@spec get(Amarula.Storage.Scope.t(), profile(), namespace(), key()) :: {:ok, term()} | :error
Fetch the value at {profile, namespace, key}. :error on miss/corruption.
@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.
@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.
@spec put(Amarula.Storage.Scope.t(), profile(), namespace(), key(), term()) :: :ok | {:error, term()}
Store value at {profile, namespace, key}.
@spec scope(Amarula.Storage.Scope.t() | {module(), keyword()} | keyword()) :: Amarula.Storage.Scope.t()
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