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