Skip to main content

type_bridge/
error.rs

1//! Error handling for the public TypeBridge client.
2
3use std::collections::BTreeMap;
4use std::error::Error as StdError;
5use std::fmt;
6
7use type_bridge_contract::diagnostic::{
8    Diagnostic, DiagnosticCategory, DiagnosticDetailValue, DiagnosticPathSegment,
9};
10use type_bridge_orm::match_request::{MatchError, MatchErrorCategory};
11
12/// Stable public classification for TypeBridge client failures.
13#[derive(Clone, Copy, Debug, Eq, PartialEq)]
14#[non_exhaustive]
15pub enum ErrorCategory {
16    /// Connection establishment or connectivity failed.
17    Connection,
18    /// Generated or installed schema authority failed verification.
19    Schema,
20    /// Generated input or provider evidence failed model validation.
21    ModelValidation,
22    /// A typed query was invalid before provider execution.
23    QueryAuthoring,
24    /// The provider failed while executing an accepted query.
25    QueryExecution,
26    /// A transaction lifecycle operation failed.
27    Transaction,
28    /// A remote envelope, reply, transport, or integrity contract failed.
29    Remote,
30    /// The selected provider or remote executor lacks a required capability.
31    Capability,
32    /// A canonical client, provider, or remote resource ceiling was exceeded.
33    ResourceLimit,
34    /// A requested entity or schema element was not found.
35    NotFound,
36    /// A generated-model lifecycle hook rejected or failed an operation.
37    Lifecycle,
38    /// An underlying database operation failed outside a narrower category.
39    Database,
40    /// A client invariant failed outside the stable categories above.
41    Other,
42}
43
44impl ErrorCategory {
45    /// Return the stable language-neutral category spelling.
46    #[must_use]
47    pub const fn as_str(self) -> &'static str {
48        match self {
49            Self::Connection => "connection",
50            Self::Schema => "schema",
51            Self::ModelValidation => "model_validation",
52            Self::QueryAuthoring => "query_authoring",
53            Self::QueryExecution => "query_execution",
54            Self::Transaction => "transaction",
55            Self::Remote => "remote",
56            Self::Capability => "capability",
57            Self::ResourceLimit => "resource_limit",
58            Self::NotFound => "not_found",
59            Self::Lifecycle => "lifecycle",
60            Self::Database => "database",
61            Self::Other => "other",
62        }
63    }
64}
65
66impl fmt::Display for ErrorCategory {
67    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
68        formatter.write_str(self.as_str())
69    }
70}
71
72/// Stage at which generated-model evidence failed validation.
73#[derive(Clone, Copy, Debug, Eq, PartialEq)]
74pub enum ModelValidationPhase {
75    /// Generated constructor input failed before provider execution.
76    Input,
77    /// Provider row evidence failed while hydrating a generated model.
78    Hydration,
79}
80
81/// One typed value from a structured engine or remote diagnostic.
82#[derive(Clone, Debug, Eq, PartialEq)]
83#[non_exhaustive]
84pub enum ErrorDetail {
85    /// Textual context.
86    Text(String),
87    /// A signed integer.
88    Long(i64),
89    /// A boolean fact.
90    Boolean(bool),
91    /// An ordered list of text values.
92    TextList(Vec<String>),
93}
94
95/// One typed segment from a structured engine or remote diagnostic path.
96#[derive(Clone, Debug, Eq, PartialEq)]
97#[non_exhaustive]
98pub enum ErrorPathSegment {
99    /// A named field in the rejected contract.
100    Field(String),
101    /// An indexed member in the rejected contract.
102    Index(u64),
103    /// A schema or query identifier.
104    Identifier(String),
105}
106
107/// Complete typed metadata supplied by one structured engine or remote
108/// diagnostic.
109#[derive(Clone, Debug, Eq, PartialEq)]
110pub struct ErrorDiagnostic {
111    path: Vec<ErrorPathSegment>,
112    details: BTreeMap<String, ErrorDetail>,
113}
114
115impl ErrorDiagnostic {
116    /// Return the typed diagnostic path.
117    #[must_use]
118    pub fn path(&self) -> &[ErrorPathSegment] {
119        &self.path
120    }
121
122    /// Return the deterministic typed detail map.
123    #[must_use]
124    pub fn details(&self) -> &BTreeMap<String, ErrorDetail> {
125        &self.details
126    }
127}
128
129/// Primary error type for the TypeBridge client SDK.
130#[derive(Debug, thiserror::Error)]
131#[non_exhaustive]
132pub enum Error {
133    /// Generated-model evidence did not match the installed schema projection.
134    #[error("Model validation failed during {phase:?}: {message}")]
135    ModelValidation {
136        /// Validation stage at which the evidence failed.
137        phase: ModelValidationPhase,
138        /// Stable language-neutral failure code.
139        code: String,
140        /// Canonical path to the rejected value or model evidence.
141        path: Vec<String>,
142        /// Human-readable failure summary.
143        message: String,
144        /// Optional underlying error that caused the validation failure.
145        #[source]
146        source: Option<Box<dyn StdError + Send + Sync + 'static>>,
147    },
148
149    /// A structured engine or remote-contract failure mapped into stable
150    /// client-owned categories, codes, and paths.
151    #[error("{category} error [{code}]: {message}")]
152    Classified {
153        /// Stable public error category.
154        category: ErrorCategory,
155        /// Model-validation phase when applicable to this failure.
156        phase: Option<ModelValidationPhase>,
157        /// Stable language-neutral failure code.
158        code: String,
159        /// Canonical flattened path to the rejected contract member.
160        path: Vec<String>,
161        /// Typed engine or remote diagnostic metadata, when supplied.
162        diagnostic: Option<Box<ErrorDiagnostic>>,
163        /// Human-readable failure summary.
164        message: String,
165        /// Optional underlying error that caused the classified failure.
166        #[source]
167        source: Option<Box<dyn StdError + Send + Sync + 'static>>,
168    },
169
170    /// Schema verification or installation failed.
171    #[error("Schema verification failed: {message}")]
172    SchemaVerification {
173        /// Human-readable schema verification failure summary.
174        message: String,
175        /// Optional underlying schema decoding or installation error.
176        #[source]
177        source: Option<Box<dyn StdError + Send + Sync + 'static>>,
178    },
179
180    /// Connection to the database failed.
181    #[error("Connection error: {message}")]
182    Connection {
183        /// Human-readable connection failure summary.
184        message: String,
185        /// Optional underlying transport or provider error.
186        #[source]
187        source: Option<Box<dyn StdError + Send + Sync + 'static>>,
188    },
189
190    /// Database query or operation failed.
191    #[error("Query execution error: {message}")]
192    QueryExecution {
193        /// Human-readable query execution failure summary.
194        message: String,
195        /// Optional underlying provider or remote execution error.
196        #[source]
197        source: Option<Box<dyn StdError + Send + Sync + 'static>>,
198    },
199
200    /// Database transaction failed.
201    #[error("Transaction error: {message}")]
202    Transaction {
203        /// Human-readable transaction failure summary.
204        message: String,
205        /// Optional underlying provider transaction error.
206        #[source]
207        source: Option<Box<dyn StdError + Send + Sync + 'static>>,
208    },
209
210    /// Requested schema element or database entity was not found.
211    #[error("Entity not found: {message}")]
212    NotFound {
213        /// Human-readable description of the missing resource.
214        message: String,
215        /// Optional underlying lookup error.
216        #[source]
217        source: Option<Box<dyn StdError + Send + Sync + 'static>>,
218    },
219
220    /// Underlying database error.
221    #[error("Database error: {message}")]
222    Database {
223        /// Human-readable database failure summary.
224        message: String,
225        /// Optional underlying database driver error.
226        #[source]
227        source: Option<Box<dyn StdError + Send + Sync + 'static>>,
228    },
229
230    /// Client request or execution error.
231    #[error("Client error: {message}")]
232    Other {
233        /// Human-readable client failure summary.
234        message: String,
235        /// Optional underlying error outside the narrower variants.
236        #[source]
237        source: Option<Box<dyn StdError + Send + Sync + 'static>>,
238    },
239}
240
241impl Error {
242    #[allow(dead_code)]
243    pub(crate) fn model_validation(
244        phase: ModelValidationPhase,
245        code: impl Into<String>,
246        path: Vec<String>,
247        message: impl Into<String>,
248        source: Option<Box<dyn StdError + Send + Sync + 'static>>,
249    ) -> Self {
250        Self::ModelValidation {
251            phase,
252            code: code.into(),
253            path,
254            message: message.into(),
255            source,
256        }
257    }
258
259    pub(crate) fn classified(
260        category: ErrorCategory,
261        phase: Option<ModelValidationPhase>,
262        code: impl Into<String>,
263        path: Vec<String>,
264        message: impl Into<String>,
265        source: Option<Box<dyn StdError + Send + Sync + 'static>>,
266    ) -> Self {
267        Self::classified_with_diagnostic(category, phase, code, path, None, message, source)
268    }
269
270    fn classified_with_diagnostic(
271        category: ErrorCategory,
272        phase: Option<ModelValidationPhase>,
273        code: impl Into<String>,
274        path: Vec<String>,
275        diagnostic: Option<ErrorDiagnostic>,
276        message: impl Into<String>,
277        source: Option<Box<dyn StdError + Send + Sync + 'static>>,
278    ) -> Self {
279        Self::Classified {
280            category,
281            phase,
282            code: code.into(),
283            path,
284            diagnostic: diagnostic.map(Box::new),
285            message: message.into(),
286            source,
287        }
288    }
289
290    /// Construct one application-owned remote transport failure.
291    ///
292    /// Transport implementations should use a stable lowercase snake-case
293    /// code so callers can handle the failure without parsing its message.
294    #[must_use]
295    pub fn remote(
296        code: impl Into<String>,
297        message: impl Into<String>,
298        source: Option<Box<dyn StdError + Send + Sync + 'static>>,
299    ) -> Self {
300        Self::classified(
301            ErrorCategory::Remote,
302            None,
303            code,
304            Vec::new(),
305            message,
306            source,
307        )
308    }
309
310    pub(crate) fn from_match(error: MatchError, phase: ModelValidationPhase) -> Self {
311        let category = match error.category() {
312            MatchErrorCategory::InvalidPlan => ErrorCategory::QueryAuthoring,
313            MatchErrorCategory::Cardinality | MatchErrorCategory::ResultDecode => {
314                ErrorCategory::ModelValidation
315            }
316            MatchErrorCategory::UnsupportedCapability => ErrorCategory::Capability,
317            MatchErrorCategory::StaleSchema => ErrorCategory::Schema,
318            MatchErrorCategory::ResourceLimit => ErrorCategory::ResourceLimit,
319            MatchErrorCategory::Provider => ErrorCategory::QueryExecution,
320        };
321        let model_phase = (category == ErrorCategory::ModelValidation).then_some(phase);
322        let code = error.code().as_str().to_owned();
323        let path = error
324            .path()
325            .segments()
326            .iter()
327            .map(ToString::to_string)
328            .collect();
329        let message = error.message().to_owned();
330        Self::classified(
331            category,
332            model_phase,
333            code,
334            path,
335            message,
336            Some(Box::new(error)),
337        )
338    }
339
340    pub(crate) fn from_remote_diagnostic(error: Diagnostic) -> Self {
341        let category = match error.category() {
342            DiagnosticCategory::UnsupportedCapability => ErrorCategory::Capability,
343            DiagnosticCategory::ResourceLimit => ErrorCategory::ResourceLimit,
344            DiagnosticCategory::InvalidContract | DiagnosticCategory::Integrity => {
345                ErrorCategory::Remote
346            }
347        };
348        let code = error.code().as_str().to_owned();
349        let path = error
350            .path()
351            .segments()
352            .iter()
353            .map(|segment| match segment {
354                DiagnosticPathSegment::Field(value) => value.clone(),
355                DiagnosticPathSegment::Index(value) => format!("[{value}]"),
356                DiagnosticPathSegment::Identifier(value) => value.clone(),
357            })
358            .collect();
359        let diagnostic_path = error
360            .path()
361            .segments()
362            .iter()
363            .map(|segment| match segment {
364                DiagnosticPathSegment::Field(value) => ErrorPathSegment::Field(value.clone()),
365                DiagnosticPathSegment::Index(value) => ErrorPathSegment::Index(*value),
366                DiagnosticPathSegment::Identifier(value) => {
367                    ErrorPathSegment::Identifier(value.clone())
368                }
369            })
370            .collect();
371        let details = error
372            .details()
373            .iter()
374            .map(|(key, value)| {
375                let value = match value {
376                    DiagnosticDetailValue::Text(value) => ErrorDetail::Text(value.clone()),
377                    DiagnosticDetailValue::Long(value) => ErrorDetail::Long(*value),
378                    DiagnosticDetailValue::Boolean(value) => ErrorDetail::Boolean(*value),
379                    DiagnosticDetailValue::TextList(value) => ErrorDetail::TextList(value.clone()),
380                };
381                (key.clone(), value)
382            })
383            .collect();
384        let message = error.message().to_owned();
385        Self::classified_with_diagnostic(
386            category,
387            None,
388            code,
389            path,
390            Some(ErrorDiagnostic {
391                path: diagnostic_path,
392                details,
393            }),
394            message,
395            Some(Box::new(error)),
396        )
397    }
398
399    pub(crate) fn from_hook(error: crate::hooks::HookError) -> Self {
400        let code = match error {
401            crate::hooks::HookError::Rejected { .. } => "lifecycle_hook_rejected",
402            crate::hooks::HookError::Internal { .. } => "lifecycle_hook_failed",
403        };
404        Self::classified(
405            ErrorCategory::Lifecycle,
406            None,
407            code,
408            Vec::new(),
409            error.to_string(),
410            Some(Box::new(error)),
411        )
412    }
413
414    #[allow(dead_code)]
415    pub(crate) fn from_orm(err: type_bridge_orm::OrmError) -> Self {
416        match err {
417            type_bridge_orm::OrmError::Match(error) => {
418                Self::from_match(error, ModelValidationPhase::Input)
419            }
420            error @ type_bridge_orm::OrmError::Connection(_) => Self::Connection {
421                message: error.to_string(),
422                source: Some(Box::new(error)),
423            },
424            error @ type_bridge_orm::OrmError::QueryExecution(_) => Self::QueryExecution {
425                message: error.to_string(),
426                source: Some(Box::new(error)),
427            },
428            error @ type_bridge_orm::OrmError::Transaction(_) => Self::Transaction {
429                message: error.to_string(),
430                source: Some(Box::new(error)),
431            },
432            error @ type_bridge_orm::OrmError::NotFound(_) => Self::NotFound {
433                message: error.to_string(),
434                source: Some(Box::new(error)),
435            },
436            error @ type_bridge_orm::OrmError::Hydration { .. } => Self::ModelValidation {
437                phase: ModelValidationPhase::Hydration,
438                code: "invalid_provider_evidence".into(),
439                path: vec![],
440                message: error.to_string(),
441                source: Some(Box::new(error)),
442            },
443            error => Self::Database {
444                message: error.to_string(),
445                source: Some(Box::new(error)),
446            },
447        }
448    }
449
450    pub(crate) fn from_orm_hydration(err: type_bridge_orm::OrmError) -> Self {
451        match err {
452            type_bridge_orm::OrmError::Match(error) => {
453                Self::from_match(error, ModelValidationPhase::Hydration)
454            }
455            error => Self::from_orm(error),
456        }
457    }
458
459    /// Return the stable public failure category.
460    #[must_use]
461    pub const fn category(&self) -> ErrorCategory {
462        match self {
463            Self::ModelValidation { .. } => ErrorCategory::ModelValidation,
464            Self::Classified { category, .. } => *category,
465            Self::SchemaVerification { .. } => ErrorCategory::Schema,
466            Self::Connection { .. } => ErrorCategory::Connection,
467            Self::QueryExecution { .. } => ErrorCategory::QueryExecution,
468            Self::Transaction { .. } => ErrorCategory::Transaction,
469            Self::NotFound { .. } => ErrorCategory::NotFound,
470            Self::Database { .. } => ErrorCategory::Database,
471            Self::Other { .. } => ErrorCategory::Other,
472        }
473    }
474
475    /// Return the error message string.
476    #[must_use]
477    pub fn message(&self) -> &str {
478        match self {
479            Self::ModelValidation { message, .. }
480            | Self::Classified { message, .. }
481            | Self::SchemaVerification { message, .. }
482            | Self::Connection { message, .. }
483            | Self::QueryExecution { message, .. }
484            | Self::Transaction { message, .. }
485            | Self::NotFound { message, .. }
486            | Self::Database { message, .. }
487            | Self::Other { message, .. } => message,
488        }
489    }
490
491    /// Return the stable machine-readable failure code, when available.
492    #[must_use]
493    pub fn code(&self) -> Option<&str> {
494        match self {
495            Self::ModelValidation { code, .. } | Self::Classified { code, .. } => Some(code),
496            _ => None,
497        }
498    }
499
500    /// Return the owned structured diagnostic path, when available.
501    #[must_use]
502    pub fn path(&self) -> Option<&[String]> {
503        match self {
504            Self::ModelValidation { path, .. } | Self::Classified { path, .. } => Some(path),
505            _ => None,
506        }
507    }
508
509    /// Return the typed diagnostic path, when the source supplied one.
510    ///
511    /// [`Self::path`] remains available as the compatibility-oriented textual
512    /// projection of the same path.
513    #[must_use]
514    pub fn diagnostic_path(&self) -> Option<&[ErrorPathSegment]> {
515        match self {
516            Self::Classified { diagnostic, .. } => diagnostic.as_deref().map(ErrorDiagnostic::path),
517            _ => None,
518        }
519    }
520
521    /// Return deterministic typed diagnostic details, when available.
522    #[must_use]
523    pub fn details(&self) -> Option<&BTreeMap<String, ErrorDetail>> {
524        match self {
525            Self::Classified { diagnostic, .. } => {
526                diagnostic.as_deref().map(ErrorDiagnostic::details)
527            }
528            _ => None,
529        }
530    }
531
532    /// Return the model-validation phase, when applicable.
533    #[must_use]
534    pub const fn model_validation_phase(&self) -> Option<ModelValidationPhase> {
535        match self {
536            Self::ModelValidation { phase, .. } => Some(*phase),
537            Self::Classified { phase, .. } => *phase,
538            _ => None,
539        }
540    }
541}
542
543/// Convenience Result type for the TypeBridge client.
544pub type Result<T, E = Error> = std::result::Result<T, E>;
545
546#[cfg(test)]
547mod tests {
548    use super::{Error, ErrorCategory};
549
550    #[test]
551    fn public_error_categories_and_remote_constructor_are_stable() {
552        let categories = [
553            (ErrorCategory::Connection, "connection"),
554            (ErrorCategory::Schema, "schema"),
555            (ErrorCategory::ModelValidation, "model_validation"),
556            (ErrorCategory::QueryAuthoring, "query_authoring"),
557            (ErrorCategory::QueryExecution, "query_execution"),
558            (ErrorCategory::Transaction, "transaction"),
559            (ErrorCategory::Remote, "remote"),
560            (ErrorCategory::Capability, "capability"),
561            (ErrorCategory::ResourceLimit, "resource_limit"),
562            (ErrorCategory::NotFound, "not_found"),
563            (ErrorCategory::Lifecycle, "lifecycle"),
564            (ErrorCategory::Database, "database"),
565            (ErrorCategory::Other, "other"),
566        ];
567        for (category, spelling) in categories {
568            assert_eq!(category.as_str(), spelling);
569            assert_eq!(category.to_string(), spelling);
570        }
571
572        let error = Error::remote("remote_transport", "connection reset", None);
573        assert_eq!(error.category(), ErrorCategory::Remote);
574        assert_eq!(error.code(), Some("remote_transport"));
575        assert_eq!(error.path(), Some(&[][..]));
576        assert_eq!(error.message(), "connection reset");
577    }
578}