es-entity 0.16.6

Event Sourcing Entity Framework
Documentation
//! Types for working with errors produced by es-entity.

/// Transient or fatal, never rejected: every read, and every write whose
/// statement can hit no constraint (see `RepositoryOptions::update_can_reject`
/// in the macro crate for exactly which generated updates qualify). Reads
/// cannot reject or deny; optional reads represent absence with `None`.
pub type RepoFault = errlanes::Fault<errlanes::lanes!(Transient, Fatal)>;

/// Repository write failures, with `C` carrying the typed constraint violation.
/// Writes may reject, fail transiently, or fail fatally, but cannot deny.
pub type RepoWriteError<C> = errlanes::Fail<C, errlanes::lanes!(Transient, Fatal)>;

/// Error type for entity hydration failures (reconstructing entities from events).
#[derive(Debug, errlanes::Classify)]
pub enum EntityHydrationError {
    #[classify(fatal(CorruptState), from)]
    UninitializedFieldError(derive_builder::UninitializedFieldError),
    // Pinned explicitly, not `delegate`: a persisted event row's JSON
    // failing to decode is corrupt *stored* data specifically, regardless
    // of what errlanes' generic `serde_json::Error` classification defaults
    // to elsewhere.
    #[classify(fatal(CorruptState), from)]
    EventDeserialization(serde_json::Error),
    /// A snapshot row matched the fingerprint bind but failed to deserialize
    /// into `S`. Never silently ignored, unlike a fingerprint mismatch — the
    /// operator fix is `DELETE FROM <tbl>_snapshots WHERE id = …`.
    ///
    /// Pinned explicitly for the same reason as `EventDeserialization`.
    #[error("EntityHydrationError - SnapshotDecode at sequence {sequence}: {source}")]
    #[classify(fatal(CorruptState))]
    SnapshotDecode {
        sequence: i32,
        #[source]
        source: serde_json::Error,
    },
    /// The first tail event after a snapshot did not immediately follow it.
    #[error(
        "EntityHydrationError - SnapshotGap: snapshot at sequence {snapshot_sequence}, next event at {next_event_sequence}"
    )]
    #[classify(fatal(CorruptState))]
    SnapshotGap {
        snapshot_sequence: i32,
        next_event_sequence: i32,
    },
    /// A hydration row carried neither an event nor a usable snapshot.
    #[classify(fatal(CorruptState))]
    NoEvents,
}

#[derive(Debug, errlanes::Classify)]
#[error("CursorDestructureError: couldn't turn {0} into {1}")]
#[classify(fatal(Config))]
pub struct CursorDestructureError(&'static str, &'static str);

impl From<(&'static str, &'static str)> for CursorDestructureError {
    fn from((name, variant): (&'static str, &'static str)) -> Self {
        Self(name, variant)
    }
}

#[doc(hidden)]
/// Extracts the conflicting value from a PostgreSQL constraint violation detail message.
///
/// PostgreSQL formats unique violation details as:
/// `Key (column)=(value) already exists.` — but `lc_messages` localises the
/// surrounding words (and sometimes the quote characters) around that
/// `(column)=(value)` core, so this anchors on the first `=(` and the *last*
/// `)` in the detail rather than on the English `") already"`. Every fixed
/// case below (plain, composite, a value containing `)` or `, `, an empty
/// value, and a translated detail) is honoured by this rule unchanged.
///
/// Returns `None` if the detail is missing or doesn't match the expected format.
///
/// Known weakness, accepted: trailing text after the value that itself
/// contains a `)` (not something PostgreSQL's own detail format produces)
/// would overshoot and swallow it into the returned value.
///
/// **Security note:** the extracted value is attacker-influenced input that
/// was rejected by a unique constraint and may be PII (e.g. an email
/// address). Returning it to untrusted API clients enables user
/// enumeration; logging it may place PII in log pipelines.
pub fn parse_constraint_detail_value(detail: Option<&str>) -> Option<String> {
    let detail = detail?;
    let start = detail.find("=(")? + 2;
    let end = detail.rfind(')')?;
    if start <= end {
        Some(detail[start..end].to_string())
    } else {
        None
    }
}

/// Extracts the conflicting value from a database error's constraint violation.
///
/// Downcasts to [`sqlx::postgres::PgDatabaseError`], reads its `detail()`,
/// and parses the conflicting value. Called by generated `create_all` code
/// to attribute a batch's duplicate-id conflict to one of its own ids — see
/// [`crate::IdConflict`].
///
/// **Security note:** see [`parse_constraint_detail_value`] — the returned
/// value may be PII and must not be exposed to untrusted clients.
pub fn extract_constraint_value(db_err: &dyn sqlx::error::DatabaseError) -> Option<String> {
    let pg_err = db_err.try_downcast_ref::<sqlx::postgres::PgDatabaseError>()?;
    parse_constraint_detail_value(pg_err.detail())
}

