Skip to main content

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}