Backpex.Preferences.Adapter behaviour (Backpex v0.20.0)

Copy Markdown View Source

Behavior implemented by Backpex preference storage adapters.

Backpex.Preferences dispatches each call to an adapter selected by the key's prefix. Backpex ships with Backpex.Preferences.Adapters.Session by default and configure others per prefix:

config :backpex, Backpex.Preferences,
  adapters: [
    {"global.*",   Backpex.Preferences.Adapters.Session, []},
    {"resource.*", Backpex.Preferences.Adapters.Ecto,
     repo: MyApp.Repo, schema: MyApp.Preference, scope_fields: [:user_id, :tenant_id]},
    {:default,     Backpex.Preferences.Adapters.Session, []}
  ],
  scope: {MyAppWeb.PreferencesScope, :resolve, []}

Return semantics

The three atoms in the return types deserve a note:

  • {:ok, :not_found} — the adapter successfully determined that no value is stored for this key. Backpex.Preferences.get/3 callers fall back to their :default option.
  • {:error, :unscoped} — the adapter needs a resolved preference scope (see Backpex.Preferences.Context.scope) and does not have one. Reads should be treated as "not found"; writes surface {ok: false} to the caller without crashing.
  • {:error, :requires_http} — the adapter can only write via a %Plug.Conn{} (e.g. the Session adapter writing to the session cookie) and was invoked from a context that has no conn (mount / socket). The server-side helper in Backpex.Preferences catches this and falls back to a push_event/3 round-trip so the browser can retry via the HTTP endpoint.
  • {:error, :too_large} — the value would push the adapter's store past a size limit it cannot exceed (e.g. the ~4KB cookie session store). The write is refused whole; the previously stored value is untouched. Backpex.PreferencesController surfaces this as a 422. An adapter with no meaningful size ceiling never returns it.

Side-effect protocol

A write describes what the caller should do rather than mutating the conn itself. put/4 returns exactly one of:

  • {:ok, :persisted} — the adapter stored the value on its own (a database write) and needs nothing from the caller.
  • {:ok, {:put_session, key, map}} — the adapter needs the caller to put map under the given Phoenix session key.

Keeping adapters side-effect-free this way lets them be exercised in unit tests without a conn and supports server-side writes that do not have one.

{:put_session, _, _} can only be honored on a %Plug.Conn{}; Plug.Session is HTTP-only. An adapter that stores in the session must therefore return {:error, :requires_http} when it is called outside a controller, so the dispatcher can round-trip the write through the browser.

Retry semantics

put/4 must be idempotent for the same key and value. Browser writes may be coalesced into a batch. If one entry is rejected, Backpex retries the other entries because the controller discards accumulated Session effects and does not dispatch entries after the failure. An eager adapter may therefore receive the same successful put twice. Implement durable writes as assignment/upsert operations; do not attach non-idempotent work such as counters, notifications, or one-shot external calls directly to put/4.

Summary

Types

The outcome of a successful put/4.

Work the caller is responsible for applying after put/4.

Callbacks

Returns the adapter namespace used to scope browser-carried preference values.

Read a single key.

Read every value under prefix and return them as a nested map.

Persist a value.

Functions

Writes value at path in a nested map, creating intermediate maps.

Builds the nested map get_map/3 must return from flat {key, value} rows.

Types

put_result()

@type put_result() :: :persisted | side_effect()

The outcome of a successful put/4.

Either the adapter persisted the value itself (:persisted) or it needs the caller to apply a single side effect.

side_effect()

@type side_effect() :: {:put_session, key :: String.t(), value :: map()}

Work the caller is responsible for applying after put/4.

Asks the caller to put value under key in the Phoenix session.

Callbacks

client_namespace(ctx, opts)

(optional)
@callback client_namespace(ctx :: Backpex.Preferences.Context.t(), opts :: keyword()) ::
  {:ok, term()} | {:error, :unscoped | term()}

Returns the adapter namespace used to scope browser-carried preference values.

