mnesis_store/execute.rs
1//! Store-side command combinator — the aggregate analogue of
2//! [`SagaRepository`](crate::SagaRepository).
3//!
4//! [`CommandRepository::execute`] fuses `decide → save` into one call so the
5//! decided events can't be forgotten or misthreaded (#251). It adds no
6//! persistence machinery — it is the "imperative shell" over the pure
7//! [`AggregateRoot::handle`](mnesis::AggregateRoot::handle) and the atomic
8//! [`Repository::save`](crate::Repository::save).
9//!
10//! See `docs/plans/2026-07-02-execute-command-combinator-design.md`.
11
12use core::fmt;
13use core::future::Future;
14
15use mnesis::{Aggregate, AggregateRoot, DomainEvent, EventOf, Events, Handle};
16
17use crate::conflict::ConflictPredicate;
18use crate::repository::Repository;
19
20/// Error from a command `execute`. Two failure domains kept distinct
21/// (CLAUDE.md rule 3 — one variant = one domain).
22#[derive(Debug, thiserror::Error)]
23#[non_exhaustive]
24pub enum ExecuteError<DecideErr, StoreErr> {
25 /// The aggregate rejected the command (a domain invariant). Nothing persisted.
26 #[error("command rejected: {0}")]
27 Decide(#[source] DecideErr),
28
29 /// `save` failed (adapter / codec / conflict / version overflow).
30 #[error(transparent)]
31 Store(StoreErr),
32}
33
34impl<DecideErr, StoreErr: ConflictPredicate> ExecuteError<DecideErr, StoreErr> {
35 /// `true` iff the save failed on an optimistic-concurrency conflict.
36 /// `Decide` is never a conflict (rule 3 — rejection is not retryable).
37 #[must_use]
38 pub fn is_conflict(&self) -> bool {
39 matches!(self, Self::Store(e) if e.is_conflict())
40 }
41}
42
43/// Outcome of one [`CommandRepository::execute`] — the aggregate-side dual of
44/// [`Reaction`](crate::Reaction).
45///
46/// Two variants, exactly mirroring [`Handle::handle`]'s own `Option`: a no-op
47/// decision persists nothing and has no position; an accepted command's events
48/// are durable and carry the `$all` position they landed at.
49///
50/// `#[must_use]`: the `Executed` position is the read-your-writes token — a
51/// caller that reads its own write back needs it (#330).
52#[must_use = "the read-your-writes position and the decided events should be inspected"]
53pub enum Execution<A: Aggregate, P, const N: usize> {
54 /// [`Handle::handle`] returned `Ok(None)` — accepted, decided nothing.
55 /// **No append was issued**, so `root` keeps its version, no `GlobalSeq`
56 /// is burned, and there is no position. Read the unchanged state off `root`.
57 Ignored,
58 /// The command was accepted and its events are durable.
59 Executed {
60 /// The `$all` position the **last** decided event landed at — the
61 /// read-your-writes token. A projection whose checkpoint has reached it
62 /// has necessarily observed this whole append.
63 position: P,
64 /// The decided events, for inspection.
65 events: Events<EventOf<A>, N>,
66 },
67}
68
69// Manual Debug: `A` is a bare marker (never `Debug`); its event type and the
70// position are, so no extra bound leaks onto the marker.
71impl<A: Aggregate, P: fmt::Debug, const N: usize> fmt::Debug for Execution<A, P, N>
72where
73 EventOf<A>: DomainEvent,
74{
75 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
76 match self {
77 Self::Ignored => f.write_str("Ignored"),
78 Self::Executed { position, events } => f
79 .debug_struct("Executed")
80 .field("position", position)
81 .field("events", events)
82 .finish(),
83 }
84 }
85}
86
87/// The command-facing port: `decide → save` as one callable transaction.
88///
89/// Extends [`Repository<A>`] and inherits its `load`/`save` unchanged. The one
90/// provided method rides on every repository via the blanket impl below — bare
91/// [`EventStore`](crate::EventStore) and the
92/// [`Snapshotting`](crate::snapshot::Snapshotting) decorator alike.
93pub trait CommandRepository<A: Aggregate>: Repository<A> {
94 /// Decide `command` against `root`, persist the decided events atomically,
95 /// advance `root`, and return an [`Execution`] carrying the read-your-writes
96 /// position and the decided events.
97 ///
98 /// - `Ok(Execution::Executed { position, events })` — accepted and durable;
99 /// `position` is the `$all` position the last event landed at (#330).
100 /// - `Ok(Execution::Ignored)` — the command was accepted and decided nothing
101 /// ([`Handle::handle`] returned `Ok(None)`); **no append is issued**, so
102 /// `root` keeps its version, no `GlobalSeq` is burned, and a fresh
103 /// aggregate stays streamless. Read the unchanged state off `root`.
104 /// - `Err(ExecuteError::Decide)` — the aggregate rejected it; nothing persisted.
105 /// - `Err(ExecuteError::Store)` — the save failed (see [`ExecuteError::is_conflict`]).
106 ///
107 /// [`Execution`] is a two-variant enum rather than [`Handle`]'s bare
108 /// `Option` (#330): once `save` returns a position, the accepted branch has
109 /// a second field to pair with the events — the exact symmetry with the
110 /// saga side's [`Reaction`](crate::Reaction). The no-op branch stays
111 /// positionless, since nothing was appended.
112 ///
113 /// On a version conflict this returns `Err(ExecuteError::Store(..))` with
114 /// `is_conflict() == true` and does **not** retry — retry is the runtime's
115 /// job (CLAUDE.md rule 5), matching `SagaRepository::react_and_save`.
116 ///
117 /// # Errors
118 /// See the variants above.
119 #[allow(
120 clippy::type_complexity,
121 reason = "the Execution-or-typed-error return is intrinsic to the contract; an \
122 alias would hide the `impl Future`/`Send` capture the API depends on"
123 )]
124 fn execute<C, const N: usize>(
125 &self,
126 root: &mut AggregateRoot<A>,
127 command: C,
128 ) -> impl Future<
129 Output = Result<Execution<A, Self::Position, N>, ExecuteError<A::Error, Self::Error>>,
130 > + Send
131 where
132 A: Handle<C, N>,
133 C: Send,
134 {
135 execute_inner(self, root, command)
136 }
137}
138
139/// Inner body of [`CommandRepository::execute`] — extracted so the
140/// `mnesis.aggregate.execute` span can attach to an `async fn` (times the
141/// future's polling, not the construction of the `impl Future`). The
142/// `tracing::Instrument` combinator shape trips this workspace's deny-level
143/// `shadow_reuse`/`let_and_return` lints; a private `async fn` carrying
144/// `#[cfg_attr(feature = "tracing", ...)]` is lint-clean.
145#[allow(
146 clippy::type_complexity,
147 reason = "the Execution-or-typed-error return is the same intrinsic contract as the trait method; \
148 an alias would hide the `impl Future`/`Send` capture the API depends on"
149)]
150#[cfg_attr(
151 feature = "tracing",
152 tracing::instrument(
153 name = "mnesis.aggregate.execute",
154 level = "debug",
155 skip_all,
156 fields(
157 aggregate = core::any::type_name::<A>(),
158 stream = %root.id()
159 )
160 )
161)]
162async fn execute_inner<A, R, C, const N: usize>(
163 repo: &R,
164 root: &mut AggregateRoot<A>,
165 command: C,
166) -> Result<
167 Execution<A, <R as Repository<A>>::Position, N>,
168 ExecuteError<A::Error, <R as Repository<A>>::Error>,
169>
170where
171 A: Aggregate + Handle<C, N>,
172 R: Repository<A> + ?Sized,
173 C: Send,
174{
175 // A no-op decision never reaches the store: no append, no version,
176 // no GlobalSeq burned.
177 match root.handle::<C, N>(command).map_err(ExecuteError::Decide)? {
178 None => Ok(Execution::Ignored),
179 Some(decided) => {
180 let position = repo
181 .save(root, &decided)
182 .await
183 .map_err(ExecuteError::Store)?;
184 Ok(Execution::Executed {
185 position,
186 events: decided,
187 })
188 }
189 }
190}
191
192// Rides on every repository — bare `EventStore` AND the `Snapshotting`
193// decorator — with zero per-type code. Fully static dispatch.
194impl<A: Aggregate, R: Repository<A>> CommandRepository<A> for R {}
195
196#[cfg(test)]
197mod error_tests {
198 use super::ExecuteError;
199 use crate::error::StoreError;
200 use mnesis::{ErrorId, Version};
201
202 type TestStoreError =
203 StoreError<std::io::Error, std::convert::Infallible, std::convert::Infallible>;
204 type TestExecuteError = ExecuteError<&'static str, TestStoreError>;
205
206 #[test]
207 fn conflict_store_error_is_conflict() {
208 let e: TestExecuteError = ExecuteError::Store(StoreError::Conflict {
209 stream_id: ErrorId::from_display(&"s"),
210 expected: Some(Version::INITIAL),
211 actual: None,
212 });
213 assert!(e.is_conflict());
214 }
215
216 #[test]
217 fn decide_error_is_not_conflict() {
218 let e: TestExecuteError = ExecuteError::Decide("rejected");
219 assert!(!e.is_conflict());
220 }
221}