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