Skip to main content

mnesis_store/
saga.rs

1//! Store-side bounded saga repository — the saga analogue of [`Repository`].
2//!
3//! Because [`Saga`](mnesis::Saga) is an [`Aggregate`](mnesis::Aggregate), the
4//! existing [`Repository`] already loads and saves sagas. This module adds only
5//! the saga-specific seam: [`SagaRepository`] (`react → save → project` as one
6//! callable bounded transaction), the version-pinned capability-token return
7//! types ([`ProjectedIntent`] / [`ProjectedIntents`] / [`Reaction`]), and the
8//! two-domain [`SagaError`]. The runtime loop, cursor, correlation *resolution*,
9//! conflict *retry*, and intent *dispatch* remain the consumer's (Agency's).
10//!
11//! See `docs/plans/2026-06-18-saga-repository-design.md`.
12
13use core::fmt;
14use core::future::Future;
15use core::iter::Chain;
16use core::option;
17
18use arrayvec::ArrayVec;
19use mnesis::{AggregateRoot, DomainEvent, React, Saga, Version};
20
21use crate::conflict::ConflictPredicate;
22use crate::repository::{Repository, first_persisted_version};
23
24/// Error from a saga react+persist. Two failure domains plus a defensive
25/// overflow guard (CLAUDE.md rule 3 — one variant = one domain).
26#[derive(Debug, thiserror::Error)]
27#[non_exhaustive]
28pub enum SagaError<SagaErr, StoreErr> {
29    /// `react` rejected the upstream event (a saga invariant). Nothing persisted.
30    #[error("saga rejected event: {0}")]
31    React(#[source] SagaErr),
32
33    /// `load` or `save` failed (adapter / codec / conflict / version overflow).
34    #[error(transparent)]
35    Store(StoreErr),
36
37    /// Version arithmetic overflowed while pinning intents to event versions.
38    /// Defensive: unreachable after a successful `save`, surfaced rather than
39    /// panicked (CLAUDE.md rule 2 — no `expect` on data paths).
40    #[error("version overflow while projecting saga intents")]
41    VersionOverflow,
42}
43
44impl<SagaErr, StoreErr: ConflictPredicate> SagaError<SagaErr, StoreErr> {
45    /// `true` iff the underlying store error is an optimistic-concurrency
46    /// conflict. `React` and `VersionOverflow` are never conflicts (rule 3 —
47    /// limit/overflow errors are not retry-eligible conflicts).
48    #[must_use]
49    pub fn is_conflict(&self) -> bool {
50        matches!(self, Self::Store(e) if e.is_conflict())
51    }
52}
53
54/// One outgoing intent, pinned to the saga-own-event version it projects from.
55///
56/// **Capability token.** Fields are `pub(crate)` and there is no public
57/// constructor: the only way to obtain a `ProjectedIntent` is to receive one
58/// from [`SagaRepository::react_and_save`]/[`dispatch`](SagaRepository::dispatch)
59/// *after* the append committed. Holding one is a type-level witness that the
60/// intent's event is durable — Model A's "never dispatch an unrecorded intent"
61/// becomes unrepresentable-otherwise rather than a convention.
62pub struct ProjectedIntent<S: Saga> {
63    pub(crate) saga_id: S::Id,
64    pub(crate) source_version: Version,
65    pub(crate) intent: S::Command,
66}
67
68impl<S: Saga> ProjectedIntent<S> {
69    /// Internal constructor — see the type docs for why this is not public.
70    pub(crate) const fn new(saga_id: S::Id, source_version: Version, intent: S::Command) -> Self {
71        Self {
72            saga_id,
73            source_version,
74            intent,
75        }
76    }
77
78    /// `(saga_id, source_version)` — the globally stable, idempotent dedup key
79    /// for the runtime's at-least-once outbox. Free under Model A because the
80    /// intent *is* a recorded event's projection.
81    #[must_use]
82    pub const fn dedup_key(&self) -> (&S::Id, Version) {
83        (&self.saga_id, self.source_version)
84    }
85
86    /// The saga instance this intent belongs to.
87    #[must_use]
88    pub const fn saga_id(&self) -> &S::Id {
89        &self.saga_id
90    }
91
92    /// The saga-own-event version this intent projects from.
93    #[must_use]
94    pub const fn source_version(&self) -> Version {
95        self.source_version
96    }
97
98    /// Borrow the intent payload.
99    #[must_use]
100    pub const fn intent(&self) -> &S::Command {
101        &self.intent
102    }
103
104    /// Consume the token, yielding the bare intent for dispatch.
105    #[must_use]
106    pub fn into_intent(self) -> S::Command {
107        self.intent
108    }
109}
110
111// Manual Debug: `S` itself is not `Debug`, but `S::Id` (Id: Debug),
112// `S::Command` (Message: Debug), and `Version` all are — no extra bounds.
113impl<S: Saga> fmt::Debug for ProjectedIntent<S> {
114    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
115        f.debug_struct("ProjectedIntent")
116            .field("saga_id", &self.saga_id)
117            .field("source_version", &self.source_version)
118            .field("intent", &self.intent)
119            .finish()
120    }
121}
122
123/// A bounded, **heap-free** collection of [`ProjectedIntent`]s — at most
124/// `N + 1` (the producing [`Events<_, N>`](mnesis::Events) capacity).
125///
126/// Mirrors `Events`' first-plus-rest layout to hit capacity `N + 1` without the
127/// unstable `generic_const_exprs` (`{ N + 1 }`). `first` is `Option` because a
128/// saga event may project no intent, so the collection can be empty.
129pub struct ProjectedIntents<S: Saga, const N: usize> {
130    first: Option<ProjectedIntent<S>>,
131    rest: ArrayVec<ProjectedIntent<S>, N>,
132}
133
134impl<S: Saga, const N: usize> ProjectedIntents<S, N> {
135    pub(crate) const fn new() -> Self {
136        Self {
137            first: None,
138            rest: ArrayVec::new_const(),
139        }
140    }
141
142    /// Append a token. Total pushes are bounded by the producing event count
143    /// (`<= N + 1`) by construction, so the `rest` capacity (`N`) is never
144    /// exceeded once `first` absorbs the first push.
145    #[allow(
146        clippy::expect_used,
147        reason = "capacity N+1 is guaranteed by the producing Events<_, N>; overflow is a programmer bug"
148    )]
149    pub(crate) fn push(&mut self, intent: ProjectedIntent<S>) {
150        if self.first.is_none() {
151            self.first = Some(intent);
152        } else {
153            self.rest.try_push(intent).expect(
154                "ProjectedIntents capacity exceeded: intents must not exceed the producing Events<_, N> count",
155            );
156        }
157    }
158
159    /// Iterate the tokens in projection order.
160    pub fn iter(
161        &self,
162    ) -> Chain<option::Iter<'_, ProjectedIntent<S>>, core::slice::Iter<'_, ProjectedIntent<S>>>
163    {
164        self.first.iter().chain(self.rest.iter())
165    }
166
167    /// Number of intents (`0..=N + 1`).
168    #[must_use]
169    pub fn len(&self) -> usize {
170        usize::from(self.first.is_some()) + self.rest.len()
171    }
172
173    /// `true` when the saga produced events but none projected an intent.
174    #[must_use]
175    pub const fn is_empty(&self) -> bool {
176        self.first.is_none()
177    }
178}
179
180impl<'a, S: Saga, const N: usize> IntoIterator for &'a ProjectedIntents<S, N> {
181    type Item = &'a ProjectedIntent<S>;
182    type IntoIter =
183        Chain<option::Iter<'a, ProjectedIntent<S>>, core::slice::Iter<'a, ProjectedIntent<S>>>;
184
185    fn into_iter(self) -> Self::IntoIter {
186        self.iter()
187    }
188}
189
190/// Owning iterator over [`ProjectedIntents`], yielding `first` then each
191/// token in `rest`.
192///
193/// A named newtype wrapping the concrete `Chain<option::IntoIter, _>` so the
194/// `arrayvec::IntoIter` type does not appear in the public API as
195/// `ProjectedIntents`' associated `IntoIter` (sealing `arrayvec` out of our
196/// `SemVer`). Mirrors the kernel's `EventsIntoIter`.
197pub struct ProjectedIntentsIntoIter<S: Saga, const N: usize> {
198    inner: Chain<option::IntoIter<ProjectedIntent<S>>, arrayvec::IntoIter<ProjectedIntent<S>, N>>,
199}
200
201impl<S: Saga, const N: usize> Iterator for ProjectedIntentsIntoIter<S, N> {
202    type Item = ProjectedIntent<S>;
203
204    fn next(&mut self) -> Option<Self::Item> {
205        self.inner.next()
206    }
207
208    fn size_hint(&self) -> (usize, Option<usize>) {
209        self.inner.size_hint()
210    }
211}
212
213impl<S: Saga, const N: usize> DoubleEndedIterator for ProjectedIntentsIntoIter<S, N> {
214    fn next_back(&mut self) -> Option<Self::Item> {
215        self.inner.next_back()
216    }
217}
218
219// Sound: both halves of the chain (`option::IntoIter` and `arrayvec::IntoIter`)
220// yield `None` permanently once exhausted, so the chain is fused.
221impl<S: Saga, const N: usize> core::iter::FusedIterator for ProjectedIntentsIntoIter<S, N> {}
222
223impl<S: Saga, const N: usize> IntoIterator for ProjectedIntents<S, N> {
224    type Item = ProjectedIntent<S>;
225    type IntoIter = ProjectedIntentsIntoIter<S, N>;
226
227    fn into_iter(self) -> Self::IntoIter {
228        ProjectedIntentsIntoIter {
229            inner: self.first.into_iter().chain(self.rest),
230        }
231    }
232}
233
234impl<S: Saga, const N: usize> fmt::Debug for ProjectedIntents<S, N> {
235    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
236        f.debug_list().entries(self.iter()).finish()
237    }
238}
239
240/// Outcome of one [`SagaRepository::react_and_save`]/[`dispatch`](SagaRepository::dispatch).
241///
242/// `#[must_use]`: discarding it drops intents the runtime was meant to dispatch
243/// — a lost-work bug the compiler now warns on (a `Vec` return could not).
244#[must_use = "projected intents must be handed to the runtime for dispatch"]
245pub enum Reaction<S: Saga, P, const N: usize> {
246    /// `react` returned `Ok(None)` — routed, no-op, nothing persisted.
247    Ignored,
248    /// `react` produced events; they were appended atomically.
249    Reacted {
250        /// Version the saga stream advanced to (the last appended event's version).
251        version: Version,
252        /// The `$all` position the last appended event landed at — the
253        /// read-your-writes token for the saga's own stream (#330). Symmetric
254        /// with the aggregate side's [`Execution`](crate::Execution).
255        position: P,
256        /// Intents projected from the recorded events, in order (`<= one` per event).
257        intents: ProjectedIntents<S, N>,
258    },
259}
260
261impl<S: Saga, P: fmt::Debug, const N: usize> fmt::Debug for Reaction<S, P, N> {
262    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
263        match self {
264            Self::Ignored => f.write_str("Ignored"),
265            Self::Reacted {
266                version,
267                position,
268                intents,
269            } => f
270                .debug_struct("Reacted")
271                .field("version", version)
272                .field("position", position)
273                .field("intents", intents)
274                .finish(),
275        }
276    }
277}
278
279/// The saga-facing port: `react → save → project` as one callable bounded
280/// transaction.
281///
282/// Extends [`Repository<S>`] and inherits its snapshot-aware `load` and atomic,
283/// optimistic `save` unchanged. Both methods are provided; the blanket impl
284/// below gives them to every repository for free.
285pub trait SagaRepository<S: Saga>: Repository<S> {
286    /// **Core (single-writer / world A *and* the base for world B).** React to
287    /// one upstream `event` against a saga `root` already in hand, persist any
288    /// produced own-events atomically, and return their intents pinned to the
289    /// versions `save` just assigned. No load — the caller supplies the root.
290    ///
291    /// - `Ok(Reaction::Ignored)` — `react` returned `Ok(None)`; nothing persisted.
292    /// - `Ok(Reaction::Reacted { .. })` — events appended; intents projected.
293    /// - `Err(SagaError::React)` — `react` rejected the event; nothing persisted.
294    /// - `Err(SagaError::Store)` — load/save failed (use [`SagaError::is_conflict`]).
295    ///
296    /// # Errors
297    /// See the variants above.
298    #[allow(
299        clippy::type_complexity,
300        reason = "the Reaction-or-typed-error return is intrinsic to the contract; an \
301                  alias would hide the `impl Future`/`Send` capture the API depends on"
302    )]
303    fn react_and_save<E, const N: usize>(
304        &self,
305        root: &mut AggregateRoot<S>,
306        event: &E,
307    ) -> impl Future<
308        Output = Result<Reaction<S, Self::Position, N>, SagaError<S::Error, Self::Error>>,
309    > + Send
310    where
311        S: React<E, N>,
312        E: DomainEvent,
313    {
314        react_and_save_inner(self, root, event)
315    }
316
317    /// **Convenience (stateless concurrent reactors / world B).** `load` the
318    /// instance then [`react_and_save`](Self::react_and_save). One call per
319    /// upstream event; a concurrent writer may cause `save` to surface
320    /// `Err(SagaError::Store)` with [`is_conflict`](SagaError::is_conflict) — the
321    /// caller reloads and retries. `load` is whichever `Repository<S>::load` is
322    /// in play, so snapshot hydration composes for free.
323    ///
324    /// # Errors
325    /// As [`react_and_save`](Self::react_and_save), plus `Err(SagaError::Store)`
326    /// from the `load`.
327    #[allow(
328        clippy::type_complexity,
329        reason = "the Reaction-or-typed-error return is intrinsic to the contract; an \
330                  alias would hide the `impl Future`/`Send` capture the API depends on"
331    )]
332    fn dispatch<E, const N: usize>(
333        &self,
334        id: S::Id,
335        event: &E,
336    ) -> impl Future<
337        Output = Result<Reaction<S, Self::Position, N>, SagaError<S::Error, Self::Error>>,
338    > + Send
339    where
340        S: React<E, N>,
341        E: DomainEvent,
342    {
343        async move {
344            let mut root = self.load(id).await.map_err(SagaError::Store)?;
345            self.react_and_save(&mut root, event).await
346        }
347    }
348}
349
350// Rides on every repository — bare `EventStore` AND the
351// `Snapshotting` decorator — with zero per-type code. Fully static dispatch.
352impl<S: Saga, R: Repository<S>> SagaRepository<S> for R {}
353
354/// Inner body of [`SagaRepository::react_and_save`] — extracted so the
355/// `mnesis.saga.react` span can attach to an `async fn` (times the future's
356/// polling, not the construction of the `impl Future`). The `tracing::Instrument`
357/// combinator shape trips this workspace's deny-level `shadow_reuse`/
358/// `let_and_return` lints; a private `async fn` carrying
359/// `#[cfg_attr(feature = "tracing", ...)]` is lint-clean.
360#[allow(
361    clippy::type_complexity,
362    reason = "the Reaction-or-typed-error return is the same intrinsic contract as the trait method; \
363              an alias would hide the `impl Future`/`Send` capture the API depends on"
364)]
365#[cfg_attr(
366    feature = "tracing",
367    tracing::instrument(
368        name = "mnesis.saga.react",
369        level = "debug",
370        skip_all,
371        fields(
372            saga = core::any::type_name::<S>(),
373            stream = %root.id(),
374            intents = tracing::field::Empty,
375            version = tracing::field::Empty
376        )
377    )
378)]
379async fn react_and_save_inner<S, R, E, const N: usize>(
380    repo: &R,
381    root: &mut AggregateRoot<S>,
382    event: &E,
383) -> Result<
384    Reaction<S, <R as Repository<S>>::Position, N>,
385    SagaError<S::Error, <R as Repository<S>>::Error>,
386>
387where
388    S: Saga + React<E, N>,
389    R: Repository<S> + ?Sized,
390    E: DomainEvent,
391{
392    let before = root.version();
393
394    // React is pure. Ok(None) ⇒ routed but no-op; persist nothing.
395    let Some(produced) = root.react::<E, N>(event).map_err(SagaError::React)? else {
396        return Ok(Reaction::Ignored);
397    };
398
399    // First version this append assigns. Checked pre-save so overflow is
400    // a clean error (save would also reject it).
401    let first = first_persisted_version(before).ok_or(SagaError::VersionOverflow)?;
402
403    // Persist atomically (optimistic concurrency enforced inside `save`).
404    // `&produced` carries the kernel's `>= 1` guarantee straight into
405    // `save` with no Vec materialization; `produced` stays alive as the
406    // projection source below (intents are minted only after durability).
407    // `save` hands back the `$all` position the last event landed at.
408    let position = repo.save(root, &produced).await.map_err(SagaError::Store)?;
409
410    // Project intents, each pinned to its event's assigned version.
411    // `produced` is non-empty (`react` returned `Some`; `Events` holds
412    // >= 1), so the loop runs at least once and `current` ends on the
413    // last event's version. `peekable` advances the version only when a
414    // successor exists, sidestepping a bare `len() - 1` index computation.
415    let mut intents = ProjectedIntents::<S, N>::new();
416    let mut current = first;
417    let mut iter = produced.iter().peekable();
418    while let Some(recorded) = iter.next() {
419        if let Some(intent) = S::intent_for(recorded) {
420            intents.push(ProjectedIntent::new(root.id().clone(), current, intent));
421        }
422        if iter.peek().is_some() {
423            current = current.next().ok_or(SagaError::VersionOverflow)?;
424        }
425    }
426
427    #[cfg(feature = "tracing")]
428    tracing::Span::current().record("intents", intents.len());
429    #[cfg(feature = "tracing")]
430    tracing::Span::current().record("version", tracing::field::display(current));
431
432    Ok(Reaction::Reacted {
433        version: current,
434        position,
435        intents,
436    })
437}
438
439#[cfg(test)]
440mod error_tests {
441    use super::SagaError;
442    use crate::error::StoreError;
443    use mnesis::{ErrorId, Version};
444
445    type TestStoreError =
446        StoreError<std::io::Error, std::convert::Infallible, std::convert::Infallible>;
447    type TestSagaError = SagaError<&'static str, TestStoreError>;
448
449    #[test]
450    fn conflict_store_error_is_conflict() {
451        let e: TestSagaError = SagaError::Store(StoreError::Conflict {
452            stream_id: ErrorId::from_display(&"s"),
453            expected: Some(Version::INITIAL),
454            actual: None,
455        });
456        assert!(e.is_conflict());
457    }
458
459    #[test]
460    fn react_error_is_not_conflict() {
461        let e: TestSagaError = SagaError::React("rejected");
462        assert!(!e.is_conflict());
463    }
464
465    #[test]
466    fn version_overflow_is_not_conflict() {
467        let e: TestSagaError = SagaError::VersionOverflow;
468        assert!(!e.is_conflict());
469    }
470}
471
472#[cfg(test)]
473mod projected_intents_tests {
474    use super::{ProjectedIntent, ProjectedIntents, ProjectedIntentsIntoIter};
475    use mnesis::{Aggregate, AggregateState, DomainEvent, Events, Message, React, Saga, Version};
476
477    // Minimal saga purely to instantiate the generic collection.
478    #[derive(Debug, Clone, PartialEq, Eq, Hash)]
479    struct Sid(u8);
480    impl core::fmt::Display for Sid {
481        fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
482            write!(f, "{}", self.0)
483        }
484    }
485    impl AsRef<[u8]> for Sid {
486        fn as_ref(&self) -> &[u8] {
487            core::slice::from_ref(&self.0)
488        }
489    }
490
491    #[derive(Debug, Clone, PartialEq, Eq)]
492    struct Ev;
493    impl Message for Ev {}
494    impl DomainEvent for Ev {
495        fn name(&self) -> &'static str {
496            "Ev"
497        }
498    }
499
500    #[derive(Debug, Clone, PartialEq, Eq)]
501    struct Cmd(u8);
502    impl Message for Cmd {}
503
504    #[derive(Debug)]
505    struct St;
506    impl AggregateState for St {
507        type Event = Ev;
508        fn initial() -> Self {
509            Self
510        }
511        fn apply(self, _e: &Ev) -> Self {
512            self
513        }
514    }
515
516    #[derive(Debug, thiserror::Error, PartialEq)]
517    #[error("err")]
518    struct Err;
519
520    struct M;
521    impl Aggregate for M {
522        type State = St;
523        type Error = Err;
524        type Id = Sid;
525    }
526    impl Saga for M {
527        type CorrelationKey = u8;
528        type Command = Cmd;
529        fn intent_for(_e: &Ev) -> Option<Cmd> {
530            None
531        }
532    }
533    impl React<Ev> for M {
534        fn correlate(_e: &Ev) -> Option<u8> {
535            Some(0)
536        }
537        fn react(_s: &St, _e: &Ev) -> Result<Option<Events<Ev, 0>>, Err> {
538            Ok(None)
539        }
540    }
541
542    #[test]
543    fn empty_collection_reports_empty() {
544        let intents = ProjectedIntents::<M, 2>::new();
545        assert!(intents.is_empty());
546        assert_eq!(intents.len(), 0);
547        assert_eq!(intents.iter().count(), 0);
548    }
549
550    #[test]
551    fn holds_n_plus_one_without_panic_and_iterates_in_order() {
552        // N = 2 → capacity 3.
553        let mut intents = ProjectedIntents::<M, 2>::new();
554        for v in 1u64..=3 {
555            let version = Version::new(v).expect("non-zero");
556            #[allow(
557                clippy::cast_possible_truncation,
558                clippy::as_conversions,
559                reason = "test: v ranges 1..=3, fits u8"
560            )]
561            let tag = v as u8;
562            intents.push(ProjectedIntent::new(Sid(9), version, Cmd(tag)));
563        }
564        assert_eq!(intents.len(), 3);
565        assert!(!intents.is_empty());
566        let versions: Vec<u64> = intents
567            .iter()
568            .map(|p| p.source_version().as_u64())
569            .collect();
570        assert_eq!(versions, vec![1, 2, 3]);
571        let owned: Vec<u8> = intents.into_iter().map(|p| p.into_intent().0).collect();
572        assert_eq!(owned, vec![1, 2, 3]);
573    }
574
575    // PR2 (#208): the owning `IntoIter` is the named, sealed
576    // `ProjectedIntentsIntoIter` (no `arrayvec::IntoIter` in the public API),
577    // preserving the underlying `Chain`'s capabilities.
578    #[test]
579    fn into_iter_is_named_sealed_type_double_ended_fused_and_sized() {
580        let mut intents = ProjectedIntents::<M, 2>::new();
581        for v in 1u64..=3 {
582            let version = Version::new(v).expect("non-zero");
583            let tag = u8::try_from(v).expect("fits u8");
584            intents.push(ProjectedIntent::new(Sid(9), version, Cmd(tag)));
585        }
586
587        // The associated `IntoIter` is the named type, not an arrayvec type.
588        let it: ProjectedIntentsIntoIter<M, 2> = intents.into_iter();
589        // `size_hint` counts first + rest exactly.
590        assert_eq!(it.size_hint(), (3, Some(3)));
591        // Double-ended: reversed yields last-to-first.
592        let reversed: Vec<u8> = it.rev().map(|p| p.into_intent().0).collect();
593        assert_eq!(reversed, vec![3, 2, 1]);
594
595        // Fused: once exhausted it keeps yielding `None`.
596        let mut single = ProjectedIntents::<M, 0>::new();
597        single.push(ProjectedIntent::new(Sid(1), Version::INITIAL, Cmd(7)));
598        let mut single_it = single.into_iter();
599        assert_eq!(single_it.next().map(|p| p.into_intent().0), Some(7));
600        assert!(single_it.next().is_none());
601        assert!(single_it.next().is_none());
602    }
603}