# `PhoenixKitEntities.EntityData`
[🔗](https://github.com/BeamLabEU/phoenix_kit_entities/blob/v0.4.6/lib/phoenix_kit_entities/entity_data.ex#L1)

Entity data records for PhoenixKit entities system.

This module manages actual data records that follow entity blueprints.
Each record is associated with an entity type and stores its field values
in a JSONB column for flexibility.

## Schema Fields

- `entity_uuid`: Foreign key to the entity blueprint
- `title`: Display title/name for the record
- `slug`: URL-friendly identifier (optional)
- `status`: Record status ("draft", "published", "archived", "trashed")
- `data`: JSONB map of all field values based on entity definition
- `metadata`: JSONB map for additional information (tags, categories, etc.)
- `created_by`: User UUID who created the record
- `date_created`: When the record was created
- `date_updated`: When the record was last modified

## Core Functions

### Data Management
- `list_all/0` - Get all entity data records (excludes trashed by default)
- `list_by_entity/1` - Get all records for a specific entity (excludes trashed by default)
- `list_by_entity_and_status/2` - Filter records by entity and status
- `list_trashed_by_entity/1` - Get only trashed records for an entity
- `get!/1` - Get a record by ID (raises if not found)
- `get_by_slug/2` - Get a record by entity and slug (returns trashed too — slug uniqueness)
- `create/1` - Create a new record
- `update/2` - Update an existing record
- `trash/1` - Soft-delete (sets status to "trashed", row stays alive — parent FKs keep resolving)
- `restore_from_trash/1` - Move a trashed record back to "published"
- `delete/1` - Hard-delete a record (returns `{:error, :referenced_by_external}` on FK violation)
- `change/2` - Get changeset for forms

### Query Helpers
- `search_by_title/2` - Search records by title
- `filter_by_status/1` - Get records by status
- `count_by_entity/1` - Count records for an entity
- `published_records/1` - Get all published records for an entity

## Usage Examples

    # Create a brand data record
    {:ok, data} = PhoenixKitEntities.EntityData.create(%{
      entity_uuid: brand_entity.uuid,
      title: "Acme Corporation",
      slug: "acme-corporation",
      status: "published",
      created_by_uuid: user.uuid,
      data: %{
        "name" => "Acme Corporation",
        "tagline" => "Quality products since 1950",
        "description" => "<p>Leading manufacturer of innovative products</p>",
        "industry" => "Manufacturing",
        "founded_date" => "1950-03-15",
        "featured" => true
      },
      metadata: %{
        "tags" => ["manufacturing", "industrial"],
        "contact_email" => "info@acme.com"
      }
    })

    # Get all records for an entity
    records = PhoenixKitEntities.EntityData.list_by_entity(brand_entity.uuid)

    # Search by title
    results = PhoenixKitEntities.EntityData.search_by_title("Acme", brand_entity.uuid)

# `t`

```elixir
@type t() :: %PhoenixKitEntities.EntityData{
  __meta__: term(),
  children: term(),
  created_by_uuid: term(),
  creator: term(),
  data: term(),
  date_created: term(),
  date_updated: term(),
  entity: term(),
  entity_uuid: term(),
  metadata: term(),
  parent: term(),
  parent_uuid: term(),
  position: term(),
  slug: term(),
  status: term(),
  title: term(),
  uuid: term()
}
```

# `bulk_delete`

```elixir
@spec bulk_delete(
  [String.t()],
  keyword()
) ::
  {non_neg_integer(), nil} | {:error, :referenced_by_external | :has_children}
```

Bulk hard-deletes records by UUIDs.

Prefer `bulk_trash/2` for soft-delete. Use this for emptying-the-trash
flows where the records are confirmed unreferenced.

Catches `Postgrex.Error` for FK / NOT NULL violations and returns
`{:error, :referenced_by_external}` so the admin UI can flash a
friendly message rather than 500. The transaction rolls back on this
error — no records are deleted.

## Options

  * `:actor_uuid` — UUID of the user performing the bulk delete.

## Examples

    iex> PhoenixKitEntities.EntityData.bulk_delete(["uuid1", "uuid2"], actor_uuid: admin.uuid)
    {2, nil}

    iex> PhoenixKitEntities.EntityData.bulk_delete([referenced_uuid])
    {:error, :referenced_by_external}

# `bulk_restore_from_trash`

```elixir
@spec bulk_restore_from_trash(
  [String.t()],
  keyword()
) :: {non_neg_integer(), nil}
```

Bulk restores trashed records to `"published"` status.

Only rows currently `"trashed"` are touched. Logs
`entity_data.bulk_restored` with the affected count.

## Examples

    iex> EntityData.bulk_restore_from_trash(["uuid1", "uuid2"], actor_uuid: admin.uuid)
    {2, nil}

# `bulk_trash`

```elixir
@spec bulk_trash(
  [String.t()],
  keyword()
) :: {non_neg_integer(), nil}
```

Bulk soft-deletes records by setting their status to `"trashed"`.

Returns `{count, nil}` for the number of records actually trashed
(already-trashed records are skipped via the WHERE clause). Logs a
single `entity_data.bulk_trashed` row.

## Examples

    iex> EntityData.bulk_trash(["uuid1", "uuid2"], actor_uuid: admin.uuid)
    {2, nil}

# `bulk_update_positions`

```elixir
@spec bulk_update_positions(
  [{binary(), integer()}],
  keyword()
) :: :ok | {:error, :too_many_uuids | term()}
```

Bulk updates positions for multiple records.

Accepts a list of `{uuid, position}` tuples. Each record is updated
individually to trigger events and maintain consistency.

## Options

  * `:entity_uuid` — when provided, the WHERE clause also filters on
    `entity_uuid`, so a stray UUID from another entity in the input
    list cannot have its position rewritten by the wrong scope. The
    LV reorder paths always pass this; programmatic callers should
    too unless they really intend cross-entity rewrites.

Returns `{:error, :too_many_uuids}` if more than `1000` pairs are
supplied — the cap protects the transaction from N+1 unbounded
`update_all` work and from a malformed LV payload turning into a
long-running write storm.

## Examples

    iex> bulk_update_positions([{"uuid1", 1}, {"uuid2", 2}, {"uuid3", 3}], entity_uuid: e_uuid)
    :ok

# `bulk_update_status`

```elixir
@spec bulk_update_status([String.t()], String.t(), keyword()) ::
  {non_neg_integer(), nil}
```

Bulk updates the status of multiple records by UUIDs.

Returns a tuple with the count of updated records and nil. Logs a
single `entity_data.bulk_status_changed` activity row carrying the
count + new status. Per-record `entity_data.updated` rows are NOT
emitted — bulk operations log at the operation level so the audit
trail doesn't explode.

## Options

  * `:actor_uuid` — UUID of the user performing the bulk update.
    Threaded through to the activity log entry.

## Examples

    iex> PhoenixKitEntities.EntityData.bulk_update_status(["uuid1", "uuid2"], "archived",
    ...>   actor_uuid: admin.uuid)
    {2, nil}

# `change`

```elixir
@spec change(t(), map()) :: Ecto.Changeset.t()
```

Returns an `%Ecto.Changeset{}` for tracking entity data changes.

## Examples

    iex> PhoenixKitEntities.EntityData.change(record)
    %Ecto.Changeset{data: %PhoenixKitEntities.EntityData{}}

# `changeset`

```elixir
@spec changeset(t() | Ecto.Changeset.t(), map()) :: Ecto.Changeset.t()
```

Creates a changeset for entity data creation and updates.

Validates that entity exists, title is present, and data validates against entity definition.
Automatically sets date_created on new records.

# `count_by_entity`

```elixir
@spec count_by_entity(
  binary(),
  keyword()
) :: non_neg_integer()
```

Counts records for an entity.

Excludes trashed records by default. Pass `include_trashed: true` to
count everything.

## Examples

    iex> PhoenixKitEntities.EntityData.count_by_entity(entity_uuid)
    42

    iex> PhoenixKitEntities.EntityData.count_by_entity(entity_uuid, include_trashed: true)
    45

# `count_external_references`

```elixir
@spec count_external_references(t(), map() | nil) :: non_neg_integer()
```

Counts external (parent-app) rows that reference this record.

Reads `:reverse_references` from `Application.get_env/2` — a list of
`{entity_name, count_fn}` tuples where `count_fn` is a 1-arity
function that takes the entity_data uuid and returns a non-negative
integer. Returns the total count across all matching callbacks for
the record's entity, or `0` if no callbacks are registered.

**Informational only — NOT a delete-blocker.** Soft-delete keeps the
row alive regardless of how many parent rows reference it; this
count is just for the admin UI to surface "Used by N rows" hints
before the operator clicks Trash or Permanently delete. Don't wire
it into the actual delete path.

## Performance — pass `entity` when the caller already has it

The single-arg form preloads `:entity` on every call. Parent-app
callers rendering many records in a loop can pass the already-loaded
entity as the second arg to skip the per-call N+1:

    entity = Entities.get_entity!(entity_uuid)

    Enum.map(records, fn record ->
      EntityData.count_external_references(record, entity)
    end)

## Configuration

    # In parent app config:
    config :phoenix_kit_entities,
      reverse_references: [
        {"order_status", &MyApp.Orders.count_orders_with_status/1},
        {"sub_order_status", &MyApp.Orders.count_sub_orders_with_status/1}
      ]

Multiple callbacks per entity name are supported — every matching
tuple's count contributes to the total. Useful when the same
entity_data acts as a controlled vocabulary for several parent
tables (e.g. `orders` and `audit_log` both referencing
`order_status`).

# `create`

```elixir
@spec create(
  map(),
  keyword()
) :: {:ok, t()} | {:error, Ecto.Changeset.t()}
```

Creates an entity data record.

## Examples

    iex> PhoenixKitEntities.EntityData.create(%{entity_uuid: entity_uuid, title: "Test"})
    {:ok, %PhoenixKitEntities.EntityData{}}

    iex> PhoenixKitEntities.EntityData.create(%{title: ""})
    {:error, %Ecto.Changeset{}}

Note: `created_by` is auto-filled with the first admin or user ID if not provided,
but only if at least one user exists in the system. If no users exist, the changeset
will fail with a validation error on `created_by`.

# `delete`

```elixir
@spec delete(
  t(),
  keyword()
) ::
  {:ok, t()}
  | {:error, Ecto.Changeset.t() | :referenced_by_external | :has_children}
```

Hard-deletes an entity data record.

Prefer `trash/2` for soft-delete — it keeps the row alive so parent-app
FK references stay valid. Use this only when the record is genuinely
unreferenced (e.g. emptying the trash bin).

Catches `Postgrex.Error` for FK / NOT NULL violations and returns
`{:error, :referenced_by_external}` so the admin UI can render a friendly
flash instead of a 500. The row stays in the DB on this error.

## Examples

    iex> PhoenixKitEntities.EntityData.delete(record)
    {:ok, %PhoenixKitEntities.EntityData{}}

    iex> # parent_app.orders has a NOT NULL FK to this row
    iex> PhoenixKitEntities.EntityData.delete(record)
    {:error, :referenced_by_external}

# `delete_data`

```elixir
@spec delete_data(
  t(),
  keyword()
) :: {:ok, t()} | {:error, Ecto.Changeset.t() | :referenced_by_external}
```

Alias for delete/1 for consistency with LiveView naming.

# `descendant_uuids`

```elixir
@spec descendant_uuids(binary() | nil, binary(), keyword()) :: [binary()]
```

Returns all descendant UUIDs of a record (children, grandchildren, …).

Used by the parent picker to exclude rows that would create a cycle.
Walks the in-memory tree of the same entity rather than recursing
through the DB. Trashed rows are excluded by default — they cannot
meaningfully participate in a new cycle and including them would
leak trashed rows into the picker's exclusion set.

Returns `[]` for unknown / NULL `uuid`.

## Options

* `:include_trashed` — when `true`, include trashed descendants

# `descendant_uuids_from_rows`

```elixir
@spec descendant_uuids_from_rows(binary() | nil, [t()]) :: [binary()]
```

Same as `descendant_uuids/3` but operates on an already-loaded row
list. Pair with `tree_from_rows/1` when both shapes are needed from
one fetch.

# `filter_by_status`

```elixir
@spec filter_by_status(
  String.t(),
  keyword()
) :: [t()]
```

Gets records filtered by status across all entities.

When `status` is `"trashed"`, returns trashed records. For all other
statuses, the query is implicitly scoped (status filter excludes trashed
by virtue of matching a different status).

## Examples

    iex> PhoenixKitEntities.EntityData.filter_by_status("draft")
    [%PhoenixKitEntities.EntityData{status: "draft"}, ...]

# `get`

```elixir
@spec get(
  any(),
  keyword()
) :: t() | nil
```

Gets a single entity data record by UUID.

Returns the record if found, nil otherwise.

## Examples

    iex> PhoenixKitEntities.EntityData.get("550e8400-e29b-41d4-a716-446655440000")
    %PhoenixKitEntities.EntityData{}

    iex> PhoenixKitEntities.EntityData.get("invalid")
    nil

# `get!`

```elixir
@spec get!(
  binary(),
  keyword()
) :: t()
```

Gets a single entity data record by UUID.

Raises `Ecto.NoResultsError` if the record does not exist.

## Examples

    iex> PhoenixKitEntities.EntityData.get!("550e8400-e29b-41d4-a716-446655440000")
    %PhoenixKitEntities.EntityData{}

    iex> PhoenixKitEntities.EntityData.get!("nonexistent-uuid")
    ** (Ecto.NoResultsError)

# `get_all_title_translations`

```elixir
@spec get_all_title_translations(t()) :: %{optional(String.t()) =&gt; String.t() | nil}
```

Gets all title translations for a record.

Returns a map of language codes to title strings.

## Examples

    iex> get_all_title_translations(record)
    %{"en-US" => "My Product", "es-ES" => "Mi Producto", "fr-FR" => "Mon Produit"}

# `get_all_translations`

```elixir
@spec get_all_translations(t()) :: %{optional(String.t()) =&gt; map()}
```

Gets translations for all languages in a record.

Returns a map of language codes to their merged data.
For flat records, returns the data under the primary language key.

## Examples

    iex> get_all_translations(record)
    %{
      "en-US" => %{"name" => "Acme", "category" => "Tech"},
      "es-ES" => %{"name" => "Acme España", "category" => "Tech"}
    }

# `get_by_slug`

```elixir
@spec get_by_slug(binary(), String.t(), keyword()) :: t() | nil
```

Gets a single entity data record by entity and slug.

Returns the record if found, nil otherwise.

## Examples

    iex> PhoenixKitEntities.EntityData.get_by_slug(entity_uuid, "acme-corporation")
    %PhoenixKitEntities.EntityData{}

    iex> PhoenixKitEntities.EntityData.get_by_slug(entity_uuid, "invalid")
    nil

# `get_data!`

```elixir
@spec get_data!(
  binary(),
  keyword()
) :: t()
```

Alias for get!/2 for consistency with LiveView naming.

# `get_data_stats`

```elixir
@spec get_data_stats(binary() | nil) :: %{
  total_records: non_neg_integer(),
  published_records: non_neg_integer(),
  draft_records: non_neg_integer(),
  archived_records: non_neg_integer(),
  trashed_records: non_neg_integer()
}
```

Gets statistical data about entity data records.

`total_records` is the count of *non-trashed* records (the visible
total). `trashed_records` is reported separately so the admin can
surface the trash-bin badge.

## Examples

    iex> PhoenixKitEntities.EntityData.get_data_stats()
    %{
      total_records: 150,
      published_records: 120,
      draft_records: 25,
      archived_records: 5,
      trashed_records: 3
    }

# `get_raw_translation`

```elixir
@spec get_raw_translation(t(), String.t()) :: map()
```

Gets the raw (non-merged) data for a specific language.

For secondary languages, returns only the override fields (not merged with primary).
Useful for seeing which fields have explicit translations.

## Examples

    iex> get_raw_translation(record, "es-ES")
    %{"name" => "Acme España"}

# `get_title_translation`

```elixir
@spec get_title_translation(t(), String.t()) :: String.t() | nil
```

Gets the title translation for a specific language.

Reads from `data[lang]["_title"]` (unified JSONB storage). Falls back to
the old `metadata["translations"]` location for unmigrated records, and
finally to the `title` column.

## Examples

    iex> get_title_translation(record, "en-US")
    "My Product"

    iex> get_title_translation(record, "es-ES")
    "Mi Producto"

# `get_translation`

```elixir
@spec get_translation(t(), String.t()) :: map()
```

Gets the data fields for a specific language, merged with primary language defaults.

For multilang records, returns `Map.merge(primary_data, language_overrides)`.
For flat (non-multilang) records, returns the data as-is.

## Examples

    iex> get_translation(record, "es-ES")
    %{"name" => "Acme España", "category" => "Tech"}

    iex> get_translation(flat_record, "en-US")
    %{"name" => "Acme", "category" => "Tech"}

# `list_all`

```elixir
@spec list_all(keyword()) :: [t()]
```

Returns all entity data records ordered by creation date.

Trashed records are excluded by default. Pass `include_trashed: true` to
return them too — used by admin trash views and reverse-reference checks.

## Examples

    iex> PhoenixKitEntities.EntityData.list_all()
    [%PhoenixKitEntities.EntityData{}, ...]

# `list_all_data`

```elixir
@spec list_all_data(keyword()) :: [t()]
```

Alias for list_all/1 for consistency with LiveView naming.

# `list_by_entity`

```elixir
@spec list_by_entity(
  binary(),
  keyword()
) :: [t()]
```

Returns all entity data records for a specific entity.

Trashed records are excluded by default. Pass `include_trashed: true` to
return them too.

## Examples

    iex> PhoenixKitEntities.EntityData.list_by_entity(entity_uuid)
    [%PhoenixKitEntities.EntityData{}, ...]

# `list_by_entity_and_status`

```elixir
@spec list_by_entity_and_status(binary(), String.t(), keyword()) :: [t()]
```

Returns entity data records filtered by entity and status.

## Examples

    iex> PhoenixKitEntities.EntityData.list_by_entity_and_status(entity_uuid, "published")
    [%PhoenixKitEntities.EntityData{status: "published"}, ...]

# `list_data_by_entity`

```elixir
@spec list_data_by_entity(
  binary(),
  keyword()
) :: [t()]
```

Alias for list_by_entity/2 for consistency with LiveView naming.

# `list_data_by_status`

```elixir
@spec list_data_by_status(
  String.t(),
  keyword()
) :: [t()]
```

Alias for filter_by_status/2 for consistency with LiveView naming.

# `list_trashed_by_entity`

```elixir
@spec list_trashed_by_entity(
  binary(),
  keyword()
) :: [t()]
```

Returns trashed records for a specific entity, ordered by most recently
updated (the default trash-bin view).

## Examples

    iex> PhoenixKitEntities.EntityData.list_trashed_by_entity(entity_uuid)
    [%PhoenixKitEntities.EntityData{status: "trashed"}, ...]

# `list_tree`

```elixir
@spec list_tree(
  binary(),
  keyword()
) :: [%{record: t(), depth: non_neg_integer()}]
```

Returns the entity's records as a depth-ordered flat list for
WordPress-style indented rendering — parents precede their children,
siblings preserve the entity's current sort order.

Each element is `%{record: %EntityData{}, depth: integer}` where
depth `0` is a root row.

## Options

* `:include_trashed` — when `true`, include trashed rows (default `false`)
* `:lang` — resolve multilingual fields to the given locale. Display-only —
  see the warning on `resolve_language/2` before writing a resolved record back
* any other opt accepted by `list_by_entity/2`

# `move_to_position`

```elixir
@spec move_to_position(t(), integer()) :: :ok | {:error, term()}
```

Moves a record to a specific position within its entity, shifting other records.

Records between the old and new positions are shifted up or down by 1 to
make room. This is the operation that a drag-and-drop UI would call.

## Examples

    iex> move_to_position(record, 3)
    :ok

# `next_position`

```elixir
@spec next_position(binary()) :: non_neg_integer()
```

Gets the next available position for an entity's data records.

## Examples

    iex> next_position(entity_uuid)
    6

# `public_alternates`

```elixir
@spec public_alternates(map(), map(), keyword()) :: %{
  canonical: String.t(),
  alternates: [%{locale: String.t(), href: String.t()}]
}
```

Returns hreflang alternates and a canonical URL for a record across all
enabled languages.

When the same record serves at both the unprefixed primary-language URL
(`/products/my-item`) and a per-locale prefixed URL (`/es/products/mi-item`),
search engines can index both as duplicate content. Use this helper to emit
`<link rel="alternate" hreflang="...">` and `<link rel="canonical">` tags
alongside `public_path/3` so the duplication is declared rather than
competing.

## Output shape

    %{
      canonical: "https://site.com/en/products/my-item",
      alternates: [
        %{locale: "en", href: "https://site.com/en/products/my-item"},
        %{locale: "es", href: "https://site.com/es/products/mi-item"},
        %{locale: "x-default", href: "https://site.com/en/products/my-item"}
      ]
    }

The primary-language `canonical`/`href` shape follows the site-wide
`default_language_no_prefix` setting (prefixed by default, as shown
above; unprefixed — `/products/my-item` — when the setting is ON).

Locales are emitted as base codes (`"en"`, `"es"`) — matching the locale
prefix policy used in `public_path/3` and Google's hreflang docs (which
recommend `xx` over `xx-XX` unless region targeting is required).

