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}