fraiseql_core/runtime/mutation_result.rs
1//! Mutation response parser for `app.mutation_response` composite rows.
2//!
3//! Parses a typed, column-per-concern row into [`MutationOutcome`], which the
4//! executor uses to build the GraphQL response. The row shape maps 1:1 to the
5//! `app.mutation_response` PostgreSQL composite type — see
6//! `docs/architecture/mutation-response.md` for the DDL and semantics table.
7
8use std::{
9 collections::HashMap,
10 sync::atomic::{AtomicU64, Ordering},
11};
12
13use serde::{Deserialize, Deserializer, Serialize};
14use serde_json::Value as JsonValue;
15use uuid::Uuid;
16
17use super::cascade::MutationErrorClass;
18use crate::error::{FraiseQLError, Result};
19
20/// Minimum legal HTTP status code (informational range start).
21const HTTP_STATUS_MIN: i16 = 100;
22/// Maximum legal HTTP status code (end of 5xx range).
23const HTTP_STATUS_MAX: i16 = 599;
24
25/// Whether the server checks a failed mutation's `error_detail.errors[]` (#1425).
26///
27/// Clients translate a failure by `errors[].identifier`, so a failure that carries no
28/// `errors` array, or an identifier that is not a translation key (`^[a-z][a-z0-9_]*$`),
29/// reaches a person untranslated. With [`Warn`](Self::Warn) each such response is logged
30/// at `warn` and counted in [`mutation_error_shape_violations`]; the response itself is
31/// unchanged.
32#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
33#[serde(rename_all = "lowercase")]
34#[non_exhaustive]
35pub enum MutationErrorShapeCheck {
36 /// No check (the default).
37 #[default]
38 Off,
39 /// Log and count a malformed failure response.
40 Warn,
41}
42
43/// Failed mutation responses whose `errors[]` was malformed, since startup.
44static ERROR_SHAPE_VIOLATIONS: AtomicU64 = AtomicU64::new(0);
45
46/// How many failed mutation responses [`MutationErrorShapeCheck::Warn`] found malformed
47/// since the process started (#1425). Exported by the server as
48/// `fraiseql_mutation_error_shape_violations_total`.
49#[must_use]
50pub fn mutation_error_shape_violations() -> u64 {
51 ERROR_SHAPE_VIOLATIONS.load(Ordering::Relaxed)
52}
53
54/// Check a failed outcome's `errors[]` when `check` asks for it: log at `warn` and count
55/// a malformed one. A success, or `check` off, does nothing.
56pub(crate) fn check_error_shape(
57 check: MutationErrorShapeCheck,
58 outcome: &MutationOutcome,
59 mutation: &str,
60 function: &str,
61) {
62 if check == MutationErrorShapeCheck::Off {
63 return;
64 }
65 let MutationOutcome::Error { metadata, .. } = outcome else {
66 return;
67 };
68 let problems = error_shape_problems(metadata);
69 if problems.is_empty() {
70 return;
71 }
72 ERROR_SHAPE_VIOLATIONS.fetch_add(1, Ordering::Relaxed);
73 tracing::warn!(
74 mutation = %mutation,
75 function = %function,
76 problems = %problems.join("; "),
77 "mutation failure without a translatable errors[] entry"
78 );
79}
80
81/// What is wrong with a failure's `error_detail` as the carrier of `errors[]` entries.
82///
83/// Empty when it carries a non-empty `errors` array whose every `identifier` is a
84/// translation key (`^[a-z][a-z0-9_]*$`).
85#[must_use]
86pub fn error_shape_problems(error_detail: &JsonValue) -> Vec<String> {
87 let Some(entries) = error_detail.get("errors").and_then(JsonValue::as_array) else {
88 return vec!["no `errors` array in error_detail".to_string()];
89 };
90 if entries.is_empty() {
91 return vec!["an empty `errors` array in error_detail".to_string()];
92 }
93 entries
94 .iter()
95 .enumerate()
96 .filter_map(|(i, entry)| match entry.get("identifier").and_then(JsonValue::as_str) {
97 None => Some(format!("errors[{i}] has no string `identifier`")),
98 Some(id) if !is_translation_key(id) => Some(format!(
99 "errors[{i}].identifier '{id}' is not a translation key (^[a-z][a-z0-9_]*$)"
100 )),
101 Some(_) => None,
102 })
103 .collect()
104}
105
106/// `^[a-z][a-z0-9_]*$`: the shape `fraiseql.error_entry` normalises an identifier into.
107fn is_translation_key(identifier: &str) -> bool {
108 let mut chars = identifier.chars();
109 chars.next().is_some_and(|c| c.is_ascii_lowercase())
110 && chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_')
111}
112
113/// Outcome of parsing a single `mutation_response` row.
114#[derive(Debug, Clone)]
115#[non_exhaustive]
116pub enum MutationOutcome {
117 /// The mutation succeeded; the result entity is available.
118 Success {
119 /// The entity JSONB returned by the function.
120 entity: JsonValue,
121 /// GraphQL type name for the entity (from the `entity_type` column).
122 entity_type: Option<String>,
123 /// UUID string of the mutated entity (from the `entity_id` column).
124 ///
125 /// Present for UPDATE and DELETE mutations. Used for entity-aware cache
126 /// invalidation: only cache entries containing this UUID are evicted,
127 /// leaving unrelated entries warm.
128 entity_id: Option<String>,
129 /// Cascade operations associated with this mutation.
130 cascade: Option<JsonValue>,
131 /// GraphQL field names changed by this mutation (from the `updated_fields`
132 /// column; empty on noop). Surfaced selection-gated as `updatedFields` on
133 /// the success arm, symmetric with `cascade` (#433).
134 updated_fields: Vec<String>,
135 },
136 /// The mutation failed; error metadata is available.
137 Error {
138 /// Typed classification of the failure (mirrors `app.mutation_error_class`).
139 error_class: MutationErrorClass,
140 /// Human-readable error message.
141 message: String,
142 /// Suggested HTTP status code, when the composite supplied one.
143 http_status: Option<i16>,
144 /// Concrete GraphQL type name for the failure (from the `entity_type`
145 /// column). On the error path a function stamps the declared error type it
146 /// produced (e.g. `"DuplicateEmailError"`), letting the executor route the
147 /// result onto the right `is_error` union member — symmetric with the
148 /// success arm's use of `entity_type` (#465).
149 entity_type: Option<String>,
150 /// Structured metadata JSONB containing error-type field values.
151 metadata: JsonValue,
152 },
153}
154
155/// Typed `app.mutation_response` row.
156///
157/// Field types map 1:1 to the PostgreSQL composite columns. See
158/// `docs/architecture/mutation-response.md`.
159#[derive(Debug, Clone, Deserialize)]
160#[non_exhaustive]
161pub struct MutationResponse {
162 /// Terminal outcome. `true` means the operation completed (including noops).
163 pub succeeded: bool,
164 /// Did the database actually change? Independent of `succeeded`.
165 pub state_changed: bool,
166 /// `NULL` iff `succeeded`. Drives the cascade error code 1:1.
167 #[serde(default)]
168 pub error_class: Option<MutationErrorClass>,
169 /// Human-readable subtype (e.g. `"duplicate_email"`); not parsed.
170 #[serde(default)]
171 pub status_detail: Option<String>,
172 /// HTTP status, first-class. Validated to 100..=599 on ingest.
173 #[serde(default)]
174 pub http_status: Option<i16>,
175 /// Human-readable summary safe to show to end users.
176 #[serde(default)]
177 pub message: Option<String>,
178 /// Primary key of the affected entity. Present for updates/deletes.
179 #[serde(default)]
180 pub entity_id: Option<Uuid>,
181 /// GraphQL type name (e.g. `"User"`). Used for cache invalidation.
182 #[serde(default)]
183 pub entity_type: Option<String>,
184 /// Full entity payload. Populated even for noops.
185 #[serde(default)]
186 pub entity: JsonValue,
187 /// GraphQL field names that changed. Empty on noop. A SQL-`NULL` column
188 /// (rendered by `row_to_map` as JSON `null`) is read as the empty list — see
189 /// `null_as_empty_string_vec`.
190 #[serde(default, deserialize_with = "null_as_empty_string_vec")]
191 pub updated_fields: Vec<String>,
192 /// Cascade operations (see the graphql-cascade specification).
193 #[serde(default)]
194 pub cascade: JsonValue,
195 /// Structured error payload only (field / constraint / severity).
196 #[serde(default)]
197 pub error_detail: JsonValue,
198 /// Observability only (trace IDs, timings, audit extras).
199 #[serde(default)]
200 pub metadata: JsonValue,
201}
202
203/// Deserialize a possibly-`null` `TEXT[]` column as an empty `Vec`.
204///
205/// A failed mutation's function commonly leaves `updated_fields` unset (SQL NULL),
206/// which `row_to_map` renders as JSON `null`. Serde's `#[serde(default)]` only fills
207/// an *absent* key, so an explicit null still reaches `Vec<String>`'s deserializer
208/// and fails with `invalid type: null, expected a sequence` — turning every such
209/// failure into an opaque parse error before the typed error arm is reached (#473).
210/// Treating null as the empty list matches the absent-key behaviour.
211fn null_as_empty_string_vec<'de, D>(deserializer: D) -> std::result::Result<Vec<String>, D::Error>
212where
213 D: Deserializer<'de>,
214{
215 Ok(Option::<Vec<String>>::deserialize(deserializer)?.unwrap_or_default())
216}
217
218/// Parse a `mutation_response` row into a [`MutationOutcome`].
219///
220/// Deserializes typed columns directly — no string parsing. Rejects the illegal
221/// combination `succeeded=false AND state_changed=true` (the builder refuses to
222/// construct such a row; defense in depth here so a hand-written SQL path
223/// cannot slip a partial-failure row past the parser).
224///
225/// `error_detail` (not `metadata`) feeds the executor's error-field projection
226/// so downstream consumers remain untouched: `metadata` carries observability
227/// only and must not be used as an error-data carrier.
228///
229/// # Errors
230///
231/// Returns [`FraiseQLError::Validation`] if:
232/// - the row fails to deserialize into [`MutationResponse`];
233/// - `http_status` is outside `100..=599`;
234/// - `succeeded=false` with `state_changed=true` (illegal per the semantics table);
235/// - `succeeded=false` with `error_class` missing.
236pub fn parse_mutation_row<S: ::std::hash::BuildHasher>(
237 row: &HashMap<String, JsonValue, S>,
238) -> Result<MutationOutcome> {
239 let obj: serde_json::Map<String, JsonValue> =
240 row.iter().map(|(k, v)| (k.clone(), v.clone())).collect();
241 let parsed: MutationResponse =
242 serde_json::from_value(JsonValue::Object(obj)).map_err(|e| FraiseQLError::Validation {
243 message: format!("mutation_response row failed to deserialize: {e}"),
244 path: None,
245 })?;
246 to_outcome(parsed)
247}
248
249/// Lower a deserialized [`MutationResponse`] to the shared outcome seam.
250fn to_outcome(row: MutationResponse) -> Result<MutationOutcome> {
251 if let Some(status) = row.http_status {
252 if !(HTTP_STATUS_MIN..=HTTP_STATUS_MAX).contains(&status) {
253 return Err(FraiseQLError::Validation {
254 message: format!(
255 "mutation_response 'http_status' out of range: {status} \
256 (expected {HTTP_STATUS_MIN}..={HTTP_STATUS_MAX})"
257 ),
258 path: None,
259 });
260 }
261 }
262
263 if row.succeeded {
264 if row.error_class.is_some() {
265 return Err(FraiseQLError::Validation {
266 message: "mutation_response: succeeded=true but error_class is set".to_string(),
267 path: None,
268 });
269 }
270 Ok(MutationOutcome::Success {
271 entity: row.entity,
272 entity_type: row.entity_type,
273 entity_id: row.entity_id.map(|u| u.to_string()),
274 cascade: filter_null(row.cascade),
275 updated_fields: row.updated_fields,
276 })
277 } else {
278 if row.state_changed {
279 return Err(FraiseQLError::Validation {
280 message: "mutation_response: succeeded=false with state_changed=true is illegal \
281 (partial-failure rows are builder-rejected)"
282 .to_string(),
283 path: None,
284 });
285 }
286 let Some(class) = row.error_class else {
287 return Err(FraiseQLError::Validation {
288 message: "mutation_response: succeeded=false requires error_class".to_string(),
289 path: None,
290 });
291 };
292 Ok(MutationOutcome::Error {
293 error_class: class,
294 message: row.message.unwrap_or_default(),
295 http_status: row.http_status,
296 entity_type: row.entity_type,
297 metadata: row.error_detail,
298 })
299 }
300}
301
302fn filter_null(v: JsonValue) -> Option<JsonValue> {
303 if v.is_null() { None } else { Some(v) }
304}