## Options

  * `:base_url` — explicit absolute URL prefix (e.g. `"https://shop.example.com"`).
    Falls back to the `site_url` setting, then `""` (which yields path-only
    `href` values — useful in tests but not in production HTML).
  * `:routes_cache` — pre-built cache from `UrlResolver.build_routes_cache/0`
    (avoid rebuilding per call when emitting many records).
  * `:primary_locale` — overrides the locale considered "canonical".
    Defaults to `Multilang.primary_language/0` with rescue fallback to the
    first enabled locale.

When the Multilang module is unavailable or only one language is enabled,
the result has a single canonical entry and no `:alternates`.

# `public_path`

```elixir
@spec public_path(map(), map(), keyword()) :: String.t()
```

Returns a public path for a record, respecting locale and the configured URL pattern.

URL pattern resolution chain (shared with `PhoenixKitEntities.SitemapSource`):
1. `entity.settings["sitemap_url_pattern"]`
2. Router introspection (via `PhoenixKit.Modules.Sitemap.RouteResolver`)
3. Per-entity setting `sitemap_entity_<name>_pattern`
4. Global pattern setting `sitemap_entities_pattern`
5. Fallback `/<entity_name>/:slug`

Locale prefix policy (matches `PhoenixKit.Utils.Routes.path/2`):
- `:locale` omitted or `nil` → no prefix
- Single-language mode → no prefix
- Primary language → follows the site-wide `default_language_no_prefix`
  setting: prefixed by default (setting OFF), unprefixed when ON
