Database-backed Backpex.Preferences adapter, one row per scoped preference
key.
Reach for this when preferences should follow a user across devices, need more room than the Session adapter's cookie budget, or belong to a compound namespace such as a user inside a tenant.
You supply the table and the fields that make up its scope; Backpex supplies the adapter.
Setup
defmodule MyApp.Repo.Migrations.CreateBackpexPreferences do
use Ecto.Migration
def change do
create table(:backpex_preferences) do
add :user_id, references(:users, on_delete: :delete_all), null: false
add :tenant_id, references(:tenants, on_delete: :delete_all), null: false
add :key, :string, null: false
add :value, :map, null: false, default: %{}
timestamps(type: :utc_datetime_usec)
end
create unique_index(:backpex_preferences, [:user_id, :tenant_id, :key])
end
end
defmodule MyApp.Preferences.Preference do
use Ecto.Schema
schema "backpex_preferences" do
field :user_id, :integer
field :tenant_id, :integer
field :key, :string
field :value, :map, default: %{}
timestamps(type: :utc_datetime_usec)
end
end
config :backpex, Backpex.Preferences,
adapters: [
{:default, Backpex.Preferences.Adapters.Ecto,
repo: MyApp.Repo,
schema: MyApp.Preferences.Preference,
scope_fields: [:user_id, :tenant_id],
storage_key_prefix: "backpex."}
],
scope: {MyAppWeb.PreferencesScope, :resolve, []}The unique index must contain scope_fields ++ [:key] in the same order —
writes use that list as their conflict target.
Options
:repo— theEcto.Repoto read and write through. Required.:schema— theEcto.Schemabacking the preference table. Required.:scope_fields— non-empty list of schema fields that identify the preference namespace. Required. The:keyand:valuefield names are fixed.:storage_key_prefix— string prepended to keys in the database and removed again on reads. Defaults to"". The value is used exactly as configured, so include any separator you want in storage, for example"backpex.".:max_key_bytes— upper bound for the byte size of a stored key (prefix included), or:infinity. Defaults to255, matching thevarchar(255)column the documented migration creates. Longer keys are refused with{:error, :key_too_long}instead of surfacing as a database error.:max_value_bytes— write budget for a single value, or:infinity. Defaults to65536. Values are measured via:erlang.external_size/1of the stored envelope — an approximation of the row's storage footprint, which is the right direction for a budget check. Oversized values are refused with{:error, :too_large}.:max_keys— how many distinct keys one scope may hold, or:infinity. Defaults to1000. Writes that would create a row beyond the cap are refused with{:error, :too_many_keys}; updates to existing keys always go through.
Write limits
Preference writes arrive from the browser, so without a ceiling a single
authenticated caller could grow the table without bound — the
Backpex.Preferences.Keys gate checks value shape for built-in keys, not
size, and keys Backpex does not own pass through unchecked. The three limit
options above close that hole with defaults far beyond what legitimate
Backpex traffic writes (a handful of small maps per resource). A refusal
surfaces to the client as a 422 from Backpex.PreferencesController, the
same designed outcome as the Session adapter's {:error, :too_large}.
Scope
Every call needs a resolved, non-empty atom-keyed scope map. Configure a
:scope resolver (see Backpex.Preferences.Context) that returns at least
every field listed in :scope_fields. Extra fields are ignored by this adapter, which lets two
routes use different projections of one application-wide scope. For example,
one adapter may use [:user_id] while another uses
[:user_id, :tenant_id].
Without a usable scope, reads report nothing stored and writes fail with
{:error, :unscoped} rather than writing rows nobody can read back. Missing
configured fields return {:error, {:invalid_scope, fields}}.
Scope field types must match the values returned by the resolver.
How values are stored
Preference values are frequently scalars: metrics_visible and every
global.sidebar_section.<id> are booleans, global.theme is a string. A
:map column cannot hold those, so every value is wrapped in a
%{"value" => term} envelope on write and unwrapped on read.
Keys are stored whole, one row each. Backpex.Preferences.Adapter.get_map/3
rebuilds the nested shape it must return via
Backpex.Preferences.Adapter.nest/2.
Writes do not use your schema's changeset/2
Rows are built with Ecto.Changeset.change/2 and inserted by this module, so
a changeset/2 on your schema is not consulted. The adapter owns every
field it writes and the database resolves conflicts atomically.