# `Backpex.Preferences.Adapters.Ecto`
[🔗](https://github.com/naymspace/backpex/blob/0.20.0/lib/backpex/preferences/adapters/ecto.ex#L1)

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. `c: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.

---

*Consult [api-reference.md](api-reference.md) for complete listing*