- Other locales → prefixed with the base code (`/es/...`, `/ru/...`)

Slug resolution:
- When `:locale` is given and the record has a secondary-language slug
  override stored as `data[locale]["_slug"]`, that override is substituted
  for `:slug` in the pattern. Otherwise the primary `record.slug` is used
  (falling back to the UUID when slug is nil).

## Options

  * `:locale` — locale code (dialect like `"es-ES"` or base `"es"`). Omit to skip prefixing.
  * `:routes_cache` — pre-built cache from `UrlResolver.build_routes_cache/0` (for batches).

## Examples

    iex> EntityData.public_path(entity, record)
    "/products/my-item"

    iex> EntityData.public_path(entity, record, locale: "es-ES")
    "/es/products/my-item"

    iex> EntityData.public_path(entity, record, locale: "en-US")  # primary, default_language_no_prefix OFF (default)
    "/en/products/my-item"

# `public_url`

```elixir
@spec public_url(map(), map(), keyword()) :: String.t()
```

Returns a full public URL for a record by prepending a base URL to `public_path/3`.

## Options

  * `:base_url` — explicit base (e.g. `"https://site.com"`). Falls back to the
    `site_url` setting, then an empty string.
  * `:locale` — forwarded to `public_path/3`.
  * `:routes_cache` — forwarded to `public_path/3`.

