Backpex.Preferences.Adapters.Ecto (Backpex v0.20.0)

Copy Markdown View Source

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 — the Ecto.Repo to read and write through. Required.
  • :schema — the Ecto.Schema backing the preference table. Required.
  • :scope_fields — non-empty list of schema fields that identify the preference namespace. Required. The :key and :value field 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 to 255, matching the varchar(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 to 65536. Values are measured via :erlang.external_size/1 of 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 to 1000. 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.