Skip to main content

fraiseql_error/
core_error.rs

1//! Core error types for FraiseQL operations.
2//!
3//! This module provides the primary error enum `FraiseQLError` used throughout
4//! the FraiseQL compilation and execution pipeline.
5
6use serde::{Deserialize, Serialize};
7use thiserror::Error;
8
9/// Result type alias for FraiseQL operations.
10pub type Result<T> = std::result::Result<T, FraiseQLError>;
11
12/// The constraint a database error names (#1531), as PostgreSQL reports it.
13///
14/// A unique **index** is reported under its index name (it has no `pg_constraint` row). The
15/// table is the one PostgreSQL names: for a foreign-key violation that is the referencing
16/// table in both directions, so the direction is not recoverable from it. A not-null
17/// violation names no constraint, only its column; PostgreSQL 18 catalogues the not-null
18/// constraint itself, so its name is resolvable from the column.
19#[derive(Debug, Clone, PartialEq, Eq)]
20pub struct ConstraintViolation {
21    /// The constraint's (or unique index's) name, when the error names one.
22    pub name:   Option<String>,
23    /// The schema of the table the error names.
24    pub schema: Option<String>,
25    /// The table the error names.
26    pub table:  Option<String>,
27    /// The column the error names (a not-null violation's).
28    pub column: Option<String>,
29}
30
31/// Main error type for FraiseQL operations.
32///
33/// All errors in the core library are converted to this type.
34/// Language bindings convert this to their native error types.
35///
36/// # Error Categories
37///
38/// Errors are organized by domain:
39///
40/// ## GraphQL Errors
41/// - `Parse` — Malformed GraphQL syntax
42/// - `Validation` — Schema validation failures
43/// - `UnknownField` — Field doesn't exist on type
44/// - `UnknownType` — Type doesn't exist in schema
45///
46/// ## Database Errors
47/// - `Database` — PostgreSQL/MySQL/SQLite errors (includes SQL state code)
48/// - `ConnectionPool` — Connection pool exhausted or unavailable
49/// - `Timeout` — Query exceeded configured timeout
50/// - `Cancelled` — Query was cancelled by caller
51///
52/// ## Authorization/Security Errors
53/// - `Authorization` — User lacks permission for operation
54/// - `Authentication` — Invalid/expired JWT token
55/// - `RateLimited` — Too many requests (includes retry-after)
56///
57/// ## Resource Errors
58/// - `NotFound` — Resource doesn't exist (404)
59/// - `Conflict` — Operation would violate constraints (409)
60///
61/// ## Configuration Errors
62/// - `Configuration` — Invalid setup/configuration
63/// - `Unsupported` — Operation not supported by current database backend
64///
65/// ## Internal Errors
66/// - `Internal` — Unexpected internal failures
67///
68/// # Stability
69///
70/// This enum is marked `#[non_exhaustive]` to allow adding new error variants
71/// in future minor versions without breaking backward compatibility.
72///
73/// External `match` expressions must include a wildcard `_` arm:
74///
75/// ```rust
76/// use fraiseql_error::FraiseQLError;
77///
78/// fn describe(e: &FraiseQLError) -> &'static str {
79///     match e {
80///         FraiseQLError::Parse { .. } => "parse error",
81///         FraiseQLError::Validation { .. } => "validation error",
82///         _ => "other error", // required: FraiseQLError is #[non_exhaustive]
83///     }
84/// }
85/// ```
86///
87/// The following would **not** compile, because the `#[non_exhaustive]`
88/// attribute forces downstream crates to handle the possibility of new
89/// variants — a wildcard arm is required even when every currently-defined
90/// variant is enumerated:
91///
92/// ```compile_fail
93/// use fraiseql_error::FraiseQLError;
94///
95/// fn describe(e: &FraiseQLError) -> &'static str {
96///     // Missing wildcard arm: rejected by rustc even though it lists
97///     // a few real variants — the `#[non_exhaustive]` attribute makes
98///     // the enum effectively open from any downstream crate's point
99///     // of view.
100///     match e {
101///         FraiseQLError::Parse { .. } => "parse",
102///         FraiseQLError::Validation { .. } => "validation",
103///         FraiseQLError::Database { .. } => "database",
104///     }
105/// }
106/// ```
107#[derive(Error, Debug)]
108#[non_exhaustive]
109pub enum FraiseQLError {
110    // ========================================================================
111    // GraphQL Errors
112    // ========================================================================
113    /// GraphQL parsing error.
114    #[error("Parse error at {location}: {message}")]
115    Parse {
116        /// Error message describing the parse failure.
117        message:  String,
118        /// Location in the query where the error occurred.
119        location: String,
120    },
121
122    /// GraphQL validation error.
123    #[error("Validation error: {message}")]
124    Validation {
125        /// Error message describing the validation failure.
126        message: String,
127        /// Path to the field with the error (e.g., "user.posts.0.title").
128        path:    Option<String>,
129    },
130
131    /// Unknown field error.
132    #[error("Unknown field '{field}' on type '{type_name}'")]
133    UnknownField {
134        /// The field name that was not found.
135        field:     String,
136        /// The type on which the field was queried.
137        type_name: String,
138    },
139
140    /// Unknown type error.
141    #[error("Unknown type '{type_name}'")]
142    UnknownType {
143        /// The type name that was not found.
144        type_name: String,
145    },
146
147    // ========================================================================
148    // Database Errors
149    // ========================================================================
150    /// Database operation error.
151    #[error("Database error: {message}")]
152    Database {
153        /// Error message from the database.
154        message:    String,
155        /// SQL state code if available (e.g., "23505" for unique violation).
156        sql_state:  Option<String>,
157        /// The constraint a violation names (#1531): the database's own `CONSTRAINT NAME`,
158        /// `SCHEMA NAME` and `TABLE NAME` fields. Schema identifiers, never row values.
159        /// `None` for an error that names no constraint.
160        constraint: Option<Box<ConstraintViolation>>,
161    },
162
163    /// Connection pool error.
164    #[error("Connection pool error: {message}")]
165    ConnectionPool {
166        /// Error message.
167        message: String,
168    },
169
170    /// Query timeout error.
171    #[error("Query timeout after {timeout_ms}ms")]
172    Timeout {
173        /// Timeout duration in milliseconds.
174        timeout_ms: u64,
175        /// The query that timed out (truncated if too long).
176        query:      Option<String>,
177    },
178
179    /// Query cancellation error.
180    #[error("Query cancelled: {reason}")]
181    Cancelled {
182        /// Query identifier for tracking/logging.
183        query_id: String,
184        /// Reason for cancellation.
185        reason:   String,
186    },
187
188    // ========================================================================
189    // Authorization Errors
190    // ========================================================================
191    /// Authorization error.
192    #[error("Authorization error: {message}")]
193    Authorization {
194        /// Error message.
195        message:  String,
196        /// The action that was denied.
197        action:   Option<String>,
198        /// The resource that was being accessed.
199        resource: Option<String>,
200    },
201
202    /// Authentication error.
203    #[error("Authentication error: {message}")]
204    Authentication {
205        /// Error message.
206        message: String,
207    },
208
209    /// Rate limiting error.
210    #[error("Rate limit exceeded: {message}")]
211    RateLimited {
212        /// Error message.
213        message:          String,
214        /// Number of seconds to wait before retrying.
215        retry_after_secs: u64,
216    },
217
218    /// Operation cost budget exceeded (#379).
219    ///
220    /// Distinct from [`RateLimited`](Self::RateLimited): a per-request cost
221    /// ceiling (`retry_after_secs: None`) is permanent for the operation —
222    /// retrying the same query cannot succeed — while an exhausted rolling
223    /// budget window (`Some`) is retryable once the window resets.
224    #[error("Operation cost exceeded: {message}")]
225    CostExceeded {
226        /// Error message.
227        message:          String,
228        /// Estimated cost of the rejected operation.
229        cost:             u64,
230        /// The budget the cost exceeded.
231        limit:            u64,
232        /// Seconds until the budget window resets; `None` for a per-request
233        /// ceiling.
234        retry_after_secs: Option<u64>,
235    },
236
237    /// The bytes a read would deliver exceed `[validation] max_response_bytes`.
238    ///
239    /// Distinct from [`CostExceeded`](Self::CostExceeded), which scores what a
240    /// request *asks for* before the database runs. This one measures what came
241    /// back. A materialised read costs what its documents weigh, and that is not
242    /// recoverable from the request: the same query against the same view returns
243    /// a kilobyte for one tenant and a gigabyte for another.
244    ///
245    /// Permanent for the request as issued — retrying it cannot succeed — so it is
246    /// a 4xx with no `retry_after`. The caller narrows `?select=` or lowers its
247    /// page size.
248    #[error(
249        "Response exceeds the maximum of {limit} bytes: {bytes} bytes delivered ([validation] \
250         max_response_bytes)"
251    )]
252    ResponseTooLarge {
253        /// Bytes measured when the ceiling was crossed. This is the running total
254        /// at the moment of refusal, not the full size of the untruncated
255        /// response — a stream is cut as soon as it goes over rather than being
256        /// read to the end to find out how far over it was.
257        bytes: u64,
258        /// The configured ceiling.
259        limit: u64,
260    },
261
262    /// The tenant's storage is over its `max_storage_bytes` quota (#633): its writes are
263    /// refused until its usage falls back under it. Its reads are unaffected.
264    ///
265    /// `507 Insufficient Storage`, the status for "the server cannot store what this
266    /// request needs". Not retried by waiting alone: the tenant's deletes are refused too,
267    /// so the operator frees space in its schema or raises the quota, and the next
268    /// measurement lifts the refusal.
269    #[error(
270        "Tenant storage is over its quota: {usage_bytes} bytes measured, max_storage_bytes is \
271         {limit_bytes}; writes are refused until usage falls back under it"
272    )]
273    StorageQuotaExceeded {
274        /// The usage the last measurement found.
275        usage_bytes: u64,
276        /// The tenant's `max_storage_bytes`.
277        limit_bytes: u64,
278    },
279
280    // ========================================================================
281    // Resource Errors
282    // ========================================================================
283    /// Resource not found error.
284    #[error("{resource_type} not found: {identifier}")]
285    NotFound {
286        /// Type of resource (e.g., "User", "Post").
287        resource_type: String,
288        /// Identifier that was looked up.
289        identifier:    String,
290    },
291
292    /// Conflict error.
293    #[error("Conflict: {message}")]
294    Conflict {
295        /// Error message.
296        message: String,
297    },
298
299    // ========================================================================
300    // Configuration Errors
301    // ========================================================================
302    /// Configuration error.
303    #[error("Configuration error: {message}")]
304    Configuration {
305        /// Error message.
306        message: String,
307    },
308
309    /// Unsupported operation error.
310    #[error("Unsupported operation: {message}")]
311    Unsupported {
312        /// Error message describing what is not supported.
313        message: String,
314    },
315
316    /// The service is temporarily unavailable (e.g. tenant suspended).
317    ///
318    /// Maps to HTTP 503. `retry_after` is the number of seconds to wait, if known.
319    #[error("Service unavailable: {message}")]
320    ServiceUnavailable {
321        /// Human-readable reason for the unavailability.
322        message:     String,
323        /// Number of seconds to wait before retrying, if known.
324        retry_after: Option<u64>,
325    },
326
327    // ========================================================================
328    // Domain Subsystem Errors (composed via `From` impls in subsystem crates)
329    // ========================================================================
330    /// An authentication or authorisation error originating from the auth subsystem.
331    ///
332    /// The boxed source is the subsystem-specific error type (e.g.
333    /// `fraiseql_auth::AuthError`). To preserve subsystem vocabulary while
334    /// keeping `fraiseql-error` a leaf crate, the boxed payload is
335    /// type-erased here; subsystem crates provide their own
336    /// `impl From<SubsystemError> for FraiseQLError` (the sqlx pattern).
337    ///
338    /// `#[source]` is explicit: `thiserror` 2.x does not auto-detect a single
339    /// tuple field as the source, and downstream chain-walkers (`tracing`,
340    /// `miette`, `anyhow`) rely on `Error::source()` returning the underlying
341    /// subsystem error rather than `None`.
342    ///
343    /// # Pattern-matching on the inner error
344    ///
345    /// Because the payload is boxed and `dyn`-erased, downstream `match`
346    /// statements cannot bind on subsystem variants directly. Recover the
347    /// concrete type via [`std::error::Error::source`] + `downcast_ref`:
348    ///
349    /// ```ignore
350    /// use std::error::Error;
351    /// use fraiseql_error::FraiseQLError;
352    /// use fraiseql_auth::AuthError;
353    ///
354    /// if let FraiseQLError::Auth(_) = &err {
355    ///     if let Some(inner) = err.source().and_then(|s| s.downcast_ref::<AuthError>()) {
356    ///         match inner {
357    ///             AuthError::TokenExpired => {/* handle */},
358    ///             _ => {},
359    ///         }
360    ///     }
361    /// }
362    /// ```
363    #[error("Auth error: {0}")]
364    Auth(#[source] Box<dyn std::error::Error + Send + Sync>),
365
366    /// A webhook-processing error originating from the webhook subsystem.
367    ///
368    /// `#[source]` is explicit for the same reason as [`Self::Auth`]; see
369    /// that variant for the `downcast_ref` recovery pattern on the boxed
370    /// `fraiseql_webhooks::WebhookError`.
371    #[error("Webhook error: {0}")]
372    Webhook(#[source] Box<dyn std::error::Error + Send + Sync>),
373
374    /// An observer subsystem error (event dispatch, action execution, retry exhaustion).
375    ///
376    /// `#[source]` is explicit for the same reason as [`Self::Auth`]; see
377    /// that variant for the `downcast_ref` recovery pattern on the boxed
378    /// `fraiseql_observers::ObserverError`.
379    #[error("Observer error: {0}")]
380    Observer(#[source] Box<dyn std::error::Error + Send + Sync>),
381
382    /// A file-handling error (size limit, unsupported type, virus scan, quota).
383    ///
384    /// Unlike `Auth`, `Webhook`, and `Observer`, the file-domain vocabulary
385    /// lives inside `fraiseql-error` itself ([`crate::FileError`]) because no
386    /// subsystem crate owns it — file operations are spread across
387    /// `fraiseql-storage` and `fraiseql-server/storage`.
388    #[error("File error: {0}")]
389    File(#[from] crate::FileError),
390
391    // ========================================================================
392    // Internal Errors
393    // ========================================================================
394    /// Internal error.
395    #[error("Internal error: {message}")]
396    Internal {
397        /// Error message.
398        message: String,
399        /// Optional source error for debugging.
400        #[source]
401        source:  Option<Box<dyn std::error::Error + Send + Sync>>,
402    },
403}
404
405impl FraiseQLError {
406    /// Create a parse error.
407    #[must_use]
408    pub fn parse(message: impl Into<String>) -> Self {
409        Self::Parse {
410            message:  message.into(),
411            location: "unknown".to_string(),
412        }
413    }
414
415    /// Create a parse error with location.
416    #[must_use]
417    pub fn parse_at(message: impl Into<String>, location: impl Into<String>) -> Self {
418        Self::Parse {
419            message:  message.into(),
420            location: location.into(),
421        }
422    }
423
424    /// Create a validation error.
425    #[must_use]
426    pub fn validation(message: impl Into<String>) -> Self {
427        Self::Validation {
428            message: message.into(),
429            path:    None,
430        }
431    }
432
433    /// Create a validation error with path.
434    #[must_use]
435    pub fn validation_at(message: impl Into<String>, path: impl Into<String>) -> Self {
436        Self::Validation {
437            message: message.into(),
438            path:    Some(path.into()),
439        }
440    }
441
442    /// Create a database error.
443    #[must_use]
444    pub fn database(message: impl Into<String>) -> Self {
445        Self::Database {
446            message:    message.into(),
447            sql_state:  None,
448            constraint: None,
449        }
450    }
451
452    /// Create an authorization error.
453    #[must_use]
454    pub fn unauthorized(message: impl Into<String>) -> Self {
455        Self::Authorization {
456            message:  message.into(),
457            action:   None,
458            resource: None,
459        }
460    }
461
462    /// Create a not found error.
463    #[must_use]
464    pub fn not_found(resource_type: impl Into<String>, identifier: impl Into<String>) -> Self {
465        Self::NotFound {
466            resource_type: resource_type.into(),
467            identifier:    identifier.into(),
468        }
469    }
470
471    /// Create a configuration error.
472    #[must_use]
473    pub fn config(message: impl Into<String>) -> Self {
474        Self::Configuration {
475            message: message.into(),
476        }
477    }
478
479    /// Create an internal error.
480    #[must_use]
481    pub fn internal(message: impl Into<String>) -> Self {
482        Self::Internal {
483            message: message.into(),
484            source:  None,
485        }
486    }
487
488    /// Create a cancellation error.
489    #[must_use]
490    pub fn cancelled(query_id: impl Into<String>, reason: impl Into<String>) -> Self {
491        Self::Cancelled {
492            query_id: query_id.into(),
493            reason:   reason.into(),
494        }
495    }
496
497    /// The same error, saying it came from item `index` (from 0) of a batch of `count`.
498    ///
499    /// A batch commits all of its items or none (#1574), so its caller needs to know which
500    /// item refused it. The variant is kept: a unique violation in item 3 is still a unique
501    /// violation, with its SQLSTATE and constraint, and maps to the same status. A variant
502    /// with no message to carry the item is returned unchanged.
503    #[must_use]
504    pub fn in_batch_item(self, index: usize, count: usize) -> Self {
505        let at = |message: String| format!("Bulk item {} of {count}: {message}", index + 1);
506        match self {
507            Self::Parse { message, location } => Self::Parse {
508                message: at(message),
509                location,
510            },
511            Self::Validation { message, path } => Self::Validation {
512                message: at(message),
513                path,
514            },
515            Self::Database {
516                message,
517                sql_state,
518                constraint,
519            } => Self::Database {
520                message: at(message),
521                sql_state,
522                constraint,
523            },
524            Self::ConnectionPool { message } => Self::ConnectionPool {
525                message: at(message),
526            },
527            Self::Authorization {
528                message,
529                action,
530                resource,
531            } => Self::Authorization {
532                message: at(message),
533                action,
534                resource,
535            },
536            Self::Authentication { message } => Self::Authentication {
537                message: at(message),
538            },
539            Self::RateLimited {
540                message,
541                retry_after_secs,
542            } => Self::RateLimited {
543                message: at(message),
544                retry_after_secs,
545            },
546            Self::CostExceeded {
547                message,
548                cost,
549                limit,
550                retry_after_secs,
551            } => Self::CostExceeded {
552                message: at(message),
553                cost,
554                limit,
555                retry_after_secs,
556            },
557            Self::Conflict { message } => Self::Conflict {
558                message: at(message),
559            },
560            Self::Configuration { message } => Self::Configuration {
561                message: at(message),
562            },
563            Self::Unsupported { message } => Self::Unsupported {
564                message: at(message),
565            },
566            Self::ServiceUnavailable {
567                message,
568                retry_after,
569            } => Self::ServiceUnavailable {
570                message: at(message),
571                retry_after,
572            },
573            Self::Internal { message, source } => Self::Internal {
574                message: at(message),
575                source,
576            },
577            unchanged @ (Self::UnknownField { .. }
578            | Self::UnknownType { .. }
579            | Self::Timeout { .. }
580            | Self::Cancelled { .. }
581            | Self::ResponseTooLarge { .. }
582            | Self::StorageQuotaExceeded { .. }
583            | Self::NotFound { .. }
584            | Self::Auth(_)
585            | Self::Webhook(_)
586            | Self::Observer(_)
587            | Self::File(_)) => unchanged,
588        }
589    }
590
591    /// Check if this is a client error (4xx equivalent).
592    ///
593    /// Derived from [`Self::status_code`] so the answer stays consistent
594    /// with the variant's actual HTTP routing (notably for `File(_)`, which
595    /// straddles 4xx and 5xx after F050).
596    #[must_use]
597    pub const fn is_client_error(&self) -> bool {
598        let code = self.status_code();
599        code >= 400 && code < 500
600    }
601
602    /// Check if this is a server error (5xx equivalent).
603    ///
604    /// Derived from [`Self::status_code`] for the same consistency reason
605    /// as [`Self::is_client_error`].
606    #[must_use]
607    pub const fn is_server_error(&self) -> bool {
608        let code = self.status_code();
609        code >= 500 && code < 600
610    }
611
612    /// Check if this error is retryable.
613    #[must_use]
614    pub const fn is_retryable(&self) -> bool {
615        matches!(
616            self,
617            Self::ConnectionPool { .. }
618                | Self::Timeout { .. }
619                | Self::Cancelled { .. }
620                | Self::ServiceUnavailable { .. }
621        )
622    }
623
624    /// Get HTTP status code equivalent.
625    ///
626    /// # Future variants
627    ///
628    /// `FraiseQLError` is `#[non_exhaustive]`. The trailing `_ => 500` arm
629    /// is a deliberate safety net so a new variant added without updating
630    /// this match still gets a defined (and *safe*) HTTP status — generic
631    /// 500 Internal Server Error — rather than leaking implementation
632    /// details to the client by returning the wrong code.
633    // Reason: the trailing wildcard arm intentionally duplicates the 500
634    // server-error arm above (silencing `match_same_arms`), and is currently
635    // unreachable within this crate because the match enumerates every
636    // existing variant (silencing `unreachable_patterns`). The duplication is
637    // the security guarantee: when a future variant is added to this
638    // `#[non_exhaustive]` enum, the wildcard becomes reachable and prevents a
639    // wrong-status leak. See docs/history/IMPROVEMENTS.md F055.
640    // Reason: deliberate duplicate arms — non_exhaustive forward-compat guard (F055), see above
641    #[allow(clippy::match_same_arms, unreachable_patterns)]
642    #[must_use]
643    pub const fn status_code(&self) -> u16 {
644        match self {
645            Self::Parse { .. }
646            | Self::Validation { .. }
647            | Self::UnknownField { .. }
648            | Self::UnknownType { .. }
649            | Self::Webhook(_) => 400,
650            // File is per-variant: validation failures stay 400, backend
651            // failures escalate to 5xx, NotFound → 404, PermissionDenied →
652            // 403 (matches the legacy `storage_error_response` routing of
653            // `FraiseQLError::Storage` which F050 replaces).
654            Self::File(e) => e.status_code(),
655            Self::Authentication { .. } | Self::Auth(_) => 401,
656            Self::Authorization { .. } => 403,
657            Self::NotFound { .. } => 404,
658            Self::Conflict { .. } => 409,
659            Self::RateLimited { .. } => 429,
660            // #1201: a cost ceiling is the caller asking for too much, never a
661            // server fault. Which 4xx depends on whether asking again can ever
662            // work — the distinction this variant's own doc draws: an exhausted
663            // rolling window resets (429, retryable), a per-request ceiling does
664            // not (400, permanent). Before this it matched no arm and fell to the
665            // `_ => 500` default, so every over-budget request was reported as a
666            // server error and every retry policy treated it as retryable.
667            Self::CostExceeded {
668                retry_after_secs: Some(_),
669                ..
670            } => 429,
671            Self::CostExceeded {
672                retry_after_secs: None,
673                ..
674            } => 400,
675            // The response the caller asked for is larger than the operator
676            // allows. 413 rather than 400 to match how this codebase already
677            // spells "what you asked for is too big": `sse_max_replay_events`
678            // answers `413 RESUME_TOO_FAR_BEHIND` for the same shape of refusal.
679            Self::ResponseTooLarge { .. } => 413,
680            Self::StorageQuotaExceeded { .. } => 507,
681            Self::Timeout { .. } | Self::Cancelled { .. } => 408,
682            Self::Database { .. }
683            | Self::ConnectionPool { .. }
684            | Self::Configuration { .. }
685            | Self::Internal { .. }
686            | Self::Observer(_) => 500,
687            Self::Unsupported { .. } => 501,
688            Self::ServiceUnavailable { .. } => 503,
689            // SECURITY: any future variant defaults to 500 Internal Server
690            // Error until explicitly mapped, so we never accidentally return
691            // an inappropriate status (e.g. 200, 401) to clients.
692            _ => 500,
693        }
694    }
695
696    /// Get error code for GraphQL response.
697    ///
698    /// # Future variants
699    ///
700    /// `FraiseQLError` is `#[non_exhaustive]`. The trailing `_` arm returns
701    /// `"INTERNAL_SERVER_ERROR"` so a new variant added without updating
702    /// this match still receives a stable (if generic) error code.
703    // Reason: see `status_code` — same security-defence rationale; same two
704    // lints (`match_same_arms` for the duplicated arm body, `unreachable_patterns`
705    // because the in-crate match currently covers every variant).
706    #[allow(clippy::match_same_arms, unreachable_patterns)]
707    #[must_use]
708    pub const fn error_code(&self) -> &'static str {
709        match self {
710            Self::Parse { .. } => "GRAPHQL_PARSE_FAILED",
711            Self::Validation { .. } => "GRAPHQL_VALIDATION_FAILED",
712            Self::UnknownField { .. } => "UNKNOWN_FIELD",
713            Self::UnknownType { .. } => "UNKNOWN_TYPE",
714            Self::Database { .. } => "DATABASE_ERROR",
715            Self::ConnectionPool { .. } => "CONNECTION_POOL_ERROR",
716            Self::Timeout { .. } => "TIMEOUT",
717            Self::Cancelled { .. } => "CANCELLED",
718            Self::Authorization { .. } => "FORBIDDEN",
719            Self::Authentication { .. } => "UNAUTHENTICATED",
720            Self::Auth(_) => "AUTH_ERROR",
721            Self::Webhook(_) => "WEBHOOK_ERROR",
722            Self::Observer(_) => "OBSERVER_ERROR",
723            Self::File(_) => "FILE_ERROR",
724            Self::RateLimited { .. } => "RATE_LIMITED",
725            Self::StorageQuotaExceeded { .. } => "INSUFFICIENT_STORAGE",
726            Self::NotFound { .. } => "NOT_FOUND",
727            Self::Conflict { .. } => "CONFLICT",
728            Self::Configuration { .. } => "CONFIGURATION_ERROR",
729            Self::Unsupported { .. } => "UNSUPPORTED_OPERATION",
730            Self::ServiceUnavailable { .. } => "SERVICE_UNAVAILABLE",
731            Self::Internal { .. } => "INTERNAL_SERVER_ERROR",
732            // SECURITY: see `status_code` — fallback to the safe generic
733            // category until the new variant is explicitly classified.
734            _ => "INTERNAL_SERVER_ERROR",
735        }
736    }
737
738    /// Create an unknown field error with helpful suggestions.
739    #[must_use]
740    pub fn unknown_field_with_suggestion(
741        field: impl Into<String>,
742        type_name: impl Into<String>,
743        available_fields: &[&str],
744    ) -> Self {
745        let field = field.into();
746        let type_name = type_name.into();
747
748        let suggestion = available_fields
749            .iter()
750            .map(|f| (*f, Self::levenshtein_distance(&field, f)))
751            .filter(|(_, distance)| *distance <= 2)
752            .min_by_key(|(_, distance)| *distance)
753            .map(|(f, _)| f);
754
755        if let Some(suggested_field) = suggestion {
756            Self::UnknownField {
757                field: format!("{field} (did you mean '{suggested_field}'?)"),
758                type_name,
759            }
760        } else {
761            Self::UnknownField { field, type_name }
762        }
763    }
764
765    fn levenshtein_distance(s1: &str, s2: &str) -> usize {
766        let chars2: Vec<char> = s2.chars().collect();
767        let len2 = chars2.len();
768
769        // Two-row rolling buffer eliminates the nested `Vec<Vec<_>>` indexing.
770        // `prev[j]` = distance(s1[..i],   s2[..j])
771        // `curr[j]` = distance(s1[..i+1], s2[..j])
772        // Both rows have `len2 + 1` entries; iteration is bounded by their
773        // length, so every access uses `.get()?` with an `unreachable_or_zero`
774        // saturating fallback (provably unreachable in this control flow).
775        let mut prev: Vec<usize> = (0..=len2).collect();
776        let mut curr: Vec<usize> = vec![0; len2 + 1];
777
778        for (i, c1) in s1.chars().enumerate() {
779            // Initialise the leftmost column for this row.
780            // Reason: `curr` is sized `len2 + 1 >= 1`, so index 0 is always valid.
781            if let Some(slot) = curr.get_mut(0) {
782                *slot = i + 1;
783            }
784
785            for (j, &c2) in chars2.iter().enumerate() {
786                let cost = usize::from(c1 != c2);
787                // All four lookups read positions in `[0, len2]`, which are
788                // valid by construction (`prev`/`curr` both have len `len2+1`,
789                // and `j` ranges over `0..len2`). The `.get()` + `unwrap_or(0)`
790                // pattern keeps the function panic-free without changing the
791                // computed result.
792                let deletion = prev.get(j + 1).copied().unwrap_or(0).saturating_add(1);
793                let insertion = curr.get(j).copied().unwrap_or(0).saturating_add(1);
794                let substitution = prev.get(j).copied().unwrap_or(0).saturating_add(cost);
795                let value = deletion.min(insertion).min(substitution);
796                if let Some(slot) = curr.get_mut(j + 1) {
797                    *slot = value;
798                }
799            }
800
801            std::mem::swap(&mut prev, &mut curr);
802        }
803
804        // Final answer sits at `prev[len2]` after the last swap.
805        // Reason: `prev` is always sized `len2 + 1`, so this index is valid.
806        prev.get(len2).copied().unwrap_or(0)
807    }
808
809    /// Create a database error from PostgreSQL error code.
810    #[must_use]
811    pub fn from_postgres_code(code: &str, message: impl Into<String>) -> Self {
812        let message = message.into();
813        match code {
814            "42P01" => Self::Database {
815                message: "The table or view you're querying doesn't exist. \
816                          Check that the schema is compiled and the database is initialized."
817                    .to_string(),
818                sql_state: Some(code.to_string()),
819                constraint: None,
820            },
821            "42703" => Self::Database {
822                message: "A column referenced in the query doesn't exist in the table. \
823                          This may indicate the database schema is out of sync with the compiled schema."
824                    .to_string(),
825                sql_state: Some(code.to_string()),
826                constraint: None,
827            },
828            "23505" => Self::Conflict {
829                message: "A unique constraint was violated. This value already exists in the database.".to_string(),
830            },
831            "23503" => Self::Conflict {
832                message: "A foreign key constraint was violated. The referenced record doesn't exist."
833                    .to_string(),
834            },
835            "23502" => Self::Conflict {
836                message: "A NOT NULL constraint was violated. The field cannot be empty.".to_string(),
837            },
838            "22P02" => Self::Validation {
839                message: "Invalid input value. The provided value doesn't match the expected data type.".to_string(),
840                path: None,
841            },
842            _ => Self::Database {
843                message,
844                sql_state: Some(code.to_string()),
845                constraint: None,
846            },
847        }
848    }
849
850    /// Create a rate limit error with retry information.
851    #[must_use]
852    pub fn rate_limited_with_retry(retry_after_secs: u64) -> Self {
853        Self::RateLimited {
854            message: format!(
855                "Rate limit exceeded. Please try again in {retry_after_secs} seconds. \
856                 For permanent increases, contact support."
857            ),
858            retry_after_secs,
859        }
860    }
861
862    /// Create an authentication error with context.
863    #[must_use]
864    pub fn auth_error(reason: impl Into<String>) -> Self {
865        Self::Authentication {
866            message: reason.into(),
867        }
868    }
869}
870
871impl From<serde_json::Error> for FraiseQLError {
872    fn from(e: serde_json::Error) -> Self {
873        Self::Parse {
874            message:  e.to_string(),
875            location: format!("line {}, column {}", e.line(), e.column()),
876        }
877    }
878}
879
880impl From<std::io::Error> for FraiseQLError {
881    fn from(e: std::io::Error) -> Self {
882        Self::Internal {
883            message: format!("I/O error: {e}"),
884            source:  Some(Box::new(e)),
885        }
886    }
887}
888
889impl From<std::env::VarError> for FraiseQLError {
890    fn from(e: std::env::VarError) -> Self {
891        Self::Configuration {
892            message: format!("Environment variable error: {e}"),
893        }
894    }
895}
896
897/// Extension trait for adding context to errors.
898pub trait ErrorContext<T> {
899    /// Add context to an error.
900    ///
901    /// # Errors
902    ///
903    /// Returns `Err` if the original value was `Err`, wrapping it in an `Internal` error with the
904    /// given message.
905    fn context(self, message: impl Into<String>) -> Result<T>;
906
907    /// Add context lazily (only computed on error).
908    ///
909    /// # Errors
910    ///
911    /// Returns `Err` if the original value was `Err`, wrapping it in an `Internal` error with the
912    /// context message.
913    fn with_context<F, M>(self, f: F) -> Result<T>
914    where
915        F: FnOnce() -> M,
916        M: Into<String>;
917}
918
919impl<T, E: Into<FraiseQLError>> ErrorContext<T> for std::result::Result<T, E> {
920    fn context(self, message: impl Into<String>) -> Result<T> {
921        self.map_err(|e| {
922            let inner = e.into();
923            FraiseQLError::Internal {
924                message: format!("{}: {inner}", message.into()),
925                source:  None,
926            }
927        })
928    }
929
930    fn with_context<F, M>(self, f: F) -> Result<T>
931    where
932        F: FnOnce() -> M,
933        M: Into<String>,
934    {
935        self.map_err(|e| {
936            let inner = e.into();
937            FraiseQLError::Internal {
938                message: format!("{}: {inner}", f().into()),
939                source:  None,
940            }
941        })
942    }
943}
944
945/// A validation error for a specific field in an input object.
946#[derive(Debug, Clone, Serialize, Deserialize)]
947pub struct ValidationFieldError {
948    /// Path to the field that failed validation.
949    pub field:     String,
950    /// Type of validation rule that failed.
951    pub rule_type: String,
952    /// Human-readable error message.
953    pub message:   String,
954}
955
956impl ValidationFieldError {
957    /// Create a new validation field error.
958    #[must_use]
959    pub fn new(
960        field: impl Into<String>,
961        rule_type: impl Into<String>,
962        message: impl Into<String>,
963    ) -> Self {
964        Self {
965            field:     field.into(),
966            rule_type: rule_type.into(),
967            message:   message.into(),
968        }
969    }
970}
971
972impl std::fmt::Display for ValidationFieldError {
973    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
974        write!(f, "{} ({}): {}", self.field, self.rule_type, self.message)
975    }
976}
977
978#[cfg(test)]
979mod tests;