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.` — but `lc_messages` localises the
64/// surrounding words (and sometimes the quote characters) around that
65/// `(column)=(value)` core, so this anchors on the first `=(` and the *last*
66/// `)` in the detail rather than on the English `") already"`. Every fixed
67/// case below (plain, composite, a value containing `)` or `, `, an empty
68/// value, and a translated detail) is honoured by this rule unchanged.
69///
70/// Returns `None` if the detail is missing or doesn't match the expected format.
71///
72/// Known weakness, accepted: trailing text after the value that itself
73/// contains a `)` (not something PostgreSQL's own detail format produces)
74/// would overshoot and swallow it into the returned value.
75///
76/// **Security note:** the extracted value is attacker-influenced input that
77/// was rejected by a unique constraint and may be PII (e.g. an email
78/// address). Returning it to untrusted API clients enables user
79/// enumeration; logging it may place PII in log pipelines.
80pub fn parse_constraint_detail_value(detail: Option<&str>) -> Option<String> {
81    let detail = detail?;
82    let start = detail.find("=(")? + 2;
83    let end = detail.rfind(')')?;
84    if start <= end {
85        Some(detail[start..end].to_string())
86    } else {
87        None
88    }
89}
90
91/// Extracts the conflicting value from a database error's constraint violation.
92///
93/// Downcasts to [`sqlx::postgres::PgDatabaseError`], reads its `detail()`,
94/// and parses the conflicting value. Called by generated `create_all` code
95/// to attribute a batch's duplicate-id conflict to one of its own ids — see
96/// [`crate::IdConflict`].
97///
98/// **Security note:** see [`parse_constraint_detail_value`] — the returned
99/// value may be PII and must not be exposed to untrusted clients.
100pub fn extract_constraint_value(db_err: &dyn sqlx::error::DatabaseError) -> Option<String> {
101    let pg_err = db_err.try_downcast_ref::<sqlx::postgres::PgDatabaseError>()?;
102    parse_constraint_detail_value(pg_err.detail())
103}
104
105/// Extracts the conflicting id from an events-table primary-key violation.
106///
107/// The events tables' primary key is `(id, sequence)`, so the violation
108/// detail reads `Key (id, sequence)=(<id>, <seq>) already exists.` — the id
109/// is everything before the last `, `. The sequence is an integer and can
110/// never contain `, `, so splitting at the last occurrence is unambiguous
111/// even for ids that themselves contain commas. Called by generated
112/// `create_all` code — see [`crate::IdConflict`].
113///
114/// **Security note:** see [`parse_constraint_detail_value`] — the returned
115/// value may be PII and must not be exposed to untrusted clients.
116pub fn extract_events_pkey_id_value(db_err: &dyn sqlx::error::DatabaseError) -> Option<String> {
117    events_pkey_id_from_value(extract_constraint_value(db_err)?)
118}
119
120fn events_pkey_id_from_value(value: String) -> Option<String> {
121    match value.rsplit_once(", ") {
122        Some((id, _sequence)) => Some(id.to_string()),
123        None => Some(value),
124    }
125}
126
127/// The kind of database constraint behind a classified `ConstraintViolation`.
128///
129/// Structured kind exposed by a generated constraint rejection's diagnostics.
130/// An unrecognised constraint has no variant here: a generated repository
131/// classifies one as `Fatal(Invariant)` rather than exposing a rejection case,
132/// so every value that reaches this type names a constraint the catalog knows.
133#[derive(Debug, Clone, Copy, PartialEq, Eq)]
134pub enum ConstraintKind {
135    Unique,
136    ForeignKey,
137    Check,
138}
139
140/// A repo op that requires a row (`find_by_*`) found none. There is no
141/// `NotFound` rejection — callers that tolerate absence use
142/// `maybe_find_by_*`, which returns `Option` instead. Construct one and
143/// propagate it with `?`: it converts into any `errlanes::Fault` or
144/// `errlanes::Fail<D>` as `Fatal(Invariant)`.
145///
146/// **Never `impl errlanes::Rejection for NotFound`** — `#[derive(Classify)]`
147/// below makes `NotFound` a fault wrapper, and a type that is both a
148/// `Rejection` and a direct `Classify` impl conflicts (`E0119`).
149///
150/// **Security note:** `value`'s `Debug` may contain PII (e.g. an email
151/// address looked up by a caller-supplied value). `Display` omits it.
152#[derive(Debug, errlanes::Classify)]
153#[classify(fatal(Invariant), error = manual)]
154pub struct NotFound {
155    pub entity: &'static str,
156    pub column: Option<&'static str>,
157    pub value: String,
158}
159
160impl NotFound {
161    pub fn new(
162        entity: &'static str,
163        column: Option<&'static str>,
164        value: impl Into<String>,
165    ) -> Self {
166        Self {
167            entity,
168            column,
169            value: value.into(),
170        }
171    }
172}
173
174impl std::fmt::Display for NotFound {
175    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
176        match self.column {
177            Some(column) => write!(f, "{} not found by {column}", self.entity),
178            None => write!(f, "{} not found", self.entity),
179        }
180    }
181}
182
183impl std::error::Error for NotFound {}
184
185#[doc(hidden)]
186pub fn fatal_is_not_found(fatal: &errlanes::Fatal) -> bool {
187    std::error::Error::source(fatal).is_some_and(|s| s.is::<NotFound>())
188}
189
190#[doc(hidden)]
191/// `true` when the database error is a violation kind the generated repo
192/// classifiers surface as `ConstraintViolation`: unique (SQLSTATE 23505),
193/// foreign key (23503), or check (23514). `NOT NULL` (23502) and exclusion
194/// (23P01) violations are not classified and surface as `Sqlx`.
195pub fn is_classified_constraint_violation(db_err: &dyn sqlx::error::DatabaseError) -> bool {
196    db_err.is_unique_violation() || db_err.is_foreign_key_violation() || db_err.is_check_violation()
197}
198
199#[doc(hidden)]
200/// Wrapper used by generated code to format not-found values.
201/// Prefers `Display` over `Debug` via inherent-vs-trait method resolution.
202pub struct NotFoundValue<'a, T: ?Sized>(pub &'a T);
203
204impl<T: std::fmt::Display + ?Sized> NotFoundValue<'_, T> {
205    pub fn to_not_found_value(&self) -> String {
206        self.0.to_string()
207    }
208}
209
210#[doc(hidden)]
211pub trait ToNotFoundValueFallback {
212    fn to_not_found_value(&self) -> String;
213}
214
215impl<T: std::fmt::Debug + ?Sized> ToNotFoundValueFallback for NotFoundValue<'_, T> {
216    fn to_not_found_value(&self) -> String {
217        format!("{:?}", self.0)
218    }
219}
220
221#[cfg(test)]
222mod tests {
223    use super::*;
224    use proptest::prelude::*;
225
226    #[test]
227    fn parse_simple_uuid_value() {
228        let detail = Some("Key (id)=(550e8400-e29b-41d4-a716-446655440000) already exists.");
229        assert_eq!(
230            parse_constraint_detail_value(detail),
231            Some("550e8400-e29b-41d4-a716-446655440000".to_string())
232        );
233    }
234
235    #[test]
236    fn parse_string_value() {
237        let detail = Some("Key (email)=(user@example.com) already exists.");
238        assert_eq!(
239            parse_constraint_detail_value(detail),
240            Some("user@example.com".to_string())
241        );
242    }
243
244    #[test]
245    fn parse_composite_key_value() {
246        let detail = Some("Key (tenant_id, email)=(abc, user@example.com) already exists.");
247        assert_eq!(
248            parse_constraint_detail_value(detail),
249            Some("abc, user@example.com".to_string())
250        );
251    }
252
253    #[test]
254    fn parse_none_detail() {
255        assert_eq!(parse_constraint_detail_value(None), None);
256    }
257
258    #[test]
259    fn parse_unexpected_format() {
260        let detail = Some("something unexpected");
261        assert_eq!(parse_constraint_detail_value(detail), None);
262    }
263
264    #[test]
265    fn parse_value_containing_parentheses() {
266        let detail = Some("Key (name)=(foo (bar)) already exists.");
267        assert_eq!(
268            parse_constraint_detail_value(detail),
269            Some("foo (bar)".to_string())
270        );
271    }
272
273    #[test]
274    fn events_pkey_id_strips_the_sequence() {
275        let value = parse_constraint_detail_value(Some(
276            "Key (id, sequence)=(550e8400-e29b-41d4-a716-446655440000, 1) already exists.",
277        ))
278        .unwrap();
279        assert_eq!(
280            events_pkey_id_from_value(value),
281            Some("550e8400-e29b-41d4-a716-446655440000".to_string())
282        );
283    }
284
285    #[test]
286    fn events_pkey_id_keeps_commas_inside_the_id() {
287        assert_eq!(
288            events_pkey_id_from_value("a, b, 3".to_string()),
289            Some("a, b".to_string())
290        );
291    }
292
293    #[test]
294    fn events_pkey_id_without_separator_returns_value_as_is() {
295        assert_eq!(
296            events_pkey_id_from_value("plain".to_string()),
297            Some("plain".to_string())
298        );
299    }
300
301    #[test]
302    fn parse_empty_value() {
303        let detail = Some("Key (col)=() already exists.");
304        assert_eq!(parse_constraint_detail_value(detail), Some("".to_string()));
305    }
306
307    /// `lc_messages` localises the surrounding words and, as here, the quote
308    /// characters — German renders it as `Schlüssel »(id)=(wert)«
309    /// existiert bereits.`. The parser anchors on `=(` and the last `)`,
310    /// neither of which depends on the English wording, so this must parse
311    /// exactly like the English case.
312    #[test]
313    fn parse_translated_detail_value() {
314        let detail =
315            Some("Schlüssel »(id)=(550e8400-e29b-41d4-a716-446655440000)« existiert bereits.");
316        assert_eq!(
317            parse_constraint_detail_value(detail),
318            Some("550e8400-e29b-41d4-a716-446655440000".to_string())
319        );
320    }
321
322    #[test]
323    fn not_found_value_uses_display_when_available() {
324        #[allow(unused_imports)]
325        use crate::ToNotFoundValueFallback;
326
327        // String implements Display - should get clean output
328        let val = "hello";
329        assert_eq!(NotFoundValue(val).to_not_found_value(), "hello");
330
331        // i32 implements Display
332        let num = 42;
333        assert_eq!(NotFoundValue(&num).to_not_found_value(), "42");
334    }
335
336    #[test]
337    fn not_found_value_falls_back_to_debug() {
338        use crate::ToNotFoundValueFallback;
339
340        // A type with Debug but no Display
341        #[derive(Debug)]
342        #[allow(dead_code)]
343        struct OnlyDebug(i32);
344
345        let val = OnlyDebug(7);
346        assert_eq!(NotFoundValue(&val).to_not_found_value(), "OnlyDebug(7)");
347    }
348
349    proptest! {
350        /// The parser does byte-index arithmetic over attacker-controlled strings.
351        /// It must never panic, and any value it returns must be an honest
352        /// substring bounded by the real markers.
353        #[test]
354        fn constraint_detail_never_panics_and_is_honest(detail in ".*") {
355            match parse_constraint_detail_value(Some(&detail)) {
356                None => {}
357                Some(v) => {
358                    // Returned value is always a genuine substring of the input.
359                    prop_assert!(detail.contains(&v));
360                    // The markers that drove the indices must actually be present.
361                    let start = detail.find("=(").expect("start marker present") + 2;
362                    let end = detail.rfind(')').expect("end marker present");
363                    prop_assert!(start <= end);
364                    prop_assert_eq!(&detail[start..end], v.as_str());
365                }
366            }
367        }
368
369        #[test]
370        fn constraint_detail_none_input_returns_none(_ in Just(())) {
371            prop_assert_eq!(parse_constraint_detail_value(None), None);
372        }
373    }
374}