PhoenixKitCatalogue.Catalogue.AttributeSets (PhoenixKitCatalogue v0.18.0)

Copy Markdown View Source

Attribute SETS — the 2026-08-18 rework of the group/attribute system.

A set is one dimension from one vendor ("Ikea colors"), stored as a MANAGED entities blueprint (created only through this module, hidden from the generic entities admin); its data records are the values. Items attach any number of sets through the catalogue-owned phoenix_kit_cat_item_attribute_sets join (V177).

The blueprint contract

name:      "catalogue_set_<slug>"          (immutable identity)
settings:  "managed_by"  => "catalogue"
           "locked_keys" => ["kind", "default_value_slug"]
           "catalogue"   => %{"kind" => "fixed" | "multi",
                              "default_value_slug" => slug | nil}
records:   slug = the value's stable key, title = display text,
           position = order, data = extras (per-set fields)

Everything else on the blueprint (display name, translations, fields_definition extras like "price per liter") is freely editable. contract/1 validates the shape on every resolve; a broken contract is surfaced ({:error, :contract_broken}), never guessed around.

Enablement

Requires the entities module (PhoenixKitEntities.enabled?/0). Every WRITE returns {:error, :entities_disabled} when it is off — same loud-failure doctrine as the :catalogue_pdf queue guard. Reads degrade quietly instead ([], nil, %{}, 0): UI callers render empty rather than crash during a feature toggle.

Public surface re-exported from PhoenixKitCatalogue.Catalogue.

Summary

Functions

Adds an extra field to the set's blueprint (:label required, :type one of extra_field_types/0; select additionally needs :options, a non-empty list). Every value can then carry data for it. The key is derived from the label and is stable.

Attaches a set to an item (appends; no-op when already attached).

How many items attach each of the given sets: %{set_uuid => count}.

Migrates any remaining legacy groups into sets, silently and safely — there is no legacy UI once sets are live ("it should just migrate", boss direction 2026-08-18), so this runs from the supervision-tree startup task and again from the attributes page as a backstop (boot can race the repo/settings, and entities can be enabled at runtime).

Registers the catalogue's blueprint delete guard with entities. Ships as a supervision child via PhoenixKitCatalogue.children/0, so it runs once per boot; deleting a set with item attachments is refused at the entities write path.

Validates a set blueprint's catalogue contract. Returns {:ok, %{kind: atom, default: slug | nil}} or {:error, :contract_broken} — never a guessed fallback.

Provisions a new set: a managed blueprint from the locked template.

Adds a value to a set. attrs: :label (required), :slug (derived from label when absent), :extras (map merged into the record's data — cast against the blueprint's fields the same way update_value/4 casts them).

The set's default value slug, or nil. See kind/1.

Deletes a set. Refused ({:error, :set_in_use}) while any item attaches it — the same guard entities consults on its own delete path.

Deletes a value record. When the value is the set's default, the default is cleared first so the contract never points at a ghost.

Detaches a set from an item (no-op when not attached).

True when the sets feature is live: the entities module is enabled AND its package carries the Managed API (entities > 0.4.0) — on an older package the whole feature degrades to :entities_disabled rather than crashing on missing functions. UI surfaces branch on this to decide sets-vs-legacy rendering.

Extra-field types the set editor offers (a curated entities subset).

Fetches one set by blueprint uuid (nil when missing/not a set).

Fetches one value record, scoped to the set (nil when foreign/missing).

The set's kind string ("fixed"/"multi", tolerant default "multi"). Public so UI layers read the contract through one accessor instead of destructuring settings["catalogue"] — the strict validating read stays contract/1.

The item's attachments in order.

Lists the catalogue's sets (managed blueprints), locale-resolved.

Lists a set's values in display order, locale-resolved.

Migrates the legacy group→attribute→value data into sets

Removes attachments whose set blueprint no longer exists (called by AttributeSets.OrphanPruner off entities PubSub delete events).

Removes an extra field from the blueprint. Existing per-value data for the key is left in place (harmless, invisible) — same doctrine as entities' own field removal.

Reorders an item's attachments to the given set_uuid order. No-op (no writes, no activity row) when the order already matches — this runs on every item save.

Reorders a set's values to the given record-uuid order.

Resolves the attached sets for many items in one batched pass: one attachment query + one value listing per DISTINCT set (values are shared across items, so a 50-item page with 6 sets is 7 queries).

