Skip to main content

nodedb_lite/error/
from_crdt.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! The class of each CRDT engine error, as Origin classifies it.
4//!
5//! A client request causes most CRDT errors: a malformed delta, a version
6//! that no longer exists, a write that names an absent document. Each keeps
7//! a client class here. Only engine faults stay `Storage`.
8
9use nodedb_crdt::CrdtError;
10
11use super::LiteError;
12
13/// Exhaustive, so a new CRDT error picks its own class rather than
14/// defaulting to `Storage`.
15impl From<CrdtError> for LiteError {
16    fn from(e: CrdtError) -> Self {
17        match e {
18            CrdtError::ConstraintViolation {
19                constraint,
20                collection,
21                detail,
22            } => Self::ConstraintViolation {
23                collection,
24                constraint,
25                detail,
26            },
27            // Delta or snapshot bytes that are malformed, past a limit, or of
28            // a shape no collection holds, and block-list paths or indexes
29            // that do not resolve. Class `22`.
30            client @ (CrdtError::ImportTooLarge { .. }
31            | CrdtError::ImportMalformed { .. }
32            | CrdtError::ImportInvalidOperationRange
33            | CrdtError::ImportOperationLimitExceeded { .. }
34            | CrdtError::PreviewDeltaTooLarge { .. }
35            | CrdtError::PreviewMalformed { .. }
36            | CrdtError::PreviewInvalidOperationRange
37            | CrdtError::PreviewOperationLimitExceeded { .. }
38            | CrdtError::PreviewWriteSetLimitExceeded { .. }
39            | CrdtError::PreviewTargetMismatch { .. }
40            | CrdtError::PreviewPostImageTooLarge { .. }
41            | CrdtError::NonMapRootContainer { .. }
42            | CrdtError::NonMapRowValue { .. }
43            | CrdtError::BlockListPathUnresolved { .. }
44            | CrdtError::BlockListIndexOutOfBounds { .. }) => Self::DataException {
45                detail: client.to_string(),
46            },
47            // A value of the wrong kind for the field or path it targets.
48            // `42804`.
49            CrdtError::ScalarFieldShadowsContainer { ref collection, .. } => Self::TypeMismatch {
50                collection: collection.clone(),
51                detail: e.to_string(),
52            },
53            client @ CrdtError::BlockListNotMovable { .. } => Self::TypeMismatch {
54                collection: String::new(),
55                detail: client.to_string(),
56            },
57            CrdtError::RowAbsentAtVersion { collection, row_id } => Self::UndefinedObject {
58                object: format!(
59                    "document \"{row_id}\" in collection \"{collection}\" at the target version"
60                ),
61            },
62            CrdtError::BlockListRowAbsent { collection, row_id } => Self::UndefinedObject {
63                object: format!("document \"{row_id}\" in collection \"{collection}\""),
64            },
65            CrdtError::UnknownCollection(collection) => Self::UndefinedObject {
66                object: format!("collection \"{collection}\""),
67            },
68            client @ CrdtError::VersionBeforeCompactionBoundary { .. } => Self::ObjectNotReady {
69                object: "CRDT version".into(),
70                detail: client.to_string(),
71            },
72            // The same bytes succeed once the missing causal history
73            // arrives, or once the dead-letter queue drains.
74            retry @ (CrdtError::ImportPendingDependencies
75            | CrdtError::PreviewPendingDependencies
76            | CrdtError::DlqFull { .. }) => Self::RetryableRefusal {
77                detail: retry.to_string(),
78            },
79            denied @ (CrdtError::AuthExpired { .. } | CrdtError::InvalidSignature { .. }) => {
80                Self::AuthorizationDenied {
81                    resource: denied.to_string(),
82                }
83            }
84            // A replayed sequence number fails the same way every time.
85            replay @ CrdtError::ReplayDetected { .. } => Self::PrevalidationRejected {
86                reason: replay.to_string(),
87            },
88            // Engine faults: a Loro failure, a pending transaction on the
89            // authoritative document, or an apply this replica could not run.
90            fault @ (CrdtError::DeltaApplyFailed(_)
91            | CrdtError::PreviewSourceTransactionPending { .. }
92            | CrdtError::Loro(_)) => Self::Storage {
93                detail: fault.to_string(),
94            },
95        }
96    }
97}
98
99#[cfg(test)]
100mod tests {
101    use nodedb_types::error::{ErrorCode, NodeDbError};
102
103    use super::*;
104
105    fn public(e: CrdtError) -> NodeDbError {
106        NodeDbError::from(LiteError::from(e))
107    }
108
109    #[test]
110    fn a_missing_document_or_collection_is_an_undefined_object() {
111        for e in [
112            CrdtError::RowAbsentAtVersion {
113                collection: "notes".into(),
114                row_id: "doc".into(),
115            },
116            CrdtError::BlockListRowAbsent {
117                collection: "pages".into(),
118                row_id: "p".into(),
119            },
120            CrdtError::UnknownCollection("c".into()),
121        ] {
122            assert_eq!(public(e).code(), ErrorCode::UNDEFINED_OBJECT);
123        }
124    }
125
126    #[test]
127    fn a_version_below_the_compaction_boundary_is_not_ready() {
128        let e = CrdtError::VersionBeforeCompactionBoundary {
129            peer: 1,
130            requested: 2,
131            discarded: 3,
132        };
133        assert_eq!(public(e).code(), ErrorCode::OBJECT_NOT_READY);
134    }
135
136    #[test]
137    fn malformed_client_input_is_a_data_exception() {
138        for e in [
139            CrdtError::ImportMalformed { detail: "x".into() },
140            CrdtError::ImportTooLarge {
141                limit: 1,
142                actual: 2,
143            },
144            CrdtError::PreviewPostImageTooLarge {
145                limit: 1,
146                actual: 2,
147            },
148            CrdtError::NonMapRowValue {
149                collection: "c".into(),
150                row_id: "r".into(),
151                value: "a List container".into(),
152            },
153            CrdtError::BlockListPathUnresolved {
154                list_path: "a.blocks".into(),
155                segment: "a".into(),
156            },
157            CrdtError::BlockListIndexOutOfBounds {
158                list_path: "blocks".into(),
159                index: 9,
160                len: 1,
161            },
162        ] {
163            assert_eq!(public(e).code(), ErrorCode::DATA_EXCEPTION);
164        }
165    }
166
167    #[test]
168    fn a_wrong_kind_of_value_is_a_type_mismatch() {
169        let shadow = LiteError::from(CrdtError::ScalarFieldShadowsContainer {
170            collection: "c".into(),
171            row_id: "r".into(),
172            field: "blocks".into(),
173        });
174        assert!(
175            matches!(&shadow, LiteError::TypeMismatch { collection, .. } if collection == "c"),
176            "{shadow:?}"
177        );
178        assert_eq!(NodeDbError::from(shadow).code(), ErrorCode::TYPE_MISMATCH);
179        let plain = CrdtError::BlockListNotMovable {
180            list_path: "blocks".into(),
181        };
182        assert_eq!(public(plain).code(), ErrorCode::TYPE_MISMATCH);
183    }
184
185    #[test]
186    fn missing_causal_history_and_a_full_queue_are_retriable() {
187        for e in [
188            CrdtError::ImportPendingDependencies,
189            CrdtError::PreviewPendingDependencies,
190            CrdtError::DlqFull {
191                capacity: 1,
192                pending: 1,
193            },
194        ] {
195            let mapped = public(e);
196            assert_eq!(mapped.code(), ErrorCode::WRITE_CONFLICT);
197            assert!(mapped.is_retriable(), "{mapped}");
198        }
199    }
200
201    #[test]
202    fn a_constraint_violation_keeps_its_constraint() {
203        let mapped = LiteError::from(CrdtError::ConstraintViolation {
204            constraint: "users_email_unique".into(),
205            collection: "users".into(),
206            detail: "duplicate".into(),
207        });
208        assert!(
209            matches!(&mapped, LiteError::ConstraintViolation { constraint, collection, .. }
210                if constraint == "users_email_unique" && collection == "users"),
211            "{mapped:?}"
212        );
213        assert!(NodeDbError::from(mapped).is_constraint_violation());
214    }
215
216    #[test]
217    fn auth_and_replay_refusals_keep_their_class() {
218        let expired = public(CrdtError::AuthExpired {
219            user_id: 1,
220            expired_at: 2,
221        });
222        assert!(expired.is_auth_denied(), "{expired}");
223        let replay = public(CrdtError::ReplayDetected {
224            user_id: 1,
225            device_id: 2,
226            seq_no: 3,
227            last_seen: 4,
228        });
229        assert_eq!(replay.code(), ErrorCode::PREVALIDATION_REJECTED);
230    }
231
232    #[test]
233    fn only_engine_faults_stay_storage() {
234        for e in [
235            CrdtError::PreviewSourceTransactionPending { operations: 1 },
236            CrdtError::Loro("boom".into()),
237            CrdtError::DeltaApplyFailed("boom".into()),
238        ] {
239            assert!(matches!(LiteError::from(e), LiteError::Storage { .. }));
240        }
241    }
242
243    #[test]
244    fn a_storage_class_crdt_error_names_its_operation() {
245        let e = LiteError::crdt("CRDT upsert", CrdtError::Loro("boom".into()));
246        assert!(
247            matches!(&e, LiteError::Storage { detail } if detail.starts_with("CRDT upsert: ")),
248            "{e:?}"
249        );
250        let client = LiteError::crdt("CRDT upsert", CrdtError::UnknownCollection("c".into()));
251        assert!(matches!(client, LiteError::UndefinedObject { .. }));
252    }
253}