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}