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}