## Examples

    iex> EntityData.public_url(entity, record, base_url: "https://shop.example.com")
    "https://shop.example.com/products/my-item"

# `published_records`

```elixir
@spec published_records(
  binary(),
  keyword()
) :: [t()]
```

Gets all published records for a specific entity.

## Examples

    iex> PhoenixKitEntities.EntityData.published_records(entity_uuid)
    [%PhoenixKitEntities.EntityData{status: "published"}, ...]

# `remove_translation`

```elixir
@spec remove_translation(t(), String.t()) ::
  {:ok, t()}
  | {:error, :cannot_remove_primary | :not_multilang | Ecto.Changeset.t()}
```

Removes all data for a specific language from a record.

Cannot remove the primary language. Returns `{:error, :cannot_remove_primary}`
if the primary language is targeted.

## Examples

    iex> remove_translation(record, "es-ES")
    {:ok, %EntityData{}}

    iex> remove_translation(record, "en-US")
    {:error, :cannot_remove_primary}

# `reorder`

```elixir
@spec reorder(binary(), [binary()], keyword()) ::
  :ok | {:error, :too_many_uuids | term()}
```

Reorders all records for an entity based on a list of UUIDs in the desired order.

This is the full reorder operation — takes a list of UUIDs representing the
new order and assigns positions 1, 2, 3, ... accordingly.

