Skip to main content

es_entity/
error.rs

1//! Types for working with errors produced by es-entity.
2
3/// Repository read failures: transient infrastructure faults or fatal failures.
4/// Reads cannot reject or deny; optional reads represent absence with `None`.
5pub type RepoReadError = errlanes::Fault<errlanes::lanes!(Transient, Fatal)>;
6
7/// Repository write failures, with `C` carrying the typed constraint violation.
8/// Writes may reject, fail transiently, or fail fatally, but cannot deny.
9pub type RepoWriteError<C> = errlanes::Fail<C, errlanes::lanes!(Transient, Fatal)>;
10
11/// Error type for entity hydration failures (reconstructing entities from events).
12#[derive(Debug, errlanes::Classify)]
13pub enum EntityHydrationError {
14    #[classify(fatal(CorruptState), from)]
15    UninitializedFieldError(derive_builder::UninitializedFieldError),
16    // Pinned explicitly, not `delegate`: a persisted event row's JSON
17    // failing to decode is corrupt *stored* data specifically, regardless
18    // of what errlanes' generic `serde_json::Error` classification defaults
19    // to elsewhere.
20    #[classify(fatal(CorruptState), from)]
21    EventDeserialization(serde_json::Error),
22    /// A snapshot row matched the fingerprint bind but failed to deserialize
23    /// into `S`. Never silently ignored, unlike a fingerprint mismatch — the
24    /// operator fix is `DELETE FROM <tbl>_snapshots WHERE id = …`.
25    ///
26    /// Pinned explicitly for the same reason as `EventDeserialization`.
27    #[error("EntityHydrationError - SnapshotDecode at sequence {sequence}: {source}")]
28    #[classify(fatal(CorruptState))]
29    SnapshotDecode {
30        sequence: i32,
31        #[source]
32        source: serde_json::Error,
33    },
34    /// The first tail event after a snapshot did not immediately follow it.
35    #[error(
36        "EntityHydrationError - SnapshotGap: snapshot at sequence {snapshot_sequence}, next event at {next_event_sequence}"
37    )]
38    #[classify(fatal(CorruptState))]
39    SnapshotGap {
40        snapshot_sequence: i32,
41        next_event_sequence: i32,
42    },
43    /// A hydration row carried neither an event nor a usable snapshot.
44    #[classify(fatal(CorruptState))]
45    NoEvents,
46}
47
48#[derive(Debug, errlanes::Classify)]
49#[error("CursorDestructureError: couldn't turn {0} into {1}")]
50#[classify(fatal(Config))]
51pub struct CursorDestructureError(&'static str, &'static str);
52
53impl From<(&'static str, &'static str)> for CursorDestructureError {
54    fn from((name, variant): (&'static str, &'static str)) -> Self {
55        Self(name, variant)
56    }
57}
58
59#[doc(hidden)]
60/// Extracts the conflicting value from a PostgreSQL constraint violation detail message.
61///
62/// PostgreSQL formats unique violation details as:
63/// `Key (column)=(value) already exists.`
64///
65/// Returns `None` if the detail is missing or doesn't match the expected format.
66///
67/// **Security note:** the extracted value is attacker-influenced input that
68/// was rejected by a unique constraint and may be PII (e.g. an email
69/// address). Returning it to untrusted API clients enables user
70/// enumeration; logging it may place PII in log pipelines.
71pub fn parse_constraint_detail_value(detail: Option<&str>) -> Option<String> {
72    let detail = detail?;
73    let start = detail.find("=(")? + 2;
74    let end = detail.rfind(") already")?;
75    if start <= end {
76        Some(detail[start..end].to_string())
77    } else {
78        None
79    }
80}
81
82#[doc(hidden)]
83/// Extracts the conflicting value from a database error's constraint violation.
84///
85/// Downcasts to [`sqlx::postgres::PgDatabaseError`], reads its `detail()`,
86/// and parses the conflicting value.
87///
88/// **Security note:** see [`parse_constraint_detail_value`] — the returned
89/// value may be PII and must not be exposed to untrusted clients.
90pub fn extract_constraint_value(db_err: &dyn sqlx::error::DatabaseError) -> Option<String> {
91    let pg_err = db_err.try_downcast_ref::<sqlx::postgres::PgDatabaseError>()?;
92    parse_constraint_detail_value(pg_err.detail())
93}
94
95#[doc(hidden)]
96/// Extracts the conflicting id from an events-table primary-key violation.
97///
98/// The events tables' primary key is `(id, sequence)`, so the violation
99/// detail reads `Key (id, sequence)=(<id>, <seq>) already exists.` — the id
100/// is everything before the last `, `. The sequence is an integer and can
101/// never contain `, `, so splitting at the last occurrence is unambiguous
102/// even for ids that themselves contain commas.
103///
104/// **Security note:** see [`parse_constraint_detail_value`] — the returned
105/// value may be PII and must not be exposed to untrusted clients.
106pub fn extract_events_pkey_id_value(db_err: &dyn sqlx::error::DatabaseError) -> Option<String> {
107    events_pkey_id_from_value(extract_constraint_value(db_err)?)
108}
109
110fn events_pkey_id_from_value(value: String) -> Option<String> {
111    match value.rsplit_once(", ") {
112        Some((id, _sequence)) => Some(id.to_string()),
113        None => Some(value),
114    }
115}
116
117/// The kind of database constraint behind a classified `ConstraintViolation`.
118///
119/// Structured kind exposed by a generated constraint rejection's diagnostics.
120/// An unrecognised constraint has no variant here: a generated repository
121/// classifies one as `Fatal(Invariant)` rather than exposing a rejection case,
122/// so every value that reaches this type names a constraint the catalog knows.
123#[derive(Debug, Clone, Copy, PartialEq, Eq)]
124pub enum ConstraintKind {
125    Unique,
126    ForeignKey,
127    Check,
128}
129
130/// A repo op that requires a row (`find_by_*`) found none. There is no
131/// `NotFound` rejection — callers that tolerate absence use
132/// `maybe_find_by_*`, which returns `Option` instead. Construct one and
133/// propagate it with `?`: it converts into any `errlanes::Fault` or
134/// `errlanes::Fail<D>` as `Fatal(Invariant)`.
135///
136/// **Never `impl errlanes::Rejection for NotFound`** — `#[derive(Classify)]`
137/// below makes `NotFound` a fault wrapper, and a type that is both a
138/// `Rejection` and a direct `Classify` impl conflicts (`E0119`).
139///
140/// **Security note:** `value`'s `Debug` may contain PII (e.g. an email
141/// address looked up by a caller-supplied value). `Display` omits it.
142#[derive(Debug, errlanes::Classify)]
143#[classify(fatal(Invariant), error = manual)]
144pub struct NotFound {
145    pub entity: &'static str,
146    pub column: Option<&'static str>,
147    pub value: String,
148}
149
150impl NotFound {
151    pub fn new(
152        entity: &'static str,
153        column: Option<&'static str>,
154        value: impl Into<String>,
155    ) -> Self {
156        Self {
157            entity,
158            column,
159            value: value.into(),
160        }
161    }
162}
163
164impl std::fmt::Display for NotFound {
165    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
166        match self.column {
167            Some(column) => write!(f, "{} not found by {column}", self.entity),
168            None => write!(f, "{} not found", self.entity),
169        }
170    }
171}
172
173impl std::error::Error for NotFound {}
174
175#[doc(hidden)]
176pub fn fatal_is_not_found(fatal: &errlanes::Fatal) -> bool {
177    std::error::Error::source(fatal).is_some_and(|s| s.is::<NotFound>())
178}
179
180#[doc(hidden)]
181/// `true` when the database error is a violation kind the generated repo
182/// classifiers surface as `ConstraintViolation`: unique (SQLSTATE 23505),
183/// foreign key (23503), or check (23514). `NOT NULL` (23502) and exclusion
184/// (23P01) violations are not classified and surface as `Sqlx`.
185pub fn is_classified_constraint_violation(db_err: &dyn sqlx::error::DatabaseError) -> bool {
186    db_err.is_unique_violation() || db_err.is_foreign_key_violation() || db_err.is_check_violation()
187}
188
189#[doc(hidden)]
190/// Wrapper used by generated code to format not-found values.
191/// Prefers `Display` over `Debug` via inherent-vs-trait method resolution.
192pub struct NotFoundValue<'a, T: ?Sized>(pub &'a T);
193
194impl<T: std::fmt::Display + ?Sized> NotFoundValue<'_, T> {
195    pub fn to_not_found_value(&self) -> String {
196        self.0.to_string()
197    }
198}
199
200#[doc(hidden)]
201pub trait ToNotFoundValueFallback {
202    fn to_not_found_value(&self) -> String;
203}
204
205impl<T: std::fmt::Debug + ?Sized> ToNotFoundValueFallback for NotFoundValue<'_, T> {
206    fn to_not_found_value(&self) -> String {
207        format!("{:?}", self.0)
208    }
209}
210
211#[cfg(test)]
212mod tests {
213    use super::*;
214    use proptest::prelude::*;
215
216    #[test]
217    fn parse_simple_uuid_value() {
218        let detail = Some("Key (id)=(550e8400-e29b-41d4-a716-446655440000) already exists.");
219        assert_eq!(
220            parse_constraint_detail_value(detail),
221            Some("550e8400-e29b-41d4-a716-446655440000".to_string())
222        );
223    }
224
225    #[test]
226    fn parse_string_value() {
227        let detail = Some("Key (email)=(user@example.com) already exists.");
228        assert_eq!(
229            parse_constraint_detail_value(detail),
230            Some("user@example.com".to_string())
231        );
232    }
233
234    #[test]
235    fn parse_composite_key_value() {
236        let detail = Some("Key (tenant_id, email)=(abc, user@example.com) already exists.");
237        assert_eq!(
238            parse_constraint_detail_value(detail),
239            Some("abc, user@example.com".to_string())
240        );
241    }
242
243    #[test]
244    fn parse_none_detail() {
245        assert_eq!(parse_constraint_detail_value(None), None);
246    }
247
248    #[test]
249    fn parse_unexpected_format() {
250        let detail = Some("something unexpected");
251        assert_eq!(parse_constraint_detail_value(detail), None);
252    }
253
254    #[test]
255    fn parse_value_containing_parentheses() {
256        let detail = Some("Key (name)=(foo (bar)) already exists.");
257        assert_eq!(
258            parse_constraint_detail_value(detail),
259            Some("foo (bar)".to_string())
260        );
261    }
262
263    #[test]
264    fn events_pkey_id_strips_the_sequence() {
265        let value = parse_constraint_detail_value(Some(
266            "Key (id, sequence)=(550e8400-e29b-41d4-a716-446655440000, 1) already exists.",
267        ))
268        .unwrap();
269        assert_eq!(
270            events_pkey_id_from_value(value),
271            Some("550e8400-e29b-41d4-a716-446655440000".to_string())
272        );
273    }
274
275    #[test]
276    fn events_pkey_id_keeps_commas_inside_the_id() {
277        assert_eq!(
278            events_pkey_id_from_value("a, b, 3".to_string()),
279            Some("a, b".to_string())
280        );
281    }
282
283    #[test]
284    fn events_pkey_id_without_separator_returns_value_as_is() {
285        assert_eq!(
286            events_pkey_id_from_value("plain".to_string()),
287            Some("plain".to_string())
288        );
289    }
290
291    #[test]
292    fn parse_empty_value() {
293        let detail = Some("Key (col)=() already exists.");
294        assert_eq!(parse_constraint_detail_value(detail), Some("".to_string()));
295    }
296
297    #[test]
298    fn not_found_value_uses_display_when_available() {
299        #[allow(unused_imports)]
300        use crate::ToNotFoundValueFallback;
301
302        // String implements Display - should get clean output
303        let val = "hello";
304        assert_eq!(NotFoundValue(val).to_not_found_value(), "hello");
305
306        // i32 implements Display
307        let num = 42;
308        assert_eq!(NotFoundValue(&num).to_not_found_value(), "42");
309    }
310
311    #[test]
312    fn not_found_value_falls_back_to_debug() {
313        use crate::ToNotFoundValueFallback;
314
315        // A type with Debug but no Display
316        #[derive(Debug)]
317        #[allow(dead_code)]
318        struct OnlyDebug(i32);
319
320        let val = OnlyDebug(7);
321        assert_eq!(NotFoundValue(&val).to_not_found_value(), "OnlyDebug(7)");
322    }
323
324    proptest! {
325        /// The parser does byte-index arithmetic over attacker-controlled strings.
326        /// It must never panic, and any value it returns must be an honest
327        /// substring bounded by the real markers.
328        #[test]
329        fn constraint_detail_never_panics_and_is_honest(detail in ".*") {
330            match parse_constraint_detail_value(Some(&detail)) {
331                None => {}
332                Some(v) => {
333                    // Returned value is always a genuine substring of the input.
334                    prop_assert!(detail.contains(&v));
335                    // The markers that drove the indices must actually be present.
336                    let start = detail.find("=(").expect("start marker present") + 2;
337                    let end = detail
338                        .rfind(") already")
339                        .expect("end marker present");
340                    prop_assert!(start <= end);
341                    prop_assert_eq!(&detail[start..end], v.as_str());
342                }
343            }
344        }
345
346        #[test]
347        fn constraint_detail_none_input_returns_none(_ in Just(())) {
348            prop_assert_eq!(parse_constraint_detail_value(None), None);
349        }
350    }
351}