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}