Resolves ONE set to the v2 per-set shape (%{uuid, key, name, kind, default, values, fields}), or nil when the set is missing or its contract is broken. No attachment context, so no :selected key — that exists only on resolve_for_items/2's per-item sets. Powers the item form's attach-preview; the batched item reads go through resolve_for_items/2.

True when any item attaches the set (drives the delete guard).

Stores the per-attachment value selection (selected_value_slugs in the join row's reserved data) — the boss's two modes: ONE slug says "this exact object is Red", several say "this object comes in these options", empty clears the statement. Unknown slugs are dropped against the set's current values; {:error, :not_attached} when the item doesn't attach the set.

Updates an existing extra field: :label renames the display text (the key — referenced by stored per-value data — never changes), :options replaces a select field's option list (non-empty required). The type is immutable after creation: stored values were cast for it.

Updates a set's unlocked surface: :name (display), :description, :kind, :default_value_slug. Kind/default ride the owner bypass — they are locked against GENERIC writes, not against this module.

Updates a value: :label rewrites the display text (the slug — the stable key — never changes), :extras merges into the record data.

Filters stored selection slugs against a resolved set's CURRENT values — THE single implementation of the ghost rule, shared with every hydration path (the item form stages selections off raw attachment rows).

Functions

add_extra_field(set, attrs, opts \\ [])

@spec add_extra_field(struct(), map(), keyword()) ::
  {:ok, struct()} | {:error, term()}

Adds an extra field to the set's blueprint (:label required, :type one of extra_field_types/0; select additionally needs :options, a non-empty list). Every value can then carry data for it. The key is derived from the label and is stable.

attach_set(item_uuid, set_uuid, opts \\ [])

@spec attach_set(Ecto.UUID.t(), Ecto.UUID.t(), keyword()) ::
  {:ok, PhoenixKitCatalogue.Schemas.ItemAttributeSet.t()} | {:error, term()}

Attaches a set to an item (appends; no-op when already attached).

Runs under the per-set advisory lock shared with delete_set/2 — without it, an attach racing a delete could commit after the guard's set_attached? check read false, leaving an instant orphan row (panel finding, 2026-08-18 review).

attachment_counts(set_uuids)

@spec attachment_counts([Ecto.UUID.t()]) :: %{
  optional(Ecto.UUID.t()) => non_neg_integer()
}

How many items attach each of the given sets: %{set_uuid => count}.

auto_migrate_legacy()

@spec auto_migrate_legacy() :: :ok

Migrates any remaining legacy groups into sets, silently and safely — there is no legacy UI once sets are live ("it should just migrate", boss direction 2026-08-18), so this runs from the supervision-tree startup task and again from the attributes page as a backstop (boot can race the repo/settings, and entities can be enabled at runtime).

Never raises: any failure is logged and swallowed — a broken migration must not take down boot or an admin page. Idempotent by way of migrate_groups_to_sets/1.

child_spec(opts)

@spec child_spec(keyword()) :: Supervisor.child_spec()

Registers the catalogue's blueprint delete guard with entities. Ships as a supervision child via PhoenixKitCatalogue.children/0, so it runs once per boot; deleting a set with item attachments is refused at the entities write path.

contract(set)

@spec contract(struct()) :: {:ok, map()} | {:error, :contract_broken}

Validates a set blueprint's catalogue contract. Returns {:ok, %{kind: atom, default: slug | nil}} or {:error, :contract_broken} — never a guessed fallback.

create_set(attrs, opts \\ [])

@spec create_set(
  map(),
  keyword()
) :: {:ok, struct()} | {:error, term()}

Provisions a new set: a managed blueprint from the locked template.

attrs: :name (display, required), :slug (optional — derived from the name when absent), :kind ("fixed"/"multi", default "multi"), :description.

create_value(set, attrs, opts \\ [])

@spec create_value(struct(), map(), keyword()) :: {:ok, struct()} | {:error, term()}

Adds a value to a set. attrs: :label (required), :slug (derived from label when absent), :extras (map merged into the record's data — cast against the blueprint's fields the same way update_value/4 casts them).

default_value_slug(set)

@spec default_value_slug(struct()) :: String.t() | nil

The set's default value slug, or nil. See kind/1.

delete_set(set, opts \\ [])

@spec delete_set(struct(), keyword()) :: {:ok, struct()} | {:error, term()}

Deletes a set. Refused ({:error, :set_in_use}) while any item attaches it — the same guard entities consults on its own delete path.

delete_value(set, value, opts \\ [])

@spec delete_value(struct(), struct(), keyword()) ::
  {:ok, struct()} | {:error, term()}

Deletes a value record. When the value is the set's default, the default is cleared first so the contract never points at a ghost.

detach_set(item_uuid, set_uuid, opts \\ [])

@spec detach_set(Ecto.UUID.t(), Ecto.UUID.t(), keyword()) :: :ok

Detaches a set from an item (no-op when not attached).

enabled?()

@spec enabled?() :: boolean()

True when the sets feature is live: the entities module is enabled AND its package carries the Managed API (entities > 0.4.0) — on an older package the whole feature degrades to :entities_disabled rather than crashing on missing functions. UI surfaces branch on this to decide sets-vs-legacy rendering.

extra_field_types()

@spec extra_field_types() :: [String.t()]

Extra-field types the set editor offers (a curated entities subset).

get_set(uuid, opts \\ [])

@spec get_set(
  Ecto.UUID.t(),
  keyword()
) :: struct() | nil

Fetches one set by blueprint uuid (nil when missing/not a set).

get_value(set, value_uuid)

@spec get_value(
  struct(),
  Ecto.UUID.t()
) :: struct() | nil

Fetches one value record, scoped to the set (nil when foreign/missing).

kind(set)

@spec kind(struct()) :: String.t()

The set's kind string ("fixed"/"multi", tolerant default "multi"). Public so UI layers read the contract through one accessor instead of destructuring settings["catalogue"] — the strict validating read stays contract/1.

list_attachments(item_uuid)

@spec list_attachments(Ecto.UUID.t()) :: [
  PhoenixKitCatalogue.Schemas.ItemAttributeSet.t()
]

The item's attachments in order.

list_sets(opts \\ [])

@spec list_sets(keyword()) :: [struct()]

Lists the catalogue's sets (managed blueprints), locale-resolved.

list_values(set_or_uuid, opts \\ [])

@spec list_values(
  struct() | Ecto.UUID.t(),
  keyword()
) :: [struct()]

Lists a set's values in display order, locale-resolved.

migrate_groups_to_sets(opts \\ [])

@spec migrate_groups_to_sets(keyword()) :: {:ok, map()} | {:error, term()}

Migrates the legacy group→attribute→value data into sets:

  • each (group, attribute) pair → one set blueprint, slug catalogue_set_<group>_<attr-key> (display "<Group> — <Attr>");
  • attribute values → records, slug = the old value key (stable, so existing order-line picks keep resolving), old is_default → the set's default_value_slug;
  • every item's single group assignment explodes into one attachment per attribute of that group, in attribute order.

Idempotent: an existing blueprint with the target slug is reused (its values/attachments are topped up, never duplicated), so re-running after a partial failure is safe. Old tables are left untouched (read-only by convention; dropped by a later core migration after cutover). Returns {:ok, %{sets: n, values: n, attachments: n}}.

prune_orphan_attachments(set_uuid)

@spec prune_orphan_attachments(Ecto.UUID.t()) :: non_neg_integer()

Removes attachments whose set blueprint no longer exists (called by AttributeSets.OrphanPruner off entities PubSub delete events).

Guarded on enablement: with entities disabled, get_set/1 returns nil for EVERY uuid — without the guard a stray call during a feature toggle would read that as "blueprint deleted" and destroy valid attachments (panel finding, 2026-08-18 review).

remove_extra_field(set, key, opts \\ [])

@spec remove_extra_field(struct(), String.t(), keyword()) ::
  {:ok, struct()} | {:error, term()}

Removes an extra field from the blueprint. Existing per-value data for the key is left in place (harmless, invisible) — same doctrine as entities' own field removal.

reorder_attachments(item_uuid, set_uuids, opts \\ [])

@spec reorder_attachments(Ecto.UUID.t(), [Ecto.UUID.t()], keyword()) :: :ok

Reorders an item's attachments to the given set_uuid order. No-op (no writes, no activity row) when the order already matches — this runs on every item save.

reorder_values(set, ordered_uuids, opts \\ [])

@spec reorder_values(struct(), [Ecto.UUID.t()], keyword()) :: :ok | {:error, term()}

Reorders a set's values to the given record-uuid order.

resolve_for_item(item_uuid, opts \\ [])

@spec resolve_for_item(
  Ecto.UUID.t(),
  keyword()
) :: map()

Single-item convenience over resolve_for_items/2.

resolve_for_items(item_uuids, opts \\ [])

@spec resolve_for_items(
  [Ecto.UUID.t()],
  keyword()
) :: %{optional(Ecto.UUID.t()) => map()}

Resolves the attached sets for many items in one batched pass: one attachment query + one value listing per DISTINCT set (values are shared across items, so a 50-item page with 6 sets is 7 queries).

Returns %{item_uuid => resolved} where resolved is the v2 shape:

%{schema_version: 2,
  sets: [%{uuid, key, name, kind, default,
           values:   [%{key, label, extras}],
           fields:   [%{key, label, type}],
           selected: [slug]}]}

:fields mirrors the blueprint's extra-field definitions (what each value's extras keys mean); :selected is the per-ATTACHMENT value selection, already intersected against current values (ghost slugs degrade out). :selected exists ONLY on this batched read — resolve_set/2 resolves a bare set with no attachment context and carries no :selected key.

