HTTP endpoint for persisting user preferences.
Accepts JSON requests from the BackpexPreferences JS hook. Each call
routes through Backpex.Preferences, which dispatches to the adapter
configured for the key's prefix (see Backpex.Preferences.Router).
Contracts
Single write:
POST /backpex_preferences
{"key": "global.theme", "value": "dark"}Batch write:
POST /backpex_preferences
{"preferences": [
{"key": "global.theme", "value": "dark"},
{"key": "global.sidebar_open", "value": true}
]}The batch form is best-effort, first-error-wins: if any adapter refuses
a write, the dispatcher halts at that entry, no further adapters are
called, and the response is 422 {ok: false, error: %{key: _, reason: _}}.
Session-backed effects from earlier successful entries in the same batch
are also dropped (the controller never applies them on the error path), so
the session cookie is left unchanged. However, adapters that persist
eagerly (e.g. a DB-backed adapter that wrote via Repo.insert!) may have
already committed earlier writes — the adapter behaviour has no rollback
primitive, so callers should treat partial success as possible.
An adapter that refuses a write because its store cannot hold it returns
{:error, :too_large}, which surfaces as
422 {ok: false, error: %{key: _, reason: "too_large"}} — see the size limit
section of Backpex.Preferences.Adapters.Session. The refusal is the
designed outcome, not a bug: the alternative is a CookieOverflowError 500
on this and every later request.
Single-write :unscoped is treated as a no-op rather than an error:
the response is 200 {ok: false, error: %{reason: "unscoped"}} and no
warning is logged. The JS hook fires writes from anonymous visitors
whenever the session lapses — this avoids surfacing them as 4xx noise.
Batches always halt on any error (including :unscoped) and return
422.
Entries in a batch are retained only when they are maps containing a binary
"key" and a "value" field. Other members are silently discarded; an
empty or all-invalid list is therefore a successful no-op (200 {ok: true}).
A payload that matches neither the single nor batch shape returns
400 {ok: false, error: "missing key/value"}.
Backpex.Preferences.put_batch/3 refuses a value that the built-in reader
for its key cannot consume (Backpex.Preferences.Keys.valid_value?/2),
returning 422 {ok: false, error: %{key: _, reason: "invalid_value"}}. Keys
Backpex does not own (custom.*, unknown resource: suffixes) have no known
shape and pass through unchecked — an adapter that needs constraints on those
enforces its own.
That gate is a shape check, not authorization. This controller does not ask whether the caller may write the key, only whether the value would break a later render. Authorization belongs in the pipeline the route is mounted in, or in the adapter.