Skip to main content

nodedb_types/
error.rs

1//! Standardized error types for the NodeDB public API.
2//!
3//! [`NodeDbError`] is a **struct** (not an enum) that separates:
4//! - `code` — stable numeric code for programmatic handling (`NDB-1000`)
5//! - `message` — human-readable explanation
6//! - `details` — machine-matchable [`ErrorDetails`] enum with structured data
7//! - `cause` — optional chained error for debugging
8//!
9//! # Wire format
10//!
11//! Serializes to:
12//! ```json
13//! {
14//!   "code": "NDB-1000",
15//!   "message": "constraint violation on users: duplicate email",
16//!   "details": { "kind": "constraint_violation", "collection": "users" }
17//! }
18//! ```
19//!
20//! # Error code ranges
21//!
22//! | Range       | Category      |
23//! |-------------|---------------|
24//! | 1000–1099   | Write path    |
25//! | 1100–1199   | Read path     |
26//! | 1200–1299   | Query         |
27//! | 2000–2099   | Auth/Security |
28//! | 3000–3099   | Sync          |
29//! | 4000–4099   | Storage       |
30//! | 4100–4199   | WAL           |
31//! | 4200–4299   | Serialization |
32//! | 5000–5099   | Config        |
33//! | 6000–6099   | Cluster       |
34//! | 7000–7099   | Memory        |
35//! | 8000–8099   | Encryption    |
36//! | 9000–9099   | Internal      |
37
38use std::fmt;
39
40use serde::{Deserialize, Serialize};
41
42// ---------------------------------------------------------------------------
43// Error codes
44// ---------------------------------------------------------------------------
45
46/// Stable numeric error codes for programmatic error handling.
47#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
48pub struct ErrorCode(pub u16);
49
50impl ErrorCode {
51    // Write path (1000–1099)
52    pub const CONSTRAINT_VIOLATION: Self = Self(1000);
53    pub const WRITE_CONFLICT: Self = Self(1001);
54    pub const DEADLINE_EXCEEDED: Self = Self(1002);
55    pub const PREVALIDATION_REJECTED: Self = Self(1003);
56    pub const APPEND_ONLY_VIOLATION: Self = Self(1010);
57    pub const BALANCE_VIOLATION: Self = Self(1011);
58    pub const PERIOD_LOCKED: Self = Self(1012);
59    pub const STATE_TRANSITION_VIOLATION: Self = Self(1013);
60    pub const TRANSITION_CHECK_VIOLATION: Self = Self(1014);
61    pub const RETENTION_VIOLATION: Self = Self(1015);
62    pub const LEGAL_HOLD_ACTIVE: Self = Self(1016);
63    pub const TYPE_MISMATCH: Self = Self(1020);
64    pub const OVERFLOW: Self = Self(1021);
65    pub const INSUFFICIENT_BALANCE: Self = Self(1022);
66    pub const RATE_EXCEEDED: Self = Self(1023);
67    pub const TYPE_GUARD_VIOLATION: Self = Self(1024);
68
69    // Read path (1100–1199)
70    pub const COLLECTION_NOT_FOUND: Self = Self(1100);
71    pub const DOCUMENT_NOT_FOUND: Self = Self(1101);
72
73    // Query (1200–1299)
74    pub const PLAN_ERROR: Self = Self(1200);
75    pub const FAN_OUT_EXCEEDED: Self = Self(1201);
76    pub const SQL_NOT_ENABLED: Self = Self(1202);
77
78    // Auth / Security (2000–2099)
79    pub const AUTHORIZATION_DENIED: Self = Self(2000);
80    pub const AUTH_EXPIRED: Self = Self(2001);
81
82    // Sync (3000–3099)
83    pub const SYNC_CONNECTION_FAILED: Self = Self(3000);
84    pub const SYNC_DELTA_REJECTED: Self = Self(3001);
85    pub const SHAPE_SUBSCRIPTION_FAILED: Self = Self(3002);
86
87    // Storage (4000–4099)
88    pub const STORAGE: Self = Self(4000);
89    pub const SEGMENT_CORRUPTED: Self = Self(4001);
90    pub const COLD_STORAGE: Self = Self(4002);
91
92    // WAL (4100–4199)
93    pub const WAL: Self = Self(4100);
94
95    // Serialization (4200–4299)
96    pub const SERIALIZATION: Self = Self(4200);
97    pub const CODEC: Self = Self(4201);
98
99    // Config (5000–5099)
100    pub const CONFIG: Self = Self(5000);
101    pub const BAD_REQUEST: Self = Self(5001);
102
103    // Cluster (6000–6099)
104    pub const NO_LEADER: Self = Self(6000);
105    pub const NOT_LEADER: Self = Self(6001);
106    pub const MIGRATION_IN_PROGRESS: Self = Self(6002);
107    pub const NODE_UNREACHABLE: Self = Self(6003);
108    pub const CLUSTER: Self = Self(6010);
109
110    // Memory (7000–7099)
111    pub const MEMORY_EXHAUSTED: Self = Self(7000);
112
113    // Encryption (8000–8099)
114    pub const ENCRYPTION: Self = Self(8000);
115
116    // Internal (9000–9099)
117    pub const INTERNAL: Self = Self(9000);
118    pub const BRIDGE: Self = Self(9001);
119    pub const DISPATCH: Self = Self(9002);
120}
121
122impl fmt::Display for ErrorCode {
123    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
124        write!(f, "NDB-{:04}", self.0)
125    }
126}
127
128// ---------------------------------------------------------------------------
129// ErrorDetails — machine-matchable structured data
130// ---------------------------------------------------------------------------
131
132/// Structured error details for programmatic matching.
133///
134/// Clients match on the variant to determine the error category, then
135/// extract structured fields. The `message` on [`NodeDbError`] carries
136/// the human-readable explanation.
137#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
138#[serde(tag = "kind", rename_all = "snake_case")]
139pub enum ErrorDetails {
140    // Write path
141    ConstraintViolation {
142        collection: String,
143    },
144    WriteConflict {
145        collection: String,
146        document_id: String,
147    },
148    DeadlineExceeded,
149    PrevalidationRejected {
150        constraint: String,
151    },
152    AppendOnlyViolation {
153        collection: String,
154    },
155    BalanceViolation {
156        collection: String,
157    },
158    PeriodLocked {
159        collection: String,
160    },
161    StateTransitionViolation {
162        collection: String,
163    },
164    TransitionCheckViolation {
165        collection: String,
166    },
167    TypeGuardViolation {
168        collection: String,
169    },
170    RetentionViolation {
171        collection: String,
172    },
173    LegalHoldActive {
174        collection: String,
175    },
176    TypeMismatch {
177        collection: String,
178    },
179    Overflow {
180        collection: String,
181    },
182    InsufficientBalance {
183        collection: String,
184    },
185    RateExceeded {
186        gate: String,
187    },
188
189    // Read path
190    CollectionNotFound {
191        collection: String,
192    },
193    DocumentNotFound {
194        collection: String,
195        document_id: String,
196    },
197
198    // Query
199    PlanError,
200    FanOutExceeded {
201        shards_touched: u16,
202        limit: u16,
203    },
204    SqlNotEnabled,
205
206    // Auth
207    AuthorizationDenied {
208        resource: String,
209    },
210    AuthExpired,
211
212    // Sync
213    SyncConnectionFailed,
214    SyncDeltaRejected {
215        compensation: Option<crate::sync::compensation::CompensationHint>,
216    },
217    ShapeSubscriptionFailed {
218        shape_id: String,
219    },
220
221    // Storage (opaque infrastructure)
222    Storage,
223    SegmentCorrupted,
224    ColdStorage,
225    Wal,
226
227    // Serialization
228    Serialization {
229        format: String,
230    },
231    Codec,
232
233    // Config
234    Config,
235    BadRequest,
236
237    // Cluster
238    NoLeader,
239    NotLeader {
240        leader_addr: String,
241    },
242    MigrationInProgress,
243    NodeUnreachable,
244    Cluster,
245
246    // Memory
247    MemoryExhausted {
248        engine: String,
249    },
250
251    // Encryption
252    Encryption,
253
254    // Bridge / Dispatch / Internal
255    Bridge,
256    Dispatch,
257    Internal,
258}
259
260// ---------------------------------------------------------------------------
261// NodeDbError — the public error struct
262// ---------------------------------------------------------------------------
263
264/// Public error type returned by all `NodeDb` trait methods.
265///
266/// Separates machine-readable data ([`ErrorCode`] + [`ErrorDetails`]) from
267/// the human-readable `message`. Optional `cause` preserves the error chain.
268#[derive(Debug, Clone, Serialize, Deserialize)]
269pub struct NodeDbError {
270    code: ErrorCode,
271    message: String,
272    details: ErrorDetails,
273    #[serde(skip_serializing_if = "Option::is_none")]
274    cause: Option<Box<NodeDbError>>,
275}
276
277impl fmt::Display for NodeDbError {
278    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
279        write!(f, "[{}] {}", self.code, self.message)
280    }
281}
282
283impl std::error::Error for NodeDbError {
284    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
285        self.cause.as_deref().map(|e| e as &dyn std::error::Error)
286    }
287}
288
289// ---------------------------------------------------------------------------
290// Accessors
291// ---------------------------------------------------------------------------
292
293impl NodeDbError {
294    /// The stable numeric error code.
295    pub fn code(&self) -> ErrorCode {
296        self.code
297    }
298
299    /// Human-readable error message.
300    pub fn message(&self) -> &str {
301        &self.message
302    }
303
304    /// Machine-matchable error details.
305    pub fn details(&self) -> &ErrorDetails {
306        &self.details
307    }
308
309    /// The chained cause, if any.
310    pub fn cause(&self) -> Option<&NodeDbError> {
311        self.cause.as_deref()
312    }
313
314    /// Attach a cause to this error.
315    pub fn with_cause(mut self, cause: NodeDbError) -> Self {
316        self.cause = Some(Box::new(cause));
317        self
318    }
319
320    /// Whether this error is retriable by the client.
321    pub fn is_retriable(&self) -> bool {
322        matches!(
323            self.details,
324            ErrorDetails::WriteConflict { .. }
325                | ErrorDetails::DeadlineExceeded
326                | ErrorDetails::NoLeader
327                | ErrorDetails::NotLeader { .. }
328                | ErrorDetails::MigrationInProgress
329                | ErrorDetails::NodeUnreachable
330                | ErrorDetails::SyncConnectionFailed
331                | ErrorDetails::Bridge
332                | ErrorDetails::MemoryExhausted { .. }
333        )
334    }
335
336    /// Whether this error indicates the client sent invalid input.
337    pub fn is_client_error(&self) -> bool {
338        matches!(
339            self.details,
340            ErrorDetails::BadRequest
341                | ErrorDetails::ConstraintViolation { .. }
342                | ErrorDetails::AppendOnlyViolation { .. }
343                | ErrorDetails::BalanceViolation { .. }
344                | ErrorDetails::PeriodLocked { .. }
345                | ErrorDetails::StateTransitionViolation { .. }
346                | ErrorDetails::TransitionCheckViolation { .. }
347                | ErrorDetails::RetentionViolation { .. }
348                | ErrorDetails::LegalHoldActive { .. }
349                | ErrorDetails::CollectionNotFound { .. }
350                | ErrorDetails::DocumentNotFound { .. }
351                | ErrorDetails::AuthorizationDenied { .. }
352                | ErrorDetails::AuthExpired
353                | ErrorDetails::Config
354                | ErrorDetails::SqlNotEnabled
355        )
356    }
357}
358
359// ---------------------------------------------------------------------------
360// Category checks
361// ---------------------------------------------------------------------------
362
363impl NodeDbError {
364    pub fn is_constraint_violation(&self) -> bool {
365        matches!(self.details, ErrorDetails::ConstraintViolation { .. })
366    }
367    pub fn is_not_found(&self) -> bool {
368        matches!(
369            self.details,
370            ErrorDetails::CollectionNotFound { .. } | ErrorDetails::DocumentNotFound { .. }
371        )
372    }
373    pub fn is_auth_denied(&self) -> bool {
374        matches!(self.details, ErrorDetails::AuthorizationDenied { .. })
375    }
376    pub fn is_storage(&self) -> bool {
377        matches!(
378            self.details,
379            ErrorDetails::Storage
380                | ErrorDetails::SegmentCorrupted
381                | ErrorDetails::ColdStorage
382                | ErrorDetails::Wal
383        )
384    }
385    pub fn is_internal(&self) -> bool {
386        matches!(self.details, ErrorDetails::Internal)
387    }
388    pub fn is_type_mismatch(&self) -> bool {
389        matches!(self.details, ErrorDetails::TypeMismatch { .. })
390    }
391    pub fn is_type_guard_violation(&self) -> bool {
392        matches!(self.details, ErrorDetails::TypeGuardViolation { .. })
393    }
394    pub fn is_overflow(&self) -> bool {
395        matches!(self.details, ErrorDetails::Overflow { .. })
396    }
397    pub fn is_insufficient_balance(&self) -> bool {
398        matches!(self.details, ErrorDetails::InsufficientBalance { .. })
399    }
400    pub fn is_rate_exceeded(&self) -> bool {
401        matches!(self.details, ErrorDetails::RateExceeded { .. })
402    }
403    pub fn is_cluster(&self) -> bool {
404        matches!(
405            self.details,
406            ErrorDetails::NoLeader
407                | ErrorDetails::NotLeader { .. }
408                | ErrorDetails::MigrationInProgress
409                | ErrorDetails::NodeUnreachable
410                | ErrorDetails::Cluster
411        )
412    }
413}
414
415// ---------------------------------------------------------------------------
416// Constructors
417// ---------------------------------------------------------------------------
418
419impl NodeDbError {
420    // ── Write path ──
421
422    pub fn constraint_violation(collection: impl Into<String>, detail: impl fmt::Display) -> Self {
423        let collection = collection.into();
424        Self {
425            code: ErrorCode::CONSTRAINT_VIOLATION,
426            message: format!("constraint violation on {collection}: {detail}"),
427            details: ErrorDetails::ConstraintViolation { collection },
428            cause: None,
429        }
430    }
431
432    pub fn write_conflict(collection: impl Into<String>, document_id: impl Into<String>) -> Self {
433        let collection = collection.into();
434        let document_id = document_id.into();
435        Self {
436            code: ErrorCode::WRITE_CONFLICT,
437            message: format!(
438                "write conflict on {collection}/{document_id}, retry with idempotency key"
439            ),
440            details: ErrorDetails::WriteConflict {
441                collection,
442                document_id,
443            },
444            cause: None,
445        }
446    }
447
448    pub fn deadline_exceeded() -> Self {
449        Self {
450            code: ErrorCode::DEADLINE_EXCEEDED,
451            message: "request exceeded deadline".into(),
452            details: ErrorDetails::DeadlineExceeded,
453            cause: None,
454        }
455    }
456
457    pub fn prevalidation_rejected(
458        constraint: impl Into<String>,
459        reason: impl fmt::Display,
460    ) -> Self {
461        let constraint = constraint.into();
462        Self {
463            code: ErrorCode::PREVALIDATION_REJECTED,
464            message: format!("pre-validation rejected: {constraint} — {reason}"),
465            details: ErrorDetails::PrevalidationRejected { constraint },
466            cause: None,
467        }
468    }
469
470    // ── Accounting enforcement ──
471
472    pub fn append_only_violation(collection: impl Into<String>, detail: impl fmt::Display) -> Self {
473        let collection = collection.into();
474        Self {
475            code: ErrorCode::APPEND_ONLY_VIOLATION,
476            message: format!("append-only violation on {collection}: {detail}"),
477            details: ErrorDetails::AppendOnlyViolation { collection },
478            cause: None,
479        }
480    }
481
482    pub fn balance_violation(collection: impl Into<String>, detail: impl fmt::Display) -> Self {
483        let collection = collection.into();
484        Self {
485            code: ErrorCode::BALANCE_VIOLATION,
486            message: format!("balance violation on {collection}: {detail}"),
487            details: ErrorDetails::BalanceViolation { collection },
488            cause: None,
489        }
490    }
491
492    pub fn period_locked(collection: impl Into<String>, detail: impl fmt::Display) -> Self {
493        let collection = collection.into();
494        Self {
495            code: ErrorCode::PERIOD_LOCKED,
496            message: format!("period locked on {collection}: {detail}"),
497            details: ErrorDetails::PeriodLocked { collection },
498            cause: None,
499        }
500    }
501
502    pub fn state_transition_violation(
503        collection: impl Into<String>,
504        detail: impl fmt::Display,
505    ) -> Self {
506        let collection = collection.into();
507        Self {
508            code: ErrorCode::STATE_TRANSITION_VIOLATION,
509            message: format!("state transition violation on {collection}: {detail}"),
510            details: ErrorDetails::StateTransitionViolation { collection },
511            cause: None,
512        }
513    }
514
515    pub fn transition_check_violation(
516        collection: impl Into<String>,
517        detail: impl fmt::Display,
518    ) -> Self {
519        let collection = collection.into();
520        Self {
521            code: ErrorCode::TRANSITION_CHECK_VIOLATION,
522            message: format!("transition check violation on {collection}: {detail}"),
523            details: ErrorDetails::TransitionCheckViolation { collection },
524            cause: None,
525        }
526    }
527
528    pub fn type_guard_violation(collection: impl Into<String>, detail: impl fmt::Display) -> Self {
529        let collection = collection.into();
530        Self {
531            code: ErrorCode::TYPE_GUARD_VIOLATION,
532            message: format!("type guard violation on {collection}: {detail}"),
533            details: ErrorDetails::TypeGuardViolation { collection },
534            cause: None,
535        }
536    }
537
538    pub fn retention_violation(collection: impl Into<String>, detail: impl fmt::Display) -> Self {
539        let collection = collection.into();
540        Self {
541            code: ErrorCode::RETENTION_VIOLATION,
542            message: format!("retention violation on {collection}: {detail}"),
543            details: ErrorDetails::RetentionViolation { collection },
544            cause: None,
545        }
546    }
547
548    pub fn legal_hold_active(collection: impl Into<String>, detail: impl fmt::Display) -> Self {
549        let collection = collection.into();
550        Self {
551            code: ErrorCode::LEGAL_HOLD_ACTIVE,
552            message: format!("legal hold active on {collection}: {detail}"),
553            details: ErrorDetails::LegalHoldActive { collection },
554            cause: None,
555        }
556    }
557
558    pub fn type_mismatch(collection: impl Into<String>, detail: impl fmt::Display) -> Self {
559        let collection = collection.into();
560        Self {
561            code: ErrorCode::TYPE_MISMATCH,
562            message: format!("type mismatch on {collection}: {detail}"),
563            details: ErrorDetails::TypeMismatch { collection },
564            cause: None,
565        }
566    }
567
568    pub fn overflow(collection: impl Into<String>, detail: impl fmt::Display) -> Self {
569        let collection = collection.into();
570        Self {
571            code: ErrorCode::OVERFLOW,
572            message: format!("arithmetic overflow on {collection}: {detail}"),
573            details: ErrorDetails::Overflow { collection },
574            cause: None,
575        }
576    }
577
578    pub fn insufficient_balance(collection: impl Into<String>, detail: impl fmt::Display) -> Self {
579        let collection = collection.into();
580        Self {
581            code: ErrorCode::INSUFFICIENT_BALANCE,
582            message: format!("insufficient balance on {collection}: {detail}"),
583            details: ErrorDetails::InsufficientBalance { collection },
584            cause: None,
585        }
586    }
587
588    pub fn rate_exceeded(gate: impl Into<String>, detail: impl fmt::Display) -> Self {
589        let gate = gate.into();
590        Self {
591            code: ErrorCode::RATE_EXCEEDED,
592            message: format!("rate limit exceeded for {gate}: {detail}"),
593            details: ErrorDetails::RateExceeded { gate },
594            cause: None,
595        }
596    }
597
598    // ── Read path ──
599
600    pub fn collection_not_found(collection: impl Into<String>) -> Self {
601        let collection = collection.into();
602        Self {
603            code: ErrorCode::COLLECTION_NOT_FOUND,
604            message: format!("collection '{collection}' not found"),
605            details: ErrorDetails::CollectionNotFound { collection },
606            cause: None,
607        }
608    }
609
610    pub fn document_not_found(
611        collection: impl Into<String>,
612        document_id: impl Into<String>,
613    ) -> Self {
614        let collection = collection.into();
615        let document_id = document_id.into();
616        Self {
617            code: ErrorCode::DOCUMENT_NOT_FOUND,
618            message: format!("document '{document_id}' not found in '{collection}'"),
619            details: ErrorDetails::DocumentNotFound {
620                collection,
621                document_id,
622            },
623            cause: None,
624        }
625    }
626
627    // ── Query ──
628
629    pub fn plan_error(detail: impl fmt::Display) -> Self {
630        Self {
631            code: ErrorCode::PLAN_ERROR,
632            message: format!("query plan error: {detail}"),
633            details: ErrorDetails::PlanError,
634            cause: None,
635        }
636    }
637
638    pub fn fan_out_exceeded(shards_touched: u16, limit: u16) -> Self {
639        Self {
640            code: ErrorCode::FAN_OUT_EXCEEDED,
641            message: format!("query fan-out exceeded: {shards_touched} shards > limit {limit}"),
642            details: ErrorDetails::FanOutExceeded {
643                shards_touched,
644                limit,
645            },
646            cause: None,
647        }
648    }
649
650    pub fn sql_not_enabled() -> Self {
651        Self {
652            code: ErrorCode::SQL_NOT_ENABLED,
653            message: "SQL not enabled (compile with 'sql' feature)".into(),
654            details: ErrorDetails::SqlNotEnabled,
655            cause: None,
656        }
657    }
658
659    // ── Auth ──
660
661    pub fn authorization_denied(resource: impl Into<String>) -> Self {
662        let resource = resource.into();
663        Self {
664            code: ErrorCode::AUTHORIZATION_DENIED,
665            message: format!("authorization denied on {resource}"),
666            details: ErrorDetails::AuthorizationDenied { resource },
667            cause: None,
668        }
669    }
670
671    pub fn auth_expired(detail: impl fmt::Display) -> Self {
672        Self {
673            code: ErrorCode::AUTH_EXPIRED,
674            message: format!("auth expired: {detail}"),
675            details: ErrorDetails::AuthExpired,
676            cause: None,
677        }
678    }
679
680    // ── Sync ──
681
682    pub fn sync_connection_failed(detail: impl fmt::Display) -> Self {
683        Self {
684            code: ErrorCode::SYNC_CONNECTION_FAILED,
685            message: format!("sync connection failed: {detail}"),
686            details: ErrorDetails::SyncConnectionFailed,
687            cause: None,
688        }
689    }
690
691    pub fn sync_delta_rejected(
692        reason: impl fmt::Display,
693        compensation: Option<crate::sync::compensation::CompensationHint>,
694    ) -> Self {
695        Self {
696            code: ErrorCode::SYNC_DELTA_REJECTED,
697            message: format!("sync delta rejected: {reason}"),
698            details: ErrorDetails::SyncDeltaRejected { compensation },
699            cause: None,
700        }
701    }
702
703    pub fn shape_subscription_failed(
704        shape_id: impl Into<String>,
705        detail: impl fmt::Display,
706    ) -> Self {
707        let shape_id = shape_id.into();
708        Self {
709            code: ErrorCode::SHAPE_SUBSCRIPTION_FAILED,
710            message: format!("shape subscription failed for '{shape_id}': {detail}"),
711            details: ErrorDetails::ShapeSubscriptionFailed { shape_id },
712            cause: None,
713        }
714    }
715
716    // ── Storage ──
717
718    pub fn storage(detail: impl fmt::Display) -> Self {
719        Self {
720            code: ErrorCode::STORAGE,
721            message: format!("storage error: {detail}"),
722            details: ErrorDetails::Storage,
723            cause: None,
724        }
725    }
726
727    pub fn segment_corrupted(detail: impl fmt::Display) -> Self {
728        Self {
729            code: ErrorCode::SEGMENT_CORRUPTED,
730            message: format!("segment corrupted: {detail}"),
731            details: ErrorDetails::SegmentCorrupted,
732            cause: None,
733        }
734    }
735
736    pub fn cold_storage(detail: impl fmt::Display) -> Self {
737        Self {
738            code: ErrorCode::COLD_STORAGE,
739            message: format!("cold storage error: {detail}"),
740            details: ErrorDetails::ColdStorage,
741            cause: None,
742        }
743    }
744
745    pub fn wal(detail: impl fmt::Display) -> Self {
746        Self {
747            code: ErrorCode::WAL,
748            message: format!("WAL error: {detail}"),
749            details: ErrorDetails::Wal,
750            cause: None,
751        }
752    }
753
754    // ── Serialization ──
755
756    pub fn serialization(format: impl Into<String>, detail: impl fmt::Display) -> Self {
757        let format = format.into();
758        Self {
759            code: ErrorCode::SERIALIZATION,
760            message: format!("serialization error ({format}): {detail}"),
761            details: ErrorDetails::Serialization { format },
762            cause: None,
763        }
764    }
765
766    pub fn codec(detail: impl fmt::Display) -> Self {
767        Self {
768            code: ErrorCode::CODEC,
769            message: format!("codec error: {detail}"),
770            details: ErrorDetails::Codec,
771            cause: None,
772        }
773    }
774
775    // ── Config ──
776
777    pub fn config(detail: impl fmt::Display) -> Self {
778        Self {
779            code: ErrorCode::CONFIG,
780            message: format!("configuration error: {detail}"),
781            details: ErrorDetails::Config,
782            cause: None,
783        }
784    }
785
786    pub fn bad_request(detail: impl fmt::Display) -> Self {
787        Self {
788            code: ErrorCode::BAD_REQUEST,
789            message: format!("bad request: {detail}"),
790            details: ErrorDetails::BadRequest,
791            cause: None,
792        }
793    }
794
795    // ── Cluster ──
796
797    pub fn no_leader(detail: impl fmt::Display) -> Self {
798        Self {
799            code: ErrorCode::NO_LEADER,
800            message: format!("no serving leader: {detail}"),
801            details: ErrorDetails::NoLeader,
802            cause: None,
803        }
804    }
805
806    pub fn not_leader(leader_addr: impl Into<String>) -> Self {
807        let leader_addr = leader_addr.into();
808        Self {
809            code: ErrorCode::NOT_LEADER,
810            message: format!("not leader; redirect to leader at {leader_addr}"),
811            details: ErrorDetails::NotLeader { leader_addr },
812            cause: None,
813        }
814    }
815
816    pub fn migration_in_progress(detail: impl fmt::Display) -> Self {
817        Self {
818            code: ErrorCode::MIGRATION_IN_PROGRESS,
819            message: format!("migration in progress: {detail}"),
820            details: ErrorDetails::MigrationInProgress,
821            cause: None,
822        }
823    }
824
825    pub fn node_unreachable(detail: impl fmt::Display) -> Self {
826        Self {
827            code: ErrorCode::NODE_UNREACHABLE,
828            message: format!("node unreachable: {detail}"),
829            details: ErrorDetails::NodeUnreachable,
830            cause: None,
831        }
832    }
833
834    pub fn cluster(detail: impl fmt::Display) -> Self {
835        Self {
836            code: ErrorCode::CLUSTER,
837            message: format!("cluster error: {detail}"),
838            details: ErrorDetails::Cluster,
839            cause: None,
840        }
841    }
842
843    // ── Memory ──
844
845    pub fn memory_exhausted(engine: impl Into<String>) -> Self {
846        let engine = engine.into();
847        Self {
848            code: ErrorCode::MEMORY_EXHAUSTED,
849            message: format!("memory budget exhausted for engine {engine}"),
850            details: ErrorDetails::MemoryExhausted { engine },
851            cause: None,
852        }
853    }
854
855    // ── Encryption ──
856
857    pub fn encryption(detail: impl fmt::Display) -> Self {
858        Self {
859            code: ErrorCode::ENCRYPTION,
860            message: format!("encryption error: {detail}"),
861            details: ErrorDetails::Encryption,
862            cause: None,
863        }
864    }
865
866    // ── Bridge / Dispatch / Internal ──
867
868    pub fn bridge(detail: impl fmt::Display) -> Self {
869        Self {
870            code: ErrorCode::BRIDGE,
871            message: format!("bridge error: {detail}"),
872            details: ErrorDetails::Bridge,
873            cause: None,
874        }
875    }
876
877    pub fn dispatch(detail: impl fmt::Display) -> Self {
878        Self {
879            code: ErrorCode::DISPATCH,
880            message: format!("dispatch error: {detail}"),
881            details: ErrorDetails::Dispatch,
882            cause: None,
883        }
884    }
885
886    pub fn internal(detail: impl fmt::Display) -> Self {
887        Self {
888            code: ErrorCode::INTERNAL,
889            message: format!("internal error: {detail}"),
890            details: ErrorDetails::Internal,
891            cause: None,
892        }
893    }
894}
895
896/// Result alias for NodeDb operations.
897pub type NodeDbResult<T> = std::result::Result<T, NodeDbError>;
898
899impl From<std::io::Error> for NodeDbError {
900    fn from(e: std::io::Error) -> Self {
901        Self::storage(e)
902    }
903}
904
905#[cfg(test)]
906mod tests {
907    use super::*;
908
909    #[test]
910    fn error_code_display() {
911        assert_eq!(ErrorCode::CONSTRAINT_VIOLATION.to_string(), "NDB-1000");
912        assert_eq!(ErrorCode::INTERNAL.to_string(), "NDB-9000");
913        assert_eq!(ErrorCode::WAL.to_string(), "NDB-4100");
914    }
915
916    #[test]
917    fn error_display_includes_code() {
918        let e = NodeDbError::constraint_violation("users", "duplicate email");
919        let msg = e.to_string();
920        assert!(msg.contains("NDB-1000"));
921        assert!(msg.contains("constraint violation"));
922        assert!(msg.contains("users"));
923    }
924
925    #[test]
926    fn error_code_accessor() {
927        let e = NodeDbError::write_conflict("orders", "abc");
928        assert_eq!(e.code(), ErrorCode::WRITE_CONFLICT);
929        assert_eq!(e.code().0, 1001);
930    }
931
932    #[test]
933    fn details_matching() {
934        let e = NodeDbError::collection_not_found("users");
935        assert!(matches!(
936            e.details(),
937            ErrorDetails::CollectionNotFound { collection } if collection == "users"
938        ));
939        assert!(e.is_not_found());
940    }
941
942    #[test]
943    fn error_cause_chaining() {
944        let inner = NodeDbError::storage("disk full");
945        let outer = NodeDbError::internal("write failed").with_cause(inner);
946        assert!(outer.cause().is_some());
947        assert!(outer.cause().unwrap().is_storage());
948        assert!(outer.cause().unwrap().message().contains("disk full"));
949    }
950
951    #[test]
952    fn sync_delta_rejected() {
953        let e = NodeDbError::sync_delta_rejected(
954            "unique violation",
955            Some(
956                crate::sync::compensation::CompensationHint::UniqueViolation {
957                    field: "email".into(),
958                    conflicting_value: "a@b.com".into(),
959                },
960            ),
961        );
962        assert!(e.to_string().contains("NDB-3001"));
963        assert!(e.to_string().contains("sync delta rejected"));
964    }
965
966    #[test]
967    fn sql_not_enabled() {
968        let e = NodeDbError::sql_not_enabled();
969        assert!(e.to_string().contains("SQL not enabled"));
970        assert_eq!(e.code(), ErrorCode::SQL_NOT_ENABLED);
971    }
972
973    #[test]
974    fn io_error_converts() {
975        let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "file missing");
976        let e: NodeDbError = io_err.into();
977        assert!(e.is_storage());
978        assert_eq!(e.code(), ErrorCode::STORAGE);
979    }
980
981    #[test]
982    fn retriable_errors() {
983        assert!(NodeDbError::write_conflict("x", "y").is_retriable());
984        assert!(NodeDbError::deadline_exceeded().is_retriable());
985        assert!(!NodeDbError::bad_request("bad").is_retriable());
986    }
987
988    #[test]
989    fn client_errors() {
990        assert!(NodeDbError::bad_request("bad").is_client_error());
991        assert!(!NodeDbError::internal("oops").is_client_error());
992    }
993
994    #[test]
995    fn json_serialization() {
996        let e = NodeDbError::collection_not_found("users");
997        let json = serde_json::to_value(&e).unwrap();
998        assert_eq!(json["code"], 1100);
999        assert!(json["message"].as_str().unwrap().contains("users"));
1000        assert_eq!(json["details"]["kind"], "collection_not_found");
1001        assert_eq!(json["details"]["collection"], "users");
1002    }
1003}