Skip to main content

fraiseql_core/runtime/
cascade.rs

1//! Cascade-spec error classification.
2//!
3//! `MutationErrorClass` mirrors the `app.mutation_error_class` PostgreSQL enum
4//! emitted by `app.mutation_response` rows. `CascadeErrorCode` is the wire
5//! representation used by the graphql-cascade error envelope. The mapping
6//! between them is 1:1 — no fallbacks, no HTTP-code tiebreakers.
7//!
8//! See `docs/architecture/mutation-response.md` (semantics table + mapping).
9
10use serde::Deserialize;
11
12/// Classification of a failed mutation.
13///
14/// Mirrors `app.mutation_error_class` in PostgreSQL. Variants serialize to the
15/// `snake_case` form used in the PG enum so rows containing `error_class` strings
16/// deserialize directly. `NULL` in the PG column corresponds to
17/// `Option::None` in the parent struct.
18#[derive(Copy, Clone, Debug, Eq, PartialEq, Hash, Deserialize)]
19#[serde(rename_all = "snake_case")]
20#[non_exhaustive]
21pub enum MutationErrorClass {
22    /// Input failed schema or business-rule validation.
23    Validation,
24    /// Uniqueness, optimistic-concurrency, or state conflict.
25    Conflict,
26    /// Target entity does not exist or the caller cannot see it.
27    NotFound,
28    /// Caller is unauthenticated.
29    Unauthorized,
30    /// Caller is authenticated but lacks permission.
31    Forbidden,
32    /// Unhandled server-side failure. Implementation details must not leak.
33    Internal,
34    /// Transaction was rolled back (serialization, deadlock, explicit abort).
35    TransactionFailed,
36    /// Operation exceeded a deadline.
37    Timeout,
38    /// Caller exceeded quota.
39    RateLimited,
40    /// Downstream dependency unreachable.
41    ServiceUnavailable,
42}
43
44/// graphql-cascade wire-level error code.
45///
46/// Serialized as `SCREAMING_SNAKE_CASE` on the wire; derived 1:1 from a
47/// `MutationErrorClass` via [`MutationErrorClass::to_cascade_code`].
48#[derive(Copy, Clone, Debug, Eq, PartialEq, Hash, Deserialize)]
49#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
50#[non_exhaustive]
51pub enum CascadeErrorCode {
52    /// Input validation failed.
53    ValidationError,
54    /// Uniqueness or concurrency conflict.
55    Conflict,
56    /// Entity not found.
57    NotFound,
58    /// Unauthenticated.
59    Unauthorized,
60    /// Forbidden.
61    Forbidden,
62    /// Internal server error.
63    InternalError,
64    /// Transaction rolled back.
65    TransactionFailed,
66    /// Operation timed out.
67    Timeout,
68    /// Rate limit exceeded.
69    RateLimited,
70    /// Downstream service unavailable.
71    ServiceUnavailable,
72}
73
74impl MutationErrorClass {
75    /// The `snake_case` string that identifies this class on the wire.
76    ///
77    /// Mirrors the `app.mutation_error_class` PostgreSQL enum label and the
78    /// `serde(rename_all = "snake_case")` serialisation form used in v2 rows.
79    #[must_use]
80    pub const fn as_str(self) -> &'static str {
81        match self {
82            Self::Validation => "validation",
83            Self::Conflict => "conflict",
84            Self::NotFound => "not_found",
85            Self::Unauthorized => "unauthorized",
86            Self::Forbidden => "forbidden",
87            Self::Internal => "internal",
88            Self::TransactionFailed => "transaction_failed",
89            Self::Timeout => "timeout",
90            Self::RateLimited => "rate_limited",
91            Self::ServiceUnavailable => "service_unavailable",
92        }
93    }
94
95    /// Map the error class to its graphql-cascade wire code (1:1, no fallbacks).
96    #[must_use]
97    pub const fn to_cascade_code(self) -> CascadeErrorCode {
98        match self {
99            Self::Validation => CascadeErrorCode::ValidationError,
100            Self::Conflict => CascadeErrorCode::Conflict,
101            Self::NotFound => CascadeErrorCode::NotFound,
102            Self::Unauthorized => CascadeErrorCode::Unauthorized,
103            Self::Forbidden => CascadeErrorCode::Forbidden,
104            Self::Internal => CascadeErrorCode::InternalError,
105            Self::TransactionFailed => CascadeErrorCode::TransactionFailed,
106            Self::Timeout => CascadeErrorCode::Timeout,
107            Self::RateLimited => CascadeErrorCode::RateLimited,
108            Self::ServiceUnavailable => CascadeErrorCode::ServiceUnavailable,
109        }
110    }
111}
112
113#[cfg(test)]
114mod tests {
115    #![allow(clippy::unwrap_used)] // Reason: test code, panics are acceptable
116
117    use serde_json::json;
118
119    use super::*;
120
121    #[test]
122    fn to_cascade_code_is_one_to_one() {
123        let pairs = [
124            (MutationErrorClass::Validation, CascadeErrorCode::ValidationError),
125            (MutationErrorClass::Conflict, CascadeErrorCode::Conflict),
126            (MutationErrorClass::NotFound, CascadeErrorCode::NotFound),
127            (MutationErrorClass::Unauthorized, CascadeErrorCode::Unauthorized),
128            (MutationErrorClass::Forbidden, CascadeErrorCode::Forbidden),
129            (MutationErrorClass::Internal, CascadeErrorCode::InternalError),
130            (MutationErrorClass::TransactionFailed, CascadeErrorCode::TransactionFailed),
131            (MutationErrorClass::Timeout, CascadeErrorCode::Timeout),
132            (MutationErrorClass::RateLimited, CascadeErrorCode::RateLimited),
133            (MutationErrorClass::ServiceUnavailable, CascadeErrorCode::ServiceUnavailable),
134        ];
135        for (class, expected) in pairs {
136            assert_eq!(class.to_cascade_code(), expected, "class = {class:?}");
137        }
138    }
139
140    #[test]
141    fn deserializes_from_pg_enum_snake_case() {
142        let pairs = [
143            ("validation", MutationErrorClass::Validation),
144            ("conflict", MutationErrorClass::Conflict),
145            ("not_found", MutationErrorClass::NotFound),
146            ("unauthorized", MutationErrorClass::Unauthorized),
147            ("forbidden", MutationErrorClass::Forbidden),
148            ("internal", MutationErrorClass::Internal),
149            ("transaction_failed", MutationErrorClass::TransactionFailed),
150            ("timeout", MutationErrorClass::Timeout),
151            ("rate_limited", MutationErrorClass::RateLimited),
152            ("service_unavailable", MutationErrorClass::ServiceUnavailable),
153        ];
154        for (raw, expected) in pairs {
155            let got: MutationErrorClass = serde_json::from_value(json!(raw)).unwrap();
156            assert_eq!(got, expected, "raw = {raw}");
157        }
158    }
159
160    #[test]
161    fn cascade_code_deserializes_from_screaming_snake_case() {
162        let pairs = [
163            (CascadeErrorCode::ValidationError, "VALIDATION_ERROR"),
164            (CascadeErrorCode::Conflict, "CONFLICT"),
165            (CascadeErrorCode::NotFound, "NOT_FOUND"),
166            (CascadeErrorCode::Unauthorized, "UNAUTHORIZED"),
167            (CascadeErrorCode::Forbidden, "FORBIDDEN"),
168            (CascadeErrorCode::InternalError, "INTERNAL_ERROR"),
169            (CascadeErrorCode::TransactionFailed, "TRANSACTION_FAILED"),
170            (CascadeErrorCode::Timeout, "TIMEOUT"),
171            (CascadeErrorCode::RateLimited, "RATE_LIMITED"),
172            (CascadeErrorCode::ServiceUnavailable, "SERVICE_UNAVAILABLE"),
173        ];
174        for (code, raw) in pairs {
175            let got: CascadeErrorCode = serde_json::from_value(json!(raw)).unwrap();
176            assert_eq!(got, code, "raw = {raw}");
177        }
178    }
179}