/// Extracts the conflicting id from an events-table primary-key violation.
///
/// The events tables' primary key is `(id, sequence)`, so the violation
/// detail reads `Key (id, sequence)=(<id>, <seq>) already exists.` — the id
/// is everything before the last `, `. The sequence is an integer and can
/// never contain `, `, so splitting at the last occurrence is unambiguous
/// even for ids that themselves contain commas. Called by generated
/// `create_all` code — see [`crate::IdConflict`].
///
/// **Security note:** see [`parse_constraint_detail_value`] — the returned
/// value may be PII and must not be exposed to untrusted clients.
pub fn extract_events_pkey_id_value(db_err: &dyn sqlx::error::DatabaseError) -> Option<String> {
    events_pkey_id_from_value(extract_constraint_value(db_err)?)
}

fn events_pkey_id_from_value(value: String) -> Option<String> {
    match value.rsplit_once(", ") {
        Some((id, _sequence)) => Some(id.to_string()),
        None => Some(value),
    }
}

/// The kind of database constraint behind a classified `ConstraintViolation`.
///
/// Structured kind exposed by a generated constraint rejection's diagnostics.
/// An unrecognised constraint has no variant here: a generated repository
/// classifies one as `Fatal(Invariant)` rather than exposing a rejection case,
/// so every value that reaches this type names a constraint the catalog knows.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ConstraintKind {
    Unique,
    ForeignKey,
    Check,
}

/// A repo op that requires a row (`find_by_*`) found none. There is no
/// `NotFound` rejection — callers that tolerate absence use
/// `maybe_find_by_*`, which returns `Option` instead. Construct one and
/// propagate it with `?`: it converts into any `errlanes::Fault` or
/// `errlanes::Fail<D>` as `Fatal(Invariant)`.
///
/// **Never `impl errlanes::Rejection for NotFound`** — `#[derive(Classify)]`
/// below makes `NotFound` a fault wrapper, and a type that is both a
/// `Rejection` and a direct `Classify` impl conflicts (`E0119`).
///
/// **Security note:** `value`'s `Debug` may contain PII (e.g. an email
/// address looked up by a caller-supplied value). `Display` omits it.
#[derive(Debug, errlanes::Classify)]
#[classify(fatal(Invariant), error = manual)]
pub struct NotFound {
    pub entity: &'static str,
    pub column: Option<&'static str>,
    pub value: String,
}

impl NotFound {
    pub fn new(
        entity: &'static str,
        column: Option<&'static str>,
        value: impl Into<String>,
    ) -> Self {
        Self {
            entity,
            column,
            value: value.into(),
        }
    }
}

impl std::fmt::Display for NotFound {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self.column {
            Some(column) => write!(f, "{} not found by {column}", self.entity),
            None => write!(f, "{} not found", self.entity),
        }
    }
}

impl std::error::Error for NotFound {}

#[doc(hidden)]
pub fn fatal_is_not_found(fatal: &errlanes::Fatal) -> bool {
    std::error::Error::source(fatal).is_some_and(|s| s.is::<NotFound>())
}

#[doc(hidden)]
/// `true` when the database error is a violation kind the generated repo
/// classifiers surface as `ConstraintViolation`: unique (SQLSTATE 23505),
/// foreign key (23503), or check (23514). `NOT NULL` (23502) and exclusion
/// (23P01) violations are not classified and surface as `Sqlx`.
pub fn is_classified_constraint_violation(db_err: &dyn sqlx::error::DatabaseError) -> bool {
    db_err.is_unique_violation() || db_err.is_foreign_key_violation() || db_err.is_check_violation()
}

#[doc(hidden)]
/// Wrapper used by generated code to format not-found values.
/// Prefers `Display` over `Debug` via inherent-vs-trait method resolution.
pub struct NotFoundValue<'a, T: ?Sized>(pub &'a T);

impl<T: std::fmt::Display + ?Sized> NotFoundValue<'_, T> {
    pub fn to_not_found_value(&self) -> String {
        self.0.to_string()
    }
}

#[doc(hidden)]
pub trait ToNotFoundValueFallback {
    fn to_not_found_value(&self) -> String;
}

