es-entity 0.12.10

Event Sourcing Entity Framework
Documentation
# Error Types

`EsRepo` generates four per-entity error types and a column enum. For an entity called `User`, the macro produces:

| Type | Used by |
|------|---------|
| `UserColumn` | Enum of indexed columns (e.g. `Id`, `Name`, `Email`) |
| `UserCreateError` | `create`, `create_all` |
| `UserModifyError` | `update`, `update_all`, `delete` |
| `UserFindError` | `find_by_*`, `maybe_find_by_*` |
| `UserQueryError` | `find_all`, `list_by_*`, `list_for_*`, `list_for_filters` |

## UserCreateError

```rust,ignore
pub enum UserCreateError {
    Sqlx(sqlx::Error),
    ConstraintViolation {
        column: Option<UserColumn>,
        value: Option<String>,
        inner: sqlx::Error,
    },
    ConcurrentModification,
    HydrationError(EntityHydrationError),
    PostPersistHookError(/* only if post_persist_hook configured */),
    PostHydrateError(/* only if post_hydrate_hook configured */),
}
```

> `PostPersistHookError` and `PostHydrateError` are only present when the corresponding hook is configured. `PostPersistHookError` wraps `sqlx::Error` by default, or a custom error type if configured via `post_persist_hook(error = "...")`. See [Hooks](./repo-hooks.md) for details.

### Handling constraint violations

When a `create` or `create_all` operation violates a **unique**, **foreign key**, or **check** constraint, the error is returned as `ConstraintViolation` rather than a raw `Sqlx` error. (`NOT NULL` and exclusion violations are not classified and remain `Sqlx`.) For unique violations, the `column` field identifies which column caused the violation and the `value` field contains the conflicting value extracted from the PostgreSQL error detail. For foreign key and check violations — or unique constraints not recognized as belonging to one of the entity's columns — `column` and `value` are `None`; use the typed `violated_constraint()` helper (or the raw `constraint_name()`) to identify the constraint instead.

> **Security note:** `value` (and the `duplicate_value()` helper) contains attacker-influenced input that was rejected by a unique constraint and is frequently PII (e.g. an email address). Do not propagate it to untrusted API clients — a caller can probe which values already exist (user enumeration) — and be aware it may end up in logs via the error's `Display`/`Debug` output. At trust boundaries, prefer the boolean helpers `was_duplicate()` / `was_duplicate_by(column)` and map the error to a neutral client-facing message.

```rust,ignore
let result = users.create(new_user).await;
match result {
    Ok(user) => { /* success */ }
    // Column-agnostic check
    Err(e) if e.was_duplicate() => {
        println!("some unique constraint violated");
    }
    Err(e) => return Err(e.into()),
}

// Or check a specific column:
match result {
    Ok(user) => { /* success */ }
    Err(e) if e.was_duplicate_by(UserColumn::Email) => {
        let value = e.duplicate_value(); // Option<&str>
        println!("email {} already taken", value.unwrap_or("unknown"));
    }
    Err(e) => return Err(e.into()),
}
```

The `was_duplicate()` / `was_duplicate_by(column)` helpers only fire for **unique** violations; `was_foreign_key_violation()` and `was_check_violation()` cover the other classified kinds.

### Typed constraints: `UserConstraint`

Alongside the column enum, the macro derives a `UserConstraint` enum with one variant per constraint on the entity's table known at compile time: the declared columns' unique constraints (convention names like `users_email_key` / `users_pkey`) plus every unique, foreign key, and check constraint discoverable from the migrations directory (the same catalog that drives `list_for_filters` specialization). Variant names strip the table prefix — `entries_account_not_account_set_fkey` on table `entries` becomes `EntryConstraint::AccountNotAccountSetFkey`.

This makes dispatching on a hand-written foreign-key or check constraint typo-proof — no string matching at the call site, and a renamed constraint in a migration surfaces as a compile error instead of a silently dead match arm:

```rust,ignore
match result {
    Ok(entry) => { /* success */ }
    Err(e) if e.violated_constraint() == Some(EntryConstraint::AccountNotAccountSetFkey) => {
        return Err(AppError::EntryTargetsAccountSet);
    }
    Err(e) => return Err(e.into()),
}
```

Each variant knows its raw name (`constraint.name()` / `Display`) and kind (`constraint.kind()` → `ConstraintKind::{Unique, ForeignKey, Check}`). Constraints created outside discoverable migrations can't be typed; fall back to `constraint_name()` for those:

