Skip to main content

lora_database/
error.rs

1//! Top-level error type and stable error-code catalog for LoraDB.
2//!
3//! Internal `lora-database` code still produces `anyhow::Result` because
4//! `?`-chaining over many lower-layer error types is convenient. The
5//! public boundary, however, surfaces a typed [`LoraError`] so transports
6//! and bindings can route on the stable [`LoraErrorCode`] wire string
7//! without parsing message text.
8//!
9//! # Stable contract
10//!
11//! - [`LoraErrorCode::as_str`] returns the wire string. These strings are
12//!   part of the public API and never change between releases.
13//! - [`LoraError::message`] returns a user-friendly clause. Wording **may**
14//!   change between minor versions to improve clarity — bindings and
15//!   integration tests must not match on it.
16//! - [`LoraError::category`] returns whether the failure was the caller's
17//!   fault (`Client`) or the engine's (`Server`). The HTTP layer uses this
18//!   to pick the response status; bindings can use it to tag exceptions.
19//!
20//! See `docs/design/error-style.md` for the message style guide.
21
22use std::error::Error;
23use std::fmt;
24
25use lora_analyzer::SemanticError;
26use lora_executor::{ExecutorError, PropertyConversionError};
27use lora_parser::ParseError;
28use lora_snapshot::SnapshotCodecError;
29use lora_store::SnapshotError;
30use lora_wal::{WalBufferedCommitError, WalCommitError, WalError};
31use thiserror::Error;
32
33use crate::transaction::TransactionError;
34use crate::DatabaseNameError;
35
36/// Stable error-code catalog. The wire string returned by [`Self::as_str`]
37/// is part of LoraDB's public API. Consumers — bindings, the HTTP layer,
38/// integration tests — should match on this rather than on the message
39/// text, which is allowed to change between releases.
40#[derive(Debug, Clone, Copy, PartialEq, Eq)]
41pub enum LoraErrorCode {
42    // -------- Client errors --------
43    /// Cypher syntax could not be parsed.
44    Parse,
45    /// Cypher analysis (unknown variable, label, function, type mismatch, …).
46    Semantic,
47    /// A parameter value passed by the caller could not be coerced.
48    InvalidParams,
49    /// A mutating statement was issued in a read-only context.
50    ReadOnlyViolation,
51    /// A named entity (database, label, key) does not exist.
52    NotFound,
53    /// A precondition (e.g. delete-with-relationships) is not satisfied.
54    ConstraintViolation,
55    /// A vector value failed dimension / coordinate-type validation.
56    InvalidVector,
57    /// A query exceeded its cooperative deadline.
58    Timeout,
59    /// A logical database name violates the portable-path rules.
60    DatabaseName,
61    /// Required parameters are missing or malformed (CLI / config flags).
62    Config,
63    /// A well-formed request failed database-layer validation.
64    Validation,
65    /// A uniqueness constraint rejected a duplicate value.
66    UniqueConstraint,
67    /// A property-existence / NOT NULL constraint rejected missing data.
68    NotNullConstraint,
69    /// A relationship or dependent record references a missing entity.
70    ForeignKeyViolation,
71    /// A transaction lifecycle rule was violated.
72    TransactionFailure,
73
74    // -------- Server errors --------
75    /// I/O failure outside the WAL / snapshot boundaries.
76    Io,
77    /// The database could not open or use its backing connection/handle.
78    Connection,
79    /// WAL record was truncated, mis-CRC'd, or otherwise unreadable.
80    WalCorruption,
81    /// The WAL is poisoned and no longer accepts durable writes.
82    WalPoisoned,
83    /// Snapshot codec failure (bad magic, version, checksum, …).
84    SnapshotCodec,
85    /// Snapshot encryption / decryption / KDF failure.
86    SnapshotCrypto,
87    /// Last-resort fallback when the engine cannot classify the failure.
88    Internal,
89}
90
91/// Whether a [`LoraErrorCode`] represents a caller-visible mistake or an
92/// engine-side failure. Used by the HTTP transport to choose between
93/// 4xx and 5xx status codes.
94#[derive(Debug, Clone, Copy, PartialEq, Eq)]
95pub enum LoraErrorCategory {
96    Client,
97    Server,
98}
99
100impl LoraErrorCategory {
101    pub fn as_str(self) -> &'static str {
102        match self {
103            Self::Client => "client",
104            Self::Server => "server",
105        }
106    }
107}
108
109impl LoraErrorCode {
110    /// Stable wire string. Part of the public API — never changes.
111    pub fn as_str(self) -> &'static str {
112        match self {
113            Self::Parse => "LORA_PARSE",
114            Self::Semantic => "LORA_SEMANTIC",
115            Self::InvalidParams => "LORA_INVALID_PARAMS",
116            Self::ReadOnlyViolation => "LORA_READ_ONLY",
117            Self::NotFound => "LORA_NOT_FOUND",
118            Self::ConstraintViolation => "LORA_CONSTRAINT",
119            Self::InvalidVector => "LORA_INVALID_VECTOR",
120            Self::Timeout => "LORA_TIMEOUT",
121            Self::DatabaseName => "LORA_DATABASE_NAME",
122            Self::Config => "LORA_CONFIG",
123            Self::Validation => "LORA_VALIDATION",
124            Self::UniqueConstraint => "LORA_UNIQUE_CONSTRAINT",
125            Self::NotNullConstraint => "LORA_NOT_NULL_CONSTRAINT",
126            Self::ForeignKeyViolation => "LORA_FOREIGN_KEY",
127            Self::TransactionFailure => "LORA_TRANSACTION",
128            Self::Io => "LORA_IO",
129            Self::Connection => "LORA_CONNECTION",
130            Self::WalCorruption => "LORA_WAL_CORRUPTION",
131            Self::WalPoisoned => "LORA_WAL_POISONED",
132            Self::SnapshotCodec => "LORA_SNAPSHOT_CODEC",
133            Self::SnapshotCrypto => "LORA_SNAPSHOT_CRYPTO",
134            Self::Internal => "LORA_INTERNAL",
135        }
136    }
137
138    /// Whether this code is the caller's fault or the engine's.
139    pub fn category(self) -> LoraErrorCategory {
140        match self {
141            Self::Parse
142            | Self::Semantic
143            | Self::InvalidParams
144            | Self::ReadOnlyViolation
145            | Self::NotFound
146            | Self::ConstraintViolation
147            | Self::InvalidVector
148            | Self::Timeout
149            | Self::DatabaseName
150            | Self::Config
151            | Self::Validation
152            | Self::UniqueConstraint
153            | Self::NotNullConstraint
154            | Self::ForeignKeyViolation
155            | Self::TransactionFailure => LoraErrorCategory::Client,
156            Self::Io
157            | Self::Connection
158            | Self::WalCorruption
159            | Self::WalPoisoned
160            | Self::SnapshotCodec
161            | Self::SnapshotCrypto
162            | Self::Internal => LoraErrorCategory::Server,
163        }
164    }
165}
166
167impl fmt::Display for LoraErrorCode {
168    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
169        f.write_str(self.as_str())
170    }
171}
172
173/// Public error type at the `lora-database` boundary.
174///
175/// Construct via [`Self::from_anyhow`] (the typical path — the engine
176/// uses `anyhow::Error` internally) or via the `From` impls for any
177/// known concrete error type.
178pub struct LoraError {
179    code: LoraErrorCode,
180    message: String,
181    source: Option<Box<dyn Error + Send + Sync + 'static>>,
182}
183
184#[derive(Debug)]
185struct InternalAnyhowSource(String);
186
187impl fmt::Display for InternalAnyhowSource {
188    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
189        f.write_str(&self.0)
190    }
191}
192
193impl Error for InternalAnyhowSource {}
194
195impl LoraError {
196    pub fn new(code: LoraErrorCode, message: impl Into<String>) -> Self {
197        Self {
198            code,
199            message: message.into(),
200            source: None,
201        }
202    }
203
204    pub fn with_source(
205        code: LoraErrorCode,
206        message: impl Into<String>,
207        source: impl Error + Send + Sync + 'static,
208    ) -> Self {
209        Self {
210            code,
211            message: message.into(),
212            source: Some(Box::new(source)),
213        }
214    }
215
216    pub fn code(&self) -> LoraErrorCode {
217        self.code
218    }
219
220    pub fn message(&self) -> &str {
221        &self.message
222    }
223
224    pub fn category(&self) -> LoraErrorCategory {
225        self.code.category()
226    }
227
228    /// Message intended for external transports. Client-side errors keep
229    /// their precise text; server-side failures return a stable, sanitized
230    /// sentence while the original details stay available through
231    /// [`Self::message`] for logs and local debugging.
232    pub fn public_message(&self) -> String {
233        match self.category() {
234            LoraErrorCategory::Client => self.message.clone(),
235            LoraErrorCategory::Server => match self.code {
236                LoraErrorCode::Io => "database storage is temporarily unavailable".to_string(),
237                LoraErrorCode::Connection => {
238                    "database connection is temporarily unavailable".to_string()
239                }
240                LoraErrorCode::WalCorruption => {
241                    "database write-ahead log is unreadable".to_string()
242                }
243                LoraErrorCode::WalPoisoned => {
244                    "database write-ahead log is unavailable until recovery".to_string()
245                }
246                LoraErrorCode::SnapshotCodec => "snapshot data could not be decoded".to_string(),
247                LoraErrorCode::SnapshotCrypto => {
248                    "snapshot encryption could not be processed".to_string()
249                }
250                LoraErrorCode::Internal => "database operation failed unexpectedly".to_string(),
251                // Exhaustive for future-proofing: if a code's category
252                // changes, expose the original client-facing text.
253                _ => self.message.clone(),
254            },
255        }
256    }
257
258    /// Detailed cause chain for diagnostics. Do not put this in
259    /// user-facing response bodies.
260    pub fn debug_context(&self) -> String {
261        let mut out = self.message.clone();
262        let mut source = self.source();
263        while let Some(err) = source {
264            out.push_str(": ");
265            out.push_str(&err.to_string());
266            source = err.source();
267        }
268        out
269    }
270
271    /// Convert an `anyhow::Error` from the engine's internal `?`-chains
272    /// into a typed `LoraError`. Best-effort: downcasts the chain to any
273    /// known concrete error type and picks the matching code; falls back
274    /// to [`LoraErrorCode::Internal`] with the original message preserved.
275    pub fn from_anyhow(err: anyhow::Error) -> Self {
276        let mapped = Self::from_anyhow_ref(&err);
277        if mapped.code == LoraErrorCode::Internal {
278            Self::with_source(
279                LoraErrorCode::Internal,
280                "database operation failed unexpectedly",
281                InternalAnyhowSource(format!("{err:#}")),
282            )
283        } else {
284            mapped
285        }
286    }
287
288    /// Borrowed version of [`Self::from_anyhow`]. Useful in binding
289    /// layers that hold `&anyhow::Error` from a `Result::Err` capture
290    /// and don't want to move the error.
291    pub fn from_anyhow_ref(err: &anyhow::Error) -> Self {
292        // If the chain already carries a typed `LoraError` (because some
293        // intermediate layer wrapped one with `?` or `.into()`), preserve
294        // its code rather than re-classifying as `Internal`.
295        if let Some(e) = err.downcast_ref::<LoraError>() {
296            return Self::new(e.code, e.message.clone());
297        }
298        if let Some(e) = err.downcast_ref::<ParseError>() {
299            return Self::new(LoraErrorCode::Parse, e.to_string());
300        }
301        if let Some(e) = err.downcast_ref::<SemanticError>() {
302            return Self::new(LoraErrorCode::Semantic, e.to_string());
303        }
304        if let Some(e) = err.downcast_ref::<ExecutorError>() {
305            return Self::new(executor_code(e), e.to_string());
306        }
307        if let Some(e) = err.downcast_ref::<PropertyConversionError>() {
308            return Self::new(LoraErrorCode::Validation, e.to_string());
309        }
310        if let Some(e) = err.downcast_ref::<WalError>() {
311            return Self::new(wal_code(e), e.to_string());
312        }
313        if let Some(e) = err.downcast_ref::<WalCommitError>() {
314            return Self::new(wal_commit_code(e), e.to_string());
315        }
316        if let Some(e) = err.downcast_ref::<WalBufferedCommitError>() {
317            return Self::new(wal_buffered_commit_code(e), e.to_string());
318        }
319        if let Some(e) = err.downcast_ref::<SnapshotCodecError>() {
320            return Self::new(snapshot_codec_code(e), e.to_string());
321        }
322        if let Some(e) = err.downcast_ref::<SnapshotError>() {
323            return Self::new(snapshot_store_code(e), e.to_string());
324        }
325        if let Some(e) = err.downcast_ref::<DatabaseNameError>() {
326            return Self::new(LoraErrorCode::DatabaseName, e.to_string());
327        }
328        if let Some(e) = err.downcast_ref::<TransactionError>() {
329            return Self::new(transaction_code(e), e.to_string());
330        }
331        if let Some(e) = err.downcast_ref::<DatabaseOperationError>() {
332            return Self::new(e.code(), e.to_string());
333        }
334        if let Some(e) = err.downcast_ref::<std::io::Error>() {
335            return Self::new(io_code(e), e.to_string());
336        }
337        if let Some(code) = legacy_message_code(&err.to_string()) {
338            return Self::new(code, err.to_string());
339        }
340        // Fallback: an external `anyhow::Error` we don't recognise. Internal
341        // sites all surface typed errors that the downcasts above route
342        // precisely, so anything that lands here is from a third-party crate
343        // or a legacy `anyhow!("...")` we have not yet converted.
344        Self::new(
345            LoraErrorCode::Internal,
346            "database operation failed unexpectedly",
347        )
348    }
349}
350
351fn executor_code(err: &ExecutorError) -> LoraErrorCode {
352    match err {
353        ExecutorError::ReadOnlyCreate { .. }
354        | ExecutorError::ReadOnlyMerge { .. }
355        | ExecutorError::ReadOnlyDelete { .. }
356        | ExecutorError::ReadOnlySet { .. }
357        | ExecutorError::ReadOnlyRemove { .. }
358        | ExecutorError::ReadOnlyForeach { .. } => LoraErrorCode::ReadOnlyViolation,
359        ExecutorError::QueryTimeout => LoraErrorCode::Timeout,
360        ExecutorError::ConstraintViolation(message) => constraint_message_code(message),
361        ExecutorError::DeleteNodeWithRelationships { .. } => LoraErrorCode::ForeignKeyViolation,
362        ExecutorError::RelationshipCreateFailed { .. } => LoraErrorCode::ForeignKeyViolation,
363        ExecutorError::NodeCreateFailed => LoraErrorCode::Internal,
364        ExecutorError::RuntimeError(message) => validation_message_code(message),
365        ExecutorError::ExpectedNodeForExpand { .. }
366        | ExecutorError::ExpectedPropertyMap { .. }
367        | ExecutorError::GroupByNotLowered
368        | ExecutorError::AggregateNotLowered
369        | ExecutorError::UnsupportedCreateRelationshipRange
370        | ExecutorError::MissingRelationshipType
371        | ExecutorError::DeleteRelationshipFailed { .. }
372        | ExecutorError::InvalidDeleteTarget { .. }
373        | ExecutorError::ExpectedNodeForRemoveLabels { .. }
374        | ExecutorError::UnboundVariableForRemove { .. }
375        | ExecutorError::ExpectedNodeForSetLabels { .. }
376        | ExecutorError::UnboundVariableForSet { .. }
377        | ExecutorError::InvalidSetTarget { .. }
378        | ExecutorError::UnsupportedRemoveTarget
379        | ExecutorError::InvalidRemoveTarget { .. }
380        | ExecutorError::UnsupportedSetTarget
381        | ExecutorError::ExpectedRelationshipForExpand { .. } => LoraErrorCode::Validation,
382    }
383}
384
385fn constraint_message_code(message: &str) -> LoraErrorCode {
386    if message.contains("22N79") || message.contains("22N70") || message.contains("22N71") {
387        LoraErrorCode::UniqueConstraint
388    } else if message.contains("22N77") {
389        LoraErrorCode::NotNullConstraint
390    } else if message.contains("42N51") {
391        LoraErrorCode::NotFound
392    } else if message.contains("22N78") || message.contains("22N90") {
393        LoraErrorCode::Validation
394    } else {
395        LoraErrorCode::ConstraintViolation
396    }
397}
398
399fn validation_message_code(message: &str) -> LoraErrorCode {
400    let lower = message.to_ascii_lowercase();
401    if lower.contains("vector") {
402        LoraErrorCode::InvalidVector
403    } else {
404        LoraErrorCode::Validation
405    }
406}
407
408fn legacy_message_code(message: &str) -> Option<LoraErrorCode> {
409    if message.contains("22N")
410        || message.contains("42N51")
411        || message.contains("50N11")
412        || message.contains("constraint")
413        || message.contains("CONSTRAINT")
414    {
415        return Some(constraint_message_code(message));
416    }
417    None
418}
419
420fn wal_code(err: &WalError) -> LoraErrorCode {
421    match err {
422        WalError::Io(inner) => io_code(inner),
423        WalError::AlreadyOpen { .. } => LoraErrorCode::Connection,
424        WalError::CrcMismatch { .. }
425        | WalError::Truncated { .. }
426        | WalError::UnknownKind(_)
427        | WalError::BadSegmentHeader(_)
428        | WalError::Malformed(_)
429        | WalError::Encode(_)
430        | WalError::Decode(_) => LoraErrorCode::WalCorruption,
431        WalError::Poisoned => LoraErrorCode::WalPoisoned,
432    }
433}
434
435fn io_code(err: &std::io::Error) -> LoraErrorCode {
436    match err.kind() {
437        std::io::ErrorKind::ConnectionRefused
438        | std::io::ErrorKind::ConnectionReset
439        | std::io::ErrorKind::ConnectionAborted
440        | std::io::ErrorKind::NotConnected
441        | std::io::ErrorKind::AddrInUse
442        | std::io::ErrorKind::AddrNotAvailable => LoraErrorCode::Connection,
443        std::io::ErrorKind::TimedOut => LoraErrorCode::Timeout,
444        _ => LoraErrorCode::Io,
445    }
446}
447
448fn wal_commit_code(err: &WalCommitError) -> LoraErrorCode {
449    match err {
450        WalCommitError::Commit(inner) | WalCommitError::Flush(inner) => wal_code(inner),
451    }
452}
453
454fn wal_buffered_commit_code(err: &WalBufferedCommitError) -> LoraErrorCode {
455    match err {
456        WalBufferedCommitError::Arm(inner) => wal_code(inner),
457        WalBufferedCommitError::Poisoned(_) | WalBufferedCommitError::ReplayPoisoned(_) => {
458            LoraErrorCode::WalPoisoned
459        }
460        WalBufferedCommitError::Commit(inner) => wal_commit_code(inner),
461    }
462}
463
464fn snapshot_codec_code(err: &SnapshotCodecError) -> LoraErrorCode {
465    match err {
466        SnapshotCodecError::Io(inner) => io_code(inner),
467        SnapshotCodecError::MissingEncryptionKey(_)
468        | SnapshotCodecError::MissingPassword(_)
469        | SnapshotCodecError::PasswordKdf(_)
470        | SnapshotCodecError::Encrypt
471        | SnapshotCodecError::Decrypt => LoraErrorCode::SnapshotCrypto,
472        SnapshotCodecError::BadMagic
473        | SnapshotCodecError::UnsupportedVersion(_)
474        | SnapshotCodecError::UnsupportedCompression(_)
475        | SnapshotCodecError::ChecksumMismatch
476        | SnapshotCodecError::Encode(_)
477        | SnapshotCodecError::Decode(_) => LoraErrorCode::SnapshotCodec,
478    }
479}
480
481fn snapshot_store_code(err: &SnapshotError) -> LoraErrorCode {
482    match err {
483        SnapshotError::Io(inner) => io_code(inner),
484        SnapshotError::Decode(_) | SnapshotError::Encode(_) => LoraErrorCode::SnapshotCodec,
485    }
486}
487
488fn transaction_code(err: &TransactionError) -> LoraErrorCode {
489    match err {
490        TransactionError::ReadOnlyMutation
491        | TransactionError::ReadOnlyCommit
492        | TransactionError::StreamingRequiresReadWrite => LoraErrorCode::ReadOnlyViolation,
493        TransactionError::AlreadyClosed
494        | TransactionError::CursorActiveCommit
495        | TransactionError::CursorActiveStatement => LoraErrorCode::TransactionFailure,
496        TransactionError::NoGraphGuard
497        | TransactionError::NoStagedGraph
498        | TransactionError::Poisoned => LoraErrorCode::TransactionFailure,
499    }
500}
501
502#[derive(Debug, Clone, Error)]
503pub(crate) enum DatabaseOperationError {
504    #[error("{0}")]
505    InvalidParams(String),
506    #[error("{0}")]
507    InvalidVector(String),
508    #[error("{0}")]
509    Validation(String),
510    #[error("{0}")]
511    NotFound(String),
512    #[error("{0}")]
513    ConstraintViolation(String),
514    #[error("{0}")]
515    UniqueConstraint(String),
516    #[error("{0}")]
517    NotNullConstraint(String),
518}
519
520impl DatabaseOperationError {
521    pub(crate) fn invalid_params(message: impl Into<String>) -> Self {
522        Self::InvalidParams(message.into())
523    }
524
525    pub(crate) fn invalid_vector(message: impl Into<String>) -> Self {
526        Self::InvalidVector(message.into())
527    }
528
529    pub(crate) fn validation(message: impl Into<String>) -> Self {
530        Self::Validation(message.into())
531    }
532
533    pub(crate) fn not_found(message: impl Into<String>) -> Self {
534        Self::NotFound(message.into())
535    }
536
537    pub(crate) fn constraint_violation(message: impl Into<String>) -> Self {
538        Self::ConstraintViolation(message.into())
539    }
540
541    pub(crate) fn unique_constraint(message: impl Into<String>) -> Self {
542        Self::UniqueConstraint(message.into())
543    }
544
545    pub(crate) fn not_null_constraint(message: impl Into<String>) -> Self {
546        Self::NotNullConstraint(message.into())
547    }
548
549    pub(crate) fn code(&self) -> LoraErrorCode {
550        match self {
551            Self::InvalidParams(_) => LoraErrorCode::InvalidParams,
552            Self::InvalidVector(_) => LoraErrorCode::InvalidVector,
553            Self::Validation(_) => LoraErrorCode::Validation,
554            Self::NotFound(_) => LoraErrorCode::NotFound,
555            Self::ConstraintViolation(_) => LoraErrorCode::ConstraintViolation,
556            Self::UniqueConstraint(_) => LoraErrorCode::UniqueConstraint,
557            Self::NotNullConstraint(_) => LoraErrorCode::NotNullConstraint,
558        }
559    }
560}
561
562impl fmt::Debug for LoraError {
563    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
564        f.debug_struct("LoraError")
565            .field("code", &self.code)
566            .field("message", &self.message)
567            .finish()
568    }
569}
570
571impl fmt::Display for LoraError {
572    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
573        f.write_str(&self.message)
574    }
575}
576
577impl Error for LoraError {
578    fn source(&self) -> Option<&(dyn Error + 'static)> {
579        self.source.as_deref().map(|s| s as &(dyn Error + 'static))
580    }
581}
582
583// -------- From impls for direct construction --------
584
585impl From<ParseError> for LoraError {
586    fn from(e: ParseError) -> Self {
587        let msg = e.to_string();
588        Self::with_source(LoraErrorCode::Parse, msg, e)
589    }
590}
591
592impl From<SemanticError> for LoraError {
593    fn from(e: SemanticError) -> Self {
594        let msg = e.to_string();
595        Self::with_source(LoraErrorCode::Semantic, msg, e)
596    }
597}
598
599impl From<ExecutorError> for LoraError {
600    fn from(e: ExecutorError) -> Self {
601        let code = executor_code(&e);
602        let msg = e.to_string();
603        Self::with_source(code, msg, e)
604    }
605}
606
607impl From<PropertyConversionError> for LoraError {
608    fn from(e: PropertyConversionError) -> Self {
609        let msg = e.to_string();
610        Self::with_source(LoraErrorCode::Validation, msg, e)
611    }
612}
613
614impl From<WalError> for LoraError {
615    fn from(e: WalError) -> Self {
616        let code = wal_code(&e);
617        let msg = e.to_string();
618        Self::with_source(code, msg, e)
619    }
620}
621
622impl From<SnapshotCodecError> for LoraError {
623    fn from(e: SnapshotCodecError) -> Self {
624        let code = snapshot_codec_code(&e);
625        let msg = e.to_string();
626        Self::with_source(code, msg, e)
627    }
628}
629
630impl From<SnapshotError> for LoraError {
631    fn from(e: SnapshotError) -> Self {
632        let code = snapshot_store_code(&e);
633        let msg = e.to_string();
634        Self::with_source(code, msg, e)
635    }
636}
637
638impl From<DatabaseNameError> for LoraError {
639    fn from(e: DatabaseNameError) -> Self {
640        let msg = e.to_string();
641        Self::with_source(LoraErrorCode::DatabaseName, msg, e)
642    }
643}
644
645impl From<TransactionError> for LoraError {
646    fn from(e: TransactionError) -> Self {
647        let code = transaction_code(&e);
648        let msg = e.to_string();
649        Self::with_source(code, msg, e)
650    }
651}
652
653impl From<std::io::Error> for LoraError {
654    fn from(e: std::io::Error) -> Self {
655        let code = io_code(&e);
656        let msg = e.to_string();
657        Self::with_source(code, msg, e)
658    }
659}
660
661impl From<anyhow::Error> for LoraError {
662    fn from(e: anyhow::Error) -> Self {
663        Self::from_anyhow(e)
664    }
665}
666
667#[cfg(test)]
668mod tests {
669    use super::*;
670
671    #[test]
672    fn parse_error_is_client_parse() {
673        let e = ParseError::new("expected `MATCH`", 0, 5);
674        let mapped: LoraError = anyhow::Error::from(e).into();
675        assert_eq!(mapped.code(), LoraErrorCode::Parse);
676        assert_eq!(mapped.category(), LoraErrorCategory::Client);
677        assert!(mapped.message().contains("parse error"));
678    }
679
680    #[test]
681    fn semantic_error_is_client_semantic() {
682        let e = SemanticError::UnknownVariable("n".into());
683        let mapped = LoraError::from(e);
684        assert_eq!(mapped.code(), LoraErrorCode::Semantic);
685        assert_eq!(mapped.message(), "unknown variable `n`");
686    }
687
688    #[test]
689    fn executor_timeout_is_client_timeout() {
690        let mapped = LoraError::from(ExecutorError::QueryTimeout);
691        assert_eq!(mapped.code(), LoraErrorCode::Timeout);
692    }
693
694    #[test]
695    fn wal_io_is_server_io() {
696        let inner = std::io::Error::other("disk full");
697        let mapped = LoraError::from(WalError::Io(inner));
698        assert_eq!(mapped.code(), LoraErrorCode::Io);
699        assert_eq!(mapped.category(), LoraErrorCategory::Server);
700    }
701
702    #[test]
703    fn unknown_anyhow_falls_back_to_internal() {
704        let e = anyhow::anyhow!("something else entirely");
705        let mapped = LoraError::from_anyhow(e);
706        assert_eq!(mapped.code(), LoraErrorCode::Internal);
707        assert_eq!(mapped.message(), "database operation failed unexpectedly");
708        assert!(mapped.debug_context().contains("something else entirely"));
709    }
710
711    #[test]
712    fn typed_transaction_error_routes_readonly() {
713        let mapped = LoraError::from(TransactionError::ReadOnlyMutation);
714        assert_eq!(mapped.code(), LoraErrorCode::ReadOnlyViolation);
715        assert_eq!(
716            mapped.message(),
717            "cannot execute mutating query in read-only transaction"
718        );
719    }
720
721    #[test]
722    fn typed_transaction_error_round_trips_through_anyhow() {
723        let any: anyhow::Error = TransactionError::AlreadyClosed.into();
724        let mapped = LoraError::from_anyhow(any);
725        assert_eq!(mapped.code(), LoraErrorCode::TransactionFailure);
726        assert_eq!(mapped.message(), "transaction is already closed");
727    }
728
729    #[test]
730    fn code_wire_strings_are_stable() {
731        // Sanity check: these strings are part of the public API and
732        // must not change between releases.
733        assert_eq!(LoraErrorCode::Parse.as_str(), "LORA_PARSE");
734        assert_eq!(LoraErrorCode::Timeout.as_str(), "LORA_TIMEOUT");
735        assert_eq!(LoraErrorCode::WalPoisoned.as_str(), "LORA_WAL_POISONED");
736        assert_eq!(
737            LoraErrorCode::UniqueConstraint.as_str(),
738            "LORA_UNIQUE_CONSTRAINT"
739        );
740        assert_eq!(
741            LoraErrorCode::NotNullConstraint.as_str(),
742            "LORA_NOT_NULL_CONSTRAINT"
743        );
744        assert_eq!(LoraErrorCode::Connection.as_str(), "LORA_CONNECTION");
745        assert_eq!(LoraErrorCode::Internal.as_str(), "LORA_INTERNAL");
746    }
747
748    #[test]
749    fn constraint_messages_route_to_specific_codes() {
750        let unique = LoraError::from(ExecutorError::ConstraintViolation(
751            "[22N79] property uniqueness constraint violated".into(),
752        ));
753        assert_eq!(unique.code(), LoraErrorCode::UniqueConstraint);
754
755        let not_null = LoraError::from(ExecutorError::ConstraintViolation(
756            "[22N77] property presence verification failed".into(),
757        ));
758        assert_eq!(not_null.code(), LoraErrorCode::NotNullConstraint);
759
760        let missing = LoraError::from_anyhow(anyhow::anyhow!(
761            "[42N51] no index named `missing` exists in the catalog"
762        ));
763        assert_eq!(missing.code(), LoraErrorCode::NotFound);
764    }
765
766    #[test]
767    fn database_operation_errors_route_to_specific_codes() {
768        let cases = [
769            (
770                DatabaseOperationError::invalid_params("bad params"),
771                LoraErrorCode::InvalidParams,
772            ),
773            (
774                DatabaseOperationError::invalid_vector("bad vector"),
775                LoraErrorCode::InvalidVector,
776            ),
777            (
778                DatabaseOperationError::validation("bad request"),
779                LoraErrorCode::Validation,
780            ),
781            (
782                DatabaseOperationError::not_found("missing"),
783                LoraErrorCode::NotFound,
784            ),
785            (
786                DatabaseOperationError::constraint_violation("conflict"),
787                LoraErrorCode::ConstraintViolation,
788            ),
789            (
790                DatabaseOperationError::unique_constraint("duplicate"),
791                LoraErrorCode::UniqueConstraint,
792            ),
793            (
794                DatabaseOperationError::not_null_constraint("missing property"),
795                LoraErrorCode::NotNullConstraint,
796            ),
797        ];
798
799        for (err, code) in cases {
800            let mapped = LoraError::from_anyhow(err.into());
801            assert_eq!(mapped.code(), code);
802            assert_eq!(mapped.category(), code.category());
803        }
804    }
805
806    #[test]
807    fn connection_io_routes_to_connection() {
808        let mapped = LoraError::from(std::io::Error::new(
809            std::io::ErrorKind::ConnectionRefused,
810            "refused",
811        ));
812        assert_eq!(mapped.code(), LoraErrorCode::Connection);
813    }
814
815    #[test]
816    fn public_message_sanitizes_server_details() {
817        let err = LoraError::new(LoraErrorCode::Internal, "panic: secret path /tmp/db");
818        assert_eq!(
819            err.public_message(),
820            "database operation failed unexpectedly"
821        );
822        assert_eq!(err.message(), "panic: secret path /tmp/db");
823    }
824}