Skip to main content

djogi/
error.rs

1//! The single error type returned by every framework CRUD operation.
2//! `DjogiError` wraps the sources of failure that can occur when a `Model`
3//! method runs: database driver errors, expected-row-count violations, and ID
4//! generation failures. Keeping one error type at the public API makes
5//! `?`-propagation ergonomic: user code calls `Post::get(&pool, id).await?`
6//! and gets a `DjogiError` without having to juggle per-subsystem errors.
7//! The variants correspond 1:1 to the failure modes the generated CRUD impls
8//! can produce:
9//! - `Db` — raw database/driver failures (network, constraints, SQL), wrapped
10//!   in [`DbError`] so Djogi does not expose `tokio_postgres` directly in its
11//!   public error surface.
12//! - `NotFound` — `.get()` / `.fetch_one()` saw zero rows; carries the
13//!   offending table name for observability.
14//! - `MultipleObjects` — `.fetch_one()` saw more than one row; carries the
15//!   table name plus the actual count observed.
16//! - `IdGeneration` — ID generation DB calls failed.
17//! - `RelationUnloaded` — a relation accessor (`ForeignKeyResolved::expect_resolved`
18//!   / `OneToOneFieldResolved::expect_resolved`) was invoked against a cache
19//!   that was never populated. Raised from the strict path where the caller
20//!   has asserted a `prefetch()` / `select_related()` happened upstream.
21//! # `#[non_exhaustive]` on the enum *and* its struct variants
22//! Both `DjogiError` and its struct-form variants (`NotFound`,
23//! `MultipleObjects`) are marked `#[non_exhaustive]`. This is a deliberate
24//! forward-compatibility choice:
25//! - **Enum-level.** Downstream matches MUST include a wildcard arm, so
26//!   introducing a new variant in a future release is not a breaking change.
27//!   Djogi is pre-publish today, but + adds filter-layer errors (type
28//!   coercion, invalid operator) that will live here.
29//! - **Variant-level.** Downstream destructuring patterns MUST use `..`, and
30//!   struct-expression *construction* from outside this crate is blocked
31//!   that is exactly the desired shape for an error type. The only legitimate
32//!   construction sites are inside djogi and inside `#[model]`-expanded code
33//!   (which runs in user crates). The expanded code goes through the public
34//!   constructors below (`DjogiError::not_found`, `DjogiError::multiple_objects`),
35//!   matching the pattern established by `std::io::Error`, `hyper::Error`,
36//!   and similar well-designed error types.
37//!   The cost is one extra line of implementation (the constructor) and one
38//!   extra pair of dots at downstream destructuring sites. The benefit is
39//!   that adding a field to either struct variant is also non-breaking.
40
41use std::borrow::Cow;
42use thiserror::Error;
43
44fn presentation_startup_error_summary(
45    errors: &[crate::presentation::PresentationStartupError],
46) -> String {
47    if errors.is_empty() {
48        return "no individual codec startup errors were collected".to_owned();
49    }
50
51    errors
52        .iter()
53        .enumerate()
54        .map(|(idx, error)| {
55            let msg = error.to_string();
56            let msg = msg
57                .strip_prefix("presentation codec startup: ")
58                .unwrap_or(msg.as_str());
59            format!("{}. {msg}", idx + 1)
60        })
61        .collect::<Vec<_>>()
62        .join("; ")
63}
64
65#[cfg(feature = "aes-codec")]
66fn field_codec_startup_error_summary(errors: &[crate::field_codec::CodecStartupError]) -> String {
67    if errors.is_empty() {
68        return "no individual codec startup errors were collected".to_owned();
69    }
70
71    errors
72        .iter()
73        .enumerate()
74        .map(|(idx, error)| format!("{}. {}", idx + 1, error))
75        .collect::<Vec<_>>()
76        .join("; ")
77}
78
79/// Public wrapper for database-driver failures surfaced through Djogi.
80/// Djogi stores the real `tokio_postgres::Error` when one exists, but also
81/// needs a local message path for framework-generated database/runtime misuse
82/// errors such as "commit called on a pool-backed context". Keeping that shape
83/// behind `DbError` avoids exposing `tokio_postgres` directly in the public
84/// enum while preserving the old generic database-error behavior.
85#[derive(Debug)]
86pub struct DbError(DbErrorKind);
87
88#[derive(Debug)]
89enum DbErrorKind {
90    Pg(tokio_postgres::Error),
91    Message(Box<str>),
92    #[cfg(test)]
93    SyntheticDb {
94        code: tokio_postgres::error::SqlState,
95        message: Box<str>,
96    },
97}
98
99impl DbError {
100    /// Construct a message-only database error for framework-generated failures
101    /// that do not come from `tokio-postgres`.
102    pub fn other(message: impl Into<String>) -> Self {
103        Self(DbErrorKind::Message(message.into().into_boxed_str()))
104    }
105
106    /// Return the SQLSTATE if this error came from a Postgres database error.
107    pub fn code(&self) -> Option<&tokio_postgres::error::SqlState> {
108        match &self.0 {
109            DbErrorKind::Pg(error) => error.code(),
110            DbErrorKind::Message(_) => None,
111            #[cfg(test)]
112            DbErrorKind::SyntheticDb { code, .. } => Some(code),
113        }
114    }
115
116    /// Return the most useful human-readable message for this database error.
117    pub fn message(&self) -> Cow<'_, str> {
118        match &self.0 {
119            DbErrorKind::Pg(error) => error
120                .as_db_error()
121                .map(|db| Cow::Borrowed(db.message()))
122                .unwrap_or_else(|| Cow::Owned(error.to_string())),
123            DbErrorKind::Message(message) => Cow::Borrowed(message),
124            #[cfg(test)]
125            DbErrorKind::SyntheticDb { message, .. } => Cow::Borrowed(message),
126        }
127    }
128
129    #[cfg(test)]
130    fn synthetic_sqlstate(code: &str, message: &str) -> Self {
131        Self(DbErrorKind::SyntheticDb {
132            code: tokio_postgres::error::SqlState::from_code(code),
133            message: message.to_owned().into_boxed_str(),
134        })
135    }
136}
137
138impl std::fmt::Display for DbError {
139    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
140        match &self.0 {
141            DbErrorKind::Pg(error) => write!(f, "{error}"),
142            DbErrorKind::Message(message) => write!(f, "{message}"),
143            #[cfg(test)]
144            DbErrorKind::SyntheticDb { message, .. } => write!(f, "{message}"),
145        }
146    }
147}
148
149impl std::error::Error for DbError {
150    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
151        match &self.0 {
152            DbErrorKind::Pg(error) => Some(error),
153            DbErrorKind::Message(_) => None,
154            #[cfg(test)]
155            DbErrorKind::SyntheticDb { .. } => None,
156        }
157    }
158}
159
160impl From<tokio_postgres::Error> for DbError {
161    fn from(error: tokio_postgres::Error) -> Self {
162        Self(DbErrorKind::Pg(error))
163    }
164}
165
166/// A newtype wrapping a boxed error for ID-generation failures.
167/// The newtype wraps any boxed `Error + Send + Sync` so callers can supply
168/// HeeRanjID postgres-codec failures without this crate coupling to a specific
169/// upstream error type.
170#[derive(Debug)]
171pub struct IdGenerationError(pub Box<dyn std::error::Error + Send + Sync>);
172
173impl std::fmt::Display for IdGenerationError {
174    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
175        write!(f, "{}", self.0)
176    }
177}
178
179impl std::error::Error for IdGenerationError {
180    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
181        Some(self.0.as_ref())
182    }
183}
184
185impl IdGenerationError {
186    /// Wrap any `Error + Send + Sync + 'static` into an `IdGenerationError`.
187    pub fn new<E: std::error::Error + Send + Sync + 'static>(e: E) -> Self {
188        IdGenerationError(Box::new(e))
189    }
190}
191
192/// The single error type returned by every Djogi CRUD operation.
193/// Every fallible call site in djogi (`Model::create`, `QuerySet::fetch_all`,
194/// `transaction::atomic`, `DjogiContext::set_tenant`, every raw escape hatch,
195/// every spatial / FTS / JSONB helper) returns `Result<T, DjogiError>`. The
196/// crate-scoped [`crate::Result<T>`] alias spells exactly that — adopter code
197/// signs functions with `-> djogi::Result<T>` and uses `?` to propagate.
198/// # Error taxonomy
199/// `DjogiError` groups failures by where they originate, not by HTTP-style
200/// status code. The most common branches:
201/// - **Database-driver errors** — [`Db`](DjogiError::Db) wraps every
202///   [`tokio_postgres::Error`] (network, constraints, syntax, auth) behind
203///   the [`DbError`] facade so this enum does not leak `tokio_postgres` types.
204/// - **Expected-row-count violations** — [`NotFound`](DjogiError::NotFound)
205///   for zero rows from `Model::get` / `QuerySet::fetch_one`,
206///   [`MultipleObjects`](DjogiError::MultipleObjects) for >1 row from
207///   `fetch_one`. Both carry the offending table name.
208/// - **Concurrency / contention** — [`LockConflict`](DjogiError::LockConflict)
209///   for `40001` / `40P01` / `55P03` SQLSTATE classes (serialization
210///   failures, deadlocks, `NOWAIT` rejections),
211///   [`PoolTimeout`](DjogiError::PoolTimeout) for `deadpool` checkout
212///   exhaustion. Both classify as transient — see
213///   [`DjogiError::is_transient`].
214/// - **Auth / RLS** — [`Auth`](DjogiError::Auth) wraps
215///   [`AuthError`](crate::auth::AuthError) for authentication / authorization
216///   failures from the auth substrate;
217///   [`SetRoleOutsideTransaction`](DjogiError::SetRoleOutsideTransaction)
218///   and [`InvalidRoleName`](DjogiError::InvalidRoleName) surface RLS-
219///   overlay misuse.
220/// - **Misuse / runtime invariants**
221///   [`Validation`](DjogiError::Validation) for runtime argument validation
222///   failures,
223///   [`MissingIdempotencyKey`](DjogiError::MissingIdempotencyKey) for
224///   upsert-attribute gaps,
225///   [`StreamOutsideTransaction`](DjogiError::StreamOutsideTransaction) for
226///   cursor / `QuerySet::stream` outside `atomic`,
227///   [`UnsupportedAggregate`](DjogiError::UnsupportedAggregate) for IR /
228///   Postgres aggregate mismatches.
229/// - **Decode / serialization** — [`Decode`](DjogiError::Decode) for
230///   `FromPgRow` failures, [`Serde`](DjogiError::Serde) for outbox JSON
231///   serialization, [`Visage`](DjogiError::Visage) for visage-projection
232///   `TryFrom<&Model>` failures.
233/// - **ID generation** — [`IdGeneration`](DjogiError::IdGeneration) wraps
234///   HeeRanjID-side codec failures.
235/// - **Aggregate lifecycle** — [`GoneAggregate`](DjogiError::GoneAggregate)
236///   for terminal "already gone" signals on a previously-observed
237///   aggregate;
238///   [`RelationUnloaded`](DjogiError::RelationUnloaded) for prefetch-cache
239///   misses on a strict-mode resolved-relation accessor.
240/// - **Spatial** — [`Geo`](DjogiError::Geo) (gated on the `spatial` feature)
241///   wraps coordinate / EWKB codec errors.
242/// # Retry classification
243/// [`DjogiError::is_transient`] returns `true` for `LockConflict`,
244/// `PoolTimeout`, and the small set of variants whose failures are expected
245/// to be retryable. The framework also recognises the SQLSTATE classes that
246/// indicate contention vs. other database failures — both classifications
247/// are intended for use in caller-side retry policies (see also
248/// [`retry_on_conflict`](crate::transaction::retry_on_conflict)).
249/// # `#[non_exhaustive]`
250/// Both `DjogiError` and its struct-form variants (`NotFound`,
251/// `MultipleObjects`, `RelationUnloaded`, `GoneAggregate`,
252/// `MissingIdempotencyKey`, `UnsupportedAggregate`, `PoolTimeout`,
253/// `InvalidRoleName`, etc.) are `#[non_exhaustive]`. Downstream `match`
254/// expressions MUST include a wildcard arm, and downstream destructuring
255/// MUST use `..` — adding a new variant or new field to a struct variant
256/// is therefore a non-breaking change. The cost is one extra wildcard arm
257/// at every match site; the benefit is the framework can grow new failure
258/// shapes (added a half-dozen) without breaking adopter code.
259/// Construction from outside this crate is also blocked by the variant-level
260/// `#[non_exhaustive]`. Use the public constructors
261/// (`DjogiError::not_found`, `DjogiError::multiple_objects`, etc.) when
262/// surfacing a Djogi-flavoured error from adopter code; this matches the
263/// pattern set by `std::io::Error`, `hyper::Error`, and similar
264/// well-designed error types.
265/// # Why one error type
266/// Every framework call returning `Result<T, DjogiError>` keeps `?`
267/// propagation ergonomic across CRUD, raw SQL, transactions, auth, and
268/// spatial/JSONB layers. Per-subsystem error types would force adopter
269/// code into manual conversion at every layer boundary — exactly the
270/// friction that drove the framework's "one error type at the public API"
271/// design.
272#[derive(Debug, Error)]
273#[non_exhaustive]
274pub enum DjogiError {
275    /// Authentication or authorization failure bubbled up through the auth
276    /// substrate. Wraps [`AuthError`](crate::auth::AuthError) so
277    /// [`DjogiAuth::authenticate`](crate::auth::DjogiAuth::authenticate) and
278    /// [`DjogiAuth::verify`](crate::auth::DjogiAuth::verify) failures flow
279    /// through `?` inside `atomic()`-managed operations without explicit
280    /// mapping.
281    /// # Transitivity
282    /// Because `AuthError` is `#[non_exhaustive]`, this variant also inherits
283    /// that forward-compatibility contract at the `DjogiError` level: a
284    /// `match` on `DjogiError::Auth(e)` that then matches on `e` must still
285    /// include a wildcard arm for `AuthError`.
286    #[error("auth error: {0}")]
287    Auth(#[from] crate::auth::AuthError),
288
289    /// Geo/spatial error from the `spatial` feature — coordinate validation
290    /// or EWKB codec failure. Wraps [`GeoError`](crate::geo::GeoError) so
291    /// spatial operations compose with `?` inside `atomic()`-managed
292    /// operations without explicit mapping.
293    #[cfg(feature = "spatial")]
294    #[error("geo error: {0}")]
295    Geo(#[from] crate::geo::GeoError),
296
297    /// Raw database or driver error.
298    #[error("database error: {0}")]
299    Db(#[source] DbError),
300
301    /// `Model::get` / `QuerySet::fetch_one` saw zero rows. The `table` field
302    /// records the SQL table that was queried so log lines and error reports
303    /// remain meaningful once errors propagate far from the call site.
304    #[error("row not found in `{table}`")]
305    #[non_exhaustive]
306    NotFound { table: &'static str },
307
308    /// `QuerySet::fetch_one` (or similar) saw more than one row when exactly
309    /// one was expected. `count_seen` is the number actually observed — with
310    /// the `LIMIT 2` strategy this is always exactly `2`, but storing the real
311    /// count keeps the error meaningful for future code paths that may
312    /// pre-count rows differently.
313    #[error("multiple objects returned from `{table}` (saw {count_seen}, expected exactly 1)")]
314    #[non_exhaustive]
315    MultipleObjects {
316        table: &'static str,
317        count_seen: usize,
318    },
319
320    /// ID generation failed.
321    #[error("id generation failed: {0}")]
322    IdGeneration(IdGenerationError),
323
324    /// A relation accessor that requires an eagerly-loaded cache
325    /// (`ForeignKeyResolved::expect_resolved` / `OneToOneFieldResolved::expect_resolved`)
326    /// was invoked against a wrapper whose cache is empty. The caller
327    /// asserted a `prefetch()` / `select_related()` ran earlier but none
328    /// did — this is a strict-mode user error, not a query failure.
329    /// `model` is the source model name (e.g. `"Vehicle"`), `field` is the
330    /// relation field on that model (e.g. `"owner_id"`). Both are compile-time
331    /// `&'static str`s — the macro fills them in from the struct definition
332    /// in . Until then, callers supply them at the call site.
333    #[error(
334        "relation field `{field}` on `{model}` was not loaded — \
335         use .prefetch() or .select_related() before .expect_resolved()"
336    )]
337    #[non_exhaustive]
338    RelationUnloaded {
339        model: &'static str,
340        field: &'static str,
341    },
342
343    /// JSON serialization / deserialization failed. Raised by the
344    /// Task 6 transactional-outbox emitter when `serde_json::to_value`
345    /// cannot lower a model row into a JSON document — typically because
346    /// a user field's `Serialize` impl returned an error. Wraps the
347    /// `serde_json::Error` verbatim so the caller can inspect the
348    /// underlying failure.
349    #[error("JSON serialization error: {0}")]
350    Serde(#[from] serde_json::Error),
351
352    /// Visage projection failure — raised when a `TryFrom<&Model>` impl on
353    /// a generated visage cannot convert the row.
354    /// Known triggers include
355    /// [`VisageError::UnresolvedRelation`](crate::visage::VisageError), raised
356    /// when a relation-nesting visage is projected from a model whose
357    /// relation fields were not `prefetch()`-ed or `select_related()`-ed,
358    /// and [`VisageError::PresentationCodec`](crate::visage::VisageError)
359    /// from fallible protected-field presentation codecs.
360    /// Introduces this variant so the visage-scoped
361    /// reverse-FK / M2M accessors can flow a fallible peer-visage conversion
362    /// through `?` without losing the VisageError structure. The
363    /// `#[from]` shorthand on the inner type produces the
364    /// `impl From<VisageError> for DjogiError` conversion the emitted
365    /// accessors rely on.
366    #[error("visage error: {0}")]
367    Visage(#[from] crate::visage::VisageError),
368
369    /// A DDD-style aggregate was hard-deleted mid-operation,
370    /// invalidating any further work against its id. Task
371    /// 7.7 introduces this variant as the canonical terminal signal
372    /// for "the aggregate you're operating on no longer exists"
373    /// distinct from `NotFound` (which covers initial-lookup
374    /// misses) in that the caller already observed the aggregate
375    /// earlier in the same operation.
376    /// `model` is the owning model's `type_name` (same source as
377    /// `MissingIdempotencyKey::model`). `id` is the PK rendered to
378    /// a string (no generic parameter so the error type stays
379    /// object-safe and usable across model boundaries). `reason` is
380    /// a `&'static str` describing why the aggregate is gone (e.g.
381    /// `"hard-deleted by admin"`, `"retention policy evicted"`).
382    /// Classified as **terminal** by
383    /// [`DjogiError::is_transient`] — `retry_on_conflict` does not
384    /// retry this variant because retrying against a deleted row
385    /// cannot succeed.
386    #[error("aggregate '{model}' id={id} is gone: {reason}")]
387    #[non_exhaustive]
388    GoneAggregate {
389        model: &'static str,
390        id: String,
391        reason: &'static str,
392    },
393
394    /// A column decode failure produced by
395    /// [`FromPgRow::from_pg_row`](crate::pg::decode::FromPgRow::from_pg_row).
396    /// Raised when `tokio_postgres::Row::try_get` returns an error for a
397    /// model field — for example, when the wire type at a given ordinal
398    /// position cannot be converted to the expected Rust type. Preserves
399    /// the contract: every CRUD failure flows through
400    /// `DjogiError` rather than aborting the task via `panic!`.
401    /// The inner `String` carries the column name and the driver error
402    /// so the caller can identify which field failed without inspecting
403    /// the raw `tokio_postgres::Error`.
404    #[error("row decode error: {0}")]
405    Decode(String),
406
407    /// A convenience method that consumes the descriptor's
408    /// `idempotency_key` slot
409    /// ([`create_or_find`](crate::model::Model) /
410    /// `bulk_upsert_by_descriptor`) was invoked against a model
411    /// whose `#[model(...)]` attribute does not set the key.
412    /// Task 7.5 introduces this variant as the runtime pointer at
413    /// the attribute the caller needs to add.
414    /// `model` is the `type_name` from [`ModelDescriptor::type_name`]
415    /// a `&'static str` the macro lifts directly from the struct
416    /// identifier.
417    #[error(
418        "model '{model}' has no #[model(idempotency_key = \"...\")] declared; \
419         set one or use bulk_upsert with an explicit conflict-key slice"
420    )]
421    #[non_exhaustive]
422    MissingIdempotencyKey { model: &'static str },
423
424    /// A runtime argument-validation failure produced by a CRUD
425    /// convenience method — the caller's request is well-typed at
426    /// compile time but fails a runtime invariant (e.g.
427    /// `bulk_upsert`'s `conflict_cols` naming a column that does not
428    /// exist on the model). Task 7d introduces this variant
429    /// for `bulk_upsert`'s allow-list check; future phases may add
430    /// more callers.
431    /// The inner `String` is a human-readable description of the
432    /// failure. No `&'static str` because callers interpolate the
433    /// offending column name / table name into the message.
434    #[error("validation error: {0}")]
435    Validation(String),
436
437    /// A `FOR UPDATE NOWAIT` / `FOR UPDATE` request could not acquire
438    /// its row lock, or a `SERIALIZABLE` / `REPEATABLE READ` transaction
439    /// encountered a serialization failure, or Postgres detected a
440    /// deadlock and aborted one participant. introduces
441    /// this variant so the retry helper (`retry_on_conflict`) and the
442    /// caller can branch on lock-contention vs other database errors.
443    /// Retryable SQLSTATE classes carried here:
444    /// - `40001` — `serialization_failure`
445    /// - `40P01` — `deadlock_detected`
446    /// - `55P03` — `lock_not_available` (`NOWAIT` rejection)
447    ///   Other database failures — `unique_violation`,
448    ///   `foreign_key_violation`, connection drops — still flow through
449    ///   [`Db`](DjogiError::Db). The classifier
450    ///   [`is_lock_error`] keeps the variant boundary tight.
451    #[error("lock conflict: {0}")]
452    LockConflict(#[source] DbError),
453
454    /// `QuerySet::stream` or `DjogiContext::raw_stream` was called on a
455    /// pool-backed context (i.e. outside an `atomic()` scope).
456    /// Postgres named cursors are transaction-local — they require an open
457    /// transaction to exist. Calling `stream` on a pool-backed context is a
458    /// caller error that is detected at stream construction time, not at the
459    /// first `poll_next`. This makes the error surface immediate and
460    /// actionable rather than deferred to the first row consume.
461    /// Fix: wrap the stream consumer in
462    /// `atomic(&mut ctx, |ctx| Box::pin(async move { … }))` so `ctx` is
463    /// transaction-backed when `stream` is called.
464    #[error("QuerySet::stream requires an active transaction — wrap the call in atomic()")]
465    StreamOutsideTransaction,
466
467    /// A transaction-backed [`crate::DjogiContext`] was marked unsafe to
468    /// continue after a nested `atomic()` future was dropped before the
469    /// framework could run savepoint cleanup.
470    /// Rust `Drop` cannot await `ROLLBACK TO SAVEPOINT` / `RELEASE
471    /// SAVEPOINT`, so the safe contract is fail-closed: framework-owned
472    /// operations reject further work, `commit` rolls the outer transaction
473    /// back instead of committing it, and the caller must retry the outer unit
474    /// of work from a fresh transaction.
475    /// Classified as **terminal** by [`DjogiError::is_transient`] — retrying
476    /// against the same poisoned context cannot make the transaction safe to
477    /// commit.
478    #[error(
479        "transaction is poisoned ({reason}): a nested atomic future was dropped before \
480         savepoint cleanup could run; the transaction is unsafe to commit, roll it back \
481         and retry the outer unit of work"
482    )]
483    #[non_exhaustive]
484    TransactionPoisoned {
485        /// Static reason tag naming the poison source.
486        reason: &'static str,
487    },
488
489    /// A transaction-backed raw SQL call attempted a session-scoped statement
490    /// that `atomic()` cannot safely scrub on commit/rollback.
491    /// This variant is used by the raw SQL bypass harness to reject
492    /// session-level control statements such as plain `SET`, `RESET`,
493    /// `LISTEN`, `UNLISTEN`, `PREPARE`, `DEALLOCATE`, and `DISCARD` when the
494    /// context is already inside an `atomic()` transaction. Those statements
495    /// either outlive the surrounding transaction entirely or invite callers
496    /// to assume rollback will clean them up when Postgres semantics say
497    /// otherwise.
498    /// `statement` is the canonical top-level keyword (`"SET"`, `"RESET"`,
499    /// etc.) that triggered the refusal. The fix is structural: use a
500    /// transaction-local form such as `SET LOCAL` / `SET CONSTRAINTS` /
501    /// `SET TRANSACTION`, or run the session-scoped statement on a pool-backed
502    /// context outside the transaction.
503    /// Classified as **terminal** by [`DjogiError::is_transient`]
504    /// retrying the same closure against the same SQL will fail the same way.
505    #[error(
506        "raw SQL statement {statement} is not allowed inside an atomic() transaction; \
507         use a transaction-local form (`SET LOCAL`, `SET CONSTRAINTS`, `SET TRANSACTION`) \
508         or run the session-scoped statement on a pool-backed context"
509    )]
510    #[non_exhaustive]
511    SessionStatementDisallowedInTransaction {
512        /// Canonical top-level statement keyword that triggered the refusal.
513        statement: &'static str,
514    },
515
516    /// A transaction-backed raw SQL call attempted to issue transaction-control
517    /// SQL through the raw escape hatch instead of using Djogi's transaction
518    /// lifecycle methods.
519    /// This variant is used by the raw SQL bypass harness (#306) to reject
520    /// transaction-control statements such as `BEGIN`, `START TRANSACTION`,
521    /// `COMMIT`, `ROLLBACK`, `END`, `ABORT`, `SAVEPOINT`, `RELEASE [SAVEPOINT]`,
522    /// and `ROLLBACK [WORK|TRANSACTION] TO [SAVEPOINT]` when the context is
523    /// already inside an `atomic()` transaction. Those statements bypass
524    /// framework bookkeeping: raw COMMIT skips `on_commit` callback drain,
525    /// raw ROLLBACK skips rollback cleanup and callback discard, and raw
526    /// savepoint control desynchronizes `savepoint_depth`.
527    /// `statement` is the canonical top-level transaction-control keyword
528    /// (`"BEGIN"`, `"COMMIT"`, etc.) that triggered the refusal. The fix is
529    /// structural: use Djogi's `atomic()` / `commit()` / `rollback()` API, or
530    /// run the transaction-control SQL on a pool-backed context outside any
531    /// `atomic()` scope.
532    /// Classified as **terminal** by [`DjogiError::is_transient`]
533    /// retrying the same closure against the same SQL will fail the same way.
534    #[error(
535        "raw transaction-control statement {statement} is not allowed on a \
536         transaction-backed DjogiContext; use djogi's transaction API so COMMIT \
537         drains on_commit callbacks, ROLLBACK clears framework state, and savepoint \
538         depth stays synchronized"
539    )]
540    #[non_exhaustive]
541    RawTransactionControlDisallowedInTransaction {
542        /// Canonical top-level transaction-control statement that triggered refusal.
543        statement: &'static str,
544    },
545
546    /// An aggregate's DISTINCT modifier combination is not supported by
547    /// Postgres syntax or by Djogi's current IR.
548    /// # When this surfaces
549    /// Raised by the fetch-time legality check in
550    /// [`crate::expr::sql::check_aggregate_legality`] for three value-aggregate
551    /// cases that cannot be represented by the kind-state split alone:
552    /// - `COUNT(DISTINCT *)` — `COUNT(DISTINCT *)` is not valid SQL.
553    ///   Use `COUNT(DISTINCT col)` via [`crate::query::field::FieldRef::count`]
554    ///   instead.
555    /// - `STRING_AGG(DISTINCT col, sep)` without per-aggregate `ORDER BY`
556    ///   Postgres requires an explicit ordering when DISTINCT is combined
557    ///   with `STRING_AGG`. Chain `.order_by(...)` on the aggregate to make
558    ///   the shape well-formed.
559    /// - `COUNT(*) ORDER BY ...` — the `COUNT(*)` emitter has no argument
560    ///   slot to attach per-aggregate ordering to, so accepting the modifier
561    ///   would silently drop it.
562    ///   `op` is the aggregate function keyword (e.g. `"COUNT(*)"`,
563    ///   `"STRING_AGG"`). `reason` is a human-readable description of why
564    ///   the combination is rejected.
565    #[error("unsupported aggregate: {op} — {reason}")]
566    #[non_exhaustive]
567    UnsupportedAggregate {
568        /// The aggregate function keyword or name, e.g. `"COUNT(*)"` or
569        /// `"STRING_AGG"`.
570        op: &'static str,
571        /// Human-readable explanation of why this combination is rejected.
572        reason: &'static str,
573    },
574
575    /// A subquery conversion rejected a modifier or quantified operator
576    /// that does not have valid SQL meaning in that subquery position.
577    /// # When this surfaces
578    /// - `VisageQuerySet::selecting` and `VisageExists::new` reject
579    ///   `order_by` / `limit` / `offset` carried on a visage queryset:
580    ///   silently dropping those clauses would change semantics.
581    /// - Quantified subquery comparisons reject
582    ///   `IS [NOT] DISTINCT FROM`, which has no Postgres `ANY` / `ALL`
583    ///   form.
584    #[error("invalid subquery modifier: {op} — {reason}")]
585    #[non_exhaustive]
586    InvalidSubqueryModifier {
587        /// The rejecting entry point or operator family.
588        op: &'static str,
589        /// Human-readable explanation of the rejected modifier.
590        reason: &'static str,
591    },
592
593    /// The emitted SELECT list contains two columns with the same alias;
594    /// the decoder would read the wrong value for one of the columns.
595    /// This is a Djogi internal bug — a future API extension likely introduced
596    /// a path that collides with a group-key column name or another aggregate
597    /// alias. The check runs before any SQL is sent to Postgres so the
598    /// collision is caught immediately rather than silently returning wrong
599    /// data.
600    #[error("alias collision in SELECT list: {alias}")]
601    AliasCollision {
602        /// The alias string that appears more than once in the SELECT list.
603        alias: String,
604    },
605
606    /// A pool checkout exceeded its configured wait / create / recycle
607    /// timeout. Pairs with
608    /// [`DjogiPoolBuilder::timeout`](crate::pg::pool::DjogiPoolBuilder::timeout)
609    /// so callers can branch on saturation explicitly without inspecting
610    /// the underlying `deadpool_postgres::PoolError`.
611    /// `phase` distinguishes the deadpool timeout type:
612    /// - `"wait"` — the pool is at `max_size` and no slot freed within the
613    ///   configured wait window. Tune `max_size` upward or stop holding
614    ///   connections across awaits unrelated to the database.
615    /// - `"create"` — `Manager::create` (opening a fresh socket) timed out.
616    ///   Network or DB-side problem, not pool sizing.
617    /// - `"recycle"` — recycling an existing object on the checkout path
618    ///   timed out. Same root cause as `"create"` for `Verified`/`Clean`
619    ///   recycling methods that issue queries.
620    ///   All three are saturation / slow-recovery signals: the right
621    ///   response is to back off and retry, not to fail the operation
622    ///   permanently. [`DjogiError::is_transient`] returns `true` for
623    ///   `PoolTimeout` so generic retry helpers that branch on
624    ///   `is_transient` (or its inverse `is_terminal`) treat pool
625    ///   timeouts as retryable rather than dead-lettering them as
626    ///   permanent business failures.
627    ///   Note that djogi exposes two retry helpers with different
628    ///   policy: [`crate::transaction::retry_on_conflict`] retries
629    ///   immediately, while
630    ///   [`crate::transaction::retry_on_conflict_with_backoff`]
631    ///   sleeps between transient failures using
632    ///   [`crate::transaction::TransactionRetryBackoff`]. Pool
633    ///   saturation usually belongs on the backoff path, not the
634    ///   immediate-retry path. Callers that need a bespoke policy can
635    ///   still match on `PoolTimeout` explicitly, and the backoff policy
636    ///   can include/exclude `PoolTimeout` retries via
637    ///   `with_retryable_error_classes(...)`.
638    #[error("pool timeout ({phase})")]
639    #[non_exhaustive]
640    PoolTimeout {
641        /// Which deadpool timeout fired — `"wait"`, `"create"`, or
642        /// `"recycle"`. A `&'static str` because the set of phases is
643        /// closed and tracking the exact variant lets callers match on it
644        /// in tracing without depending on deadpool's enum.
645        phase: &'static str,
646    },
647
648    /// `DjogiContext::set_role` was invoked on a pool-backed context
649    /// rather than inside an `atomic` transaction.
650    /// introduces this variant for the security-overlay row-level
651    /// security helper: `SET LOCAL ROLE` is bound to the surrounding
652    /// transaction and reverts at COMMIT/ROLLBACK, so calling it
653    /// outside a transaction would either fail outright or — worse
654    /// leak the role onto the pooled connection where the next
655    /// checkout-victim would inherit it. Surfacing this as a
656    /// dedicated variant lets callers branch on the misuse without
657    /// inspecting the underlying SQLSTATE.
658    /// Classified as **terminal** by [`DjogiError::is_transient`]
659    /// retrying the same closure cannot turn a pool-backed context
660    /// into a transactional one.
661    #[error(
662        "set_role can only be called inside an atomic() transaction; \
663         pool-backed contexts have no transaction scope to bind SET LOCAL ROLE to"
664    )]
665    SetRoleOutsideTransaction,
666
667    /// `DjogiContext::set_role` was invoked with a role name that
668    /// fails the byte-level Postgres identifier check.
669    /// introduces this variant so role-name validation surfaces
670    /// before any SQL is sent — the framework refuses to interpolate
671    /// an untrusted string into `SET LOCAL ROLE` even when the
672    /// caller has already quoted it.
673    /// The accepted grammar is the standard Postgres unquoted
674    /// identifier shape: an ASCII letter or underscore followed by
675    /// ASCII alphanumerics or underscores, up to 63 bytes. Embedded
676    /// quotes, control characters, and non-ASCII bytes are all
677    /// rejected. The variant carries the offending name so log
678    /// lines and error reports can identify what was rejected.
679    /// Classified as **terminal** by [`DjogiError::is_transient`]
680    /// a malformed role name is a programming error, not a
681    /// race-condition.
682    #[error(
683        "invalid Postgres role name {0:?}: must match Postgres identifier grammar \
684         (ASCII letter or underscore followed by ASCII alphanumerics or underscores, \
685         up to 63 bytes; no embedded quotes or control characters)"
686    )]
687    InvalidRoleName(String),
688
689    /// A portable predicate could not be lowered to SQL.
690    /// installs the direct-`Q<T>` SQL walker; portable predicate leaves
691    /// dispatch through [`crate::model::Model::__djogi_emit_field_predicate`]
692    /// (overridden by PR2d's macro on every PK-backed `#[model]`-emitted
693    /// impl). Failure modes — unknown model, unknown field, unknown
694    /// `LookupOp` for a known field, payload-shape mismatch, future
695    /// Sassi predicate variant — surface here as a typed
696    /// [`crate::query::PortablePredicateError`] before the SQL ever
697    /// touches the database.
698    /// Classified as **terminal** by [`DjogiError::is_transient`] — a
699    /// portable-predicate lowering failure is a framework / model
700    /// invariant violation, not a transient database condition.
701    /// Retrying the same closure cannot turn an unknown field into a
702    /// known one.
703    #[error("predicate cannot be lowered to SQL: {0}")]
704    Predicate(#[from] crate::query::PortablePredicateError),
705
706    /// A [`SetOpQuerySet`](crate::query::SetOpQuerySet) arm carried
707    /// state that cannot ride through a Postgres set-operation
708    /// subquery: registered prefetch paths, registered select_related
709    /// paths, a row-level lock, or a cache binding. Cluster
710    /// 4B (issue #101) introduces this variant alongside the typed
711    /// set-op surface (`.union(...)` / `.union_all(...)` /
712    /// `.intersect(...)` / `.except(...)`).
713    /// # Why this is a typed error, not a silent drop
714    /// Quietly stripping `select_related` / `prefetch` registrations
715    /// when an arm enters a set op would silently change the row
716    /// shape the caller expected: `select_related` extends the
717    /// projection, prefetch fans out follow-up queries on the
718    /// returned rows. Both would either change the column count
719    /// (breaking the set-op type compatibility rule) or silently drop
720    /// data the caller asked for. Returning a typed error at the
721    /// terminal — before any SQL hits the database — keeps the
722    /// failure mode actionable. Locks (`FOR UPDATE`) inside a set-op
723    /// subquery are rejected by Postgres at parse time anyway; we
724    /// surface a higher-fidelity error before the round trip.
725    /// `side` identifies which arm tripped the check (`"left"` or
726    /// `"right"`); `reason` is a short human-readable explanation
727    /// that names the offending registration.
728    /// Classified as **terminal** by [`DjogiError::is_transient`]
729    /// the caller built an incompatible set-op shape; retrying the
730    /// same call cannot turn a `.cache(...)`-bound arm into a
731    /// cache-free one.
732    #[error(
733        "set-op arm `{side}` on `{table}` is incompatible with set-operation subquery: {reason}"
734    )]
735    #[non_exhaustive]
736    SetOpArmInvalid {
737        table: &'static str,
738        side: &'static str,
739        reason: &'static str,
740    },
741
742    /// A [`SetOpQuerySet`](crate::query::SetOpQuerySet)'s outer
743    /// `ORDER BY` carried an expression-form ordering term that
744    /// Postgres rejects on set-operation outer ordering.
745    /// (issue #101) introduces this variant alongside the
746    /// typed set-op surface.
747    /// # Why this is a typed error, not a silent pass-through
748    /// Postgres set-operation `ORDER BY` only accepts output column
749    /// names (or column position numbers) — arbitrary expressions are
750    /// rejected at parse time. Today the only way to produce a
751    /// non-column outer ordering is the spatial
752    /// `order_by_distance(...)` helper, which emits a `ST_Distance(...)`
753    /// expression. Letting that ride through to Postgres would surface
754    /// a low-level parser error (`syntax error at or near "("`,
755    /// `ORDER BY position out of range`, or similar) that does not name
756    /// the offending operation. Djogi catches the case at SQL-build
757    /// time and surfaces a higher-fidelity error before the round
758    /// trip, naming the table and explaining the constraint.
759    /// `table` identifies the model whose set-op carries the
760    /// incompatible ordering; `reason` is a short human-readable
761    /// explanation that names the kind of ordering rejected and the
762    /// recommended workaround.
763    /// Classified as **terminal** by [`DjogiError::is_transient`]
764    /// the caller built an incompatible set-op shape; retrying the
765    /// same call cannot turn an expression-form ordering into a
766    /// column-form one. The fix is at the call site.
767    #[error("set-op outer ORDER BY on `{table}` is incompatible with set-operation: {reason}")]
768    #[non_exhaustive]
769    SetOpOuterOrderingInvalid {
770        table: &'static str,
771        reason: &'static str,
772    },
773
774    /// `djogi::transaction::atomic_with(level, &mut tx_ctx, ...)` was
775    /// invoked on a transaction-backed [`crate::DjogiContext`] — i.e.
776    /// inside an already-open `atomic` scope.
777    /// (issue #168) introduces this variant alongside the typed
778    /// [`crate::transaction::IsolationLevel`] surface.
779    /// Postgres pins the isolation level for the entire transaction at
780    /// the outer `BEGIN`; `SAVEPOINT` does not open a sub-transaction
781    /// with its own isolation knob, and `SET TRANSACTION ISOLATION
782    /// LEVEL` issued after the first non-control statement is rejected
783    /// with SQLSTATE `25001` (active SQL transaction). Surfacing this
784    /// as a typed variant lets callers branch on the misuse before
785    /// the SQL flies; the alternative would be a deferred SQLSTATE
786    /// surprise that names neither the outer BEGIN nor the requested
787    /// level.
788    /// The variant carries the [`crate::transaction::IsolationLevel`]
789    /// the caller requested so log lines and error reports identify
790    /// what was rejected. Use [`crate::transaction::atomic`] for
791    /// nested scopes — the savepoint inherits the outermost
792    /// transaction's isolation level.
793    /// Classified as **terminal** by [`DjogiError::is_transient`] — a
794    /// nested-scope isolation request is a programming error, not a
795    /// race condition. Retrying the same closure cannot turn a
796    /// savepoint into a fresh outermost transaction.
797    #[error(
798        "atomic_with(level={requested}) called inside an open atomic() scope; \
799         Postgres pins the isolation level at the outer BEGIN, savepoints \
800         cannot change it — use atomic() for nested scopes, or move the \
801         atomic_with call outside the enclosing transaction"
802    )]
803    IsolationLevelOnNestedScope {
804        /// Isolation level the caller requested. `&'static`-like
805        /// the enum is `Copy` so logging callers can read it without
806        /// borrowing the variant.
807        requested: crate::transaction::IsolationLevel,
808    },
809
810    /// `DjogiContext::defer_constraints` / `set_constraints_immediate`
811    /// was invoked on a pool-backed context rather than inside an
812    /// `atomic` transaction. issue #169)
813    /// introduces this variant alongside the typed
814    /// [`crate::transaction::DeferScope`] surface.
815    /// `SET CONSTRAINTS` is transaction-scoped in Postgres — outside a
816    /// transaction it would either fail outright or, on the
817    /// implicit per-statement transaction surrounding a single
818    /// statement, evaporate before any subsequent statement could
819    /// observe the deferred state. Both outcomes are programming
820    /// errors, so the framework refuses to issue the SQL.
821    /// Classified as **terminal** by [`DjogiError::is_transient`]
822    /// retrying cannot turn a pool-backed context into a
823    /// transactional one. Wrap the call in
824    /// [`crate::transaction::atomic`] to get a transaction scope.
825    #[error(
826        "defer_constraints / set_constraints_immediate can only be called \
827         inside an atomic() transaction; pool-backed contexts have no \
828         transaction scope for SET CONSTRAINTS to bind to"
829    )]
830    ConstraintModeOutsideTransaction,
831
832    /// `DjogiContext::defer_constraints` /
833    /// `set_constraints_immediate` was called with a
834    /// [`crate::transaction::DeferScope::Named`] payload that
835    /// referenced an unknown constraint. issue
836    /// #169) introduces this variant.
837    /// "Unknown" means the constraint name was not found on any
838    /// `#[derive(Model)]`-emitted [`crate::DeferrabilitySpec`]
839    /// inventory entry. The lookup uses the conventional
840    /// `<table>_<column>_fkey` shape (`{table}_{column}_fkey`,
841    /// truncated to Postgres' 63-byte identifier limit when
842    /// necessary) for foreign-key constraints declared in
843    /// adopter `#[model]` structs.
844    /// Surfacing the typo as a typed error before the SQL flies is
845    /// the value-add over `ctx.raw_execute("SET CONSTRAINTS \"typo\"
846    /// DEFERRED")`: Postgres would raise `42704
847    /// (undefined_object)` for an unknown constraint, but only
848    /// after a round trip and without naming the descriptor it
849    /// should have come from.
850    /// The variant carries the offending name so log lines identify
851    /// what was rejected. Classified as **terminal** by
852    /// [`DjogiError::is_transient`] — retrying cannot turn an
853    /// unknown name into a known one.
854    #[error(
855        "unknown constraint name {0:?} — no `#[derive(Model)]`-declared FK \
856         registers under that name. Expected names follow the convention \
857         `<table>_<column>_fkey` (truncated to 63 bytes for long names)"
858    )]
859    UnknownConstraintName(String),
860
861    /// `DjogiContext::defer_constraints` was called with a
862    /// [`crate::transaction::DeferScope::Named`] payload that
863    /// referenced a constraint declared as
864    /// non-deferrable (`#[field(deferrable = false)]`, the default).
865    /// Issue #169) introduces this variant.
866    /// Postgres rejects `SET CONSTRAINTS <name> DEFERRED` on a
867    /// non-deferrable constraint with SQLSTATE `0A000`
868    /// (feature_not_supported). The framework checks the descriptor's
869    /// [`crate::DeferrabilitySpec`] inventory and surfaces a typed
870    /// error before the SQL flies — same value-add as
871    /// [`Self::UnknownConstraintName`].
872    /// The fix is at the model declaration: declare the FK as
873    /// `#[field(deferrable = true)]` (and optionally
874    /// `initially_deferred = true` for `DEFERRABLE INITIALLY
875    /// DEFERRED`). The constraint must be deferrable to participate
876    /// in `SET CONSTRAINTS` at runtime.
877    /// Classified as **terminal** by [`DjogiError::is_transient`] — a
878    /// non-deferrable constraint cannot be deferred at runtime
879    /// regardless of how many retries.
880    #[error(
881        "constraint {0:?} is not declared deferrable; SET CONSTRAINTS only \
882         applies to constraints declared `DEFERRABLE`. Declare the FK with \
883         `#[field(deferrable = true)]` (and optionally `initially_deferred = \
884         true`) at the model declaration"
885    )]
886    ConstraintNotDeferrable(String),
887
888    /// `DjogiContext::defer_constraints` /
889    /// `set_constraints_immediate` was called with
890    /// [`crate::transaction::DeferScope::Named`] carrying an empty
891    /// slice. issue #169)
892    /// follow-up fix.
893    /// `SET CONSTRAINTS <name list> DEFERRED|IMMEDIATE` requires at
894    /// least one name; Postgres rejects the bare-comma grammar with
895    /// SQLSTATE `42601` (syntax error). Composing the SQL from an
896    /// empty slice would produce `SET CONSTRAINTS DEFERRED` — an
897    /// extra space + missing list. Reject before SQL composition so
898    /// the caller gets a typed error naming the misuse rather than a
899    /// deferred Postgres parse error.
900    /// The canonical fix is one of:
901    /// - drop the `Named` wrapper and use [`DeferScope::All`](crate::transaction::DeferScope::All)
902    ///   if the intent is "every deferrable constraint";
903    /// - skip the call entirely when the names slice is empty;
904    /// - pass at least one valid constraint name.
905    ///   Classified as **terminal** by [`DjogiError::is_transient`]
906    ///   retrying with the same empty slice cannot succeed.
907    #[error(
908        "DeferScope::Named requires at least one constraint name; \
909         empty slices produce malformed `SET CONSTRAINTS` SQL. Use \
910         `DeferScope::All` to target every deferrable constraint, or \
911         skip the call when the list is empty"
912    )]
913    EmptyDeferConstraintsScope,
914
915    /// Runtime inventory walk for
916    /// [`DjogiContext::defer_constraints`] /
917    /// [`DjogiContext::set_constraints_immediate`] observed two
918    /// [`crate::DeferrabilitySpec`] entries sharing the same
919    /// `(model_type_name, field_name)` key but disagreeing on
920    /// `(deferrable, initially_deferred)`. issue
921    /// #169) — .
922    /// `inventory::iter` order is not deterministic across builds.
923    /// A silent last-writer-wins on a disagreeing duplicate would
924    /// make the runtime validator non-deterministic — one build
925    /// would accept a `SET CONSTRAINTS <name> DEFERRED` request, the
926    /// next would reject it as `ConstraintNotDeferrable`. Mirror the
927    /// projection-time [`ConflictingDeferrabilitySpec`] gate in
928    /// `migrate::projection` so the framework fails closed at both
929    /// schema-build time and transaction-control time.
930    /// Idempotent duplicates (same key, identical values) are
931    /// accepted — they can arise from `inventory::submit!` chains
932    /// across crates re-exporting the same model and carry no
933    /// semantic disagreement.
934    /// Classified as **terminal** by [`DjogiError::is_transient`]
935    /// the conflict is a build-time inventory misconfiguration, not
936    /// a race condition. Fix is at the model declaration.
937    /// [`ConflictingDeferrabilitySpec`]: crate::migrate::projection::ProjectionError::ConflictingDeferrabilitySpec
938    #[error(
939        "conflicting DeferrabilitySpec inventory entries for \
940         {model_type_name}.{field_name}: \
941         first = {first:?}, second = {second:?}. Two `#[derive(Model)]` \
942         emissions disagree on `(deferrable, initially_deferred)`; \
943         resolve the duplicate model definition at the source"
944    )]
945    ConflictingDeferrabilitySpec {
946        /// Rust type name carrying the FK field.
947        model_type_name: String,
948        /// Field name (Postgres column name).
949        field_name: String,
950        /// `(deferrable, initially_deferred)` from the first spec
951        /// the validator observed.
952        first: (bool, bool),
953        /// `(deferrable, initially_deferred)` from the second spec
954        /// the validator observed.
955        second: (bool, bool),
956    },
957
958    /// Runtime inventory walk for
959    /// [`DjogiContext::defer_constraints`] /
960    /// [`DjogiContext::set_constraints_immediate`] observed a
961    /// [`crate::DeferrabilitySpec`] whose `model_type_name` has no
962    /// matching [`crate::ModelDescriptor`] entry. Cluster
963    /// 4 (issue #169) — .
964    /// The descriptor is the source of truth for `type_name →
965    /// table_name`. A `DeferrabilitySpec` without a matching
966    /// `ModelDescriptor` means the FK is not really registered, but
967    /// would be silently skipped by the prior implementation. That
968    /// silent skip is the bug: a valid-looking constraint name
969    /// `<expected_table>_<field>_fkey` would then surface as
970    /// [`UnknownConstraintName`](Self::UnknownConstraintName)
971    /// instead of the actual root cause (the missing descriptor).
972    /// `#[derive(Model)]` emits the descriptor + the deferrability
973    /// spec side by side, so an orphan spec only fires under
974    /// pathological partial-emission conditions — typically a
975    /// hand-written `inventory::submit!` outside the macro.
976    /// Classified as **terminal** by [`DjogiError::is_transient`]
977    /// the cause is a build-time inventory misconfiguration.
978    #[error(
979        "orphan DeferrabilitySpec for {model_type_name}.{field_name}: \
980         no matching ModelDescriptor is registered in the inventory. \
981         `#[derive(Model)]` emits both side by side; the orphan \
982         indicates a hand-written `inventory::submit!` outside the \
983         macro or a partial-emit bug"
984    )]
985    OrphanDeferrabilitySpec {
986        /// Rust type name carrying the orphan FK field.
987        model_type_name: String,
988        /// Field name (Postgres column name).
989        field_name: String,
990    },
991
992    /// Runtime inventory walk for
993    /// [`DjogiContext::defer_constraints`] /
994    /// [`DjogiContext::set_constraints_immediate`] observed two
995    /// distinct `(model_type_name, field_name)` pairs whose
996    /// conventional FK constraint names collide. Cluster
997    /// 4 (issue #169) — .
998    /// The constraint-name convention is
999    /// `<table>_<column>_fkey` (truncated to Postgres' 63-byte
1000    /// identifier limit). Truncation can produce collisions for
1001    /// long table or column names — and a collision means the
1002    /// runtime validator has no way to know which FK the adopter
1003    /// meant. Fail closed.
1004    /// The fix at the model declaration is to shorten the offending
1005    /// table or column name, or to declare an explicit constraint
1006    /// name once that surface lands (out of scope for #169).
1007    /// Classified as **terminal** by [`DjogiError::is_transient`]
1008    /// the conflict is a build-time naming collision.
1009    #[error(
1010        "FK constraint name {constraint_name:?} collides across two \
1011         distinct fields: ({first_model}.{first_field}) and \
1012         ({second_model}.{second_field}). Postgres' 63-byte identifier \
1013         limit truncates long `<table>_<column>_fkey` strings; shorten \
1014         the offending table or column name to disambiguate"
1015    )]
1016    DuplicateConstraintName {
1017        /// The constraint name that two distinct fields produce.
1018        constraint_name: String,
1019        /// First model the validator observed under this name.
1020        first_model: String,
1021        /// First field the validator observed under this name.
1022        first_field: String,
1023        /// Second model the validator observed under this name.
1024        second_model: String,
1025        /// Second field the validator observed under this name.
1026        second_field: String,
1027    },
1028
1029    /// `QuerySet::merge_into` was invoked on a transaction-backed context. (issue #173)
1030    /// introduces this variant alongside the typed concurrent-reads
1031    /// helper.
1032    /// A transaction owns one Postgres connection; cloning the
1033    /// context for concurrent reads would either hand out aliasing
1034    /// access to the same connection (Postgres protocol violation)
1035    /// or silently break the transaction boundary. Both are
1036    /// programming errors, so the framework refuses.
1037    /// The correct shape for concurrent reads is on a pool-backed
1038    /// context: each clone gets a fresh pool checkout, the two
1039    /// contexts operate on independent connections, and
1040    /// `tokio::try_join!` over typed reads composes without an
1041    /// `E0499` mutable-borrow conflict.
1042    /// Classified as **terminal** by [`DjogiError::is_transient`]
1043    /// retrying cannot turn a transaction-backed context into a
1044    /// pool-backed one. Move the concurrent-reads block outside the
1045    /// surrounding `atomic()`.
1046    #[error(
1047        "clone_for_concurrent_reads requires a pool-backed DjogiContext; \
1048         transaction-backed contexts own a single connection that cannot \
1049         be aliased across concurrent reads. Move the concurrent-reads \
1050         block outside the surrounding atomic() scope, or fetch \
1051         sequentially"
1052    )]
1053    ConcurrentReadsRequirePoolContext,
1054
1055    /// `QuerySet::merge_into` observed a source queryset with state that
1056    /// cannot be safely represented in a `MERGE` statement: `prefetch`,
1057    /// `select_related`, `cache`, `lock`, or `distinct`.
1058    /// Issue #178.
1059    /// Classified as **terminal** by [`DjogiError::is_transient`].
1060    #[error("merge source queryset on `{table}` is invalid: {reason}")]
1061    #[non_exhaustive]
1062    MergeSourceInvalid {
1063        table: &'static str,
1064        reason: &'static str,
1065    },
1066
1067    /// `QuerySet::merge_into` observed an invalid branch configuration:
1068    /// unreachable branches (same-kind unconditional branch follows another),
1069    /// duplicate target columns in an update or insert action, or manual
1070    /// `updated_at` assignment in an update action. Issue #178.
1071    /// Classified as **terminal** by [`DjogiError::is_transient`].
1072    #[error("merge branch on `{table}` is invalid: {reason}")]
1073    #[non_exhaustive]
1074    MergeBranchInvalid { table: &'static str, reason: String },
1075
1076    /// `QuerySet::merge_into` was invoked without any `ON` conditions or
1077    /// without any `WHEN` branches. Issue #178.
1078    /// Classified as **terminal** by [`DjogiError::is_transient`].
1079    #[error("merge statement on `{table}` is invalid: {reason}")]
1080    #[non_exhaustive]
1081    MergeNoBranches { table: &'static str, reason: String },
1082
1083    /// One or more presentation codecs failed startup validation.
1084    /// Returned by
1085    /// [`validate_startup_inventory`](crate::presentation::validate_startup_inventory)
1086    /// when any [`PresentationCodecUsage`](crate::presentation::inventory::PresentationCodecUsage)
1087    /// entry's `validate_startup` hook returns an error.
1088    /// This variant is the conversion target for pool-construction callers
1089    /// (`DjogiPool::connect`, `DjogiPool::from_database_config`,
1090    /// `DjogiPoolBuilder::build`) that call `validate_startup_inventory`
1091    /// before accepting traffic. GH #227 wires those callers.
1092    /// The inner `Vec` carries one
1093    /// [`PresentationStartupError`](crate::presentation::PresentationStartupError)
1094    /// per failing codec usage. Each entry names the `(model, field, scope,
1095    /// codec)` quadruple and the underlying error so operators can identify
1096    /// every misconfigured codec in one pass rather than discovering failures
1097    /// one at a time.
1098    /// Classified as **terminal** by the framework — a codec with a missing
1099    /// or invalid key cannot serve traffic until the key is provided. The
1100    /// fix is an environment-variable or configuration change, not a retry.
1101    /// `Display` includes the total count plus a concise summary of each
1102    /// failing usage so operator logs can point directly at the actionable
1103    /// `(model, field, scope, codec)` entry without requiring `Debug`.
1104    #[error(
1105        "presentation codec startup validation failed ({} error(s)): {}",
1106        .0.len(),
1107        crate::error::presentation_startup_error_summary(&.0)
1108    )]
1109    PresentationStartup(Vec<crate::presentation::PresentationStartupError>),
1110
1111    /// Field codec startup validation failed (missing key, malformed env var).
1112    /// Aggregates errors from all codec startup validators via
1113    /// [`validate_codec_startup_inventory`](crate::field_codec::validate_codec_startup_inventory);
1114    /// matches the [`PresentationStartup`](Self::PresentationStartup) pattern
1115    /// for multi-error collection.
1116    /// Classified as **terminal** — a codec with a missing or invalid key
1117    /// cannot serve traffic until the environment variable is fixed.
1118    #[cfg(feature = "aes-codec")]
1119    #[error(
1120        "field codec startup validation failed ({} error(s)): {}",
1121        .0.len(),
1122        crate::error::field_codec_startup_error_summary(&.0)
1123    )]
1124    #[non_exhaustive]
1125    FieldCodecStartup(Vec<crate::field_codec::CodecStartupError>),
1126
1127    /// Field codec encode failed during CRUD write.
1128    /// Carries the model, field, and codec ID so operators can identify which
1129    /// protected field failed from log output. Terminal — not retryable.
1130    #[cfg(feature = "aes-codec")]
1131    #[error("field codec encode error on `{model}`.{field} (codec={codec_id}): {error}")]
1132    #[non_exhaustive]
1133    FieldCodecEncode {
1134        model: &'static str,
1135        field: &'static str,
1136        codec_id: &'static str,
1137        error: String,
1138    },
1139
1140    /// Field codec decode failed during CRUD read.
1141    /// Carries the model, field, and codec ID so operators can identify which
1142    /// protected field failed from log output. Terminal — not retryable.
1143    #[cfg(feature = "aes-codec")]
1144    #[error("field codec decode error on `{model}`.{field} (codec={codec_id}): {error}")]
1145    #[non_exhaustive]
1146    FieldCodecDecode {
1147        model: &'static str,
1148        field: &'static str,
1149        codec_id: &'static str,
1150        error: String,
1151    },
1152
1153    /// Connected PostgreSQL server version is below Djogi's minimum supported
1154    /// version. Raised by [`check_postgres_version`](crate::pg::preflight::check_postgres_version)
1155    /// preflight when the detected major version is less than 18.
1156    /// `detected_major` and `detected_minor` are the actual server version components.
1157    /// `minimum_major` is Djogi's minimum supported major version (always 18).
1158    #[error(
1159        "PostgreSQL {detected_major}.{detected_minor} is below the minimum supported \
1160         version {minimum_major}. Upgrade to PostgreSQL {minimum_major} or later"
1161    )]
1162    #[non_exhaustive]
1163    UnsupportedPostgresVersion {
1164        detected_major: u32,
1165        detected_minor: u32,
1166        minimum_major: u32,
1167    },
1168
1169    /// A Phase 0 migration statement was classified as stale at the deepest
1170    /// execution boundary (immediately before raw `batch_execute`).
1171    /// This variant is the statement-level safety net in
1172    /// [`migrate::runner::execute_runner_statement`] — the runner's carve-out
1173    /// for Phase 0 version allows raw `batch_execute` to bypass the
1174    /// session-statement guard, so this classifier catches stale artifacts
1175    /// that somehow evaded the pre-bootstrap artifact checks.
1176    ///
1177    /// **What triggers this.** The statement-level guard emits `seed-dml`
1178    /// for top-level HeeRanjID seed-table mutations and `generated-stale`
1179    /// for literal database-default statements such as
1180    /// `ALTER DATABASE "<hardcoded_name>" SET heer.node_id` or
1181    /// `heer.ranj_node_id` instead of dynamic defaults like
1182    /// `current_database()`. Current production Phase 0 omits node seeding
1183    /// entirely; single-node-dev current uses dynamic
1184    /// `EXECUTE format('ALTER DATABASE %I ...', current_database(), ...)`.
1185    ///
1186    /// `statement` carries the offending SQL prefix so log lines can
1187    /// identify what was rejected. Classified as **terminal** — a stale
1188    /// statement against the same database cannot succeed on retry.
1189    #[error(
1190        "stale Phase 0 statement refused: {refusal_reason} — \
1191         offending SQL: {statement}"
1192    )]
1193    #[non_exhaustive]
1194    StalePhaseZeroStatement {
1195        /// Short refusal reason tag (`"seed-dml"` or `"generated-stale"`).
1196        refusal_reason: &'static str,
1197        /// Offending SQL statement (truncated for log safety).
1198        statement: String,
1199    },
1200}
1201
1202/// Bridge: convert `tokio_postgres::Error` into `DjogiError`.
1203impl From<tokio_postgres::Error> for DjogiError {
1204    fn from(e: tokio_postgres::Error) -> Self {
1205        map_pg_err(e)
1206    }
1207}
1208
1209/// `Infallible` → `DjogiError` coercion.
1210/// `Infallible` has no inhabitants, so `match never {}` is exhaustive.
1211/// The impl exists so macro-emitted chains that invoke
1212/// `<Visage as TryFrom<&Model>>::try_from(row)?` propagate through `?`
1213/// uniformly regardless of whether the visage is scalar-only (the
1214/// stdlib blanket returns `Infallible`) or relation-nesting (returns
1215/// `VisageError`). Without this impl the scalar-only path fails
1216/// compilation with "`?` couldn't convert the error to `DjogiError`".
1217impl From<std::convert::Infallible> for DjogiError {
1218    fn from(never: std::convert::Infallible) -> Self {
1219        match never {}
1220    }
1221}
1222
1223impl DjogiError {
1224    /// Construct a `NotFound` error with a table-name context.
1225    /// This is the public escape hatch for `#[non_exhaustive]` on the
1226    /// `NotFound` variant: struct-expression construction is blocked outside
1227    /// this crate, so `#[model]`-expanded CRUD methods (which run in user
1228    /// crates) call this constructor instead. Keep the signature stable
1229    /// any future additional context fields must gain their own constructor
1230    /// or builder rather than changing this one.
1231    pub fn not_found(table: &'static str) -> Self {
1232        DjogiError::NotFound { table }
1233    }
1234
1235    /// Construct a `MultipleObjects` error with a table name and the number
1236    /// of rows actually observed.
1237    /// Mirror of `not_found` — exists so that cross-crate callers (macro
1238    /// output, future filter-layer builders) can produce this variant
1239    /// without running into `#[non_exhaustive]`.
1240    pub fn multiple_objects(table: &'static str, count_seen: usize) -> Self {
1241        DjogiError::MultipleObjects { table, count_seen }
1242    }
1243
1244    /// Construct a `RelationUnloaded` error naming the model and relation
1245    /// field that the caller asked to resolve without loading.
1246    /// Exists for the same reason as `not_found` / `multiple_objects`: the
1247    /// `#[non_exhaustive]` attribute on the variant blocks struct-expression
1248    /// construction outside this crate, so macro-expanded code and +
1249    /// relation wrappers go through this constructor instead.
1250    pub fn relation_unloaded(model: &'static str, field: &'static str) -> Self {
1251        DjogiError::RelationUnloaded { model, field }
1252    }
1253
1254    /// Construct a `MissingIdempotencyKey` error naming the model
1255    /// whose `#[model(idempotency_key = "...")]` attribute was not
1256    /// declared.
1257    /// Mirror of `not_found` / `multiple_objects` — exists so that
1258    /// cross-crate callers (macro output in user crates) can produce
1259    /// this variant despite `#[non_exhaustive]` blocking struct-
1260    /// expression construction outside this crate.
1261    pub fn missing_idempotency_key(model: &'static str) -> Self {
1262        DjogiError::MissingIdempotencyKey { model }
1263    }
1264
1265    /// Construct a `GoneAggregate` error.
1266    /// Mirror of the other constructors — exists so that cross-crate
1267    /// callers can produce this `#[non_exhaustive]` variant. `id` is
1268    /// typically produced via `format!("{}", pk)` so the error is
1269    /// independent of the originating model's `Pk` type.
1270    pub fn gone_aggregate(model: &'static str, id: String, reason: &'static str) -> Self {
1271        DjogiError::GoneAggregate { model, id, reason }
1272    }
1273
1274    /// Construct an `UnsupportedPostgresVersion` error with the detected
1275    /// server version and the framework's minimum supported major version.
1276    /// Mirror of the other constructors — exists so that cross-crate callers
1277    /// (the `pg::preflight` module, CLI entry points) can produce this variant
1278    /// despite `#[non_exhaustive]` blocking struct-expression construction
1279    /// outside this crate.
1280    pub fn unsupported_postgres_version(
1281        detected_major: u32,
1282        detected_minor: u32,
1283        minimum_major: u32,
1284    ) -> Self {
1285        DjogiError::UnsupportedPostgresVersion {
1286            detected_major,
1287            detected_minor,
1288            minimum_major,
1289        }
1290    }
1291
1292    /// Construct an [`InvalidSubqueryModifier`](Self::InvalidSubqueryModifier)
1293    /// error. The call sites for this variant all use literal strings, so the
1294    /// constructor keeps the shape `&'static str` rather than widening to an
1295    /// owned `String`.
1296    pub fn invalid_subquery_modifier(op: &'static str, reason: &'static str) -> Self {
1297        Self::InvalidSubqueryModifier { op, reason }
1298    }
1299
1300    /// Construct a `FieldCodecStartup` error. Called by startup validation when
1301    /// codec key is missing or malformed. Accepts a Vec of errors (collected
1302    /// from all validators), matching the [`PresentationStartup`](Self::PresentationStartup) pattern.
1303    #[cfg(feature = "aes-codec")]
1304    pub fn field_codec_startup(errors: Vec<crate::field_codec::CodecStartupError>) -> Self {
1305        Self::FieldCodecStartup(errors)
1306    }
1307
1308    /// Construct a `FieldCodecEncode` error. Called by macro-emitted write-path
1309    /// code when codec encode fails. The `error` string wraps `CodecError` via `.to_string()`.
1310    #[cfg(feature = "aes-codec")]
1311    pub fn field_codec_encode(
1312        model: &'static str,
1313        field: &'static str,
1314        codec_id: &'static str,
1315        error: String,
1316    ) -> Self {
1317        Self::FieldCodecEncode {
1318            model,
1319            field,
1320            codec_id,
1321            error,
1322        }
1323    }
1324
1325    /// Construct a `FieldCodecDecode` error. Called by macro-emitted read-path
1326    /// code when codec decode fails. The `error` string wraps `CodecError` via `.to_string()`.
1327    #[cfg(feature = "aes-codec")]
1328    pub fn field_codec_decode(
1329        model: &'static str,
1330        field: &'static str,
1331        codec_id: &'static str,
1332        error: String,
1333    ) -> Self {
1334        Self::FieldCodecDecode {
1335            model,
1336            field,
1337            codec_id,
1338            error,
1339        }
1340    }
1341
1342    /// Classify this error as **transient** (retrying the closure
1343    /// may succeed) or **terminal** (retrying will not help).
1344    /// `retry_on_conflict` uses this predicate to decide whether to
1345    /// re-run its closure. The contract:
1346    /// | Variant | Classification |
1347    /// |---------|----------------|
1348    /// | [`LockConflict`](Self::LockConflict) | transient |
1349    /// | [`Db`](Self::Db) with SQLSTATE `40001` / `40P01` / `55P03` | transient |
1350    /// | [`Db`](Self::Db) with any other SQLSTATE | terminal |
1351    /// | [`NotFound`](Self::NotFound) | terminal |
1352    /// | [`MultipleObjects`](Self::MultipleObjects) | terminal |
1353    /// | [`IdGeneration`](Self::IdGeneration) | terminal |
1354    /// | [`RelationUnloaded`](Self::RelationUnloaded) | terminal |
1355    /// | [`Decode`](Self::Decode) | terminal |
1356    /// | [`Serde`](Self::Serde) | terminal |
1357    /// | [`Validation`](Self::Validation) | terminal |
1358    /// | [`MissingIdempotencyKey`](Self::MissingIdempotencyKey) | terminal |
1359    /// | [`GoneAggregate`](Self::GoneAggregate) | terminal |
1360    /// | [`StreamOutsideTransaction`](Self::StreamOutsideTransaction) | terminal |
1361    /// | [`TransactionPoisoned`](Self::TransactionPoisoned) | terminal |
1362    /// | [`SessionStatementDisallowedInTransaction`](Self::SessionStatementDisallowedInTransaction) | terminal |
1363    /// | [`RawTransactionControlDisallowedInTransaction`](Self::RawTransactionControlDisallowedInTransaction) | terminal |
1364    /// | [`PoolTimeout`](Self::PoolTimeout) | transient |
1365    /// | [`SetRoleOutsideTransaction`](Self::SetRoleOutsideTransaction) | terminal |
1366    /// | [`InvalidRoleName`](Self::InvalidRoleName) | terminal |
1367    /// | [`IsolationLevelOnNestedScope`](Self::IsolationLevelOnNestedScope) | terminal |
1368    /// | [`ConstraintModeOutsideTransaction`](Self::ConstraintModeOutsideTransaction) | terminal |
1369    /// | [`UnknownConstraintName`](Self::UnknownConstraintName) | terminal |
1370    /// | [`ConstraintNotDeferrable`](Self::ConstraintNotDeferrable) | terminal |
1371    /// | [`EmptyDeferConstraintsScope`](Self::EmptyDeferConstraintsScope) | terminal |
1372    /// | [`ConflictingDeferrabilitySpec`](Self::ConflictingDeferrabilitySpec) | terminal |
1373    /// | [`OrphanDeferrabilitySpec`](Self::OrphanDeferrabilitySpec) | terminal |
1374    /// | [`DuplicateConstraintName`](Self::DuplicateConstraintName) | terminal |
1375    /// | [`ConcurrentReadsRequirePoolContext`](Self::ConcurrentReadsRequirePoolContext) | terminal |
1376    /// | [`UnsupportedPostgresVersion`](Self::UnsupportedPostgresVersion) | terminal |
1377    /// The Db row reflects the existing `is_lock_error`
1378    /// classifier: Postgres SQLSTATEs `40001` (serialization
1379    /// failure), `40P01` (deadlock detected), and `55P03`
1380    /// (lock not available / `NOWAIT` rejection) are the three
1381    /// retryable codes. Unique-violation, foreign-key violation,
1382    /// connection drops, and protocol errors all fall through to
1383    /// terminal because retrying the same closure against a
1384    /// constraint violation will fail the same way.
1385    pub fn is_transient(&self) -> bool {
1386        match self {
1387            DjogiError::LockConflict(_) => true,
1388            DjogiError::Db(e) => is_lock_error(e),
1389            // Pool saturation / slow connection creation / slow recycle
1390            // are all retry-with-backoff conditions, not permanent
1391            // failures. Generic retry helpers branching on
1392            // `is_transient` should treat these as transient; callers
1393            // wanting bespoke `PoolTimeout`-vs-`LockConflict` policy
1394            // should match on the variant explicitly.
1395            DjogiError::PoolTimeout { .. } => true,
1396            _ => false,
1397        }
1398    }
1399
1400    /// Inverse of [`is_transient`](Self::is_transient) — returns
1401    /// `true` when retrying will not help.
1402    /// Provided as a convenience for call sites that read more
1403    /// naturally as `err.is_terminal()` than `!err.is_transient()`.
1404    /// Same contract, inverted.
1405    pub fn is_terminal(&self) -> bool {
1406        !self.is_transient()
1407    }
1408}
1409
1410/// Return `true` if the database error wraps a Postgres lock/serialization
1411/// conflict — the class of failures that `retry_on_conflict()` is
1412/// willing to re-run the closure through.
1413/// Matches three SQLSTATEs:
1414/// - `40001` (`serialization_failure`) — the classic MVCC serialization
1415///   error on `SERIALIZABLE`/`REPEATABLE READ` isolation.
1416/// - `40P01` (`deadlock_detected`) — Postgres detected a circular wait
1417///   and aborted one of the participants.
1418/// - `55P03` (`lock_not_available`) — a `NOWAIT` lock request could not
1419///   acquire its lock immediately.
1420pub(crate) fn is_lock_error(e: &DbError) -> bool {
1421    use tokio_postgres::error::SqlState;
1422    e.code()
1423        .map(|code| {
1424            code == &SqlState::T_R_SERIALIZATION_FAILURE
1425                || code == &SqlState::T_R_DEADLOCK_DETECTED
1426                || code == &SqlState::LOCK_NOT_AVAILABLE
1427        })
1428        .unwrap_or(false)
1429}
1430
1431/// Lower a `tokio_postgres::Error` into either `DjogiError::LockConflict`
1432/// (for retryable SQLSTATEs) or `DjogiError::Db` (for everything else).
1433pub(crate) fn map_pg_err(e: tokio_postgres::Error) -> DjogiError {
1434    let error = DbError::from(e);
1435    if is_lock_error(&error) {
1436        DjogiError::LockConflict(error)
1437    } else {
1438        DjogiError::Db(error)
1439    }
1440}
1441
1442#[cfg(test)]
1443mod tests {
1444    use super::*;
1445
1446    #[test]
1447    fn not_found_displays_table_name() {
1448        let err = DjogiError::not_found("posts");
1449        let msg = format!("{err}");
1450        assert!(
1451            msg.contains("posts"),
1452            "expected table name in error message, got: {msg}"
1453        );
1454        assert!(msg.to_lowercase().contains("not found"));
1455    }
1456
1457    #[test]
1458    fn multiple_objects_displays_table_and_count() {
1459        let err = DjogiError::multiple_objects("posts", 2);
1460        let msg = format!("{err}");
1461        assert!(msg.contains("posts"));
1462        assert!(msg.contains("2"));
1463        assert!(msg.to_lowercase().contains("multiple"));
1464    }
1465
1466    #[test]
1467    fn invalid_subquery_modifier_constructs_and_displays() {
1468        let err = DjogiError::invalid_subquery_modifier(
1469            "selecting",
1470            "ordering is not meaningful in subquery context",
1471        );
1472        let msg = err.to_string();
1473        assert!(msg.contains("selecting"), "got: {msg}");
1474        assert!(msg.contains("ordering is not meaningful"), "got: {msg}");
1475    }
1476
1477    #[test]
1478    fn relation_unloaded_displays_model_and_field() {
1479        let err = DjogiError::relation_unloaded("Vehicle", "owner_id");
1480        let msg = format!("{err}");
1481        assert!(msg.contains("Vehicle"), "expected model name, got: {msg}");
1482        assert!(msg.contains("owner_id"), "expected field name, got: {msg}");
1483        assert!(
1484            msg.contains("prefetch") || msg.contains("select_related"),
1485            "expected remediation hint, got: {msg}"
1486        );
1487    }
1488
1489    fn db_err_with_code(code: &str) -> DbError {
1490        DbError::synthetic_sqlstate(code, "synthetic database error")
1491    }
1492
1493    #[test]
1494    fn db_variant_constructs_from_tokio_postgres_error() {
1495        let driver_error = tokio_postgres::Error::__private_api_timeout();
1496        let mapped = DjogiError::from(driver_error);
1497        assert!(matches!(mapped, DjogiError::Db(_)));
1498    }
1499
1500    #[test]
1501    fn is_lock_error_matches_retryable_sqlstates() {
1502        assert!(is_lock_error(&db_err_with_code("40001")));
1503        assert!(is_lock_error(&db_err_with_code("40P01")));
1504        assert!(is_lock_error(&db_err_with_code("55P03")));
1505    }
1506
1507    #[test]
1508    fn is_lock_error_rejects_unrelated_sqlstate() {
1509        assert!(!is_lock_error(&db_err_with_code("23505")));
1510    }
1511
1512    #[test]
1513    fn is_lock_error_rejects_message_only_error() {
1514        assert!(!is_lock_error(&DbError::other("no sqlstate here")));
1515    }
1516
1517    #[test]
1518    fn db_error_message_accessor_preserves_framework_generated_message() {
1519        let err = DbError::other("DjogiContext::commit called on a pool-backed context");
1520        assert_eq!(
1521            err.message(),
1522            "DjogiContext::commit called on a pool-backed context"
1523        );
1524    }
1525
1526    #[test]
1527    fn is_transient_covers_lock_conflict_and_retryable_db() {
1528        let lc = DjogiError::LockConflict(db_err_with_code("55P03"));
1529        assert!(lc.is_transient(), "LockConflict must be transient");
1530        assert!(!lc.is_terminal(), "LockConflict must not be terminal");
1531
1532        for code in ["40001", "40P01", "55P03"] {
1533            let err = DjogiError::Db(db_err_with_code(code));
1534            assert!(
1535                err.is_transient(),
1536                "Db with SQLSTATE {code} must be transient"
1537            );
1538        }
1539
1540        let unique = DjogiError::Db(db_err_with_code("23505"));
1541        assert!(unique.is_terminal(), "unique_violation must be terminal");
1542        assert!(
1543            !unique.is_transient(),
1544            "unique_violation must not be transient"
1545        );
1546    }
1547
1548    #[test]
1549    fn is_terminal_covers_every_known_variant() {
1550        // Every non-Db, non-LockConflict variant is terminal. Spell
1551        // each out so the classification table in the rustdoc is
1552        // pinned against drift: adding a new variant that should be
1553        // transient forces the test author to update this match.
1554        assert!(DjogiError::not_found("t").is_terminal());
1555        assert!(DjogiError::multiple_objects("t", 2).is_terminal());
1556        assert!(DjogiError::relation_unloaded("M", "f").is_terminal());
1557        assert!(DjogiError::missing_idempotency_key("M").is_terminal());
1558        assert!(DjogiError::Validation("bad".into()).is_terminal());
1559        assert!(
1560            DjogiError::Decode("column `id`: type mismatch".into()).is_terminal(),
1561            "Decode must be terminal — a type mismatch cannot be resolved by retrying"
1562        );
1563        assert!(
1564            DjogiError::gone_aggregate("M", "42".into(), "deleted").is_terminal(),
1565            "GoneAggregate must be terminal — retry cannot resurrect a deleted aggregate"
1566        );
1567        assert!(
1568            DjogiError::Visage(crate::visage::VisageError::UnresolvedRelation {
1569                model: "M",
1570                field: "f",
1571                scope: "public"
1572            })
1573            .is_terminal(),
1574            "Visage conversion failures must be terminal unless explicitly reclassified"
1575        );
1576        assert!(
1577            DjogiError::invalid_subquery_modifier("selecting", "limit is not meaningful")
1578                .is_terminal(),
1579            "InvalidSubqueryModifier must be terminal — retrying will not make the subquery shape valid"
1580        );
1581        assert!(
1582            DjogiError::PresentationStartup(vec![]).is_terminal(),
1583            "PresentationStartup must be terminal — missing startup prerequisites need operator action"
1584        );
1585
1586        // Field codec errors are terminal: startup failures require operator action,
1587        // encode/decode failures indicate key mismatch or tampered data.
1588        #[cfg(feature = "aes-codec")]
1589        {
1590            assert!(
1591                DjogiError::field_codec_startup(vec![]).is_terminal(),
1592                "FieldCodecStartup must be terminal — missing codec key requires operator action"
1593            );
1594            assert!(
1595                DjogiError::field_codec_encode("M", "f", "aes256_gcm_v1", "detail".into())
1596                    .is_terminal(),
1597                "FieldCodecEncode must be terminal — encode failures don't resolve on retry"
1598            );
1599            assert!(
1600                DjogiError::field_codec_decode("M", "f", "aes256_gcm_v1", "detail".into())
1601                    .is_terminal(),
1602                "FieldCodecDecode must be terminal — decode failures don't resolve on retry"
1603            );
1604        }
1605
1606        let presentation_startup_msg = DjogiError::PresentationStartup(vec![
1607            crate::presentation::PresentationStartupError::MissingEnvVar {
1608                name: "DJOGI_PRESENTATION_HMAC_KEY",
1609            },
1610            crate::presentation::PresentationStartupError::Usage {
1611                model: "User",
1612                field: "email",
1613                scope: "public",
1614                codec_path: "djogi::presentation::builtins::HmacSha256HexString",
1615                source: Box::new(
1616                    crate::presentation::PresentationStartupError::MissingEnvVar {
1617                        name: "DJOGI_PRESENTATION_HMAC_KEY",
1618                    },
1619                ),
1620            },
1621        ])
1622        .to_string();
1623        assert!(
1624            presentation_startup_msg.contains("2 error(s)"),
1625            "PresentationStartup display must include the error count: {presentation_startup_msg}"
1626        );
1627        assert!(
1628            presentation_startup_msg.contains("missing env var `DJOGI_PRESENTATION_HMAC_KEY`"),
1629            "PresentationStartup display must include the actionable inner error: {presentation_startup_msg}"
1630        );
1631        assert!(
1632            presentation_startup_msg.contains("User.email scope `public` codec `djogi::presentation::builtins::HmacSha256HexString` failed"),
1633            "PresentationStartup display must include the usage context: {presentation_startup_msg}"
1634        );
1635        assert!(
1636            DjogiError::PoolTimeout { phase: "wait" }.is_transient(),
1637            "PoolTimeout must be transient — saturation is a retry-with-backoff condition"
1638        );
1639        assert!(
1640            !DjogiError::PoolTimeout { phase: "wait" }.is_terminal(),
1641            "PoolTimeout must NOT be terminal — generic retry helpers must not dead-letter it"
1642        );
1643        assert!(
1644            DjogiError::TransactionPoisoned {
1645                reason: "nested atomic future dropped before savepoint cleanup"
1646            }
1647            .is_terminal(),
1648            "TransactionPoisoned must be terminal — retry cannot clean the same context"
1649        );
1650        assert!(
1651            DjogiError::SetRoleOutsideTransaction.is_terminal(),
1652            "SetRoleOutsideTransaction must be terminal — retry cannot promote a pool-backed context"
1653        );
1654        assert!(
1655            DjogiError::InvalidRoleName("readonly".into()).is_terminal(),
1656            "InvalidRoleName must be terminal — a malformed role name is a programming error"
1657        );
1658        // #101) — set-op outer ORDER BY rejection.
1659        // An expression-form outer ordering cannot become a column-form
1660        // one by retrying; the fix is at the call site.
1661        assert!(
1662            DjogiError::SetOpOuterOrderingInvalid {
1663                table: "t",
1664                reason: "spatial ST_Distance(...) is an expression, not an output column"
1665            }
1666            .is_terminal(),
1667            "SetOpOuterOrderingInvalid must be terminal — retry cannot reshape the ordering"
1668        );
1669        assert!(
1670            DjogiError::MergeSourceInvalid {
1671                table: "t",
1672                reason: "prefetch is not supported"
1673            }
1674            .is_terminal(),
1675            "MergeSourceInvalid must be terminal"
1676        );
1677        assert!(
1678            DjogiError::MergeBranchInvalid {
1679                table: "t",
1680                reason: "duplicate column".into()
1681            }
1682            .is_terminal(),
1683            "MergeBranchInvalid must be terminal"
1684        );
1685        assert!(
1686            DjogiError::MergeNoBranches {
1687                table: "t",
1688                reason: "at least one branch required".into()
1689            }
1690            .is_terminal(),
1691            "MergeNoBranches must be terminal"
1692        );
1693        assert!(
1694            DjogiError::unsupported_postgres_version(17, 4, 18).is_terminal(),
1695            "UnsupportedPostgresVersion must be terminal — version mismatch cannot be resolved by retrying"
1696        );
1697        assert!(
1698            DjogiError::RawTransactionControlDisallowedInTransaction {
1699                statement: "COMMIT"
1700            }
1701            .is_terminal(),
1702            "RawTransactionControlDisallowedInTransaction must be terminal"
1703        );
1704    }
1705
1706    /// 1 — `SetRoleOutsideTransaction` is a misuse signal,
1707    /// never retryable. Mirrors the `StreamOutsideTransaction`
1708    /// classification: the caller must restructure their code to wrap
1709    /// the call in `atomic()`, not back off and retry.
1710    #[test]
1711    fn set_role_outside_transaction_is_terminal() {
1712        let err = DjogiError::SetRoleOutsideTransaction;
1713        assert!(
1714            err.is_terminal(),
1715            "SetRoleOutsideTransaction must be terminal"
1716        );
1717        assert!(
1718            !err.is_transient(),
1719            "SetRoleOutsideTransaction must not be transient"
1720        );
1721    }
1722
1723    /// 1 — `InvalidRoleName` is a validation error;
1724    /// retrying with the same string would fail again. The variant
1725    /// carries the offending name but the classification is fixed.
1726    #[test]
1727    fn invalid_role_name_is_terminal() {
1728        let err = DjogiError::InvalidRoleName("x".to_string());
1729        assert!(err.is_terminal(), "InvalidRoleName must be terminal");
1730        assert!(!err.is_transient(), "InvalidRoleName must not be transient");
1731    }
1732
1733    /// 1 — the `Display` formatter uses `{0:?}` to debug-
1734    /// quote the offending role name. This protects log lines from
1735    /// confusion when the input contains embedded quotes, semicolons,
1736    /// or other shell/SQL-loaded characters: the rendered form makes
1737    /// the exact bytes unambiguous.
1738    #[test]
1739    fn invalid_role_name_display_includes_offending_value() {
1740        let err = DjogiError::InvalidRoleName("readonly\"; DROP TABLE".into());
1741        let msg = format!("{err}");
1742        assert!(
1743            msg.contains("readonly\\\"; DROP TABLE"),
1744            "expected debug-quoted role name in error message, got: {msg}"
1745        );
1746    }
1747
1748    /// #168 — `IsolationLevelOnNestedScope` is a programming
1749    /// error. Postgres pins isolation at the outer BEGIN; retrying the
1750    /// same nested call cannot make Postgres reset isolation
1751    /// mid-transaction. The variant must classify as terminal so
1752    /// generic retry helpers do not pointlessly re-run the closure.
1753    #[test]
1754    fn isolation_level_on_nested_scope_is_terminal() {
1755        let err = DjogiError::IsolationLevelOnNestedScope {
1756            requested: crate::transaction::IsolationLevel::Serializable,
1757        };
1758        assert!(
1759            err.is_terminal(),
1760            "IsolationLevelOnNestedScope must be terminal"
1761        );
1762        assert!(
1763            !err.is_transient(),
1764            "IsolationLevelOnNestedScope must not be transient"
1765        );
1766    }
1767
1768    /// #168 — `Display` for `IsolationLevelOnNestedScope`
1769    /// includes the requested isolation level so operators reading
1770    /// logs can identify what was rejected without consulting the
1771    /// stack trace.
1772    #[test]
1773    fn isolation_level_on_nested_scope_display_includes_level() {
1774        let err = DjogiError::IsolationLevelOnNestedScope {
1775            requested: crate::transaction::IsolationLevel::RepeatableRead,
1776        };
1777        let msg = format!("{err}");
1778        assert!(
1779            msg.contains("REPEATABLE READ"),
1780            "expected requested isolation level in message, got: {msg}"
1781        );
1782    }
1783
1784    /// #281 — a nested atomic cancellation poisons the parent
1785    /// transaction. The only safe next step is rollback and retry from a fresh
1786    /// outer transaction, so generic retry classifiers must not treat the same
1787    /// context as reusable.
1788    #[test]
1789    fn transaction_poisoned_is_terminal() {
1790        let err = DjogiError::TransactionPoisoned {
1791            reason: "nested atomic future dropped before savepoint cleanup",
1792        };
1793        assert!(err.is_terminal(), "TransactionPoisoned must be terminal");
1794        assert!(
1795            !err.is_transient(),
1796            "TransactionPoisoned must not be transient"
1797        );
1798    }
1799
1800    /// #282 — refusing a session-scoped raw statement inside an
1801    /// existing transaction is a caller-structure error, not a transient
1802    /// runtime failure. Retrying the same closure against the same SQL will
1803    /// fail the same way.
1804    #[test]
1805    fn session_statement_disallowed_in_transaction_is_terminal() {
1806        let err = DjogiError::SessionStatementDisallowedInTransaction { statement: "SET" };
1807        assert!(
1808            err.is_terminal(),
1809            "SessionStatementDisallowedInTransaction must be terminal"
1810        );
1811        assert!(
1812            !err.is_transient(),
1813            "SessionStatementDisallowedInTransaction must not be transient"
1814        );
1815    }
1816
1817    /// Issue #306 — refusing a transaction-control raw statement inside an
1818    /// existing transaction is a caller-structure error. Raw COMMIT bypasses
1819    /// on_commit drain; raw ROLLBACK bypasses rollback cleanup and callback
1820    /// discard; raw savepoint control desynchronizes savepoint_depth.
1821    #[test]
1822    fn raw_transaction_control_disallowed_in_transaction_is_terminal() {
1823        let err = DjogiError::RawTransactionControlDisallowedInTransaction {
1824            statement: "COMMIT",
1825        };
1826        assert!(
1827            err.is_terminal(),
1828            "RawTransactionControlDisallowedInTransaction must be terminal"
1829        );
1830        assert!(
1831            !err.is_transient(),
1832            "RawTransactionControlDisallowedInTransaction must not be transient"
1833        );
1834
1835        let msg = err.to_string();
1836        assert!(
1837            msg.contains("COMMIT"),
1838            "display must name the refused statement, got: {msg}"
1839        );
1840        assert!(
1841            msg.contains("transaction-backed"),
1842            "display must explain this is about transaction-backed contexts, got: {msg}"
1843        );
1844    }
1845
1846    /// #169 — `ConstraintModeOutsideTransaction` mirrors the
1847    /// `SetRoleOutsideTransaction` classification: a caller invoking a
1848    /// transaction-scoped helper on a pool-backed context must
1849    /// restructure their code to wrap the call in `atomic()`, not back
1850    /// off and retry.
1851    #[test]
1852    fn constraint_mode_outside_transaction_is_terminal() {
1853        let err = DjogiError::ConstraintModeOutsideTransaction;
1854        assert!(
1855            err.is_terminal(),
1856            "ConstraintModeOutsideTransaction must be terminal"
1857        );
1858        assert!(
1859            !err.is_transient(),
1860            "ConstraintModeOutsideTransaction must not be transient"
1861        );
1862    }
1863
1864    /// #169 — `UnknownConstraintName` is a validation
1865    /// error; retry with the same string would fail again. The
1866    /// variant carries the offending name verbatim so log scrapers
1867    /// can identify what was rejected.
1868    #[test]
1869    fn unknown_constraint_name_is_terminal() {
1870        let err = DjogiError::UnknownConstraintName("typo_fkey".into());
1871        assert!(err.is_terminal());
1872        assert!(!err.is_transient());
1873        let msg = format!("{err}");
1874        assert!(
1875            msg.contains("typo_fkey"),
1876            "expected offending name in message, got: {msg}"
1877        );
1878    }
1879
1880    /// #169 — `ConstraintNotDeferrable` is terminal because
1881    /// a constraint declared non-deferrable cannot be deferred at
1882    /// runtime; the fix is at the model declaration. Verifies that
1883    /// the message names the offending constraint.
1884    #[test]
1885    fn constraint_not_deferrable_is_terminal() {
1886        let err = DjogiError::ConstraintNotDeferrable("posts_author_id_fkey".into());
1887        assert!(err.is_terminal());
1888        assert!(!err.is_transient());
1889        let msg = format!("{err}");
1890        assert!(
1891            msg.contains("posts_author_id_fkey"),
1892            "expected offending name in message, got: {msg}"
1893        );
1894        assert!(
1895            msg.contains("deferrable"),
1896            "expected remediation hint mentioning `deferrable`, got: {msg}"
1897        );
1898    }
1899
1900    /// #173 — `ConcurrentReadsRequirePoolContext` is
1901    /// terminal because the fix is structural (move outside
1902    /// `atomic()`), not transient. Mirrors the
1903    /// `SetRoleOutsideTransaction` shape.
1904    #[test]
1905    fn concurrent_reads_require_pool_context_is_terminal() {
1906        let err = DjogiError::ConcurrentReadsRequirePoolContext;
1907        assert!(err.is_terminal());
1908        assert!(!err.is_transient());
1909    }
1910
1911    /// #169 (
1912    /// `EmptyDeferConstraintsScope` is terminal because the empty
1913    /// slice is a programming error that retries cannot resolve.
1914    /// The message must mention `DeferScope::All` as the remediation
1915    /// hint for the common "I just meant everything" mistake.
1916    #[test]
1917    fn empty_defer_constraints_scope_is_terminal_and_names_alternative() {
1918        let err = DjogiError::EmptyDeferConstraintsScope;
1919        assert!(
1920            err.is_terminal(),
1921            "EmptyDeferConstraintsScope must be terminal"
1922        );
1923        assert!(
1924            !err.is_transient(),
1925            "EmptyDeferConstraintsScope must not be transient"
1926        );
1927        let msg = format!("{err}");
1928        assert!(
1929            msg.contains("DeferScope::All"),
1930            "expected `DeferScope::All` remediation hint in message, got: {msg}"
1931        );
1932    }
1933
1934    /// #169 (
1935    /// `ConflictingDeferrabilitySpec` mirrors the projection-time
1936    /// gate at runtime. The fix is at the model declaration; retrying
1937    /// cannot resolve the underlying inventory disagreement.
1938    #[test]
1939    fn conflicting_deferrability_spec_is_terminal_and_carries_payload() {
1940        let err = DjogiError::ConflictingDeferrabilitySpec {
1941            model_type_name: "Post".into(),
1942            field_name: "author_id".into(),
1943            first: (true, false),
1944            second: (true, true),
1945        };
1946        assert!(
1947            err.is_terminal(),
1948            "ConflictingDeferrabilitySpec must be terminal"
1949        );
1950        assert!(!err.is_transient());
1951        let msg = format!("{err}");
1952        assert!(
1953            msg.contains("Post.author_id"),
1954            "expected `model.field` in message, got: {msg}"
1955        );
1956        assert!(
1957            msg.contains("(true, false)") && msg.contains("(true, true)"),
1958            "expected both conflicting (deferrable, initially_deferred) tuples in message, got: {msg}"
1959        );
1960    }
1961
1962    /// #169 (
1963    /// `OrphanDeferrabilitySpec` is terminal because the inventory
1964    /// shape is fixed at link time; no amount of retrying re-emits
1965    /// the missing `ModelDescriptor`.
1966    #[test]
1967    fn orphan_deferrability_spec_is_terminal_and_names_field() {
1968        let err = DjogiError::OrphanDeferrabilitySpec {
1969            model_type_name: "Ghost".into(),
1970            field_name: "haunts".into(),
1971        };
1972        assert!(err.is_terminal());
1973        assert!(!err.is_transient());
1974        let msg = format!("{err}");
1975        assert!(
1976            msg.contains("Ghost.haunts"),
1977            "expected `model.field` in message, got: {msg}"
1978        );
1979    }
1980
1981    /// #169 (
1982    /// `DuplicateConstraintName` is terminal; the fix is at the
1983    /// model declaration, not at the retry site.
1984    #[test]
1985    fn duplicate_constraint_name_is_terminal_and_carries_both_fields() {
1986        let err = DjogiError::DuplicateConstraintName {
1987            constraint_name: "long_table_long_column_fkey".into(),
1988            first_model: "Alpha".into(),
1989            first_field: "ref".into(),
1990            second_model: "Beta".into(),
1991            second_field: "ref".into(),
1992        };
1993        assert!(err.is_terminal());
1994        assert!(!err.is_transient());
1995        let msg = format!("{err}");
1996        assert!(
1997            msg.contains("Alpha") && msg.contains("Beta"),
1998            "expected both colliding models in message, got: {msg}"
1999        );
2000        assert!(
2001            msg.contains("long_table_long_column_fkey"),
2002            "expected colliding constraint name in message, got: {msg}"
2003        );
2004    }
2005
2006    #[test]
2007    fn unsupported_postgres_version_is_terminal() {
2008        let err = DjogiError::unsupported_postgres_version(16, 4, 18);
2009        assert!(
2010            err.is_terminal(),
2011            "UnsupportedPostgresVersion must be terminal"
2012        );
2013        assert!(
2014            !err.is_transient(),
2015            "UnsupportedPostgresVersion must not be transient"
2016        );
2017    }
2018
2019    #[test]
2020    fn unsupported_postgres_version_displays_versions_and_upgrade() {
2021        let err = DjogiError::unsupported_postgres_version(16, 4, 18);
2022        let msg = format!("{err}");
2023        assert!(
2024            msg.contains("16.4"),
2025            "Display must name detected version, got: {msg}"
2026        );
2027        assert!(
2028            msg.contains("18"),
2029            "Display must name minimum version, got: {msg}"
2030        );
2031        assert!(
2032            msg.contains("upgrade") || msg.contains("Upgrade"),
2033            "Display must suggest upgrade, got: {msg}"
2034        );
2035    }
2036
2037    #[test]
2038    fn unsupported_postgres_version_error_is_clear() {
2039        let err = DjogiError::unsupported_postgres_version(17, 0, 18);
2040        let msg = format!("{err}");
2041        assert!(
2042            msg.contains("PostgreSQL"),
2043            "Display must mention PostgreSQL, got: {msg}"
2044        );
2045        assert!(
2046            msg.contains("minimum") || msg.contains("below"),
2047            "Display must indicate minimum requirement, got: {msg}"
2048        );
2049    }
2050}