Skip to main content

es_entity/
error.rs

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