impl<T: std::fmt::Debug + ?Sized> ToNotFoundValueFallback for NotFoundValue<'_, T> {
    fn to_not_found_value(&self) -> String {
        format!("{:?}", self.0)
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use proptest::prelude::*;

    #[test]
    fn parse_simple_uuid_value() {
        let detail = Some("Key (id)=(550e8400-e29b-41d4-a716-446655440000) already exists.");
        assert_eq!(
            parse_constraint_detail_value(detail),
            Some("550e8400-e29b-41d4-a716-446655440000".to_string())
        );
    }

    #[test]
    fn parse_string_value() {
        let detail = Some("Key (email)=(user@example.com) already exists.");
        assert_eq!(
            parse_constraint_detail_value(detail),
            Some("user@example.com".to_string())
        );
    }

    #[test]
    fn parse_composite_key_value() {
        let detail = Some("Key (tenant_id, email)=(abc, user@example.com) already exists.");
        assert_eq!(
            parse_constraint_detail_value(detail),
            Some("abc, user@example.com".to_string())
        );
    }

    #[test]
    fn parse_none_detail() {
        assert_eq!(parse_constraint_detail_value(None), None);
    }

    #[test]
    fn parse_unexpected_format() {
        let detail = Some("something unexpected");
        assert_eq!(parse_constraint_detail_value(detail), None);
    }

    #[test]
    fn parse_value_containing_parentheses() {
        let detail = Some("Key (name)=(foo (bar)) already exists.");
        assert_eq!(
            parse_constraint_detail_value(detail),
            Some("foo (bar)".to_string())
        );
    }

    #[test]
    fn events_pkey_id_strips_the_sequence() {
        let value = parse_constraint_detail_value(Some(
            "Key (id, sequence)=(550e8400-e29b-41d4-a716-446655440000, 1) already exists.",
        ))
        .unwrap();
        assert_eq!(
            events_pkey_id_from_value(value),
            Some("550e8400-e29b-41d4-a716-446655440000".to_string())
        );
    }

    #[test]
    fn events_pkey_id_keeps_commas_inside_the_id() {
        assert_eq!(
            events_pkey_id_from_value("a, b, 3".to_string()),
            Some("a, b".to_string())
        );
    }

    #[test]
    fn events_pkey_id_without_separator_returns_value_as_is() {
        assert_eq!(
            events_pkey_id_from_value("plain".to_string()),
            Some("plain".to_string())
        );
    }

    #[test]
    fn parse_empty_value() {
        let detail = Some("Key (col)=() already exists.");
        assert_eq!(parse_constraint_detail_value(detail), Some("".to_string()));
    }

    /// `lc_messages` localises the surrounding words and, as here, the quote
    /// characters — German renders it as `Schlüssel »(id)=(wert)«
    /// existiert bereits.`. The parser anchors on `=(` and the last `)`,
    /// neither of which depends on the English wording, so this must parse
    /// exactly like the English case.
    #[test]
    fn parse_translated_detail_value() {
        let detail =
            Some("Schlüssel »(id)=(550e8400-e29b-41d4-a716-446655440000)« existiert bereits.");
        assert_eq!(
            parse_constraint_detail_value(detail),
            Some("550e8400-e29b-41d4-a716-446655440000".to_string())
        );
    }

    #[test]
    fn not_found_value_uses_display_when_available() {
        #[allow(unused_imports)]
        use crate::ToNotFoundValueFallback;

        // String implements Display - should get clean output
        let val = "hello";
        assert_eq!(NotFoundValue(val).to_not_found_value(), "hello");

        // i32 implements Display
        let num = 42;
        assert_eq!(NotFoundValue(&num).to_not_found_value(), "42");
    }

    #[test]
    fn not_found_value_falls_back_to_debug() {
        use crate::ToNotFoundValueFallback;

        // A type with Debug but no Display
        #[derive(Debug)]
        #[allow(dead_code)]
        struct OnlyDebug(i32);

        let val = OnlyDebug(7);
        assert_eq!(NotFoundValue(&val).to_not_found_value(), "OnlyDebug(7)");
    }

    proptest! {
        /// The parser does byte-index arithmetic over attacker-controlled strings.
        /// It must never panic, and any value it returns must be an honest
        /// substring bounded by the real markers.
        #[test]
        fn constraint_detail_never_panics_and_is_honest(detail in ".*") {
            match parse_constraint_detail_value(Some(&detail)) {
                None => {}
                Some(v) => {
                    // Returned value is always a genuine substring of the input.
                    prop_assert!(detail.contains(&v));
                    // The markers that drove the indices must actually be present.
                    let start = detail.find("=(").expect("start marker present") + 2;
                    let end = detail.rfind(')').expect("end marker present");
                    prop_assert!(start <= end);
                    prop_assert_eq!(&detail[start..end], v.as_str());
                }
            }
        }

        #[test]
        fn constraint_detail_none_input_returns_none(_ in Just(())) {
            prop_assert_eq!(parse_constraint_detail_value(None), None);
        }
    }
}