Skip to main content

mnesis_store/
error.rs

1use mnesis::{ErrorId, KernelError, Version};
2use thiserror::Error;
3
4/// Errors from the event store layer.
5///
6/// Generic over adapter (`A`), encode (`EncErr`), and decode (`DecErr`) error
7/// types — zero allocation, no `Box<dyn Error>`.
8///
9/// `EncErr` and `DecErr` are independent so write-only and read-only codecs
10/// can each set the unused side to `Infallible`. For the common case where
11/// a single underlying format powers both directions, the implementor picks
12/// the same `Error` associated type on both `Encode` and `Decode` impls and
13/// `EncErr == DecErr` falls out without a where-clause.
14///
15/// Upcast errors are *not* part of this type — the no-upcaster
16/// [`load`](crate::Repository::load) / [`save`](crate::Repository::save)
17/// path can't produce them. When the user calls
18/// [`EventStore::load_with`](crate::EventStore::load_with) (passing an
19/// upcast function), the result wraps `StoreError` in
20/// [`LoadWithError`] alongside the user's upcast error type.
21#[derive(Debug, Error)]
22#[non_exhaustive]
23pub enum StoreError<A, EncErr, DecErr> {
24    /// Optimistic concurrency conflict.
25    #[error(
26        "concurrency conflict on stream '{stream_id}': expected version {expected:?}, actual {actual:?}"
27    )]
28    Conflict {
29        stream_id: ErrorId,
30        expected: Option<Version>,
31        actual: Option<Version>,
32    },
33
34    /// Stream not found.
35    #[error("stream '{stream_id}' not found")]
36    StreamNotFound { stream_id: ErrorId },
37
38    /// Database adapter failure.
39    #[error("adapter error: {0}")]
40    Adapter(#[source] A),
41
42    /// Serialization failure on the write path.
43    #[error("encode error: {0}")]
44    Encode(#[source] EncErr),
45
46    /// Deserialization failure on the read path.
47    #[error("decode error: {0}")]
48    Decode(#[source] DecErr),
49
50    /// Kernel error during replay (e.g. version mismatch, rehydration limit).
51    #[error("kernel error: {0}")]
52    Kernel(#[from] KernelError),
53
54    /// Version overflow: cannot advance past `u64::MAX`.
55    #[error("version overflow: cannot advance past u64::MAX")]
56    VersionOverflow,
57
58    /// Failure while synthesizing a fresh envelope for codec decode.
59    ///
60    /// Reachable only from upcaster-driven paths
61    /// ([`EventStore::load_with`](crate::EventStore::load_with)):
62    /// after the user's upcast transforms the event, a fresh aligned
63    /// envelope is built from the transformed `event_type` + payload via
64    /// [`PersistedEnvelope::for_decode`](crate::PersistedEnvelope::for_decode).
65    /// The build can fail at the value-newtype boundary (oversize
66    /// `event_type`/`payload`), the wire encode (`FrameLengthOverflow`),
67    /// or the envelope construction (range invariants).
68    #[error("envelope synthesis error: {0}")]
69    EnvelopeSynthesis(#[source] crate::envelope::ForDecodeError),
70
71    /// Envelope construction rejected user-supplied bytes
72    /// (payload exceeded its size cap, or another value-newtype invariant).
73    ///
74    /// Raised on the save path when an encoded payload violates the
75    /// invariants enforced by the [`PendingEnvelope`](crate::PendingEnvelope)
76    /// builder. The schema-version-zero case is not reachable from the
77    /// typed-repository save path because `SchemaVersion` is constructed
78    /// from `NonZeroU32`.
79    #[error("envelope error: {0}")]
80    Envelope(#[from] crate::envelope::EnvelopeError),
81}
82
83impl<A, EncErr, DecErr> StoreError<A, EncErr, DecErr> {
84    /// Returns `true` if this is an optimistic-concurrency [`Conflict`].
85    ///
86    /// [`Conflict`] is the one error the store can assert a *semantic* fact
87    /// about that the consumer cannot infer otherwise: the expected version
88    /// was stale, so reloading the aggregate and re-running the (pure,
89    /// side-effect-free) decision will likely succeed. It is the natural
90    /// predicate for a consumer-owned retry loop — e.g. a supervising actor
91    /// retrying load → handle → save, or a `tower::retry::Policy` /
92    /// `backon` `.when(|e| e.is_conflict())`.
93    ///
94    /// mnesis deliberately ships no retry machinery: classifying *other*
95    /// errors as retryable (transient adapter I/O, say) needs context only
96    /// the consumer has, and the loop, backoff, and sleep are runtime
97    /// concerns. This predicate is the entire retry-facing surface.
98    ///
99    /// [`Conflict`]: StoreError::Conflict
100    #[must_use]
101    pub const fn is_conflict(&self) -> bool {
102        matches!(self, Self::Conflict { .. })
103    }
104}
105
106/// Errors from the with-upcaster load path.
107///
108/// Returned by [`EventStore::load_with`](crate::EventStore::load_with).
109/// Wraps the four error sources [`StoreError`] already carries plus the
110/// user-supplied upcast function's error type.
111///
112/// `LoadWithError<A, EncErr, DecErr, UpErr>` is structurally `StoreError +
113/// Upcast(UpErr)`. The `From<StoreError<A, EncErr, DecErr>>` impl lets the
114/// `?` operator promote a `StoreError` into the wider variant inside a
115/// `load_with` body without manual matching.
116#[derive(Debug, Error)]
117#[non_exhaustive]
118pub enum LoadWithError<A, EncErr, DecErr, UpErr> {
119    /// All non-upcast errors — wrapped verbatim from the no-upcaster path.
120    #[error(transparent)]
121    Store(#[from] StoreError<A, EncErr, DecErr>),
122
123    /// Upcast function failure — carries the user-supplied error verbatim.
124    /// No wrapper is applied; encode diagnostic context (event type, schema
125    /// version) in your own error type if needed.
126    #[error("upcast error: {0}")]
127    Upcast(#[source] UpErr),
128}
129
130impl<A, EncErr, DecErr, UpErr> From<KernelError> for LoadWithError<A, EncErr, DecErr, UpErr> {
131    fn from(err: KernelError) -> Self {
132        Self::Store(StoreError::Kernel(err))
133    }
134}
135
136/// Structured error from [`RawEventStore::append`](crate::RawEventStore::append).
137///
138/// Separates concurrency conflicts (a normal, expected condition in
139/// optimistic concurrency) from adapter-level failures (I/O, connection).
140/// This lets the `EventStore` facade map conflicts to
141/// [`StoreError::Conflict`] without opaque wrapping.
142#[derive(Debug, Error)]
143#[non_exhaustive]
144pub enum AppendError<E> {
145    /// Optimistic concurrency conflict — expected version doesn't match.
146    #[error(
147        "concurrency conflict on '{stream_id}': expected version {expected:?}, actual {actual:?}"
148    )]
149    Conflict {
150        stream_id: ErrorId,
151        expected: Option<Version>,
152        actual: Option<Version>,
153    },
154    /// Adapter-level failure (I/O, serialization, connection, etc.).
155    #[error("store error: {0}")]
156    Store(#[source] E),
157}
158/// Neutral result of validating the append contract
159/// ([`validate_append_versions`]). Adapters map this into their own
160/// `AppendError<E>` at the boundary (rule 3: one variant per failure domain).
161///
162/// `VersionOverflow` is never a retry-eligible `Conflict` — it maps to
163/// `AppendError::Store(..)`, never `AppendError::Conflict`.
164///
165/// Unlike the terminal error enums in this module ([`AppendError`],
166/// [`StoreError`]), this type is deliberately **exhaustive** (no
167/// `#[non_exhaustive]`): it is a *closed translation contract*, not an error a
168/// user receives. Every adapter maps it total-ly into its own `AppendError` at
169/// the crate boundary (`mnesis-inmemory`, `mnesis-fjall`, `mnesis-postgres`
170/// each have exactly one 2-arm `match`). A new variant here is a new class of
171/// append-validation failure that adapters MUST translate; the compile-time
172/// break that exhaustiveness forces is the intended safeguard — `#[non_exhaustive]`
173/// would convert it into a silent `_`-arm mis-map with no correct neutral target.
174#[derive(Debug, Error)]
175pub enum AppendValidationError {
176    /// Optimistic-concurrency or non-sequential-version conflict.
177    #[error("append conflict on '{stream_id}': expected {expected:?}, actual {actual:?}")]
178    Conflict {
179        stream_id: ErrorId,
180        expected: Option<Version>,
181        actual: Option<Version>,
182    },
183    /// The stream version sequence would advance past `u64::MAX`.
184    #[error("stream version overflow at u64::MAX")]
185    VersionOverflow,
186}