```rust,ignore
Err(e) if e.constraint_name() == Some("added_at_runtime_fkey") => { /* ... */ }
```

The macro maps PostgreSQL constraint names to columns automatically. It uses the convention `{table}_{column}_key` for unique constraints and `{table}_pkey` for the primary key, and additionally derives the real names of any **named** single-column unique index from your migrations (the same index catalog that drives `list_for_filters` specialization — see [list_for_filters](./repo-list-for-filters.md)). So a `CREATE UNIQUE INDEX idx_unique_email ON users (email)` in a migration is mapped to the `email` column with no extra annotation — as long as the migrations directory is discoverable (crate-local `migrations/`, an ancestor `migrations/` up to the repo root, or `ES_ENTITY_MIGRATIONS_DIR`). A composite `UNIQUE (a, b)` is mapped to its **last** key column (`b`) — the discriminating column, with the leading columns acting as its scope — so a `UNIQUE (partner_id, name)` violation reports the `name` column.

### Concurrent modification

When optimistic concurrency control detects a conflict (duplicate event sequence), the error is `ConcurrentModification`:

```rust,ignore
if e.was_concurrent_modification() {
    // retry the operation
}
```

## UserModifyError

`UserModifyError` has the same structure as `UserCreateError` (minus `HydrationError` and `PostHydrateError`) and is returned by `update`, `update_all`, and `delete`. `PostPersistHookError` is only present when `post_persist_hook` is configured. It provides the same `was_duplicate`, `was_duplicate_by`, `duplicate_value`, and `was_concurrent_modification` helpers.

### Nested entity errors

For aggregates with nested entities (e.g. `Order` containing `OrderItem`s), `CreateError` and `ModifyError` include additional variants wrapping the child's errors. The `duplicate_value` and `was_concurrent_modification` helpers cascade into nested errors automatically:

```rust,ignore
// If a nested OrderItem creation triggers a constraint violation,
// duplicate_value() still returns the conflicting value:
let val = err.duplicate_value(); // cascades into nested variants
```

The `was_duplicate_by` helper does **not** cascade because nested entities have a different column enum. To check which nested column was violated, match the nested variant directly:

```rust,ignore
match err {
    OrderModifyError::OrderItemsCreate(item_err)
        if item_err.was_duplicate_by(OrderItemColumn::Sku) =>
    {
        let val = item_err.duplicate_value();
    }
    _ => return Err(err.into()),
}
```

## UserFindError

```rust,ignore
pub enum UserFindError {
    Sqlx(sqlx::Error),
    NotFound { entity: &'static str, column: Option<UserColumn>, value: String },
    HydrationError(EntityHydrationError),
    PostHydrateError(/* only if post_hydrate_hook configured */),
}
```

The `NotFound` variant is returned by `find_by_*` methods when no matching row exists. It includes the entity name, the column searched (as the `UserColumn` enum), and the value that was not found.

### Checking for not-found

```rust,ignore
let result = users.find_by_id(some_id).await;
match result {
    Ok(user) => { /* found */ }
    Err(e) if e.was_not_found() => {
        println!("user not found");
    }
    Err(e) => return Err(e.into()),
}
```

### Matching on a specific column

Use `was_not_found_by` to check which column was searched, or pattern-match directly on the `NotFound` variant for full control:

```rust,ignore
// Helper method
if e.was_not_found_by(UserColumn::Email) {
    let value = e.not_found_value(); // Option<&str>
    println!("no user with email {}", value.unwrap_or("unknown"));
}

// Pattern matching for custom error conversion
impl From<UserFindError> for AppError {
    fn from(error: UserFindError) -> Self {
        match error {
            UserFindError::NotFound {
                column: Some(UserColumn::Id),
                value,
                ..
            } => Self::UserNotFoundById(value),
            UserFindError::NotFound {
                column: Some(UserColumn::Email),
                value,
                ..
            } => Self::UserNotFoundByEmail(value),
            other => Self::Internal(other.into()),
        }
    }
}
```

Use `maybe_find_by_*` to get `Ok(None)` instead of an error when the entity doesn't exist.

## UserQueryError

```rust,ignore
pub enum UserQueryError {
    Sqlx(sqlx::Error),
    HydrationError(EntityHydrationError),
    CursorDestructureError(CursorDestructureError),
    PostHydrateError(/* only if post_hydrate_hook configured */),
}
```

Returned by paginated list operations (`list_by_*`, `list_for_*`, `list_for_filters`). The `CursorDestructureError` variant occurs when a pagination cursor cannot be decoded.