Skip to main content

mongreldb_kit/
error.rs

1//! Error model for `mongreldb-kit`.
2//!
3//! Storage errors from MongrelDB core and validation errors from the core model
4//! are folded into a small, stable set of categories so consumers can handle
5//! failures without depending on internal crate details.
6
7use thiserror::Error;
8
9pub type Result<T> = std::result::Result<T, KitError>;
10
11#[derive(Debug, Clone, Default, PartialEq, Eq)]
12pub struct QueryErrorMetadata {
13    pub cancel_outcome: Option<String>,
14    pub cancellation_reason: Option<String>,
15    pub retryable: Option<bool>,
16    pub server_state: Option<String>,
17}
18
19/// Durable and statement-level execution facts shared by structured SQL errors.
20/// Boxed inside [`KitError`] so ordinary `Result<T>` values stay small enough
21/// for efficient propagation.
22#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
23pub struct QueryExecutionOutcome {
24    pub committed: bool,
25    pub committed_statements: Option<usize>,
26    pub last_commit_epoch: Option<u64>,
27    pub first_commit_statement_index: Option<usize>,
28    pub last_commit_statement_index: Option<usize>,
29    pub completed_statements: usize,
30    pub statement_index: usize,
31}
32
33/// A storage/transaction error in kit terminology.
34#[derive(Debug, Error, Clone, PartialEq)]
35pub enum KitError {
36    #[error("validation error: {0}")]
37    Validation(String),
38    #[error("duplicate key: {0}")]
39    Duplicate(String),
40    #[error("foreign key violation: {0}")]
41    ForeignKey(String),
42    #[error("restrict violation: {0}")]
43    Restrict(String),
44    #[error("migration error: {0}")]
45    Migration(String),
46    #[error("conflict: {0}")]
47    Conflict(String),
48    #[error("trigger validation error: {0}")]
49    TriggerValidation(String),
50    #[error("storage error: {0}")]
51    Storage(String),
52    #[error("database locked: {0}")]
53    DatabaseLocked(String),
54    #[error("integrity error: {0}")]
55    Integrity(String),
56    #[error("authentication required: {0}")]
57    AuthRequired(String),
58    #[error("authentication not required: {0}")]
59    AuthNotRequired(String),
60    #[error("invalid credentials: {0}")]
61    InvalidCredentials(String),
62    #[error("permission denied: {0}")]
63    PermissionDenied(String),
64    #[error("query {query_id} cancelled: {reason}")]
65    Cancelled {
66        query_id: Box<str>,
67        reason: Box<str>,
68        outcome: Box<QueryExecutionOutcome>,
69        metadata: Box<QueryErrorMetadata>,
70    },
71    #[error("query {query_id} deadline exceeded")]
72    DeadlineExceeded {
73        query_id: Box<str>,
74        timeout_ms: Option<u64>,
75        outcome: Box<QueryExecutionOutcome>,
76        metadata: Box<QueryErrorMetadata>,
77    },
78    #[error("query id conflict: {query_id}")]
79    QueryConflict {
80        query_id: String,
81        metadata: Box<QueryErrorMetadata>,
82    },
83    #[error("SQL query registry full: {message}")]
84    QueryRegistryFull {
85        query_id: Option<String>,
86        message: Box<str>,
87        metadata: Box<QueryErrorMetadata>,
88    },
89    #[error("query {query_id} commit outcome {code}: {message}")]
90    CommitOutcome {
91        query_id: String,
92        code: Box<str>,
93        outcome: Box<QueryExecutionOutcome>,
94        message: Box<str>,
95        metadata: Box<QueryErrorMetadata>,
96    },
97    #[error("query {query_id} failed ({code}): {message}")]
98    QueryFailed {
99        query_id: String,
100        code: Box<str>,
101        outcome: Box<QueryExecutionOutcome>,
102        message: Box<str>,
103        metadata: Box<QueryErrorMetadata>,
104    },
105    #[error("remote protocol error {code} (HTTP {status}): {message}")]
106    RemoteProtocol {
107        status: u16,
108        code: Box<str>,
109        query_id: Option<String>,
110        message: Box<str>,
111        metadata: Box<QueryErrorMetadata>,
112    },
113    #[error("query {query_id:?} result limit exceeded: {message}")]
114    ResultLimitExceeded {
115        query_id: Option<Box<str>>,
116        max_rows: Option<Box<usize>>,
117        max_bytes: Option<Box<usize>>,
118        outcome: Box<QueryExecutionOutcome>,
119        message: Box<str>,
120        metadata: Box<QueryErrorMetadata>,
121    },
122    #[error("query {query_id:?} serialization failed: {message}")]
123    SerializationFailed {
124        query_id: Option<String>,
125        outcome: Box<QueryExecutionOutcome>,
126        message: Box<str>,
127        metadata: Box<QueryErrorMetadata>,
128    },
129    #[error("query {query_id} outcome unknown: {message}")]
130    OutcomeUnknown {
131        query_id: String,
132        message: String,
133        metadata: Box<QueryErrorMetadata>,
134    },
135    #[error("transaction aborted: {message}")]
136    TransactionAborted {
137        query_id: Option<String>,
138        message: String,
139        metadata: Box<QueryErrorMetadata>,
140    },
141    #[error("unsupported feature: {0}")]
142    Unsupported(String),
143    #[error("capability unsupported: {0}")]
144    CapabilityUnsupported(String),
145    #[error("transport error for query {query_id}: {message}")]
146    Transport {
147        query_id: String,
148        message: String,
149        metadata: Box<QueryErrorMetadata>,
150    },
151}
152
153impl KitError {
154    pub fn query_outcome(&self) -> Option<&QueryExecutionOutcome> {
155        match self {
156            Self::Cancelled { outcome, .. }
157            | Self::DeadlineExceeded { outcome, .. }
158            | Self::CommitOutcome { outcome, .. }
159            | Self::QueryFailed { outcome, .. }
160            | Self::ResultLimitExceeded { outcome, .. }
161            | Self::SerializationFailed { outcome, .. } => Some(outcome),
162            _ => None,
163        }
164    }
165
166    pub fn query_metadata(&self) -> Option<&QueryErrorMetadata> {
167        match self {
168            Self::Cancelled { metadata, .. }
169            | Self::DeadlineExceeded { metadata, .. }
170            | Self::QueryConflict { metadata, .. }
171            | Self::QueryRegistryFull { metadata, .. }
172            | Self::CommitOutcome { metadata, .. }
173            | Self::QueryFailed { metadata, .. }
174            | Self::RemoteProtocol { metadata, .. }
175            | Self::ResultLimitExceeded { metadata, .. }
176            | Self::SerializationFailed { metadata, .. }
177            | Self::OutcomeUnknown { metadata, .. }
178            | Self::TransactionAborted { metadata, .. }
179            | Self::Transport { metadata, .. } => Some(metadata),
180            _ => None,
181        }
182    }
183
184    fn query_metadata_mut(&mut self) -> Option<&mut QueryErrorMetadata> {
185        match self {
186            Self::Cancelled { metadata, .. }
187            | Self::DeadlineExceeded { metadata, .. }
188            | Self::QueryConflict { metadata, .. }
189            | Self::QueryRegistryFull { metadata, .. }
190            | Self::CommitOutcome { metadata, .. }
191            | Self::QueryFailed { metadata, .. }
192            | Self::RemoteProtocol { metadata, .. }
193            | Self::ResultLimitExceeded { metadata, .. }
194            | Self::SerializationFailed { metadata, .. }
195            | Self::OutcomeUnknown { metadata, .. }
196            | Self::TransactionAborted { metadata, .. }
197            | Self::Transport { metadata, .. } => Some(metadata),
198            _ => None,
199        }
200    }
201}
202
203pub(crate) fn boxed_query_metadata(
204    cancel_outcome: Option<&str>,
205    cancellation_reason: Option<&str>,
206    retryable: Option<bool>,
207    server_state: Option<&str>,
208) -> Box<QueryErrorMetadata> {
209    Box::new(QueryErrorMetadata {
210        cancel_outcome: cancel_outcome.map(str::to_owned),
211        cancellation_reason: cancellation_reason.map(str::to_owned),
212        retryable,
213        server_state: server_state.map(str::to_owned),
214    })
215}
216
217impl From<std::io::Error> for KitError {
218    fn from(e: std::io::Error) -> Self {
219        KitError::Storage(e.to_string())
220    }
221}
222
223impl From<mongreldb_core::MongrelError> for KitError {
224    fn from(e: mongreldb_core::MongrelError) -> Self {
225        use mongreldb_core::MongrelError;
226        match e {
227            MongrelError::TriggerValidation(msg) => KitError::TriggerValidation(msg),
228            MongrelError::Conflict(msg) => KitError::Conflict(msg),
229            MongrelError::InvalidArgument(msg) => KitError::Validation(msg),
230            MongrelError::Schema(msg) => KitError::Validation(msg),
231            MongrelError::ColumnNotFound(msg) => KitError::Integrity(msg),
232            MongrelError::NotFound(msg) => KitError::Integrity(msg),
233            MongrelError::Io(e) => KitError::Storage(e.to_string()),
234            MongrelError::DatabaseLocked { .. } => KitError::DatabaseLocked(e.to_string()),
235            MongrelError::Serialization(e) => KitError::Storage(e.to_string()),
236            MongrelError::ChecksumMismatch { .. }
237            | MongrelError::MagicMismatch { .. }
238            | MongrelError::CorruptWal { .. }
239            | MongrelError::TornWrite { .. } => KitError::Integrity(e.to_string()),
240            MongrelError::EncryptionDisabled
241            | MongrelError::Encryption(_)
242            | MongrelError::Decryption(_) => KitError::Integrity(e.to_string()),
243            MongrelError::Full(msg) => KitError::Storage(msg),
244            MongrelError::Other(msg) => KitError::Storage(msg),
245            MongrelError::AuthRequired => KitError::AuthRequired(e.to_string()),
246            MongrelError::AuthNotRequired => KitError::AuthNotRequired(e.to_string()),
247            MongrelError::InvalidCredentials { username } => KitError::InvalidCredentials(username),
248            MongrelError::PermissionDenied {
249                required,
250                principal,
251            } => KitError::PermissionDenied(format!("{principal} lacks {required}")),
252            MongrelError::DurableCommit { epoch, message } => KitError::CommitOutcome {
253                query_id: "unknown".into(),
254                code: "COMMIT_OUTCOME".into(),
255                outcome: Box::new(QueryExecutionOutcome {
256                    committed: true,
257                    committed_statements: None,
258                    last_commit_epoch: Some(epoch),
259                    first_commit_statement_index: None,
260                    last_commit_statement_index: None,
261                    completed_statements: 0,
262                    statement_index: 0,
263                }),
264                message: message.into_boxed_str(),
265                metadata: boxed_query_metadata(None, None, Some(false), None),
266            },
267            MongrelError::CommitOutcomeUnknown { message, .. } => KitError::OutcomeUnknown {
268                query_id: "unknown".into(),
269                message,
270                metadata: boxed_query_metadata(None, None, Some(false), None),
271            },
272            _ => KitError::Storage(e.to_string()),
273        }
274    }
275}
276
277impl From<mongreldb_kit_core::schema::SchemaError> for KitError {
278    fn from(e: mongreldb_kit_core::schema::SchemaError) -> Self {
279        KitError::Validation(e.to_string())
280    }
281}
282
283impl From<mongreldb_kit_core::validation::ValidationError> for KitError {
284    fn from(e: mongreldb_kit_core::validation::ValidationError) -> Self {
285        KitError::Validation(e.to_string())
286    }
287}
288
289impl From<mongreldb_kit_core::planner::PlannerError> for KitError {
290    fn from(e: mongreldb_kit_core::planner::PlannerError) -> Self {
291        match e {
292            mongreldb_kit_core::planner::PlannerError::TableNotFound(msg) => {
293                KitError::Integrity(msg)
294            }
295            mongreldb_kit_core::planner::PlannerError::CircularDelete(msg) => {
296                KitError::Restrict(msg)
297            }
298        }
299    }
300}
301
302impl From<serde_json::Error> for KitError {
303    fn from(e: serde_json::Error) -> Self {
304        KitError::Storage(e.to_string())
305    }
306}
307
308impl From<mongreldb_query::MongrelQueryError> for KitError {
309    fn from(e: mongreldb_query::MongrelQueryError) -> Self {
310        use mongreldb_query::MongrelQueryError;
311        match e {
312            // Core errors carry the engine's declarative constraint failures
313            // (unique / FK / check / conflict) — route them through the same
314            // mapping as direct core errors so callers see the right category.
315            MongrelQueryError::Core(core) => KitError::from(core),
316            MongrelQueryError::Schema(msg) => KitError::Validation(msg),
317            MongrelQueryError::Arrow(msg) | MongrelQueryError::DataFusion(msg) => {
318                KitError::Storage(msg)
319            }
320            MongrelQueryError::QueryCancelled {
321                query_id,
322                reason,
323                committed,
324                committed_statements,
325                last_commit_epoch,
326                first_commit_statement_index,
327                last_commit_statement_index,
328                completed_statements,
329                cancelled_statement_index,
330            } => KitError::Cancelled {
331                query_id: query_id.to_string().into_boxed_str(),
332                reason: cancellation_reason_name(reason).into(),
333                outcome: Box::new(QueryExecutionOutcome {
334                    committed,
335                    committed_statements: Some(committed_statements),
336                    last_commit_epoch,
337                    first_commit_statement_index,
338                    last_commit_statement_index,
339                    completed_statements,
340                    statement_index: cancelled_statement_index,
341                }),
342                metadata: boxed_query_metadata(
343                    None,
344                    Some(cancellation_reason_name(reason)),
345                    Some(false),
346                    None,
347                ),
348            },
349            MongrelQueryError::DeadlineExceeded {
350                query_id,
351                timeout_ms,
352                committed,
353                committed_statements,
354                last_commit_epoch,
355                first_commit_statement_index,
356                last_commit_statement_index,
357                completed_statements,
358                cancelled_statement_index,
359            } => KitError::DeadlineExceeded {
360                query_id: query_id.to_string().into_boxed_str(),
361                timeout_ms,
362                outcome: Box::new(QueryExecutionOutcome {
363                    committed,
364                    committed_statements: Some(committed_statements),
365                    last_commit_epoch,
366                    first_commit_statement_index,
367                    last_commit_statement_index,
368                    completed_statements,
369                    statement_index: cancelled_statement_index,
370                }),
371                metadata: boxed_query_metadata(None, Some("deadline"), Some(false), None),
372            },
373            MongrelQueryError::QueryIdConflict { query_id } => KitError::QueryConflict {
374                query_id: query_id.to_string(),
375                metadata: boxed_query_metadata(None, None, Some(false), None),
376            },
377            MongrelQueryError::QueryRegistryFull => KitError::QueryRegistryFull {
378                query_id: None,
379                message: "SQL query registry is full".into(),
380                metadata: boxed_query_metadata(None, None, Some(true), None),
381            },
382            MongrelQueryError::CommitOutcome {
383                query_id,
384                committed,
385                committed_statements,
386                last_commit_epoch,
387                first_commit_statement_index,
388                last_commit_statement_index,
389                completed_statements,
390                statement_index,
391                message,
392            } => KitError::CommitOutcome {
393                query_id: query_id.to_string(),
394                code: "COMMIT_OUTCOME".into(),
395                outcome: Box::new(QueryExecutionOutcome {
396                    committed,
397                    committed_statements: Some(committed_statements),
398                    last_commit_epoch,
399                    first_commit_statement_index,
400                    last_commit_statement_index,
401                    completed_statements,
402                    statement_index,
403                }),
404                message: message.into_boxed_str(),
405                metadata: boxed_query_metadata(None, None, Some(false), None),
406            },
407            MongrelQueryError::ResultLimitExceeded {
408                query_id,
409                committed,
410                committed_statements,
411                last_commit_epoch,
412                first_commit_statement_index,
413                last_commit_statement_index,
414                completed_statements,
415                statement_index,
416                message,
417            } => KitError::ResultLimitExceeded {
418                query_id: Some(query_id.to_string().into_boxed_str()),
419                max_rows: None,
420                max_bytes: None,
421                outcome: Box::new(QueryExecutionOutcome {
422                    committed,
423                    committed_statements: Some(committed_statements),
424                    last_commit_epoch,
425                    first_commit_statement_index,
426                    last_commit_statement_index,
427                    completed_statements,
428                    statement_index,
429                }),
430                message: message.into_boxed_str(),
431                metadata: boxed_query_metadata(None, None, Some(false), None),
432            },
433            MongrelQueryError::OutcomeUnknown { query_id, message } => KitError::OutcomeUnknown {
434                query_id: query_id.to_string(),
435                message,
436                metadata: boxed_query_metadata(None, None, Some(false), None),
437            },
438            MongrelQueryError::TransactionAborted => KitError::TransactionAborted {
439                query_id: None,
440                message: "ROLLBACK or ROLLBACK TO SAVEPOINT is required".into(),
441                metadata: boxed_query_metadata(None, None, Some(false), None),
442            },
443            _ => KitError::Storage(e.to_string()),
444        }
445    }
446}
447
448fn cancellation_reason_name(reason: mongreldb_core::CancellationReason) -> &'static str {
449    use mongreldb_core::CancellationReason;
450    match reason {
451        CancellationReason::None => "none",
452        CancellationReason::ClientRequest => "client_request",
453        CancellationReason::Deadline => "deadline",
454        CancellationReason::ClientDisconnected => "client_disconnected",
455        CancellationReason::SessionClosed => "session_closed",
456        CancellationReason::ServerShutdown => "server_shutdown",
457    }
458}
459
460fn query_phase_name(phase: mongreldb_query::SqlQueryPhase) -> &'static str {
461    use mongreldb_query::SqlQueryPhase;
462    match phase {
463        SqlQueryPhase::Queued => "queued",
464        SqlQueryPhase::Planning => "planning",
465        SqlQueryPhase::Executing => "executing",
466        SqlQueryPhase::Streaming => "streaming",
467        SqlQueryPhase::Serializing => "serializing",
468        SqlQueryPhase::CommitCritical => "commit_critical",
469        SqlQueryPhase::Cancelling => "cancelling",
470        SqlQueryPhase::Completed => "completed",
471        SqlQueryPhase::Failed => "failed",
472        SqlQueryPhase::Cancelled => "cancelled",
473    }
474}
475
476fn execution_outcome_from_status(status: &mongreldb_query::QueryStatus) -> QueryExecutionOutcome {
477    QueryExecutionOutcome {
478        committed: status.durable_outcome.committed,
479        committed_statements: Some(status.durable_outcome.committed_statements),
480        last_commit_epoch: status.durable_outcome.last_commit_epoch,
481        first_commit_statement_index: status.durable_outcome.first_commit_statement_index,
482        last_commit_statement_index: status.durable_outcome.last_commit_statement_index,
483        completed_statements: status.completed_statements,
484        statement_index: status.statement_index,
485    }
486}
487
488fn merge_execution_outcome(
489    mut outcome: QueryExecutionOutcome,
490    status: Option<&mongreldb_query::QueryStatus>,
491) -> QueryExecutionOutcome {
492    let Some(status) = status else {
493        return outcome;
494    };
495    let current = execution_outcome_from_status(status);
496    outcome.committed |= current.committed;
497    outcome.committed_statements =
498        merge_count(outcome.committed_statements, current.committed_statements);
499    outcome.last_commit_epoch = current.last_commit_epoch.or(outcome.last_commit_epoch);
500    outcome.first_commit_statement_index = current
501        .first_commit_statement_index
502        .or(outcome.first_commit_statement_index);
503    outcome.last_commit_statement_index = current
504        .last_commit_statement_index
505        .or(outcome.last_commit_statement_index);
506    outcome.completed_statements = current.completed_statements;
507    outcome.statement_index = current.statement_index;
508    outcome
509}
510
511pub(crate) fn query_error_with_status(
512    error: mongreldb_query::MongrelQueryError,
513    status: Option<&mongreldb_query::QueryStatus>,
514) -> KitError {
515    let committed = status.is_some_and(|status| status.durable_outcome.committed);
516    let query_id = status.map(|status| status.query_id.to_string());
517    let mut error = KitError::from(error);
518    if let (Some(metadata), Some(status)) = (error.query_metadata_mut(), status) {
519        metadata
520            .cancellation_reason
521            .get_or_insert_with(|| cancellation_reason_name(status.cancellation_reason).into());
522        metadata
523            .server_state
524            .get_or_insert_with(|| query_phase_name(status.phase).into());
525    }
526    if status.is_some_and(|status| status.outcome_unknown) {
527        return KitError::OutcomeUnknown {
528            query_id: query_id.unwrap_or_else(|| "unknown".into()),
529            message: error.to_string(),
530            metadata: status.map_or_else(
531                || boxed_query_metadata(None, None, Some(false), None),
532                |status| {
533                    boxed_query_metadata(
534                        None,
535                        Some(cancellation_reason_name(status.cancellation_reason)),
536                        Some(false),
537                        Some(query_phase_name(status.phase)),
538                    )
539                },
540            ),
541        };
542    }
543    match error {
544        KitError::Cancelled {
545            query_id,
546            reason,
547            outcome,
548            metadata,
549        } => KitError::Cancelled {
550            query_id,
551            reason,
552            outcome: Box::new(merge_execution_outcome(*outcome, status)),
553            metadata,
554        },
555        KitError::DeadlineExceeded {
556            query_id,
557            timeout_ms,
558            outcome,
559            metadata,
560        } => KitError::DeadlineExceeded {
561            query_id,
562            timeout_ms,
563            outcome: Box::new(merge_execution_outcome(*outcome, status)),
564            metadata,
565        },
566        KitError::CommitOutcome {
567            query_id: error_query_id,
568            code,
569            outcome,
570            message,
571            metadata,
572        } => KitError::CommitOutcome {
573            query_id: if error_query_id == "unknown" {
574                query_id.unwrap_or(error_query_id)
575            } else {
576                error_query_id
577            },
578            code,
579            outcome: Box::new(merge_execution_outcome(*outcome, status)),
580            message,
581            metadata,
582        },
583        KitError::ResultLimitExceeded {
584            query_id: error_query_id,
585            max_rows,
586            max_bytes,
587            outcome,
588            message,
589            metadata,
590        } => KitError::ResultLimitExceeded {
591            query_id: error_query_id.or_else(|| query_id.map(String::into_boxed_str)),
592            max_rows,
593            max_bytes,
594            outcome: Box::new(merge_execution_outcome(*outcome, status)),
595            message,
596            metadata,
597        },
598        KitError::SerializationFailed {
599            query_id: error_query_id,
600            outcome,
601            message,
602            metadata,
603        } => KitError::SerializationFailed {
604            query_id: error_query_id.or(query_id),
605            outcome: Box::new(merge_execution_outcome(*outcome, status)),
606            message,
607            metadata,
608        },
609        KitError::TransactionAborted {
610            query_id: error_query_id,
611            message,
612            metadata,
613        } => KitError::TransactionAborted {
614            query_id: error_query_id.or(query_id),
615            message,
616            metadata,
617        },
618        KitError::OutcomeUnknown {
619            query_id: error_query_id,
620            message,
621            metadata,
622        } => KitError::OutcomeUnknown {
623            query_id: if error_query_id == "unknown" {
624                query_id.unwrap_or(error_query_id)
625            } else {
626                error_query_id
627            },
628            message,
629            metadata,
630        },
631        error if committed => KitError::CommitOutcome {
632            query_id: query_id.unwrap_or_else(|| "unknown".into()),
633            code: "COMMIT_OUTCOME".into(),
634            outcome: Box::new(
635                status
636                    .map(execution_outcome_from_status)
637                    .unwrap_or_default(),
638            ),
639            message: error.to_string().into_boxed_str(),
640            metadata: status.map_or_else(
641                || boxed_query_metadata(None, None, Some(false), None),
642                |status| {
643                    boxed_query_metadata(
644                        None,
645                        Some(cancellation_reason_name(status.cancellation_reason)),
646                        Some(false),
647                        Some(query_phase_name(status.phase)),
648                    )
649                },
650            ),
651        },
652        error => error,
653    }
654}
655
656fn merge_count(left: Option<usize>, right: Option<usize>) -> Option<usize> {
657    match (left, right) {
658        (Some(left), Some(right)) => Some(left.max(right)),
659        (left, right) => left.or(right),
660    }
661}
662
663#[cfg(test)]
664mod tests {
665    use super::{query_error_with_status, KitError};
666
667    #[test]
668    fn maps_trigger_core_errors_without_message_parsing() {
669        let trigger = KitError::from(mongreldb_core::MongrelError::TriggerValidation(
670            "trigger raised".into(),
671        ));
672        assert_eq!(
673            trigger,
674            KitError::TriggerValidation("trigger raised".into())
675        );
676
677        let conflict = KitError::from(mongreldb_core::MongrelError::Conflict(
678            "unrelated conflict contains trigger wording".into(),
679        ));
680        assert_eq!(
681            conflict,
682            KitError::Conflict("unrelated conflict contains trigger wording".into())
683        );
684
685        let invalid = KitError::from(mongreldb_core::MongrelError::InvalidArgument(
686            "unrelated invalid argument mentions external trigger bridge".into(),
687        ));
688        assert_eq!(
689            invalid,
690            KitError::Validation(
691                "unrelated invalid argument mentions external trigger bridge".into()
692            )
693        );
694    }
695
696    #[test]
697    fn maps_query_registry_and_commit_outcomes_without_strings() {
698        assert!(matches!(
699            KitError::from(mongreldb_query::MongrelQueryError::QueryRegistryFull),
700            KitError::QueryRegistryFull { query_id: None, .. }
701        ));
702        let query_id = mongreldb_query::QueryId::random().unwrap();
703        assert!(matches!(
704            KitError::from(mongreldb_query::MongrelQueryError::CommitOutcome {
705                query_id,
706                committed: true,
707                committed_statements: 1,
708                last_commit_epoch: Some(7),
709                first_commit_statement_index: Some(0),
710                last_commit_statement_index: Some(0),
711                completed_statements: 1,
712                statement_index: 0,
713                message: "post-commit refresh failed".into(),
714            }),
715            KitError::CommitOutcome {
716                outcome,
717                ..
718            } if outcome.committed
719                && outcome.committed_statements == Some(1)
720                && outcome.last_commit_epoch == Some(7)
721        ));
722    }
723
724    #[test]
725    fn committed_status_wraps_unknown_post_commit_errors() {
726        let registry = std::sync::Arc::new(mongreldb_query::SqlQueryRegistry::default());
727        let query = registry
728            .register(mongreldb_query::SqlQueryOptions::default())
729            .unwrap();
730        let query_id = query.id().to_string();
731        query.record_commit(2, 7);
732        let status = query.status();
733
734        let error = query_error_with_status(
735            mongreldb_query::MongrelQueryError::Schema("refresh failed".into()),
736            Some(&status),
737        );
738        assert!(matches!(
739            error,
740            KitError::CommitOutcome {
741                query_id: actual,
742                code,
743                outcome,
744                ..
745            } if actual == query_id
746                && code.as_ref() == "COMMIT_OUTCOME"
747                && outcome.committed
748                && outcome.committed_statements == Some(1)
749                && outcome.last_commit_epoch == Some(7)
750                && outcome.first_commit_statement_index == Some(2)
751                && outcome.last_commit_statement_index == Some(2)
752        ));
753    }
754
755    #[test]
756    fn cancellation_keeps_exact_durable_receipt() {
757        let query_id = mongreldb_query::QueryId::random().unwrap();
758        let error = KitError::from(mongreldb_query::MongrelQueryError::QueryCancelled {
759            query_id,
760            reason: mongreldb_core::CancellationReason::ClientRequest,
761            committed: true,
762            committed_statements: 2,
763            last_commit_epoch: Some(19),
764            first_commit_statement_index: Some(0),
765            last_commit_statement_index: Some(3),
766            completed_statements: 3,
767            cancelled_statement_index: 4,
768        });
769        assert!(matches!(
770            error,
771            KitError::Cancelled {
772                outcome,
773                ..
774            } if outcome.committed
775                && outcome.committed_statements == Some(2)
776                && outcome.last_commit_epoch == Some(19)
777                && outcome.first_commit_statement_index == Some(0)
778                && outcome.last_commit_statement_index == Some(3)
779                && outcome.completed_statements == 3
780                && outcome.statement_index == 4
781        ));
782    }
783
784    #[test]
785    fn kit_error_stays_compact() {
786        assert!(std::mem::size_of::<KitError>() <= 128);
787    }
788}