use core::fmt;
use core::future::Future;
use core::iter::Chain;
use core::option;
use arrayvec::ArrayVec;
use mnesis::{AggregateRoot, DomainEvent, React, Saga, Version};
use crate::conflict::ConflictPredicate;
use crate::repository::{Repository, first_persisted_version};
#[derive(Debug, thiserror::Error)]
pub enum SagaError<SagaErr, StoreErr> {
#[error("saga rejected event: {0}")]
React(#[source] SagaErr),
#[error(transparent)]
Store(StoreErr),
#[error("version overflow while projecting saga intents")]
VersionOverflow,
}
impl<SagaErr, StoreErr: ConflictPredicate> SagaError<SagaErr, StoreErr> {
#[must_use]
pub fn is_conflict(&self) -> bool {
matches!(self, Self::Store(e) if e.is_conflict())
}
}
pub struct ProjectedIntent<S: Saga> {
pub(crate) saga_id: S::Id,
pub(crate) source_version: Version,
pub(crate) intent: S::Command,
}
impl<S: Saga> ProjectedIntent<S> {
pub(crate) const fn new(saga_id: S::Id, source_version: Version, intent: S::Command) -> Self {
Self {
saga_id,
source_version,
intent,
}
}
#[must_use]
pub const fn dedup_key(&self) -> (&S::Id, Version) {
(&self.saga_id, self.source_version)
}
#[must_use]
pub const fn saga_id(&self) -> &S::Id {
&self.saga_id
}
#[must_use]
pub const fn source_version(&self) -> Version {
self.source_version
}
#[must_use]
pub const fn intent(&self) -> &S::Command {
&self.intent
}
#[must_use]
pub fn into_intent(self) -> S::Command {
self.intent
}
}
impl<S: Saga> fmt::Debug for ProjectedIntent<S> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("ProjectedIntent")
.field("saga_id", &self.saga_id)
.field("source_version", &self.source_version)
.field("intent", &self.intent)
.finish()
}
}
pub struct ProjectedIntents<S: Saga, const N: usize> {
first: Option<ProjectedIntent<S>>,
rest: ArrayVec<ProjectedIntent<S>, N>,
}
impl<S: Saga, const N: usize> ProjectedIntents<S, N> {
pub(crate) const fn new() -> Self {
Self {
first: None,
rest: ArrayVec::new_const(),
}
}
#[allow(
clippy::expect_used,
reason = "capacity N+1 is guaranteed by the producing Events<_, N>; overflow is a programmer bug"
)]
pub(crate) fn push(&mut self, intent: ProjectedIntent<S>) {
if self.first.is_none() {
self.first = Some(intent);
} else {
self.rest.try_push(intent).expect(
"ProjectedIntents capacity exceeded: intents must not exceed the producing Events<_, N> count",
);
}
}
pub fn iter(
&self,
) -> Chain<option::Iter<'_, ProjectedIntent<S>>, core::slice::Iter<'_, ProjectedIntent<S>>>
{
self.first.iter().chain(self.rest.iter())
}
#[must_use]
pub fn len(&self) -> usize {
usize::from(self.first.is_some()) + self.rest.len()
}
#[must_use]
pub const fn is_empty(&self) -> bool {
self.first.is_none()
}
}
impl<'a, S: Saga, const N: usize> IntoIterator for &'a ProjectedIntents<S, N> {
type Item = &'a ProjectedIntent<S>;
type IntoIter =
Chain<option::Iter<'a, ProjectedIntent<S>>, core::slice::Iter<'a, ProjectedIntent<S>>>;
fn into_iter(self) -> Self::IntoIter {
self.iter()
}
}
pub struct ProjectedIntentsIntoIter<S: Saga, const N: usize> {
inner: Chain<option::IntoIter<ProjectedIntent<S>>, arrayvec::IntoIter<ProjectedIntent<S>, N>>,
}
impl<S: Saga, const N: usize> Iterator for ProjectedIntentsIntoIter<S, N> {
type Item = ProjectedIntent<S>;
fn next(&mut self) -> Option<Self::Item> {
self.inner.next()
}
fn size_hint(&self) -> (usize, Option<usize>) {
self.inner.size_hint()
}
}
impl<S: Saga, const N: usize> DoubleEndedIterator for ProjectedIntentsIntoIter<S, N> {
fn next_back(&mut self) -> Option<Self::Item> {
self.inner.next_back()
}
}
impl<S: Saga, const N: usize> core::iter::FusedIterator for ProjectedIntentsIntoIter<S, N> {}
impl<S: Saga, const N: usize> IntoIterator for ProjectedIntents<S, N> {
type Item = ProjectedIntent<S>;
type IntoIter = ProjectedIntentsIntoIter<S, N>;
fn into_iter(self) -> Self::IntoIter {
ProjectedIntentsIntoIter {
inner: self.first.into_iter().chain(self.rest),
}
}
}
impl<S: Saga, const N: usize> fmt::Debug for ProjectedIntents<S, N> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_list().entries(self.iter()).finish()
}
}
#[must_use = "projected intents must be handed to the runtime for dispatch"]
pub enum Reaction<S: Saga, P, const N: usize> {
Ignored,
Reacted {
version: Version,
position: P,
intents: ProjectedIntents<S, N>,
},
}
impl<S: Saga, P: fmt::Debug, const N: usize> fmt::Debug for Reaction<S, P, N> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Ignored => f.write_str("Ignored"),
Self::Reacted {
version,
position,
intents,
} => f
.debug_struct("Reacted")
.field("version", version)
.field("position", position)
.field("intents", intents)
.finish(),
}
}
}
pub trait SagaRepository<S: Saga>: Repository<S> {
#[allow(
clippy::type_complexity,
reason = "the Reaction-or-typed-error return is intrinsic to the contract; an \
alias would hide the `impl Future`/`Send` capture the API depends on"
)]
fn react_and_save<E, const N: usize>(
&self,
root: &mut AggregateRoot<S>,
event: &E,
) -> impl Future<
Output = Result<Reaction<S, Self::Position, N>, SagaError<S::Error, Self::Error>>,
> + Send
where
S: React<E, N>,
E: DomainEvent,
{
react_and_save_inner(self, root, event)
}
#[allow(
clippy::type_complexity,
reason = "the Reaction-or-typed-error return is intrinsic to the contract; an \
alias would hide the `impl Future`/`Send` capture the API depends on"
)]
fn dispatch<E, const N: usize>(
&self,
id: S::Id,
event: &E,
) -> impl Future<
Output = Result<Reaction<S, Self::Position, N>, SagaError<S::Error, Self::Error>>,
> + Send
where
S: React<E, N>,
E: DomainEvent,
{
async move {
let mut root = self.load(id).await.map_err(SagaError::Store)?;
self.react_and_save(&mut root, event).await
}
}
}
impl<S: Saga, R: Repository<S>> SagaRepository<S> for R {}
#[allow(
clippy::type_complexity,
reason = "the Reaction-or-typed-error return is the same intrinsic contract as the trait method; \
an alias would hide the `impl Future`/`Send` capture the API depends on"
)]
#[cfg_attr(
feature = "tracing",
tracing::instrument(
name = "mnesis.saga.react",
level = "debug",
skip_all,
fields(
saga = core::any::type_name::<S>(),
stream = %root.id(),
intents = tracing::field::Empty,
version = tracing::field::Empty
)
)
)]
async fn react_and_save_inner<S, R, E, const N: usize>(
repo: &R,
root: &mut AggregateRoot<S>,
event: &E,
) -> Result<
Reaction<S, <R as Repository<S>>::Position, N>,
SagaError<S::Error, <R as Repository<S>>::Error>,
>
where
S: Saga + React<E, N>,
R: Repository<S> + ?Sized,
E: DomainEvent,
{
let before = root.version();
let Some(produced) = root.react::<E, N>(event).map_err(SagaError::React)? else {
return Ok(Reaction::Ignored);
};
let first = first_persisted_version(before).ok_or(SagaError::VersionOverflow)?;
let position = repo.save(root, &produced).await.map_err(SagaError::Store)?;
let mut intents = ProjectedIntents::<S, N>::new();
let mut current = first;
let mut iter = produced.iter().peekable();
while let Some(recorded) = iter.next() {
if let Some(intent) = S::intent_for(recorded) {
intents.push(ProjectedIntent::new(root.id().clone(), current, intent));
}
if iter.peek().is_some() {
current = current.next().ok_or(SagaError::VersionOverflow)?;
}
}
#[cfg(feature = "tracing")]
tracing::Span::current().record("intents", intents.len());
#[cfg(feature = "tracing")]
tracing::Span::current().record("version", tracing::field::display(current));
Ok(Reaction::Reacted {
version: current,
position,
intents,
})
}
#[cfg(test)]
mod error_tests {
use super::SagaError;
use crate::error::StoreError;
use mnesis::{ErrorId, Version};
type TestStoreError =
StoreError<std::io::Error, std::convert::Infallible, std::convert::Infallible>;
type TestSagaError = SagaError<&'static str, TestStoreError>;
#[test]
fn conflict_store_error_is_conflict() {
let e: TestSagaError = SagaError::Store(StoreError::Conflict {
stream_id: ErrorId::from_display(&"s"),
expected: Some(Version::INITIAL),
actual: None,
});
assert!(e.is_conflict());
}
#[test]
fn react_error_is_not_conflict() {
let e: TestSagaError = SagaError::React("rejected");
assert!(!e.is_conflict());
}
#[test]
fn version_overflow_is_not_conflict() {
let e: TestSagaError = SagaError::VersionOverflow;
assert!(!e.is_conflict());
}
}
#[cfg(test)]
mod projected_intents_tests {
use super::{ProjectedIntent, ProjectedIntents, ProjectedIntentsIntoIter};
use mnesis::{Aggregate, AggregateState, DomainEvent, Events, Message, React, Saga, Version};
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
struct Sid(u8);
impl core::fmt::Display for Sid {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
write!(f, "{}", self.0)
}
}
impl AsRef<[u8]> for Sid {
fn as_ref(&self) -> &[u8] {
core::slice::from_ref(&self.0)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct Ev;
impl Message for Ev {}
impl DomainEvent for Ev {
fn name(&self) -> &'static str {
"Ev"
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct Cmd(u8);
impl Message for Cmd {}
#[derive(Debug)]
struct St;
impl AggregateState for St {
type Event = Ev;
fn initial() -> Self {
Self
}
fn apply(self, _e: &Ev) -> Self {
self
}
}
#[derive(Debug, thiserror::Error, PartialEq)]
#[error("err")]
struct Err;
struct M;
impl Aggregate for M {
type State = St;
type Error = Err;
type Id = Sid;
}
impl Saga for M {
type CorrelationKey = u8;
type Command = Cmd;
fn intent_for(_e: &Ev) -> Option<Cmd> {
None
}
}
impl React<Ev> for M {
fn correlate(_e: &Ev) -> Option<u8> {
Some(0)
}
fn react(_s: &St, _e: &Ev) -> Result<Option<Events<Ev, 0>>, Err> {
Ok(None)
}
}
#[test]
fn empty_collection_reports_empty() {
let intents = ProjectedIntents::<M, 2>::new();
assert!(intents.is_empty());
assert_eq!(intents.len(), 0);
assert_eq!(intents.iter().count(), 0);
}
#[test]
fn holds_n_plus_one_without_panic_and_iterates_in_order() {
let mut intents = ProjectedIntents::<M, 2>::new();
for v in 1u64..=3 {
let version = Version::new(v).expect("non-zero");
#[allow(
clippy::cast_possible_truncation,
clippy::as_conversions,
reason = "test: v ranges 1..=3, fits u8"
)]
let tag = v as u8;
intents.push(ProjectedIntent::new(Sid(9), version, Cmd(tag)));
}
assert_eq!(intents.len(), 3);
assert!(!intents.is_empty());
let versions: Vec<u64> = intents
.iter()
.map(|p| p.source_version().as_u64())
.collect();
assert_eq!(versions, vec![1, 2, 3]);
let owned: Vec<u8> = intents.into_iter().map(|p| p.into_intent().0).collect();
assert_eq!(owned, vec![1, 2, 3]);
}
#[test]
fn into_iter_is_named_sealed_type_double_ended_fused_and_sized() {
let mut intents = ProjectedIntents::<M, 2>::new();
for v in 1u64..=3 {
let version = Version::new(v).expect("non-zero");
let tag = u8::try_from(v).expect("fits u8");
intents.push(ProjectedIntent::new(Sid(9), version, Cmd(tag)));
}
let it: ProjectedIntentsIntoIter<M, 2> = intents.into_iter();
assert_eq!(it.size_hint(), (3, Some(3)));
let reversed: Vec<u8> = it.rev().map(|p| p.into_intent().0).collect();
assert_eq!(reversed, vec![3, 2, 1]);
let mut single = ProjectedIntents::<M, 0>::new();
single.push(ProjectedIntent::new(Sid(1), Version::INITIAL, Cmd(7)));
let mut single_it = single.into_iter();
assert_eq!(single_it.next().map(|p| p.into_intent().0), Some(7));
assert!(single_it.next().is_none());
assert!(single_it.next().is_none());
}
}