## Examples

    iex> reorder(entity_uuid, ["uuid3", "uuid1", "uuid2"])
    :ok

# `resolve_language`

```elixir
@spec resolve_language(t(), String.t()) :: t()
```

Resolves translated fields on an entity data record for a given language.

Resolves the `title` from `_title` in the language's data, and replaces
the `data` field with the merged language data (primary as base + overrides).

For the primary language or flat (non-multilang) data, the struct is
returned with the primary language data resolved. When no translation
exists for a field, the primary language value is used as fallback.

> #### Read-only {: .warning}
>
> The returned struct is **lossy and display-only**. Its `data` is the
> flattened single-language map — `_primary_language` and every other
> language are gone. Never build an update changeset from it: writing it
> back replaces the row's whole multilang `data` with the one resolved
> language and permanently deletes the rest.
>
> Editors must load the record raw (`get!/1`, no `:lang`) and resolve
> per-language values for display themselves.

## Examples

    iex> resolve_language(record, "es-ES")
    %EntityData{title: "Mi Producto", data: %{"name" => "Acme España", ...}}

    iex> resolve_language(record, "en-US")  # primary language
    %EntityData{title: "My Product", data: %{"name" => "Acme", ...}}

# `resolve_languages`

```elixir
@spec resolve_languages([t()], String.t()) :: [t()]
```

