1use 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#[derive(Debug, thiserror::Error)]
27#[non_exhaustive]
28pub enum SagaError<SagaErr, StoreErr> {
29 #[error("saga rejected event: {0}")]
31 React(#[source] SagaErr),
32
33 #[error(transparent)]
35 Store(StoreErr),
36
37 #[error("version overflow while projecting saga intents")]
41 VersionOverflow,
42}
43
44impl<SagaErr, StoreErr: ConflictPredicate> SagaError<SagaErr, StoreErr> {
45 #[must_use]
49 pub fn is_conflict(&self) -> bool {
50 matches!(self, Self::Store(e) if e.is_conflict())
51 }
52}
53
54pub 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 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 #[must_use]
82 pub const fn dedup_key(&self) -> (&S::Id, Version) {
83 (&self.saga_id, self.source_version)
84 }
85
86 #[must_use]
88 pub const fn saga_id(&self) -> &S::Id {
89 &self.saga_id
90 }
91
92 #[must_use]
94 pub const fn source_version(&self) -> Version {
95 self.source_version
96 }
97
98 #[must_use]
100 pub const fn intent(&self) -> &S::Command {
101 &self.intent
102 }
103
104 #[must_use]
106 pub fn into_intent(self) -> S::Command {
107 self.intent
108 }
109}
110
111impl<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
123pub 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 #[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 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 #[must_use]
169 pub fn len(&self) -> usize {
170 usize::from(self.first.is_some()) + self.rest.len()
171 }
172
173 #[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
190pub 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
219impl<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#[must_use = "projected intents must be handed to the runtime for dispatch"]
245pub enum Reaction<S: Saga, P, const N: usize> {
246 Ignored,
248 Reacted {
250 version: Version,
252 position: P,
256 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
279pub trait SagaRepository<S: Saga>: Repository<S> {
286 #[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 #[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
350impl<S: Saga, R: Repository<S>> SagaRepository<S> for R {}
353
354#[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 let Some(produced) = root.react::<E, N>(event).map_err(SagaError::React)? else {
396 return Ok(Reaction::Ignored);
397 };
398
399 let first = first_persisted_version(before).ok_or(SagaError::VersionOverflow)?;
402
403 let position = repo.save(root, &produced).await.map_err(SagaError::Store)?;
409
410 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 #[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 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 #[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 let it: ProjectedIntentsIntoIter<M, 2> = intents.into_iter();
589 assert_eq!(it.size_hint(), (3, Some(3)));
591 let reversed: Vec<u8> = it.rev().map(|p| p.into_intent().0).collect();
593 assert_eq!(reversed, vec![3, 2, 1]);
594
595 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}