Skip to main content

aimdb_core/
error.rs

1//! Error handling for AimDB core operations
2//!
3//! This module provides a streamlined error type system that works across all AimDB
4//! target platforms: MCU (no_std), edge devices and cloud environments.
5//!
6//! # Platform Compatibility
7//!
8//! Every variant has a single shape on all targets: context fields use
9//! `alloc::string::String`, which is available everywhere (the crate
10//! unconditionally requires `alloc`), and `Display`/`Error` come from
11//! `thiserror` (no_std-capable since 2.x). Only variants that wrap std types
12//! (`Io`, `Json`) are gated on the `std` feature.
13//!
14//! # Error Categories
15//!
16//! The [`DbError`] enum covers only the errors actually constructed by AimDB:
17//!
18//! - **Network**: Connection failures, timeout errors
19//! - **Buffer**: Full, lagged, closed, or empty buffers
20//! - **Database**: Record not found, invalid operations
21//! - **Configuration**: Missing required configuration; mistakes collected by `build()`
22//! - **Runtime**: Runtime adapter errors
23//! - **Transform**: Dependency-graph cycles, missing transform inputs
24//! - **I/O & JSON**: Standard library integrations (std only)
25//!
26//! Layer-specific errors live with their layer: the blocking facade has
27//! `aimdb_sync::SyncError`, connectors have their own error types wrapping
28//! `DbError` where needed.
29
30use alloc::string::String;
31use alloc::vec::Vec;
32use thiserror::Error;
33
34#[cfg(feature = "std")]
35use std::io;
36
37/// One configuration mistake found while configuring an `AimDbBuilder`.
38///
39/// Builder methods never panic on user mistakes; they record one of these and
40/// `build()` reports **all** of them at once via
41/// [`DbError::InvalidConfiguration`].
42#[derive(Debug, Clone, PartialEq, Eq)]
43pub struct ConfigError {
44    /// Key of the record the mistake belongs to. Errors recorded before the
45    /// key is known (inside `TypedRecord` setters) carry an empty key that
46    /// `build()` fills in when draining.
47    pub record_key: String,
48    /// Connector URL, for link-related mistakes.
49    pub url: Option<String>,
50    /// Human-readable description of the mistake.
51    pub message: String,
52}
53
54impl ConfigError {
55    /// Builds a `ConfigError`.
56    pub fn new(
57        record_key: impl Into<String>,
58        url: Option<String>,
59        message: impl Into<String>,
60    ) -> Self {
61        Self {
62            record_key: record_key.into(),
63            url,
64            message: message.into(),
65        }
66    }
67}
68
69impl core::fmt::Display for ConfigError {
70    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
71        // Graph-level findings (cycles) span records and carry no single key.
72        if !self.record_key.is_empty() {
73            write!(f, "record '{}'", self.record_key)?;
74            if let Some(url) = &self.url {
75                write!(f, " ({url})")?;
76            }
77            write!(f, ": ")?;
78        } else if let Some(url) = &self.url {
79            write!(f, "({url}): ")?;
80        }
81        write!(f, "{}", self.message)
82    }
83}
84
85/// Joins collected configuration errors for `InvalidConfiguration`'s `Display`.
86fn join_config_errors(errors: &[ConfigError]) -> String {
87    use core::fmt::Write;
88    let mut out = String::new();
89    for (i, e) in errors.iter().enumerate() {
90        if i > 0 {
91            out.push_str("; ");
92        }
93        let _ = write!(out, "{e}");
94    }
95    out
96}
97
98/// Streamlined error type for AimDB operations
99///
100/// Only includes errors that are actually used in the codebase,
101/// removing theoretical/unused error variants for simplicity.
102///
103/// `#[non_exhaustive]`: match on [`DbError::kind`] where you only need to know
104/// what to do about the failure.
105#[derive(Debug, Error)]
106#[non_exhaustive]
107pub enum DbError {
108    // ===== Network Errors =====
109    /// Connection or timeout failures
110    #[error("Connection failed to {endpoint}: {reason}")]
111    ConnectionFailed { endpoint: String, reason: String },
112
113    // ===== Buffer Errors =====
114    /// Buffer is full and cannot accept more items
115    #[error("Buffer full: {buffer_name} ({size} items)")]
116    BufferFull { size: u32, buffer_name: String },
117
118    /// Consumer lagged behind producer (SPMC ring buffers)
119    #[error("Consumer lagged by {lag_count} messages")]
120    BufferLagged { lag_count: u64, buffer_name: String },
121
122    /// Buffer channel has been closed (shutdown)
123    #[error("Buffer channel closed: {buffer_name}")]
124    BufferClosed { buffer_name: String },
125
126    /// Non-blocking receive found no pending values
127    #[error("Buffer empty: no pending values")]
128    BufferEmpty,
129
130    // ===== Database Errors =====
131    /// Record type not found in database (legacy, by type name)
132    #[error("Record type not found: {record_name}")]
133    RecordNotFound { record_name: String },
134
135    /// Record key not found in registry
136    #[error("Record key not found: {key}")]
137    RecordKeyNotFound { key: String },
138
139    /// RecordId out of bounds or invalid
140    #[error("Invalid record ID: {id}")]
141    InvalidRecordId { id: u32 },
142
143    /// Type mismatch when accessing record by ID
144    #[error("Type mismatch: expected {expected_type}, record {record_id} has different type")]
145    TypeMismatch {
146        record_id: u32,
147        expected_type: String,
148    },
149
150    /// Invalid operation attempted
151    #[error("Invalid operation '{operation}': {reason}")]
152    InvalidOperation { operation: String, reason: String },
153
154    /// Permission denied for operation
155    #[error("Permission denied: {operation}")]
156    PermissionDenied { operation: String },
157
158    // ===== Configuration Errors =====
159    /// Missing required configuration parameter
160    #[error("Missing configuration parameter: {parameter}")]
161    MissingConfiguration { parameter: String },
162
163    /// Configuration mistakes collected by `AimDbBuilder::build()` — one
164    /// entry per finding, so a single run surfaces every mistake.
165    #[error("invalid configuration ({} error(s)): {}", errors.len(), join_config_errors(errors))]
166    InvalidConfiguration { errors: Vec<ConfigError> },
167
168    // ===== Runtime Errors =====
169    /// Runtime execution error (task spawning, scheduling, etc.)
170    #[error("Runtime error: {message}")]
171    RuntimeError { message: String },
172
173    // ===== Internal Errors =====
174    /// Internal error for unexpected conditions
175    #[error("Internal error (0x{code:04X}): {message}")]
176    Internal { code: u32, message: String },
177
178    // ===== Transform / Dependency Graph Errors =====
179    /// Transform dependency graph contains a cycle
180    #[error("Cyclic dependency detected among records: {records:?}")]
181    CyclicDependency { records: Vec<String> },
182
183    /// Transform input key references a record that was not registered
184    #[error("Transform on '{output_key}' references unregistered input '{input_key}'")]
185    TransformInputNotFound {
186        output_key: String,
187        input_key: String,
188    },
189
190    // ===== Standard Library Integrations (std only) =====
191    /// I/O operation error
192    #[cfg(feature = "std")]
193    #[error("I/O error: {source}")]
194    Io {
195        #[from]
196        source: io::Error,
197    },
198
199    /// I/O operation error with context
200    #[cfg(feature = "std")]
201    #[error("I/O error: {context}: {source}")]
202    IoWithContext {
203        context: String,
204        #[source]
205        source: io::Error,
206    },
207
208    /// JSON serialization error
209    #[cfg(feature = "std")]
210    #[error("JSON error: {source}")]
211    Json {
212        #[from]
213        source: serde_json::Error,
214    },
215
216    /// JSON serialization error with context
217    #[cfg(feature = "std")]
218    #[error("JSON error: {context}: {source}")]
219    JsonWithContext {
220        context: String,
221        #[source]
222        source: serde_json::Error,
223    },
224}
225
226// ===== DbError Implementation =====
227impl DbError {
228    /// Builds a [`RuntimeError`](DbError::RuntimeError).
229    pub fn runtime_error(message: impl Into<String>) -> Self {
230        DbError::RuntimeError {
231            message: message.into(),
232        }
233    }
234
235    /// Builds a [`PermissionDenied`](DbError::PermissionDenied).
236    pub fn permission_denied(operation: impl Into<String>) -> Self {
237        DbError::PermissionDenied {
238            operation: operation.into(),
239        }
240    }
241
242    /// Builds a [`RecordKeyNotFound`](DbError::RecordKeyNotFound).
243    pub fn record_key_not_found(key: impl Into<String>) -> Self {
244        DbError::RecordKeyNotFound { key: key.into() }
245    }
246
247    /// Builds a [`MissingConfiguration`](DbError::MissingConfiguration).
248    pub fn missing_configuration(parameter: impl Into<String>) -> Self {
249        DbError::MissingConfiguration {
250            parameter: parameter.into(),
251        }
252    }
253}
254
255/// What a caller does about a [`DbError`]: retry, resubscribe, stop, or fix
256/// something — and which something.
257///
258/// Match on this rather than on [`DbError`]'s variants when you only need to
259/// know what to do. A new variant is a compile error in [`DbError::kind`]
260/// rather than a silent reclassification at every downstream wildcard.
261#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
262#[non_exhaustive]
263pub enum DbErrorKind {
264    /// Nothing available, or no room, *right now*. Retry.
265    Retry,
266
267    /// Values dropped because this reader fell behind. Note the gap, continue.
268    Lagged,
269
270    /// The database or buffer is shutting down. Stop; retrying cannot help.
271    Closed,
272
273    /// An I/O or connection failure. Retry, or fix the deployment.
274    Transport,
275
276    /// A payload could not be encoded or decoded. Fix the schema or the sender.
277    Data,
278
279    /// The record graph is misconfigured. Fix it before starting.
280    Configuration,
281
282    /// This call was wrong for this record. Fix the call site.
283    Usage,
284
285    /// The database's own machinery or its host. Report a bug.
286    Internal,
287}
288
289impl DbError {
290    /// Classify the failure by what the caller can do about it.
291    ///
292    /// Exhaustive inside the crate that owns the enum: a variant added later
293    /// fails to compile here instead of falling into a downstream wildcard.
294    pub fn kind(&self) -> DbErrorKind {
295        match self {
296            Self::BufferEmpty | Self::BufferFull { .. } => DbErrorKind::Retry,
297
298            Self::BufferLagged { .. } => DbErrorKind::Lagged,
299
300            Self::BufferClosed { .. } => DbErrorKind::Closed,
301
302            Self::ConnectionFailed { .. } => DbErrorKind::Transport,
303
304            Self::MissingConfiguration { .. }
305            | Self::InvalidConfiguration { .. }
306            | Self::CyclicDependency { .. }
307            | Self::TransformInputNotFound { .. } => DbErrorKind::Configuration,
308
309            Self::RecordNotFound { .. }
310            | Self::RecordKeyNotFound { .. }
311            | Self::InvalidRecordId { .. }
312            | Self::TypeMismatch { .. }
313            | Self::InvalidOperation { .. }
314            | Self::PermissionDenied { .. } => DbErrorKind::Usage,
315
316            Self::RuntimeError { .. } | Self::Internal { .. } => DbErrorKind::Internal,
317
318            #[cfg(feature = "std")]
319            Self::Io { .. } | Self::IoWithContext { .. } => DbErrorKind::Transport,
320
321            #[cfg(feature = "std")]
322            Self::Json { .. } | Self::JsonWithContext { .. } => DbErrorKind::Data,
323        }
324    }
325}
326
327/// Type alias for Results using DbError
328pub type DbResult<T> = Result<T, DbError>;
329
330// ============================================================================
331// Error Conversions
332// ============================================================================
333
334/// Convert executor errors to database errors
335///
336/// This allows engine plumbing to return `ExecutorError` while the core
337/// database works with `DbError` for consistency across the API.
338impl From<crate::executor::ExecutorError> for DbError {
339    fn from(err: crate::executor::ExecutorError) -> Self {
340        use crate::executor::ExecutorError;
341
342        match err {
343            ExecutorError::QueueClosed => DbError::runtime_error("join queue closed"),
344        }
345    }
346}
347
348#[cfg(test)]
349mod tests {
350    use super::*;
351    use alloc::string::ToString;
352    use alloc::vec;
353
354    #[test]
355    fn test_error_size_constraint() {
356        let size = core::mem::size_of::<DbError>();
357        assert!(
358            size <= 64,
359            "DbError size ({} bytes) exceeds 64-byte embedded limit",
360            size
361        );
362    }
363
364    #[test]
365    fn test_invalid_configuration_display() {
366        let err = DbError::InvalidConfiguration {
367            errors: vec![
368                ConfigError::new(
369                    "rec.a",
370                    Some("mqtt://broker/a".to_string()),
371                    "missing serializer",
372                ),
373                ConfigError::new("rec.b", None, "duplicate producer"),
374            ],
375        };
376
377        let s = err.to_string();
378        assert!(s.contains("2 error(s)"), "got: {s}");
379        assert!(
380            s.contains("record 'rec.a' (mqtt://broker/a): missing serializer"),
381            "got: {s}"
382        );
383        assert!(s.contains("record 'rec.b': duplicate producer"), "got: {s}");
384    }
385
386    #[test]
387    fn test_helper_constructors() {
388        assert!(matches!(
389            DbError::runtime_error("boom"),
390            DbError::RuntimeError { .. }
391        ));
392        assert!(matches!(
393            DbError::permission_denied("write"),
394            DbError::PermissionDenied { .. }
395        ));
396        assert!(matches!(
397            DbError::record_key_not_found("k"),
398            DbError::RecordKeyNotFound { .. }
399        ));
400        assert!(matches!(
401            DbError::missing_configuration("p"),
402            DbError::MissingConfiguration { .. }
403        ));
404    }
405
406    #[cfg(feature = "std")]
407    #[test]
408    fn test_io_json_conversions() {
409        let io_error = std::io::Error::other("File not found");
410        let db_error: DbError = io_error.into();
411        assert!(matches!(db_error, DbError::Io { .. }));
412
413        let json_error = serde_json::from_str::<serde_json::Value>("invalid").unwrap_err();
414        let db_error: DbError = json_error.into();
415        assert!(matches!(db_error, DbError::Json { .. }));
416    }
417
418    #[test]
419    fn kind_maps_each_variant_to_its_action() {
420        use DbErrorKind::*;
421
422        assert_eq!(DbError::BufferEmpty.kind(), Retry);
423        assert_eq!(
424            DbError::BufferFull {
425                size: 8,
426                buffer_name: "b".to_string()
427            }
428            .kind(),
429            Retry
430        );
431
432        assert_eq!(
433            DbError::BufferLagged {
434                lag_count: 3,
435                buffer_name: "b".to_string()
436            }
437            .kind(),
438            Lagged
439        );
440
441        assert_eq!(
442            DbError::BufferClosed {
443                buffer_name: "b".to_string()
444            }
445            .kind(),
446            Closed
447        );
448
449        assert_eq!(
450            DbError::ConnectionFailed {
451                endpoint: "e".to_string(),
452                reason: "r".to_string()
453            }
454            .kind(),
455            Transport
456        );
457
458        assert_eq!(DbError::missing_configuration("p").kind(), Configuration);
459        assert_eq!(
460            DbError::InvalidConfiguration { errors: vec![] }.kind(),
461            Configuration
462        );
463        assert_eq!(
464            DbError::CyclicDependency { records: vec![] }.kind(),
465            Configuration
466        );
467        assert_eq!(
468            DbError::TransformInputNotFound {
469                output_key: "o".to_string(),
470                input_key: "i".to_string()
471            }
472            .kind(),
473            Configuration
474        );
475
476        assert_eq!(
477            DbError::RecordNotFound {
478                record_name: "r".to_string()
479            }
480            .kind(),
481            Usage
482        );
483        assert_eq!(DbError::record_key_not_found("k").kind(), Usage);
484        assert_eq!(DbError::InvalidRecordId { id: 9 }.kind(), Usage);
485        assert_eq!(
486            DbError::TypeMismatch {
487                expected_type: "T".to_string(),
488                record_id: 1
489            }
490            .kind(),
491            Usage
492        );
493        assert_eq!(
494            DbError::InvalidOperation {
495                operation: "o".to_string(),
496                reason: "r".to_string()
497            }
498            .kind(),
499            Usage
500        );
501        assert_eq!(DbError::permission_denied("op").kind(), Usage);
502
503        assert_eq!(DbError::runtime_error("m").kind(), Internal);
504        assert_eq!(
505            DbError::Internal {
506                code: 1,
507                message: "m".to_string()
508            }
509            .kind(),
510            Internal
511        );
512    }
513
514    #[cfg(feature = "std")]
515    #[test]
516    fn kind_maps_the_std_only_variants() {
517        let io: DbError = std::io::Error::other("x").into();
518        assert_eq!(io.kind(), DbErrorKind::Transport);
519
520        let json: DbError = serde_json::from_str::<serde_json::Value>("nope")
521            .unwrap_err()
522            .into();
523        assert_eq!(json.kind(), DbErrorKind::Data);
524    }
525
526    /// A caller that cannot tell these apart retries forever on a dead buffer.
527    #[test]
528    fn retry_and_closed_stay_distinguishable() {
529        assert_ne!(
530            DbError::BufferEmpty.kind(),
531            DbError::BufferClosed {
532                buffer_name: "b".to_string()
533            }
534            .kind()
535        );
536    }
537}