Backpex signs this value together with the adapter module and the Phoenix session identity. The resulting opaque token lets the browser carry values across LiveView joins without leaking a value into a different storage namespace. Return only terms that are stable for the lifetime of the underlying preference namespace.

This callback is optional. Adapters that omit it conservatively fall back to the complete application preference scope and their route options. Implement it when the adapter uses a projection of that scope or ignores it entirely.

get(ctx, key, opts)

@callback get(
  ctx :: Backpex.Preferences.Context.t(),
  key :: String.t(),
  opts :: keyword()
) ::
  {:ok, term()} | {:ok, :not_found} | {:error, :unscoped | term()}

Read a single key.

Return {:ok, :not_found} when no value is stored; callers fall back to their :default option.

get_map(ctx, prefix, opts)

@callback get_map(
  ctx :: Backpex.Preferences.Context.t(),
  prefix :: String.t(),
  opts :: keyword()
) ::
  {:ok, map()} | {:error, :unscoped | term()}

Read every value under prefix and return them as a nested map.

The returned map mirrors the structure that a get/3 at that prefix would have produced if there were a single nested value — i.e. it is keyed by the path segments that come after prefix, not by full dotted/coloned keys.

put(ctx, key, value, opts)

@callback put(
  ctx :: Backpex.Preferences.Context.t(),
  key :: String.t(),
  value :: term(),
  opts :: keyword()
) ::
  {:ok, put_result()}
  | {:error, :unscoped | :requires_http | :too_large | term()}

Persist a value.

Return {:ok, :persisted} when the adapter stored the value itself (a DB write), or {:ok, {:put_session, key, map}} to ask the caller to apply the one side effect the adapter cannot perform on its own (see the module docs).

Refuse rather than emit a write the store cannot hold: an adapter with a size ceiling returns {:error, :too_large} when the value would breach it, leaving the stored value untouched.

This callback must be idempotent for the same key and value; see "Retry semantics" in the module documentation.

Functions

deep_put(map, list, value)

@spec deep_put(map(), [String.t(), ...], term()) :: map()

Writes value at path in a nested map, creating intermediate maps.

A non-map sitting at an intermediate segment is replaced: preference keys form a tree, and a key deeper than an existing leaf wins over it.

iex> Backpex.Preferences.Adapter.deep_put(%{}, ["a", "b"], 1)
%{"a" => %{"b" => 1}}

iex> Backpex.Preferences.Adapter.deep_put(%{"a" => 1}, ["a", "b"], 2)
%{"a" => %{"b" => 2}}

nest(rows, prefix)

@spec nest([{String.t(), term()}], String.t()) :: map()

Builds the nested map get_map/3 must return from flat {key, value} rows.

Stores that keep one entry per full key — a database table, Redis, ETS — cannot answer get_map/3 directly: the callback is specified in terms of the nested shape a get/3 at that prefix would have produced. Hand it every row whose key starts with prefix and it does the rest.

Rows that are not descendants of prefix are dropped, so an over-broad fetch is safe. That matters more than it looks: SQL LIKE treats _ as a wildcard, so a prefix such as global.sidebar_section also matches a stored global.sidebarXsection.blog. Matching happens on parsed key segments (Backpex.Preferences.Key.parse/1), never on raw string prefixes, so a key is only included when it shares a whole-segment boundary with prefix.

A row whose key is prefix has nothing below it and is dropped too.

iex> alias Backpex.Preferences.Adapter
iex> Adapter.nest([{"global.sidebar_section.blog", true}], "global.sidebar_section")
%{"blog" => true}

iex> alias Backpex.Preferences.Adapter
iex> Adapter.nest([{"global.sidebar_section.blog", true}], "global")
%{"sidebar_section" => %{"blog" => true}}

iex> alias Backpex.Preferences.Adapter
iex> Adapter.nest([{"global.sidebarXsection.blog", true}], "global.sidebar_section")
%{}