Resolves translations on a list of entity data records.

## Examples

    iex> resolve_languages(records, "es-ES")
    [%EntityData{title: "Mi Producto"}, ...]

# `restore_from_trash`

```elixir
@spec restore_from_trash(
  t(),
  keyword()
) :: {:ok, t()} | {:error, :not_trashed | Ecto.Changeset.t()}
```

Restores a trashed entity data record. The status it returns to is
the one stashed in `metadata["trashed_from_status"]` when the row was
trashed — defaulting to `"draft"` if no stash is present (e.g. for
rows trashed via `bulk_trash/2`, or rows trashed before this stash
shipped).

Returns `{:error, :not_trashed}` if the record isn't currently trashed —
guardrail against re-publishing arbitrary records via the trash-restore
path. Use `update/2` for general status changes.

## Examples

    iex> EntityData.restore_from_trash(trashed_record, actor_uuid: admin.uuid)
    {:ok, %EntityData{status: "published"}}  # was published before trashing

# `search_by_title`

```elixir
@spec search_by_title(String.t()) :: [t()]
```

Searches entity data records by title.

## Examples

    iex> PhoenixKitEntities.EntityData.search_by_title("Acme")
    [%PhoenixKitEntities.EntityData{}, ...]

    iex> PhoenixKitEntities.EntityData.search_by_title("Acme", entity_uuid)
    [%PhoenixKitEntities.EntityData{}, ...]

    iex> PhoenixKitEntities.EntityData.search_by_title("Acme", entity_uuid, lang: "es")
    [%PhoenixKitEntities.EntityData{}, ...]

