Skip to main content

nodedb_lite/
error.rs

1//! Error types for NodeDB-Lite.
2
3mod from_crdt;
4mod from_sql;
5
6/// Errors specific to the Lite embedded engine.
7#[derive(Debug, thiserror::Error)]
8pub enum LiteError {
9    #[error("storage error: {detail}")]
10    Storage { detail: String },
11
12    #[error("storage backend returned poison lock")]
13    LockPoisoned,
14
15    #[error("async task join failed: {detail}")]
16    JoinError { detail: String },
17
18    #[error("serialization error: {detail}")]
19    Serialization { detail: String },
20
21    #[error("namespace {ns} not recognized")]
22    InvalidNamespace { ns: u8 },
23
24    #[error("bad request: {detail}")]
25    BadRequest { detail: String },
26
27    /// A statement named a collection the engine it addressed does not
28    /// hold. Maps to `collection_not_found` at the public API boundary.
29    #[error("{engine} collection '{collection}' does not exist")]
30    CollectionNotFound {
31        engine: &'static str,
32        collection: String,
33    },
34
35    /// A write that would duplicate a declared unique key. Maps to SQLSTATE
36    /// `23505` at the SQL boundary.
37    #[error("duplicate key value violates unique constraint on '{collection}': {detail}")]
38    UniqueViolation { collection: String, detail: String },
39
40    /// A write that would store NULL in a NOT NULL column. Maps to SQLSTATE
41    /// `23502` at the SQL boundary.
42    #[error("null value in column '{column}' of '{collection}' violates not-null constraint")]
43    NotNullViolation { collection: String, column: String },
44
45    #[error("sync error: {detail}")]
46    Sync { detail: String },
47
48    #[error("query error: {0}")]
49    Query(String),
50
51    #[error("Arrow type conversion: expected {expected}, got {got}")]
52    ArrowTypeConversion { expected: String, got: String },
53
54    #[error("backpressure: {detail}")]
55    Backpressure { detail: String },
56
57    /// Feature or SQL construct not supported in this Lite beta release.
58    #[error("unsupported: {detail}")]
59    Unsupported { detail: String },
60
61    /// The OPFS worker bridge failed to start or encountered an IPC error.
62    ///
63    /// This variant is produced when `PagedbStorage::open_opfs` cannot spawn
64    /// the dedicated Web Worker or when the worker signals a corruption-class
65    /// failure that cannot be recovered automatically (OPFS has no rename).
66    #[error("OPFS worker bridge failed: {detail}")]
67    WorkerFailed { detail: String },
68
69    /// An error during key derivation, salt I/O, or encryption setup.
70    #[error("encryption error: {detail}")]
71    Encryption { detail: String },
72
73    /// A corruption-class failure surfaced from the storage backend.
74    ///
75    /// Kept as a distinct variant (rather than folded into [`LiteError::Storage`])
76    /// so the corruption signal survives the error-type conversions and reaches
77    /// the open-sequence recovery driver, which renames the corrupt store aside,
78    /// recreates a fresh one, and retries the open exactly once.
79    #[error("storage corrupted: {detail}")]
80    Corrupted { detail: String },
81
82    /// A full-text index update failed while writing a document.
83    ///
84    /// The write that triggered it must fail too: the row and its postings are
85    /// written together, and nothing re-indexes the gap afterwards — the next
86    /// write to the document only indexes its new text, so a swallowed failure
87    /// leaves the document permanently missing from (or stale in) the index.
88    #[error("full-text index update failed for {collection}: {detail}")]
89    FtsIndex { collection: String, detail: String },
90
91    /// A full-text read named a field no document of the collection holds as
92    /// text, while other fields of the collection are text-indexed. Maps to a
93    /// bad-request error at the public API boundary.
94    #[error(
95        "collection '{collection}' has no full-text index for field '{field}': \
96         write a document with a string value in '{field}', or search with an \
97         empty field to cover every string field"
98    )]
99    TextIndexMissing { collection: String, field: String },
100
101    /// A full-text query the index cannot run, such as a query with only
102    /// negated terms. Maps to a bad-request error at the public API boundary.
103    #[error("invalid full-text query on {collection}: {detail}")]
104    FtsQueryInvalid { collection: String, detail: String },
105
106    /// A full-text index read failed. The search fails with it: an empty
107    /// result would claim that no document matches.
108    #[error("full-text search failed for {collection}: {detail}")]
109    FtsSearch { collection: String, detail: String },
110
111    /// A KV counter atomic read a stored value it cannot parse as a number,
112    /// or computed a result out of range. Maps to SQLSTATE `22P02` or `22003`
113    /// at the SQL boundary, the same as Origin.
114    #[error("{fault} on {collection}")]
115    CounterFault {
116        collection: String,
117        fault: nodedb_physical::kv_atomic::CounterFault,
118    },
119
120    /// A value of the wrong kind for its target: a KV atomic on a typed row
121    /// with no column of the type it reads, a text element in a `VECTOR`
122    /// literal, a scalar write onto a nested CRDT container. `collection` is
123    /// empty when the fault names no collection. SQLSTATE `42804`.
124    #[error("{}", type_mismatch_message(.collection, .detail))]
125    TypeMismatch { collection: String, detail: String },
126
127    /// An input the engine cannot compute on: a vector of the wrong
128    /// dimension, or index input it cannot use. The same public error
129    /// Origin raises for the same fault, SQLSTATE `22000`.
130    #[error("{detail}")]
131    DataException { detail: String },
132
133    /// A value past the range of its numeric type: an integer wider than
134    /// its column, a constant that overflows. SQLSTATE `22003`.
135    #[error("{detail}")]
136    NumericValueOutOfRange { detail: String },
137
138    /// A statement or CRDT operation named an object that does not exist,
139    /// such as a document absent at the requested version. SQLSTATE `42704`.
140    #[error("{object} does not exist")]
141    UndefinedObject { object: String },
142
143    /// The object exists, but not in the state the request needs, such as a
144    /// CRDT version below the compaction boundary. SQLSTATE `55000`.
145    #[error("{detail}")]
146    ObjectNotReady { object: String, detail: String },
147
148    /// A named constraint refused a write. `constraint` names the
149    /// constraint. SQLSTATE class `23`.
150    #[error("constraint violation: {constraint} on '{collection}': {detail}")]
151    ConstraintViolation {
152        collection: String,
153        constraint: String,
154        detail: String,
155    },
156
157    /// The request applied nothing it reports as done. The same request
158    /// succeeds once a transient condition clears: missing causal history
159    /// arrives, or a full queue drains. Retriable.
160    #[error("refused, retry: {detail}")]
161    RetryableRefusal { detail: String },
162
163    /// The caller is not allowed to perform the request: expired auth or a
164    /// delta signature that does not check out.
165    #[error("authorization denied on {resource}")]
166    AuthorizationDenied { resource: String },
167
168    /// A pre-validation gate refused the request, such as a replayed sync
169    /// sequence number. The same request fails the same way every time.
170    #[error("pre-validation rejected: {reason}")]
171    PrevalidationRejected { reason: String },
172}
173
174/// `detail` alone when no collection is named.
175fn type_mismatch_message(collection: &str, detail: &str) -> String {
176    if collection.is_empty() {
177        detail.to_string()
178    } else {
179        format!("type mismatch on {collection}: {detail}")
180    }
181}
182
183impl LiteError {
184    /// `collection` is not a collection of `engine` (`"strict"`,
185    /// `"columnar"`).
186    pub fn collection_not_found(engine: &'static str, collection: impl Into<String>) -> Self {
187        Self::CollectionNotFound {
188            engine,
189            collection: collection.into(),
190        }
191    }
192
193    /// `e` classified as `From<CrdtError>` classifies it. A storage-class
194    /// error names `op`, the operation that raised it.
195    pub(crate) fn crdt(op: &str, e: nodedb_crdt::CrdtError) -> Self {
196        Self::from(e).within(op)
197    }
198
199    /// `self` with `op` named in a storage-class detail. Every other class
200    /// keeps its variant and message.
201    pub(crate) fn within(self, op: &str) -> Self {
202        match self {
203            Self::Storage { detail } => Self::Storage {
204                detail: format!("{op}: {detail}"),
205            },
206            other => other,
207        }
208    }
209}
210
211/// A vector engine error, classified as Origin classifies it: an input
212/// vector of the wrong dimension, or index input the engine cannot use, is
213/// the caller's data error; a memory budget refusal is backpressure; a
214/// stored-data or checkpoint failure is corruption.
215impl From<nodedb_vector::error::VectorError> for LiteError {
216    fn from(e: nodedb_vector::error::VectorError) -> Self {
217        use nodedb_vector::error::VectorError as Ve;
218        let detail = e.to_string();
219        match e {
220            Ve::DimensionMismatch { .. } | Ve::InvalidInput { .. } => {
221                Self::DataException { detail }
222            }
223            Ve::BudgetExhausted(_) => Self::Backpressure { detail },
224            Ve::SegmentIo(_) | Ve::InvalidFilterBitmap { .. } => Self::Storage { detail },
225            // `VectorError` is `#[non_exhaustive]`; every other variant is a
226            // stored-data or checkpoint failure.
227            _ => Self::Corrupted { detail },
228        }
229    }
230}
231
232/// Returns `true` when `e` is the corruption-class variant that should drive
233/// the store-recovery retry (rename the corrupt store, recreate fresh, reopen).
234pub(crate) fn is_corruption(e: &LiteError) -> bool {
235    matches!(e, LiteError::Corrupted { .. })
236}
237
238/// Expression evaluation failure: division or modulo by a zero divisor, a
239/// call to a function no evaluator implements, or a function argument it
240/// cannot compute on (vectors of different dimensions, an argument of the
241/// wrong type, a malformed JSONPath). SQL requires each to fail the
242/// statement rather than fold the row to `NULL`. Mapped to
243/// [`LiteError::Query`] so it surfaces to the caller instead of silently
244/// filtering rows out. An exact aggregate total out of range is
245/// [`LiteError::NumericValueOutOfRange`], the class Origin gives it.
246impl From<nodedb_query::EvalError> for LiteError {
247    fn from(e: nodedb_query::EvalError) -> Self {
248        use nodedb_query::EvalError as Ee;
249        match e {
250            e @ Ee::NumericOverflow { .. } => Self::NumericValueOutOfRange {
251                detail: e.to_string(),
252            },
253            e @ (Ee::DivisionByZero
254            | Ee::UnknownFunction { .. }
255            | Ee::VectorDimensionMismatch { .. }
256            | Ee::ArgumentType { .. }
257            | Ee::InvalidJsonPath { .. }) => Self::Query(e.to_string()),
258        }
259    }
260}
261
262impl From<nodedb_types::columnar::SchemaError> for LiteError {
263    fn from(e: nodedb_types::columnar::SchemaError) -> Self {
264        Self::BadRequest {
265            detail: e.to_string(),
266        }
267    }
268}
269
270impl From<LiteError> for nodedb_types::error::NodeDbError {
271    fn from(e: LiteError) -> Self {
272        use nodedb_types::error::{ErrorCode, NodeDbError};
273        match e {
274            // The same public errors Origin builds for the same faults.
275            LiteError::CounterFault { collection, fault } => {
276                NodeDbError::kv_counter_fault(collection, fault, fault.is_out_of_range())
277            }
278            LiteError::TypeMismatch { collection, detail } if collection.is_empty() => {
279                NodeDbError::from_wire(ErrorCode::TYPE_MISMATCH, detail)
280            }
281            LiteError::TypeMismatch { collection, detail } => {
282                NodeDbError::type_mismatch(collection, detail)
283            }
284            LiteError::DataException { detail } => NodeDbError::data_exception(detail),
285            LiteError::NumericValueOutOfRange { detail } => {
286                NodeDbError::numeric_value_out_of_range(detail)
287            }
288            LiteError::UndefinedObject { object } => NodeDbError::undefined_object(object),
289            LiteError::ObjectNotReady { object, detail } => {
290                NodeDbError::object_not_ready(object, detail)
291            }
292            LiteError::CollectionNotFound { collection, .. } => {
293                NodeDbError::collection_not_found(collection)
294            }
295            LiteError::UniqueViolation { collection, detail } => {
296                NodeDbError::constraint_violation(collection, "unique", detail)
297            }
298            LiteError::NotNullViolation { collection, column } => {
299                NodeDbError::constraint_violation(
300                    collection,
301                    "not_null",
302                    format!("null value in column '{column}'"),
303                )
304            }
305            LiteError::ConstraintViolation {
306                collection,
307                constraint,
308                detail,
309            } => NodeDbError::constraint_violation(collection, constraint, detail),
310            // Nothing the caller asked for took effect, and the same request
311            // succeeds later: the retriable class, as Origin renders it.
312            e @ LiteError::RetryableRefusal { .. } => {
313                NodeDbError::from_wire(ErrorCode::WRITE_CONFLICT, e.to_string())
314            }
315            LiteError::AuthorizationDenied { resource } => {
316                NodeDbError::authorization_denied(resource)
317            }
318            LiteError::PrevalidationRejected { reason } => {
319                NodeDbError::prevalidation_rejected("crdt", reason)
320            }
321            LiteError::Query(detail) => NodeDbError::plan_error(detail),
322            LiteError::BadRequest { detail } => NodeDbError::bad_request(detail),
323            // SQLSTATE `0A000`: `SQL_NOT_ENABLED` is the class every
324            // feature-not-supported refusal carries.
325            LiteError::Unsupported { detail } => {
326                NodeDbError::from_wire(ErrorCode::SQL_NOT_ENABLED, detail)
327            }
328            e @ (LiteError::TextIndexMissing { .. } | LiteError::FtsQueryInvalid { .. }) => {
329                NodeDbError::bad_request(e)
330            }
331            e if is_corruption(&e) => NodeDbError::segment_corrupted(e.to_string()),
332            e => NodeDbError::storage(e),
333        }
334    }
335}
336
337#[cfg(test)]
338mod tests {
339    use super::*;
340
341    #[test]
342    fn lite_error_display() {
343        let e = LiteError::Storage {
344            detail: "disk full".into(),
345        };
346        assert!(e.to_string().contains("disk full"));
347    }
348
349    #[test]
350    fn lite_error_converts_to_nodedb_error() {
351        let e = LiteError::Storage {
352            detail: "test".into(),
353        };
354        let ndb: nodedb_types::error::NodeDbError = e.into();
355        assert!(ndb.to_string().contains("test"));
356    }
357
358    #[test]
359    fn a_unique_violation_is_a_constraint_violation() {
360        let e = LiteError::UniqueViolation {
361            collection: "users".into(),
362            detail: "key ($.email)=(a@x) already exists".into(),
363        };
364        let ndb: nodedb_types::error::NodeDbError = e.into();
365        assert!(ndb.is_constraint_violation(), "{ndb}");
366        assert!(ndb.to_string().contains("unique"), "{ndb}");
367    }
368
369    #[test]
370    fn lite_error_encryption_display_and_convert() {
371        let e = LiteError::Encryption {
372            detail: "argon2 key derivation failed".into(),
373        };
374        let rendered = e.to_string();
375        assert!(rendered.contains("encryption error"));
376        assert!(rendered.contains("argon2 key derivation failed"));
377
378        let ndb: nodedb_types::error::NodeDbError = e.into();
379        assert!(ndb.to_string().contains("argon2 key derivation failed"));
380    }
381
382    #[test]
383    fn a_missing_collection_is_collection_not_found() {
384        let e = LiteError::collection_not_found("strict", "users");
385        assert_eq!(e.to_string(), "strict collection 'users' does not exist");
386
387        let ndb: nodedb_types::error::NodeDbError = e.into();
388        assert_eq!(
389            ndb.code(),
390            nodedb_types::error::ErrorCode::COLLECTION_NOT_FOUND,
391            "{ndb}"
392        );
393    }
394
395    #[test]
396    fn a_not_null_violation_is_a_constraint_violation() {
397        let e = LiteError::NotNullViolation {
398            collection: "users".into(),
399            column: "email".into(),
400        };
401        let ndb: nodedb_types::error::NodeDbError = e.into();
402        assert!(ndb.is_constraint_violation(), "{ndb}");
403    }
404
405    #[test]
406    fn a_bad_request_keeps_its_class() {
407        let e = LiteError::BadRequest {
408            detail: "invalid version vector JSON".into(),
409        };
410        let ndb: nodedb_types::error::NodeDbError = e.into();
411        assert_eq!(ndb.code(), nodedb_types::error::ErrorCode::BAD_REQUEST);
412    }
413
414    #[test]
415    fn a_type_mismatch_without_a_collection_renders_its_detail() {
416        let e = LiteError::TypeMismatch {
417            collection: String::new(),
418            detail: "column 'v': expected a number".into(),
419        };
420        assert_eq!(e.to_string(), "column 'v': expected a number");
421        let ndb: nodedb_types::error::NodeDbError = e.into();
422        assert_eq!(ndb.code(), nodedb_types::error::ErrorCode::TYPE_MISMATCH);
423    }
424
425    #[test]
426    fn lite_error_backpressure_display() {
427        let e = LiteError::Backpressure {
428            detail: "outbound queue full".into(),
429        };
430        assert!(e.to_string().contains("backpressure"));
431        assert!(e.to_string().contains("outbound queue full"));
432    }
433}