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