# `search_by_title`

```elixir
@spec search_by_title(String.t(), binary() | nil, keyword()) :: [t()]
```

# `search_data`

```elixir
@spec search_data(String.t()) :: [t()]
```

Alias for search_by_title for consistency with LiveView naming.

# `search_data`

```elixir
@spec search_data(String.t(), binary() | nil, keyword()) :: [t()]
```

# `secondary_slug_exists?`

```elixir
@spec secondary_slug_exists?(binary(), String.t(), String.t(), binary() | nil) ::
  boolean()
```

Checks if a secondary language slug exists for another record within the same entity.

Queries the JSONB `data` column for `data->lang_code->>'_slug'` matches.
Used for uniqueness checks on translated slugs.

# `set_title_translation`

```elixir
@spec set_title_translation(t(), String.t(), String.t()) ::
  {:ok, t()} | {:error, Ecto.Changeset.t()}
```

Sets the title translation for a specific language.

Stores `_title` in the JSONB `data` column using `put_language_data`.
For the primary language, also updates the `title` DB column.

## Examples

    iex> set_title_translation(record, "es-ES", "Mi Producto")
    {:ok, %EntityData{}}

    iex> set_title_translation(record, "en-US", "My Product")
    {:ok, %EntityData{}}

# `set_translation`

