Skip to main content

radixdb_core/error/
mod.rs

1// Copyright 2026 RadixDB Contributors
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15//! Canonical error types for RadixDB
16//!
17//! This module defines all error types used throughout the storage engine.
18
19use std::fmt;
20
21use thiserror::Error;
22
23mod code;
24mod context;
25
26pub use code::{ErrorCategory, ErrorCode};
27pub use context::ErrorContext;
28
29/// Stable category for navigable-reference binding and execution failures.
30///
31/// The textual code is part of the SQL error contract. Details may gain
32/// context, but callers can classify the error without parsing prose.
33#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
34pub enum NavigationErrorCode {
35    UnknownRoot,
36    AmbiguousRoot,
37    NotAReference,
38    UnsupportedReferenceShape,
39    TargetColumnNotFound,
40    ReadOnly,
41    SchemaChanged,
42    TargetMissing,
43    TargetNotUnique,
44}
45
46impl NavigationErrorCode {
47    pub const fn as_str(self) -> &'static str {
48        match self {
49            Self::UnknownRoot => "NAVIGATION_UNKNOWN_ROOT",
50            Self::AmbiguousRoot => "NAVIGATION_AMBIGUOUS_ROOT",
51            Self::NotAReference => "NAVIGATION_NOT_A_REFERENCE",
52            Self::UnsupportedReferenceShape => "NAVIGATION_UNSUPPORTED_REFERENCE_SHAPE",
53            Self::TargetColumnNotFound => "NAVIGATION_TARGET_COLUMN_NOT_FOUND",
54            Self::ReadOnly => "NAVIGATION_READ_ONLY",
55            Self::SchemaChanged => "NAVIGATION_SCHEMA_CHANGED",
56            Self::TargetMissing => "REFERENCE_TARGET_MISSING",
57            Self::TargetNotUnique => "REFERENCE_TARGET_NOT_UNIQUE",
58        }
59    }
60}
61
62impl fmt::Display for NavigationErrorCode {
63    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
64        f.write_str(self.as_str())
65    }
66}
67
68/// Result type alias for RadixDB operations
69pub type Result<T> = std::result::Result<T, Error>;
70
71/// Main error type for RadixDB storage operations
72///
73/// This enum covers all error cases including both sentinel errors
74/// and structured errors with context.
75#[derive(Error, Debug, Clone, PartialEq, Eq)]
76pub enum Error {
77    // =========================================================================
78    // Table errors
79    // =========================================================================
80    /// Table not found in the database
81    #[error("table '{0}' not found")]
82    TableNotFound(String),
83
84    /// Table already exists when trying to create
85    #[error("table '{0}' already exists")]
86    TableAlreadyExists(String),
87
88    /// Table has been closed and cannot be used
89    #[error("table closed")]
90    TableClosed,
91
92    /// Table column count mismatch
93    #[error("table columns don't match, expected {expected}, got {got}")]
94    TableColumnsNotMatch { expected: usize, got: usize },
95
96    /// Cannot truncate table because other transactions hold uncommitted writes
97    #[error("cannot truncate table: active transactions have uncommitted changes")]
98    TableHasActiveTransactions,
99
100    // =========================================================================
101    // Column errors
102    // =========================================================================
103    /// Column not found in table schema
104    #[error("column '{0}' not found")]
105    ColumnNotFound(String),
106
107    /// A result label resolves to more than one projected column.
108    #[error("column '{0}' is ambiguous")]
109    AmbiguousColumn(String),
110
111    /// Invalid column type for operation
112    #[error("invalid column type")]
113    InvalidColumnType,
114
115    /// Vector dimension mismatch
116    #[error("Vector dimension mismatch: expected {expected}, got {got}")]
117    VectorDimensionMismatch { expected: u16, got: u16 },
118
119    /// Duplicate column name in schema
120    #[error("duplicate column")]
121    DuplicateColumn,
122
123    // =========================================================================
124    // Value errors
125    // =========================================================================
126    /// Invalid value for operation
127    #[error("invalid value")]
128    InvalidValue,
129
130    /// Invalid argument for function
131    #[error("invalid argument: {0}")]
132    InvalidArgument(String),
133
134    /// Authenticated Principal lacks authority for the requested operation.
135    #[error("authorization denied: {0}")]
136    AuthorizationDenied(String),
137
138    /// Value exceeds maximum length
139    #[error("value for column {column} is too long, max {max}, got {got}")]
140    ValueTooLong {
141        column: String,
142        max: usize,
143        got: usize,
144    },
145
146    // =========================================================================
147    // Constraint errors
148    // =========================================================================
149    /// NOT NULL constraint violation
150    #[error("not null constraint failed for column {column}")]
151    NotNullConstraint { column: String },
152
153    /// Primary key constraint violation
154    #[error("primary key constraint failed with {row_id} already exists in this table")]
155    PrimaryKeyConstraint { row_id: i64 },
156
157    /// Unique constraint violation
158    #[error("unique constraint failed for index {index} on column {column} with value {value}")]
159    UniqueConstraint {
160        index: String,
161        column: String,
162        value: String,
163        /// Row ID of the conflicting row (-1 if unknown)
164        row_id: i64,
165    },
166
167    /// CHECK constraint violation
168    #[error("CHECK constraint failed for column {column}: {expression}")]
169    CheckConstraintViolation { column: String, expression: String },
170
171    /// Foreign key constraint violation
172    #[error("foreign key constraint violation: column '{column}' in table '{table}' references '{ref_table}({ref_column})' — {detail}")]
173    ForeignKeyViolation {
174        table: String,
175        column: String,
176        ref_table: String,
177        ref_column: String,
178        detail: String,
179    },
180
181    // =========================================================================
182    // Transaction errors
183    // =========================================================================
184    /// Transaction has not been started
185    #[error("transaction not started")]
186    TransactionNotStarted,
187
188    /// Transaction has already been started
189    #[error("transaction already started")]
190    TransactionAlreadyStarted,
191
192    /// Transaction has already ended (committed or rolled back)
193    #[error("transaction already ended")]
194    TransactionEnded,
195
196    /// Transaction was aborted
197    #[error("transaction aborted")]
198    TransactionAborted,
199
200    /// Transaction has already been committed
201    #[error("transaction already committed")]
202    TransactionCommitted,
203
204    /// Transaction has been closed
205    #[error("transaction already closed")]
206    TransactionClosed,
207
208    /// A registry transition was requested for a missing transaction or from
209    /// a state that cannot legally perform that transition.
210    #[error("invalid transaction {txn_id} transition: expected {expected}, found {actual}")]
211    InvalidTransactionTransition {
212        txn_id: i64,
213        expected: String,
214        actual: String,
215    },
216
217    /// Adding a row wait would close a cycle in the transaction wait graph.
218    #[error(
219        "transaction serialization conflict while acquiring row {row_id}; retry the transaction"
220    )]
221    TransactionSerializationConflict { row_id: i64 },
222
223    /// A row writer exceeded the bounded claim wait.
224    #[error("transaction timed out while waiting to update row {row_id} after {timeout_ms} ms")]
225    RowLockTimeout { row_id: i64, timeout_ms: u64 },
226
227    /// A table accumulated more uncompacted L0 work than the configured hard
228    /// admission limit. The transaction has not published and may be retried.
229    #[error(
230        "COMPACTION_BACKPRESSURE: table '{table}' has L0 debt {segments} segments/{physical_bytes} bytes; hard limit {hard_segments} segments/{hard_bytes} bytes; retry after compaction"
231    )]
232    CompactionBackpressure {
233        table: String,
234        segments: u64,
235        physical_bytes: u64,
236        hard_segments: u64,
237        hard_bytes: u64,
238    },
239
240    // =========================================================================
241    // Index errors
242    // =========================================================================
243    /// Index not found
244    #[error("index '{0}' not found")]
245    IndexNotFound(String),
246
247    /// Index already exists
248    #[error("index '{0}' already exists")]
249    IndexAlreadyExists(String),
250
251    /// Column for index not found
252    #[error("index column not found")]
253    IndexColumnNotFound,
254
255    /// Index is closed
256    #[error("index is closed")]
257    IndexClosed,
258
259    // =========================================================================
260    // Engine errors
261    // =========================================================================
262    /// Engine is not open
263    #[error("engine is not open")]
264    EngineNotOpen,
265
266    /// Engine is already open
267    #[error("engine is already open")]
268    EngineAlreadyOpen,
269
270    // =========================================================================
271    // View errors
272    // =========================================================================
273    /// View already exists
274    #[error("view '{0}' already exists")]
275    ViewAlreadyExists(String),
276
277    /// View not found
278    #[error("view '{0}' not found")]
279    ViewNotFound(String),
280
281    // =========================================================================
282    // Lock errors
283    // =========================================================================
284    /// Failed to acquire lock
285    #[error("failed to acquire lock: {0}")]
286    LockAcquisitionFailed(String),
287
288    // =========================================================================
289    // Query result errors
290    // =========================================================================
291    /// Query returned no rows
292    #[error("query returned no rows")]
293    NoRowsReturned,
294
295    /// No statements to execute
296    #[error("no statements to execute")]
297    NoStatementsToExecute,
298
299    /// Column index out of bounds
300    #[error("column index {index} out of bounds")]
301    ColumnIndexOutOfBounds { index: usize },
302
303    /// A streaming cursor has no current row before its first successful
304    /// advance or after end-of-stream.
305    #[error("cursor is not positioned on a row")]
306    CursorNotPositioned,
307
308    // =========================================================================
309    // WAL errors
310    // =========================================================================
311    /// WAL manager is not running
312    #[error("WAL manager is not running")]
313    WalNotRunning,
314
315    /// WAL file is closed
316    #[error("WAL file is closed")]
317    WalFileClosed,
318
319    /// WAL bytes containing an outcome were written, but their durability
320    /// could not be established. The transaction must be treated as terminal
321    /// and callers must not assume rollback or safely retry the logical action.
322    #[error("WAL durability outcome is uncertain: {detail}")]
323    WalDurabilityUncertain { detail: String },
324
325    /// A bounded multi-transaction operation failed after publishing a
326    /// durable prefix. Callers must not blindly retry the whole operation.
327    #[error("{operation} failed after committing {committed_rows} rows: {cause}")]
328    PartialCommit {
329        operation: String,
330        committed_rows: i64,
331        cause: String,
332    },
333
334    /// One atomic COPY would exceed its configured resident-memory envelope.
335    #[error(
336        "COPY transaction memory budget exceeded at row {row}: limit {limit_bytes} bytes, attempted {attempted_bytes} bytes"
337    )]
338    CopyTransactionMemoryLimit {
339        row: i64,
340        limit_bytes: usize,
341        attempted_bytes: usize,
342    },
343
344    /// WAL not initialized
345    #[error("WAL not initialized")]
346    WalNotInitialized,
347
348    // =========================================================================
349    // Database errors
350    // =========================================================================
351    /// Database is locked by another process
352    #[error("database is locked by another process")]
353    DatabaseLocked,
354
355    /// Cannot drop primary key column
356    #[error("cannot drop primary key column")]
357    CannotDropPrimaryKey,
358
359    // =========================================================================
360    // Comparison errors
361    // =========================================================================
362    /// Cannot compare NULL with non-NULL value
363    #[error("cannot compare NULL with non-NULL value")]
364    NullComparison,
365
366    /// Cannot compare incompatible types
367    #[error("cannot compare incompatible types")]
368    IncomparableTypes,
369
370    // =========================================================================
371    // Other errors
372    // =========================================================================
373    /// Operation not supported
374    #[error("not supported: {0}")]
375    NotSupported(String),
376
377    /// A native extension function failed. Arguments and plugin-provided
378    /// detail are deliberately excluded from this public diagnostic.
379    #[error("native function '{function}' failed with plugin status {status}")]
380    NativeFunction { function: String, status: u32 },
381
382    /// Navigable-reference contract failure with a stable machine category.
383    #[error("{code}: {detail}")]
384    Navigation {
385        code: NavigationErrorCode,
386        detail: String,
387    },
388
389    /// Segment not found (internal storage error)
390    #[error("segment not found")]
391    SegmentNotFound,
392
393    /// Expression evaluation failed
394    #[error("expression evaluation failed")]
395    ExpressionEvaluation,
396
397    /// Expression evaluation failed with message
398    #[error("expression evaluation failed: {message}")]
399    ExpressionEvaluationWithMessage { message: String },
400
401    /// Type conversion error
402    #[error("type conversion error: cannot convert {from} to {to}")]
403    TypeConversion { from: String, to: String },
404
405    /// Parse error
406    #[error("parse error: {0}")]
407    Parse(String),
408
409    /// IO error (wrapped)
410    #[error("IO error: {message}")]
411    Io { message: String },
412
413    /// Internal error for unexpected conditions
414    #[error("{message}")]
415    Internal { message: String },
416
417    // =========================================================================
418    // Executor errors
419    // =========================================================================
420    /// Table or view not found (with name)
421    #[error("table or view '{0}' not found")]
422    TableOrViewNotFound(String),
423
424    /// Type error
425    #[error("type error: {0}")]
426    Type(String),
427
428    /// Division by zero
429    #[error("division by zero")]
430    DivisionByZero,
431
432    /// Query cancelled
433    #[error("query cancelled")]
434    QueryCancelled,
435}
436
437impl Error {
438    /// Return a stable machine-readable code without parsing presentation text.
439    pub fn code(&self) -> ErrorCode {
440        let code = match self {
441            Self::TableNotFound(_) => "TABLE_NOT_FOUND",
442            Self::TableAlreadyExists(_) => "TABLE_ALREADY_EXISTS",
443            Self::TableClosed => "TABLE_CLOSED",
444            Self::TableColumnsNotMatch { .. } => "TABLE_COLUMNS_NOT_MATCH",
445            Self::TableHasActiveTransactions => "TABLE_HAS_ACTIVE_TRANSACTIONS",
446            Self::ColumnNotFound(_) => "COLUMN_NOT_FOUND",
447            Self::AmbiguousColumn(_) => "AMBIGUOUS_COLUMN",
448            Self::InvalidColumnType => "INVALID_COLUMN_TYPE",
449            Self::VectorDimensionMismatch { .. } => "VECTOR_DIMENSION_MISMATCH",
450            Self::DuplicateColumn => "DUPLICATE_COLUMN",
451            Self::InvalidValue => "INVALID_VALUE",
452            Self::InvalidArgument(_) => "INVALID_ARGUMENT",
453            Self::AuthorizationDenied(_) => "AUTHORIZATION_DENIED",
454            Self::ValueTooLong { .. } => "VALUE_TOO_LONG",
455            Self::NotNullConstraint { .. } => "NOT_NULL_CONSTRAINT",
456            Self::PrimaryKeyConstraint { .. } => "PRIMARY_KEY_CONSTRAINT",
457            Self::UniqueConstraint { .. } => "UNIQUE_CONSTRAINT",
458            Self::CheckConstraintViolation { .. } => "CHECK_CONSTRAINT",
459            Self::ForeignKeyViolation { .. } => "FOREIGN_KEY_CONSTRAINT",
460            Self::TransactionNotStarted => "TRANSACTION_NOT_STARTED",
461            Self::TransactionAlreadyStarted => "TRANSACTION_ALREADY_STARTED",
462            Self::TransactionEnded => "TRANSACTION_ENDED",
463            Self::TransactionAborted => "TRANSACTION_ABORTED",
464            Self::TransactionCommitted => "TRANSACTION_COMMITTED",
465            Self::TransactionClosed => "TRANSACTION_CLOSED",
466            Self::InvalidTransactionTransition { .. } => "INVALID_TRANSACTION_TRANSITION",
467            Self::TransactionSerializationConflict { .. } => "TRANSACTION_SERIALIZATION_CONFLICT",
468            Self::RowLockTimeout { .. } => "ROW_LOCK_TIMEOUT",
469            Self::CompactionBackpressure { .. } => "COMPACTION_BACKPRESSURE",
470            Self::IndexNotFound(_) => "INDEX_NOT_FOUND",
471            Self::IndexAlreadyExists(_) => "INDEX_ALREADY_EXISTS",
472            Self::IndexColumnNotFound => "INDEX_COLUMN_NOT_FOUND",
473            Self::IndexClosed => "INDEX_CLOSED",
474            Self::EngineNotOpen => "ENGINE_NOT_OPEN",
475            Self::EngineAlreadyOpen => "ENGINE_ALREADY_OPEN",
476            Self::ViewAlreadyExists(_) => "VIEW_ALREADY_EXISTS",
477            Self::ViewNotFound(_) => "VIEW_NOT_FOUND",
478            Self::LockAcquisitionFailed(_) => "LOCK_ACQUISITION_FAILED",
479            Self::NoRowsReturned => "NO_ROWS_RETURNED",
480            Self::NoStatementsToExecute => "NO_STATEMENTS_TO_EXECUTE",
481            Self::ColumnIndexOutOfBounds { .. } => "COLUMN_INDEX_OUT_OF_BOUNDS",
482            Self::CursorNotPositioned => "CURSOR_NOT_POSITIONED",
483            Self::WalNotRunning => "WAL_NOT_RUNNING",
484            Self::WalFileClosed => "WAL_FILE_CLOSED",
485            Self::WalDurabilityUncertain { .. } => "WAL_DURABILITY_UNCERTAIN",
486            Self::PartialCommit { .. } => "PARTIAL_COMMIT",
487            Self::CopyTransactionMemoryLimit { .. } => "COPY_TRANSACTION_MEMORY_LIMIT",
488            Self::WalNotInitialized => "WAL_NOT_INITIALIZED",
489            Self::DatabaseLocked => "DATABASE_LOCKED",
490            Self::CannotDropPrimaryKey => "CANNOT_DROP_PRIMARY_KEY",
491            Self::NullComparison => "NULL_COMPARISON",
492            Self::IncomparableTypes => "INCOMPARABLE_TYPES",
493            Self::NotSupported(_) => "NOT_SUPPORTED",
494            Self::NativeFunction { .. } => "NATIVE_FUNCTION_ERROR",
495            Self::Navigation { code, .. } => code.as_str(),
496            Self::SegmentNotFound => "SEGMENT_NOT_FOUND",
497            Self::ExpressionEvaluation => "EXPRESSION_EVALUATION",
498            Self::ExpressionEvaluationWithMessage { .. } => "EXPRESSION_EVALUATION",
499            Self::TypeConversion { .. } => "TYPE_CONVERSION",
500            Self::Parse(_) => "PARSE_ERROR",
501            Self::Io { .. } => "IO_ERROR",
502            Self::Internal { .. } => "INTERNAL_ERROR",
503            Self::TableOrViewNotFound(_) => "TABLE_OR_VIEW_NOT_FOUND",
504            Self::Type(_) => "TYPE_ERROR",
505            Self::DivisionByZero => "DIVISION_BY_ZERO",
506            Self::QueryCancelled => "QUERY_CANCELLED",
507        };
508        ErrorCode::new(code)
509    }
510
511    /// Return neutral classification for protocol, API, and diagnostic mapping.
512    pub fn context(&self) -> ErrorContext {
513        use ErrorCategory as Category;
514
515        let category = match self {
516            Self::TableNotFound(_)
517            | Self::TableAlreadyExists(_)
518            | Self::TableClosed
519            | Self::TableColumnsNotMatch { .. }
520            | Self::TableHasActiveTransactions
521            | Self::ColumnNotFound(_)
522            | Self::AmbiguousColumn(_)
523            | Self::DuplicateColumn
524            | Self::ViewAlreadyExists(_)
525            | Self::ViewNotFound(_)
526            | Self::CannotDropPrimaryKey
527            | Self::Navigation { .. }
528            | Self::TableOrViewNotFound(_) => Category::Catalog,
529            Self::InvalidColumnType
530            | Self::VectorDimensionMismatch { .. }
531            | Self::InvalidValue
532            | Self::InvalidArgument(_)
533            | Self::ValueTooLong { .. }
534            | Self::NullComparison
535            | Self::IncomparableTypes
536            | Self::TypeConversion { .. }
537            | Self::Type(_) => Category::Value,
538            Self::AuthorizationDenied(_) => Category::Security,
539            Self::NotNullConstraint { .. }
540            | Self::PrimaryKeyConstraint { .. }
541            | Self::UniqueConstraint { .. }
542            | Self::CheckConstraintViolation { .. }
543            | Self::ForeignKeyViolation { .. } => Category::Constraint,
544            Self::TransactionNotStarted
545            | Self::TransactionAlreadyStarted
546            | Self::TransactionEnded
547            | Self::TransactionAborted
548            | Self::TransactionCommitted
549            | Self::TransactionClosed
550            | Self::InvalidTransactionTransition { .. }
551            | Self::TransactionSerializationConflict { .. }
552            | Self::RowLockTimeout { .. }
553            | Self::CompactionBackpressure { .. }
554            | Self::PartialCommit { .. }
555            | Self::CopyTransactionMemoryLimit { .. } => Category::Transaction,
556            Self::IndexNotFound(_)
557            | Self::IndexAlreadyExists(_)
558            | Self::IndexColumnNotFound
559            | Self::IndexClosed => Category::Index,
560            Self::EngineNotOpen | Self::EngineAlreadyOpen | Self::LockAcquisitionFailed(_) => {
561                Category::Engine
562            }
563            Self::NoRowsReturned
564            | Self::NoStatementsToExecute
565            | Self::ColumnIndexOutOfBounds { .. }
566            | Self::CursorNotPositioned
567            | Self::NotSupported(_)
568            | Self::NativeFunction { .. }
569            | Self::QueryCancelled => Category::Query,
570            Self::WalNotRunning
571            | Self::WalFileClosed
572            | Self::WalDurabilityUncertain { .. }
573            | Self::WalNotInitialized
574            | Self::SegmentNotFound => Category::Durability,
575            Self::DatabaseLocked => Category::Database,
576            Self::ExpressionEvaluation
577            | Self::ExpressionEvaluationWithMessage { .. }
578            | Self::DivisionByZero => Category::Evaluation,
579            Self::Parse(_) => Category::Syntax,
580            Self::Io { .. } => Category::Io,
581            Self::Internal { .. } => Category::Internal,
582        };
583        ErrorContext::new(
584            self.code(),
585            category,
586            self.is_retryable(),
587            self.is_not_found(),
588            self.is_constraint_violation(),
589        )
590    }
591
592    /// Create a new TableColumnsNotMatch error
593    pub fn table_columns_not_match(expected: usize, got: usize) -> Self {
594        Error::TableColumnsNotMatch { expected, got }
595    }
596
597    /// Create a new ValueTooLong error
598    pub fn value_too_long(column: impl Into<String>, max: usize, got: usize) -> Self {
599        Error::ValueTooLong {
600            column: column.into(),
601            max,
602            got,
603        }
604    }
605
606    /// Create a new NotNullConstraint error
607    pub fn not_null_constraint(column: impl Into<String>) -> Self {
608        Error::NotNullConstraint {
609            column: column.into(),
610        }
611    }
612
613    /// Create a new PrimaryKeyConstraint error
614    pub fn primary_key_constraint(row_id: i64) -> Self {
615        Error::PrimaryKeyConstraint { row_id }
616    }
617
618    /// Create a new UniqueConstraint error
619    pub fn unique_constraint(
620        index: impl Into<String>,
621        column: impl Into<String>,
622        value: impl Into<String>,
623    ) -> Self {
624        Error::UniqueConstraint {
625            index: index.into(),
626            column: column.into(),
627            value: value.into(),
628            row_id: -1,
629        }
630    }
631
632    /// Create a new ForeignKeyViolation error
633    pub fn foreign_key_violation(
634        table: impl Into<String>,
635        column: impl Into<String>,
636        ref_table: impl Into<String>,
637        ref_column: impl Into<String>,
638        detail: impl Into<String>,
639    ) -> Self {
640        Error::ForeignKeyViolation {
641            table: table.into(),
642            column: column.into(),
643            ref_table: ref_table.into(),
644            ref_column: ref_column.into(),
645            detail: detail.into(),
646        }
647    }
648
649    /// Create a new TypeConversion error
650    pub fn type_conversion(from: impl Into<String>, to: impl Into<String>) -> Self {
651        Error::TypeConversion {
652            from: from.into(),
653            to: to.into(),
654        }
655    }
656
657    /// Create a new Parse error
658    pub fn parse(message: impl Into<String>) -> Self {
659        Error::Parse(message.into())
660    }
661
662    /// Create a new IO error
663    pub fn io(message: impl Into<String>) -> Self {
664        Error::Io {
665            message: message.into(),
666        }
667    }
668
669    /// Create a new Internal error
670    pub fn internal(message: impl Into<String>) -> Self {
671        Error::Internal {
672            message: message.into(),
673        }
674    }
675
676    /// Create a new ExpressionEvaluationWithMessage error
677    pub fn expression_evaluation(message: impl Into<String>) -> Self {
678        Error::ExpressionEvaluationWithMessage {
679            message: message.into(),
680        }
681    }
682
683    /// Create a new InvalidArgument error
684    pub fn invalid_argument(message: impl Into<String>) -> Self {
685        Error::InvalidArgument(message.into())
686    }
687
688    /// Create a stable authorization failure without exposing credentials or
689    /// parameter values.
690    pub fn authorization_denied(message: impl Into<String>) -> Self {
691        Error::AuthorizationDenied(message.into())
692    }
693
694    /// Create a stable navigable-reference error.
695    pub fn navigation(code: NavigationErrorCode, detail: impl Into<String>) -> Self {
696        Error::Navigation {
697            code,
698            detail: detail.into(),
699        }
700    }
701
702    /// Return the stable navigable-reference category, if this is one.
703    pub fn navigation_code(&self) -> Option<NavigationErrorCode> {
704        match self {
705            Error::Navigation { code, .. } => Some(*code),
706            _ => None,
707        }
708    }
709
710    /// Check if this is a "not found" type error
711    pub fn is_not_found(&self) -> bool {
712        matches!(
713            self,
714            Error::TableNotFound(_)
715                | Error::ColumnNotFound(_)
716                | Error::IndexNotFound(_)
717                | Error::IndexColumnNotFound
718                | Error::SegmentNotFound
719                | Error::ViewNotFound(_)
720                | Error::TableOrViewNotFound(_)
721        )
722    }
723
724    /// Check if this is a constraint violation error
725    pub fn is_constraint_violation(&self) -> bool {
726        matches!(
727            self,
728            Error::NotNullConstraint { .. }
729                | Error::PrimaryKeyConstraint { .. }
730                | Error::UniqueConstraint { .. }
731                | Error::CheckConstraintViolation { .. }
732                | Error::ForeignKeyViolation { .. }
733        )
734    }
735
736    /// PK or UNIQUE violation only (excludes NOT NULL / FK).
737    pub fn is_pk_or_unique_violation(&self) -> bool {
738        matches!(
739            self,
740            Error::PrimaryKeyConstraint { .. } | Error::UniqueConstraint { .. }
741        )
742    }
743
744    /// Check if this is a transaction-related error
745    pub fn is_transaction_error(&self) -> bool {
746        matches!(
747            self,
748            Error::TransactionNotStarted
749                | Error::TransactionAlreadyStarted
750                | Error::TransactionEnded
751                | Error::TransactionAborted
752                | Error::TransactionCommitted
753                | Error::TransactionClosed
754                | Error::TransactionSerializationConflict { .. }
755                | Error::RowLockTimeout { .. }
756                | Error::CompactionBackpressure { .. }
757        )
758    }
759
760    /// The failed logical action did not publish and may be retried after the
761    /// reported transient condition changes.
762    pub fn is_retryable(&self) -> bool {
763        matches!(
764            self,
765            Error::TransactionSerializationConflict { .. }
766                | Error::RowLockTimeout { .. }
767                | Error::CompactionBackpressure { .. }
768        )
769    }
770}
771
772impl From<std::io::Error> for Error {
773    fn from(err: std::io::Error) -> Self {
774        Error::Io {
775            message: err.to_string(),
776        }
777    }
778}
779
780#[cfg(test)]
781mod tests {
782    use super::*;
783
784    #[test]
785    fn test_error_display() {
786        assert_eq!(
787            Error::TableNotFound("users".to_string()).to_string(),
788            "table 'users' not found"
789        );
790        assert_eq!(
791            Error::TableAlreadyExists("users".to_string()).to_string(),
792            "table 'users' already exists"
793        );
794        assert_eq!(
795            Error::ColumnNotFound("email".to_string()).to_string(),
796            "column 'email' not found"
797        );
798        assert_eq!(Error::InvalidValue.to_string(), "invalid value");
799        assert_eq!(
800            Error::TransactionNotStarted.to_string(),
801            "transaction not started"
802        );
803        assert_eq!(
804            Error::IndexNotFound("idx_email".to_string()).to_string(),
805            "index 'idx_email' not found"
806        );
807        assert_eq!(
808            Error::NullComparison.to_string(),
809            "cannot compare NULL with non-NULL value"
810        );
811    }
812
813    #[test]
814    fn test_structured_error_display() {
815        let err = Error::table_columns_not_match(5, 3);
816        assert_eq!(
817            err.to_string(),
818            "table columns don't match, expected 5, got 3"
819        );
820
821        let err = Error::value_too_long("name", 100, 150);
822        assert_eq!(
823            err.to_string(),
824            "value for column name is too long, max 100, got 150"
825        );
826
827        let err = Error::not_null_constraint("email");
828        assert_eq!(
829            err.to_string(),
830            "not null constraint failed for column email"
831        );
832
833        let err = Error::primary_key_constraint(42);
834        assert_eq!(
835            err.to_string(),
836            "primary key constraint failed with 42 already exists in this table"
837        );
838
839        let err = Error::unique_constraint("idx_email", "email", "test@example.com");
840        assert_eq!(
841            err.to_string(),
842            "unique constraint failed for index idx_email on column email with value test@example.com"
843        );
844    }
845
846    #[test]
847    fn test_error_classification() {
848        assert!(Error::TableNotFound("t".to_string()).is_not_found());
849        assert!(Error::ColumnNotFound("c".to_string()).is_not_found());
850        assert!(Error::IndexNotFound("i".to_string()).is_not_found());
851        assert!(!Error::InvalidValue.is_not_found());
852
853        assert!(Error::not_null_constraint("col").is_constraint_violation());
854        assert!(Error::primary_key_constraint(1).is_constraint_violation());
855        assert!(Error::unique_constraint("idx", "col", "val").is_constraint_violation());
856        assert!(Error::CheckConstraintViolation {
857            column: "<table:t>".to_string(),
858            expression: "a > b".to_string(),
859        }
860        .is_constraint_violation());
861        assert!(!Error::TableNotFound("t".to_string()).is_constraint_violation());
862
863        assert!(Error::TransactionNotStarted.is_transaction_error());
864        assert!(Error::TransactionCommitted.is_transaction_error());
865        assert!(!Error::TableNotFound("t".to_string()).is_transaction_error());
866    }
867
868    #[test]
869    fn test_transaction_classifier_covers_retryable_transaction_variants() {
870        let transaction_errors = [
871            Error::TransactionNotStarted,
872            Error::TransactionAlreadyStarted,
873            Error::TransactionEnded,
874            Error::TransactionAborted,
875            Error::TransactionCommitted,
876            Error::TransactionClosed,
877            Error::TransactionSerializationConflict { row_id: 7 },
878            Error::RowLockTimeout {
879                row_id: 7,
880                timeout_ms: 250,
881            },
882            Error::CompactionBackpressure {
883                table: "items".to_string(),
884                segments: 32,
885                physical_bytes: 1024,
886                hard_segments: 32,
887                hard_bytes: 2048,
888            },
889        ];
890
891        for error in transaction_errors {
892            assert!(
893                error.is_transaction_error(),
894                "transaction classifier rejected {error:?}"
895            );
896        }
897
898        assert!(Error::CompactionBackpressure {
899            table: "items".to_string(),
900            segments: 32,
901            physical_bytes: 1024,
902            hard_segments: 32,
903            hard_bytes: 2048,
904        }
905        .is_retryable());
906    }
907
908    #[test]
909    fn test_error_equality() {
910        assert_eq!(
911            Error::TableNotFound("t".to_string()),
912            Error::TableNotFound("t".to_string())
913        );
914        assert_ne!(
915            Error::TableNotFound("t".to_string()),
916            Error::TableAlreadyExists("t".to_string())
917        );
918
919        let err1 = Error::table_columns_not_match(5, 3);
920        let err2 = Error::table_columns_not_match(5, 3);
921        let err3 = Error::table_columns_not_match(5, 4);
922        assert_eq!(err1, err2);
923        assert_ne!(err1, err3);
924    }
925
926    #[test]
927    fn navigation_errors_have_stable_machine_categories() {
928        let error = Error::navigation(
929            NavigationErrorCode::TargetColumnNotFound,
930            "target column 'profiles.missing' does not exist",
931        );
932        assert_eq!(
933            error.navigation_code(),
934            Some(NavigationErrorCode::TargetColumnNotFound)
935        );
936        assert_eq!(
937            error.to_string(),
938            "NAVIGATION_TARGET_COLUMN_NOT_FOUND: target column 'profiles.missing' does not exist"
939        );
940    }
941
942    #[test]
943    fn neutral_context_does_not_depend_on_presentation_text() {
944        let error = Error::RowLockTimeout {
945            row_id: 17,
946            timeout_ms: 250,
947        };
948        let context = error.context();
949        assert_eq!(context.code().as_str(), "ROW_LOCK_TIMEOUT");
950        assert_eq!(context.category(), ErrorCategory::Transaction);
951        assert!(context.retryable());
952        assert!(!context.not_found());
953        assert!(!context.constraint_violation());
954
955        let error = Error::ColumnNotFound("missing".to_owned());
956        let context = error.context();
957        assert_eq!(context.code().as_str(), "COLUMN_NOT_FOUND");
958        assert_eq!(context.category(), ErrorCategory::Catalog);
959        assert!(context.not_found());
960    }
961
962    #[test]
963    fn test_io_error_conversion() {
964        let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "file not found");
965        let err: Error = io_err.into();
966        assert!(matches!(err, Error::Io { .. }));
967        assert!(err.to_string().contains("file not found"));
968    }
969}