Sets with a broken contract are skipped with a warning — a tampered blueprint must not take item pages down, but it must not render guessed data either.

resolve_set(set_uuid, opts \\ [])

@spec resolve_set(
  Ecto.UUID.t(),
  keyword()
) :: map() | nil

Resolves ONE set to the v2 per-set shape (%{uuid, key, name, kind, default, values, fields}), or nil when the set is missing or its contract is broken. No attachment context, so no :selected key — that exists only on resolve_for_items/2's per-item sets. Powers the item form's attach-preview; the batched item reads go through resolve_for_items/2.

set_attached?(set_uuid)

@spec set_attached?(Ecto.UUID.t()) :: boolean()

True when any item attaches the set (drives the delete guard).

set_attachment_selection(item_uuid, set_uuid, slugs, opts \\ [])

@spec set_attachment_selection(Ecto.UUID.t(), Ecto.UUID.t(), [String.t()], keyword()) ::
  :ok | {:error, term()}

Stores the per-attachment value selection (selected_value_slugs in the join row's reserved data) — the boss's two modes: ONE slug says "this exact object is Red", several say "this object comes in these options", empty clears the statement. Unknown slugs are dropped against the set's current values; {:error, :not_attached} when the item doesn't attach the set.

update_extra_field(set, key, attrs, opts \\ [])

@spec update_extra_field(struct(), String.t(), map(), keyword()) ::
  {:ok, struct()} | {:error, term()}

Updates an existing extra field: :label renames the display text (the key — referenced by stored per-value data — never changes), :options replaces a select field's option list (non-empty required). The type is immutable after creation: stored values were cast for it.

update_set(set, attrs, opts \\ [])

@spec update_set(struct(), map(), keyword()) :: {:ok, struct()} | {:error, term()}

Updates a set's unlocked surface: :name (display), :description, :kind, :default_value_slug. Kind/default ride the owner bypass — they are locked against GENERIC writes, not against this module.

update_value(set, value, attrs, opts \\ [])

@spec update_value(struct(), struct(), map(), keyword()) ::
  {:ok, struct()} | {:error, term()}

Updates a value: :label rewrites the display text (the slug — the stable key — never changes), :extras merges into the record data.

Extras are cast per field type through the entities pipeline (FormBuilder.cast_field/2): raw form strings coerce ("12.5" → 12.5, "" clears), invalid content returns {:error, :invalid_value} and unknown keys {:error, :unknown_field} — never a silent junk write.

valid_selection(slugs, arg2)

@spec valid_selection(term(), map() | nil) :: [String.t()]

Filters stored selection slugs against a resolved set's CURRENT values — THE single implementation of the ghost rule, shared with every hydration path (the item form stages selections off raw attachment rows).

The per-attachment selection is the boss's two modes (2026-08-19): one slug = "this exact object is Red", several = "this object comes in Red/Blue/Yellow", empty = no statement, the whole set applies. The count IS the mode — nothing else is tracked. A value deleted after being ticked must not ghost through reads: unknown slugs drop out, and a fully-ghosted selection degrades to [] ("whole set applies"), never to a vanished or mode-flipped set.