```elixir
@spec set_translation(t(), String.t(), map()) ::
  {:ok, t()} | {:error, Ecto.Changeset.t()}
```

Sets the data translation for a specific language on a record.

For the primary language, stores all fields.
For secondary languages, only stores fields that differ from primary (overrides).
Persists to the database.

## Examples

    iex> set_translation(record, "es-ES", %{"name" => "Acme España"})
    {:ok, %EntityData{}}

    iex> set_translation(record, "en-US", %{"name" => "Acme Corp", "category" => "Tech"})
    {:ok, %EntityData{}}

# `trash`

# `trash`

```elixir
@spec trash(
  t(),
  keyword()
) :: {:ok, t()} | {:error, :already_trashed | Ecto.Changeset.t()}
```

Soft-deletes an entity data record by setting its status to `"trashed"`.

The row remains in the database so any parent-app FK references stay
valid — historical orders, audit logs, etc. continue to resolve. The
record is hidden from default `list_*` queries; use `list_trashed_by_entity/2`
to surface it for the admin trash view.

Logs `entity_data.trashed` activity. Refuses with `{:error, :already_trashed}`
if the record is already trashed.

## Examples

    iex> EntityData.trash(record, actor_uuid: admin.uuid)
    {:ok, %EntityData{status: "trashed"}}

# `trashed_count`

```elixir
@spec trashed_count(binary()) :: non_neg_integer()
```

Counts trashed records for an entity. Drives the trash-bin badge.

# `tree_from_rows`

```elixir
@spec tree_from_rows([t()]) :: [%{record: t(), depth: non_neg_integer()}]
```

Depth-orders an already-loaded list of rows. Use this when the caller
already has the rows in hand (e.g. building both a tree and a
descendants set from the same fetch) — skips the per-call DB hit
that `list_tree/2` makes.

# `update`

```elixir
@spec update(t(), map(), keyword()) ::
  {:ok, t()} | {:error, Ecto.Changeset.t() | :status_mismatch}
```

Updates an entity data record.

## Options

  * `:actor_uuid` — the user performing the update, recorded on the
    activity log entry.
  * `:activity_log` — defaults to `true`; pass `false` to skip logging
    this update's activity entry (the PubSub broadcast and mirror
    export still fire regardless). Intended for high-frequency callers
    like `LiveDataForm`'s autosave, where logging every debounced
    keystroke would flood the activity log.
  * `:require_status` — defaults to `nil` (no check, existing
    behavior unchanged). Pass a list of status strings (e.g.
    `["draft"]`) to guard against a cross-session race: the record is
    re-read by `uuid` under `SELECT ... FOR UPDATE` inside a
    transaction, and the update only proceeds if the record's
    *current, freshly-read* status is in the list. If it isn't,
    returns `{:error, :status_mismatch}` without touching the row,
    the activity log, or PubSub — as if the call never happened. This
    closes the window where a stale in-memory copy (e.g. a second
    browser tab still holding an `:edit` view of a record that a
    first tab has since submitted/published) would otherwise
    overwrite `data` on a record whose status has since moved on.
    When the status does match, the changeset is built against the
    freshly-read row (not the possibly-stale `entity_data` argument),
    so any other field also reflects the latest DB state going in.
    Must be a list — a bare status string (e.g. `require_status:
    "draft"` instead of `require_status: ["draft"]`) raises
    `ArgumentError` immediately rather than reaching a confusing
    `CaseClauseError`.

## Examples

    iex> PhoenixKitEntities.EntityData.update(record, %{title: "Updated"})
    {:ok, %PhoenixKitEntities.EntityData{}}

    iex> PhoenixKitEntities.EntityData.update(record, %{title: ""})
    {:error, %Ecto.Changeset{}}

    iex> PhoenixKitEntities.EntityData.update(record, %{title: "Updated"}, require_status: ["draft"])
    {:error, :status_mismatch}

# `update_data`

```elixir
@spec update_data(t(), map(), keyword()) :: {:ok, t()} | {:error, Ecto.Changeset.t()}
```

Alias for update/2 for consistency with LiveView naming.

# `update_position`

```elixir
@spec update_position(t(), integer()) :: {:ok, t()} | {:error, Ecto.Changeset.t()}
```

Updates the position of a single entity data record.

## Examples

    iex> update_position(record, 3)
    {:ok, %EntityData{position: 3}}

---

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