Skip to main content

brainwires_storage/
error.rs

1//! Typed error taxonomy for `brainwires-storage`.
2//!
3//! Public APIs in this crate return `anyhow::Result<T>` for historical reasons
4//! and to keep the trait surface stable across nine backend impls. [`StorageError`]
5//! gives callers a structured view on top of that: backends and domain stores
6//! attach `StorageError` values to the anyhow context, and downstream code
7//! recovers the typed variant with `err.downcast_ref::<StorageError>()`.
8//!
9//! This mirrors the `ResilienceError` pattern already used by
10//! `brainwires-call-policy`.
11//!
12//! ```rust,ignore
13//! use brainwires_storage::StorageError;
14//! match err.downcast_ref::<StorageError>() {
15//!     Some(StorageError::NotFound { .. })   => /* treat as empty */,
16//!     Some(StorageError::Conflict { .. })   => /* retry or surface */,
17//!     Some(StorageError::Backend { .. })    => /* log + bubble up */,
18//!     _ => /* opaque anyhow::Error */,
19//! }
20//! ```
21
22use thiserror::Error;
23
24/// Structured error variants attachable to `anyhow::Error` returned by
25/// `StorageBackend` / `VectorDatabase` impls and the domain stores.
26///
27/// Construct via the helper methods below; surface via
28/// `anyhow::Error::from(StorageError::…)` or `Err(StorageError::….into())`.
29#[derive(Debug, Error)]
30pub enum StorageError {
31    /// Backend-level failure — connection, schema, query execution.
32    /// `backend` identifies the origin (e.g. `"lance"`, `"postgres"`, `"qdrant"`).
33    #[error("{backend} backend error: {message}")]
34    Backend {
35        /// Backend identifier.
36        backend: &'static str,
37        /// Human-readable context.
38        message: String,
39    },
40
41    /// Serialisation / deserialisation of a stored payload failed.
42    #[error("storage serialization error: {0}")]
43    Serialization(#[from] serde_json::Error),
44
45    /// Underlying I/O (file-backed stores, SQLite WAL, local vector indexes).
46    #[error("storage I/O error: {0}")]
47    Io(#[from] std::io::Error),
48
49    /// Row/document was not found. Prefer this over returning `Ok(None)` at
50    /// the `anyhow` layer when a caller explicitly asked for a known id.
51    #[error("not found: {kind} {id}")]
52    NotFound {
53        /// Object kind (e.g. `"message"`, `"session"`, `"plan"`).
54        kind: &'static str,
55        /// Caller-provided identifier.
56        id: String,
57    },
58
59    /// Unique-constraint or optimistic-concurrency violation.
60    #[error("conflict on {kind} {id}: {reason}")]
61    Conflict {
62        /// Object kind.
63        kind: &'static str,
64        /// Caller-provided identifier.
65        id: String,
66        /// Why it conflicted (duplicate, stale version, etc.).
67        reason: String,
68    },
69
70    /// Input that violates a store-level invariant (empty vector, wrong
71    /// dimension, unsupported filter shape).
72    #[error("invalid input: {0}")]
73    InvalidInput(String),
74
75    /// Feature requested is not compiled or not supported by the active
76    /// backend. Emitted by the capability gate in `BackendCapabilities`.
77    #[error("unsupported: {0}")]
78    Unsupported(String),
79}
80
81impl StorageError {
82    /// Shorthand for the `Backend` variant.
83    pub fn backend(backend: &'static str, message: impl Into<String>) -> Self {
84        Self::Backend {
85            backend,
86            message: message.into(),
87        }
88    }
89
90    /// Shorthand for the `NotFound` variant.
91    pub fn not_found(kind: &'static str, id: impl Into<String>) -> Self {
92        Self::NotFound {
93            kind,
94            id: id.into(),
95        }
96    }
97
98    /// Shorthand for the `Conflict` variant.
99    pub fn conflict(kind: &'static str, id: impl Into<String>, reason: impl Into<String>) -> Self {
100        Self::Conflict {
101            kind,
102            id: id.into(),
103            reason: reason.into(),
104        }
105    }
106}
107
108#[cfg(test)]
109mod tests {
110    use super::*;
111
112    #[test]
113    fn error_displays_include_context() {
114        let e = StorageError::not_found("message", "msg-42");
115        assert_eq!(e.to_string(), "not found: message msg-42");
116
117        let e = StorageError::backend("lance", "table missing");
118        assert!(e.to_string().contains("lance"));
119        assert!(e.to_string().contains("table missing"));
120
121        let e = StorageError::conflict("session", "s-7", "stale version");
122        assert!(e.to_string().contains("s-7"));
123        assert!(e.to_string().contains("stale version"));
124    }
125
126    #[test]
127    fn rides_anyhow_and_roundtrips_via_downcast() {
128        let e: anyhow::Error = StorageError::not_found("plan", "p-1").into();
129        let typed = e
130            .downcast_ref::<StorageError>()
131            .expect("typed variant preserved through anyhow");
132        assert!(matches!(typed, StorageError::NotFound { kind: "plan", .. }));
133    }
134}