Skip to main content

soaprs_memory/
cqrs.rs

1//! In-memory CQRS persistence adapters.
2
3use std::{
4    collections::{HashMap, HashSet},
5    marker::PhantomData,
6    sync::Mutex,
7    time::SystemTime,
8};
9
10use soaprs_core::{BoxFuture, Command, MessageId, SoapError, SoapResult};
11use soaprs_cqrs::{
12    AppendResult, ClaimedOutboxRecord, ClaimedSagaAction, DeadLetter, DeliveryClaimId,
13    EncodedEvent, EventStore, ExpectedSagaVersion, ExpectedVersion, GlobalPosition, InboxClaim,
14    InboxStore, OutboxRecord, OutboxStore, ProjectionApplyOutcome, ProjectionCheckpointStore,
15    ProjectionId, RecordedEvent, SagaAction, SagaActionId, SagaActionRecord, SagaActionStore,
16    SagaCommitResult, SagaId, SagaStore, SagaVersion, Snapshot, SnapshotStore, StoredEvent,
17    StoredEventStore, StoredSaga, StreamId, StreamVersion, TransactionalEventOutbox,
18    TransactionalProjection, TransactionalSagaOutbox,
19};
20use soaprs_events::{DomainEvent, IntegrationEvent};
21
22#[derive(Debug)]
23struct EventStoreState<E> {
24    streams: HashMap<StreamId, Vec<RecordedEvent<E>>>,
25    global: Vec<RecordedEvent<E>>,
26    next_global_position: u64,
27}
28
29#[derive(Debug)]
30struct StoredEventState<P> {
31    streams: HashMap<StreamId, Vec<StoredEvent<P>>>,
32    global: Vec<StoredEvent<P>>,
33    next_global_position: u64,
34}
35
36impl<P> Default for StoredEventState<P> {
37    fn default() -> Self {
38        Self {
39            streams: HashMap::new(),
40            global: Vec::new(),
41            next_global_position: 1,
42        }
43    }
44}
45
46impl<E> Default for EventStoreState<E> {
47    fn default() -> Self {
48        Self {
49            streams: HashMap::new(),
50            global: Vec::new(),
51            next_global_position: 1,
52        }
53    }
54}
55
56/// Append-only in-memory event store with optimistic concurrency checks.
57#[derive(Debug)]
58pub struct MemoryEventStore<E> {
59    state: Mutex<EventStoreState<E>>,
60}
61
62impl<E> MemoryEventStore<E> {
63    /// Creates an empty event store.
64    pub fn new() -> Self {
65        Self {
66            state: Mutex::new(EventStoreState::default()),
67        }
68    }
69}
70
71impl<E> Default for MemoryEventStore<E> {
72    fn default() -> Self {
73        Self::new()
74    }
75}
76
77impl<E> EventStore<E> for MemoryEventStore<E>
78where
79    E: DomainEvent + Clone,
80{
81    fn append<'a>(
82        &'a self,
83        stream_id: &'a StreamId,
84        expected: ExpectedVersion,
85        events: Vec<soaprs_events::EventEnvelope<E>>,
86    ) -> BoxFuture<'a, SoapResult<AppendResult>> {
87        Box::pin(async move {
88            if events.is_empty() {
89                return Err(SoapError::validation(
90                    "event store append requires at least one event",
91                ));
92            }
93
94            let mut state = self
95                .state
96                .lock()
97                .map_err(|_| SoapError::infrastructure("in-memory event store lock poisoned"))?;
98            let current = state
99                .streams
100                .get(stream_id)
101                .and_then(|stream| stream.last())
102                .map(|event| event.stream_version);
103            validate_expected_version(expected, current)?;
104
105            let mut stream_version = current
106                .map_or(Some(StreamVersion::FIRST), StreamVersion::checked_next)
107                .ok_or_else(|| SoapError::infrastructure("event stream version overflow"))?;
108            let event_count = events.len();
109            let mut records = Vec::with_capacity(event_count);
110            for (index, envelope) in events.into_iter().enumerate() {
111                let global_position = GlobalPosition::new(state.next_global_position);
112                state.next_global_position = state
113                    .next_global_position
114                    .checked_add(1)
115                    .ok_or_else(|| SoapError::infrastructure("global event position overflow"))?;
116                records.push(RecordedEvent {
117                    stream_id: stream_id.clone(),
118                    stream_version,
119                    global_position,
120                    envelope,
121                });
122                if index + 1 < event_count {
123                    stream_version = stream_version.checked_next().ok_or_else(|| {
124                        SoapError::infrastructure("event stream version overflow")
125                    })?;
126                }
127            }
128
129            let Some(last) = records.last() else {
130                return Err(SoapError::infrastructure(
131                    "event append lost its non-empty record set",
132                ));
133            };
134            let result = AppendResult {
135                stream_version: last.stream_version,
136                global_position: last.global_position,
137            };
138            state
139                .streams
140                .entry(stream_id.clone())
141                .or_default()
142                .extend(records.iter().cloned());
143            state.global.extend(records);
144            Ok(result)
145        })
146    }
147
148    fn load<'a>(
149        &'a self,
150        stream_id: &'a StreamId,
151        after: Option<StreamVersion>,
152    ) -> BoxFuture<'a, SoapResult<Vec<RecordedEvent<E>>>> {
153        Box::pin(async move {
154            let state = self
155                .state
156                .lock()
157                .map_err(|_| SoapError::infrastructure("in-memory event store lock poisoned"))?;
158            Ok(state
159                .streams
160                .get(stream_id)
161                .into_iter()
162                .flatten()
163                .filter(|event| after.is_none_or(|version| event.stream_version > version))
164                .cloned()
165                .collect())
166        })
167    }
168
169    fn read_all(
170        &self,
171        after: Option<GlobalPosition>,
172        limit: usize,
173    ) -> BoxFuture<'_, SoapResult<Vec<RecordedEvent<E>>>> {
174        Box::pin(async move {
175            if limit == 0 {
176                return Err(SoapError::validation(
177                    "global event read limit must be greater than zero",
178                ));
179            }
180            let state = self
181                .state
182                .lock()
183                .map_err(|_| SoapError::infrastructure("in-memory event store lock poisoned"))?;
184            Ok(state
185                .global
186                .iter()
187                .filter(|event| after.is_none_or(|position| event.global_position > position))
188                .take(limit)
189                .cloned()
190                .collect())
191        })
192    }
193}
194
195/// Append-only in-memory persistence for serialized event payloads.
196#[derive(Debug)]
197pub struct MemoryStoredEventStore<P> {
198    state: Mutex<StoredEventState<P>>,
199}
200
201impl<P> MemoryStoredEventStore<P> {
202    /// Creates an empty serialized event store.
203    pub fn new() -> Self {
204        Self {
205            state: Mutex::new(StoredEventState::default()),
206        }
207    }
208}
209
210impl<P> Default for MemoryStoredEventStore<P> {
211    fn default() -> Self {
212        Self::new()
213    }
214}
215
216impl<P> StoredEventStore<P> for MemoryStoredEventStore<P>
217where
218    P: Clone + Send,
219{
220    fn append<'a>(
221        &'a self,
222        stream_id: &'a StreamId,
223        expected: ExpectedVersion,
224        events: Vec<EncodedEvent<P>>,
225    ) -> BoxFuture<'a, SoapResult<AppendResult>> {
226        Box::pin(async move {
227            if events.is_empty() {
228                return Err(SoapError::validation(
229                    "stored event append requires at least one event",
230                ));
231            }
232            let mut state = self
233                .state
234                .lock()
235                .map_err(|_| SoapError::infrastructure("stored event store lock poisoned"))?;
236            let current = state
237                .streams
238                .get(stream_id)
239                .and_then(|stream| stream.last())
240                .map(|event| event.stream_version);
241            validate_expected_version(expected, current)?;
242
243            let mut stream_version = current
244                .map_or(Some(StreamVersion::FIRST), StreamVersion::checked_next)
245                .ok_or_else(|| SoapError::infrastructure("stored event stream version overflow"))?;
246            let event_count = events.len();
247            let mut records = Vec::with_capacity(event_count);
248            for (index, encoded) in events.into_iter().enumerate() {
249                let global_position = GlobalPosition::new(state.next_global_position);
250                state.next_global_position = state
251                    .next_global_position
252                    .checked_add(1)
253                    .ok_or_else(|| SoapError::infrastructure("global event position overflow"))?;
254                records.push(StoredEvent {
255                    stream_id: stream_id.clone(),
256                    stream_version,
257                    global_position,
258                    encoded,
259                });
260                if index + 1 < event_count {
261                    stream_version = stream_version.checked_next().ok_or_else(|| {
262                        SoapError::infrastructure("stored event stream version overflow")
263                    })?;
264                }
265            }
266            let Some(last) = records.last() else {
267                return Err(SoapError::infrastructure(
268                    "stored event append lost its non-empty record set",
269                ));
270            };
271            let result = AppendResult {
272                stream_version: last.stream_version,
273                global_position: last.global_position,
274            };
275            state
276                .streams
277                .entry(stream_id.clone())
278                .or_default()
279                .extend(records.iter().cloned());
280            state.global.extend(records);
281            Ok(result)
282        })
283    }
284
285    fn load<'a>(
286        &'a self,
287        stream_id: &'a StreamId,
288        after: Option<StreamVersion>,
289    ) -> BoxFuture<'a, SoapResult<Vec<StoredEvent<P>>>> {
290        Box::pin(async move {
291            let state = self
292                .state
293                .lock()
294                .map_err(|_| SoapError::infrastructure("stored event store lock poisoned"))?;
295            Ok(state
296                .streams
297                .get(stream_id)
298                .into_iter()
299                .flatten()
300                .filter(|event| after.is_none_or(|version| event.stream_version > version))
301                .cloned()
302                .collect())
303        })
304    }
305
306    fn read_all(
307        &self,
308        after: Option<GlobalPosition>,
309        limit: usize,
310    ) -> BoxFuture<'_, SoapResult<Vec<StoredEvent<P>>>> {
311        Box::pin(async move {
312            if limit == 0 {
313                return Err(SoapError::validation(
314                    "stored global event read limit must be greater than zero",
315                ));
316            }
317            let state = self
318                .state
319                .lock()
320                .map_err(|_| SoapError::infrastructure("stored event store lock poisoned"))?;
321            Ok(state
322                .global
323                .iter()
324                .filter(|event| after.is_none_or(|position| event.global_position > position))
325                .take(limit)
326                .cloned()
327                .collect())
328        })
329    }
330}
331
332fn validate_expected_version(
333    expected: ExpectedVersion,
334    current: Option<StreamVersion>,
335) -> SoapResult<()> {
336    let matches = match expected {
337        ExpectedVersion::Any => true,
338        ExpectedVersion::NoStream => current.is_none(),
339        ExpectedVersion::Exact(version) => current == Some(version),
340    };
341    if matches {
342        Ok(())
343    } else {
344        Err(SoapError::conflict("event stream version conflict"))
345    }
346}
347
348/// In-memory monotonically advancing projection checkpoints.
349#[derive(Debug, Default)]
350pub struct MemoryProjectionCheckpointStore {
351    checkpoints: Mutex<HashMap<ProjectionId, GlobalPosition>>,
352}
353
354impl MemoryProjectionCheckpointStore {
355    /// Creates an empty checkpoint store.
356    pub fn new() -> Self {
357        Self::default()
358    }
359}
360
361impl ProjectionCheckpointStore for MemoryProjectionCheckpointStore {
362    fn load<'a>(
363        &'a self,
364        projection: &'a ProjectionId,
365    ) -> BoxFuture<'a, SoapResult<Option<GlobalPosition>>> {
366        Box::pin(async move {
367            Ok(self
368                .checkpoints
369                .lock()
370                .map_err(|_| SoapError::infrastructure("checkpoint store lock poisoned"))?
371                .get(projection)
372                .copied())
373        })
374    }
375
376    fn store<'a>(
377        &'a self,
378        projection: &'a ProjectionId,
379        position: GlobalPosition,
380    ) -> BoxFuture<'a, SoapResult<()>> {
381        Box::pin(async move {
382            let mut checkpoints = self
383                .checkpoints
384                .lock()
385                .map_err(|_| SoapError::infrastructure("checkpoint store lock poisoned"))?;
386            if checkpoints
387                .get(projection)
388                .is_some_and(|current| position < *current)
389            {
390                return Err(SoapError::conflict(
391                    "projection checkpoint cannot move backwards",
392                ));
393            }
394            checkpoints.insert(projection.clone(), position);
395            Ok(())
396        })
397    }
398}
399
400#[derive(Debug, Clone)]
401struct TransactionalProjectionState<S> {
402    read_model: S,
403    checkpoint: Option<GlobalPosition>,
404}
405
406/// In-memory reference projection with an atomic read-model/checkpoint boundary.
407///
408/// The update closure receives a cloned candidate read model. The candidate and
409/// checkpoint become visible together only when the closure succeeds.
410pub struct MemoryTransactionalProjection<E, S, F> {
411    id: ProjectionId,
412    state: Mutex<TransactionalProjectionState<S>>,
413    apply: F,
414    event: PhantomData<fn(E)>,
415}
416
417impl<E, S, F> MemoryTransactionalProjection<E, S, F> {
418    /// Creates a projection from its stable identity, initial model, and update function.
419    pub fn new(id: ProjectionId, read_model: S, apply: F) -> Self {
420        Self {
421            id,
422            state: Mutex::new(TransactionalProjectionState {
423                read_model,
424                checkpoint: None,
425            }),
426            apply,
427            event: PhantomData,
428        }
429    }
430}
431
432impl<E, S, F> MemoryTransactionalProjection<E, S, F>
433where
434    S: Clone,
435{
436    /// Returns a consistent copy of the read model and its checkpoint.
437    pub fn snapshot(&self) -> SoapResult<(S, Option<GlobalPosition>)> {
438        let state = self
439            .state
440            .lock()
441            .map_err(|_| SoapError::infrastructure("transactional projection lock poisoned"))?;
442        Ok((state.read_model.clone(), state.checkpoint))
443    }
444}
445
446impl<E, S, F> std::fmt::Debug for MemoryTransactionalProjection<E, S, F> {
447    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
448        formatter
449            .debug_struct("MemoryTransactionalProjection")
450            .field("id", &self.id)
451            .finish_non_exhaustive()
452    }
453}
454
455impl<E, S, F> TransactionalProjection<E> for MemoryTransactionalProjection<E, S, F>
456where
457    E: DomainEvent,
458    S: Clone + Send,
459    F: Fn(&mut S, &RecordedEvent<E>) -> SoapResult<()> + Send + Sync,
460{
461    fn id(&self) -> &ProjectionId {
462        &self.id
463    }
464
465    fn checkpoint(&self) -> BoxFuture<'_, SoapResult<Option<GlobalPosition>>> {
466        Box::pin(async move {
467            Ok(self
468                .state
469                .lock()
470                .map_err(|_| SoapError::infrastructure("transactional projection lock poisoned"))?
471                .checkpoint)
472        })
473    }
474
475    fn project_and_checkpoint<'a>(
476        &'a self,
477        event: &'a RecordedEvent<E>,
478    ) -> BoxFuture<'a, SoapResult<ProjectionApplyOutcome>> {
479        Box::pin(async move {
480            let mut state = self
481                .state
482                .lock()
483                .map_err(|_| SoapError::infrastructure("transactional projection lock poisoned"))?;
484            if state
485                .checkpoint
486                .is_some_and(|checkpoint| event.global_position <= checkpoint)
487            {
488                return Ok(ProjectionApplyOutcome::AlreadyApplied);
489            }
490
491            let mut candidate = state.read_model.clone();
492            (self.apply)(&mut candidate, event)?;
493            state.read_model = candidate;
494            state.checkpoint = Some(event.global_position);
495            Ok(ProjectionApplyOutcome::Applied)
496        })
497    }
498}
499
500/// In-memory latest-snapshot persistence.
501#[derive(Debug, Default)]
502pub struct MemorySnapshotStore<S> {
503    snapshots: Mutex<HashMap<StreamId, Vec<Snapshot<S>>>>,
504}
505
506impl<S> MemorySnapshotStore<S> {
507    /// Creates an empty snapshot store.
508    pub fn new() -> Self {
509        Self {
510            snapshots: Mutex::new(HashMap::new()),
511        }
512    }
513}
514
515impl<S> SnapshotStore<S> for MemorySnapshotStore<S>
516where
517    S: Clone + Send,
518{
519    fn load_latest<'a>(
520        &'a self,
521        stream_id: &'a StreamId,
522    ) -> BoxFuture<'a, SoapResult<Option<Snapshot<S>>>> {
523        Box::pin(async move {
524            Ok(self
525                .snapshots
526                .lock()
527                .map_err(|_| SoapError::infrastructure("snapshot store lock poisoned"))?
528                .get(stream_id)
529                .and_then(|items| items.last())
530                .cloned())
531        })
532    }
533
534    fn save(&self, snapshot: Snapshot<S>) -> BoxFuture<'_, SoapResult<()>> {
535        Box::pin(async move {
536            let mut snapshots = self
537                .snapshots
538                .lock()
539                .map_err(|_| SoapError::infrastructure("snapshot store lock poisoned"))?;
540            let stream = snapshots.entry(snapshot.stream_id.clone()).or_default();
541            if stream
542                .last()
543                .is_some_and(|current| snapshot.stream_version < current.stream_version)
544            {
545                return Err(SoapError::conflict(
546                    "snapshot cannot replace a newer stream version",
547                ));
548            }
549            if stream
550                .last()
551                .is_some_and(|current| snapshot.stream_version == current.stream_version)
552            {
553                stream.pop();
554            }
555            stream.push(snapshot);
556            Ok(())
557        })
558    }
559
560    fn prune<'a>(
561        &'a self,
562        stream_id: &'a StreamId,
563        retain_from: StreamVersion,
564    ) -> BoxFuture<'a, SoapResult<u64>> {
565        Box::pin(async move {
566            let mut snapshots = self
567                .snapshots
568                .lock()
569                .map_err(|_| SoapError::infrastructure("snapshot store lock poisoned"))?;
570            let Some(stream) = snapshots.get_mut(stream_id) else {
571                return Ok(0);
572            };
573            let before = stream.len();
574            stream.retain(|snapshot| snapshot.stream_version >= retain_from);
575            Ok(before.saturating_sub(stream.len()) as u64)
576        })
577    }
578}
579
580/// In-memory application-defined saga state.
581#[derive(Debug, Default)]
582pub struct MemorySagaStore<S> {
583    sagas: Mutex<HashMap<SagaId, StoredSaga<S>>>,
584}
585
586impl<S> MemorySagaStore<S> {
587    /// Creates an empty saga store.
588    pub fn new() -> Self {
589        Self {
590            sagas: Mutex::new(HashMap::new()),
591        }
592    }
593}
594
595impl<S> SagaStore<S> for MemorySagaStore<S>
596where
597    S: Clone + Send,
598{
599    fn load<'a>(&'a self, id: &'a SagaId) -> BoxFuture<'a, SoapResult<Option<StoredSaga<S>>>> {
600        Box::pin(async move {
601            Ok(self
602                .sagas
603                .lock()
604                .map_err(|_| SoapError::infrastructure("saga store lock poisoned"))?
605                .get(id)
606                .cloned())
607        })
608    }
609
610    fn save<'a>(
611        &'a self,
612        id: &'a SagaId,
613        expected: ExpectedSagaVersion,
614        saga: S,
615    ) -> BoxFuture<'a, SoapResult<SagaVersion>> {
616        Box::pin(async move {
617            let mut sagas = self
618                .sagas
619                .lock()
620                .map_err(|_| SoapError::infrastructure("saga store lock poisoned"))?;
621            let current = sagas.get(id).map(|stored| stored.version);
622            let version = next_saga_version(expected, current)?;
623            sagas.insert(
624                id.clone(),
625                StoredSaga {
626                    version,
627                    state: saga,
628                },
629            );
630            Ok(version)
631        })
632    }
633}
634
635fn next_saga_version(
636    expected: ExpectedSagaVersion,
637    current: Option<SagaVersion>,
638) -> SoapResult<SagaVersion> {
639    let matches = match expected {
640        ExpectedSagaVersion::Any => true,
641        ExpectedSagaVersion::NoSaga => current.is_none(),
642        ExpectedSagaVersion::Exact(version) => current == Some(version),
643    };
644    if !matches {
645        return Err(SoapError::conflict("saga version conflict"));
646    }
647    current
648        .map_or(Some(SagaVersion::FIRST), SagaVersion::checked_next)
649        .ok_or_else(|| SoapError::infrastructure("saga version overflow"))
650}
651
652#[derive(Debug, Clone)]
653struct MemorySagaActionEntry<C>
654where
655    C: Command<Output = ()>,
656{
657    record: SagaActionRecord<C>,
658    claim: Option<(DeliveryClaimId, SystemTime)>,
659}
660
661#[derive(Debug)]
662struct SagaActionState<C>
663where
664    C: Command<Output = ()>,
665{
666    order: Vec<SagaActionId>,
667    entries: HashMap<SagaActionId, MemorySagaActionEntry<C>>,
668}
669
670impl<C> Default for SagaActionState<C>
671where
672    C: Command<Output = ()>,
673{
674    fn default() -> Self {
675        Self {
676            order: Vec::new(),
677            entries: HashMap::new(),
678        }
679    }
680}
681
682/// In-memory durable saga action and timer store.
683#[derive(Debug)]
684pub struct MemorySagaActionStore<C>
685where
686    C: Command<Output = ()>,
687{
688    state: Mutex<SagaActionState<C>>,
689}
690
691impl<C> MemorySagaActionStore<C>
692where
693    C: Command<Output = ()>,
694{
695    /// Creates an empty saga action store.
696    pub fn new() -> Self {
697        Self {
698            state: Mutex::new(SagaActionState::default()),
699        }
700    }
701}
702
703impl<C> Default for MemorySagaActionStore<C>
704where
705    C: Command<Output = ()>,
706{
707    fn default() -> Self {
708        Self::new()
709    }
710}
711
712impl<C> SagaActionStore<C> for MemorySagaActionStore<C>
713where
714    C: Command<Output = ()> + Clone,
715{
716    fn claim_pending(
717        &self,
718        claim_id: DeliveryClaimId,
719        now: SystemTime,
720        lease_until: SystemTime,
721        limit: usize,
722    ) -> BoxFuture<'_, SoapResult<Vec<ClaimedSagaAction<C>>>> {
723        Box::pin(async move {
724            if limit == 0 {
725                return Err(SoapError::validation(
726                    "saga action claim limit must be greater than zero",
727                ));
728            }
729            if lease_until <= now {
730                return Err(SoapError::validation(
731                    "saga action claim lease must end after the claim time",
732                ));
733            }
734            let mut state = self
735                .state
736                .lock()
737                .map_err(|_| SoapError::infrastructure("saga action store lock poisoned"))?;
738            let order = state.order.clone();
739            let mut claimed = Vec::new();
740            for id in order {
741                if claimed.len() == limit {
742                    break;
743                }
744                let Some(entry) = state.entries.get_mut(&id) else {
745                    continue;
746                };
747                if entry
748                    .claim
749                    .as_ref()
750                    .is_none_or(|(_, current_lease)| *current_lease <= now)
751                    && entry.record.completed_at.is_none()
752                    && entry.record.dead_letter.is_none()
753                    && entry.record.available_at <= now
754                {
755                    entry.claim = Some((claim_id.clone(), lease_until));
756                    claimed.push(ClaimedSagaAction {
757                        record: entry.record.clone(),
758                        claim_id: claim_id.clone(),
759                        lease_until,
760                    });
761                }
762            }
763            Ok(claimed)
764        })
765    }
766
767    fn mark_completed<'a>(
768        &'a self,
769        id: &'a SagaActionId,
770        claim_id: &'a DeliveryClaimId,
771        completed_at: SystemTime,
772    ) -> BoxFuture<'a, SoapResult<()>> {
773        Box::pin(async move {
774            let mut state = self
775                .state
776                .lock()
777                .map_err(|_| SoapError::infrastructure("saga action store lock poisoned"))?;
778            let Some(entry) = state.entries.get_mut(id) else {
779                return Err(SoapError::not_found("saga action"));
780            };
781            validate_saga_action_claim(entry.claim.as_ref(), claim_id, completed_at)?;
782            entry.record.completed_at = Some(completed_at);
783            entry.claim = None;
784            Ok(())
785        })
786    }
787
788    fn mark_failed<'a>(
789        &'a self,
790        id: &'a SagaActionId,
791        claim_id: &'a DeliveryClaimId,
792        safe_error: &'a str,
793        failed_at: SystemTime,
794        available_at: SystemTime,
795    ) -> BoxFuture<'a, SoapResult<()>> {
796        Box::pin(async move {
797            let mut state = self
798                .state
799                .lock()
800                .map_err(|_| SoapError::infrastructure("saga action store lock poisoned"))?;
801            let Some(entry) = state.entries.get_mut(id) else {
802                return Err(SoapError::not_found("saga action"));
803            };
804            validate_saga_action_claim(entry.claim.as_ref(), claim_id, failed_at)?;
805            entry.record.attempts = entry.record.attempts.saturating_add(1);
806            entry.record.available_at = available_at;
807            entry.record.last_error = Some(safe_error.to_owned());
808            entry.claim = None;
809            Ok(())
810        })
811    }
812
813    fn mark_dead_lettered<'a>(
814        &'a self,
815        id: &'a SagaActionId,
816        claim_id: &'a DeliveryClaimId,
817        safe_error: &'a str,
818        failed_at: SystemTime,
819    ) -> BoxFuture<'a, SoapResult<()>> {
820        Box::pin(async move {
821            let mut state = self
822                .state
823                .lock()
824                .map_err(|_| SoapError::infrastructure("saga action store lock poisoned"))?;
825            let Some(entry) = state.entries.get_mut(id) else {
826                return Err(SoapError::not_found("saga action"));
827            };
828            validate_saga_action_claim(entry.claim.as_ref(), claim_id, failed_at)?;
829            entry.record.attempts = entry.record.attempts.saturating_add(1);
830            entry.record.last_error = Some(safe_error.to_owned());
831            entry.record.dead_letter = Some(DeadLetter {
832                failed_at,
833                safe_error: safe_error.to_owned(),
834            });
835            entry.claim = None;
836            Ok(())
837        })
838    }
839}
840
841fn validate_saga_action_claim(
842    claim: Option<&(DeliveryClaimId, SystemTime)>,
843    claim_id: &DeliveryClaimId,
844    outcome_at: SystemTime,
845) -> SoapResult<()> {
846    let Some((current, lease_until)) = claim else {
847        return Err(SoapError::conflict("saga action has no active claim"));
848    };
849    if current != claim_id {
850        return Err(SoapError::conflict(
851            "saga action claim is not owned by this worker",
852        ));
853    }
854    if *lease_until <= outcome_at {
855        return Err(SoapError::conflict("saga action claim lease has expired"));
856    }
857    Ok(())
858}
859
860/// In-memory saga state and action outbox sharing one atomic commit boundary.
861#[derive(Debug)]
862pub struct MemorySagaOutbox<S, C>
863where
864    C: Command<Output = ()>,
865{
866    sagas: MemorySagaStore<S>,
867    actions: MemorySagaActionStore<C>,
868}
869
870impl<S, C> MemorySagaOutbox<S, C>
871where
872    C: Command<Output = ()>,
873{
874    /// Creates an empty combined saga store and action outbox.
875    pub fn new() -> Self {
876        Self {
877            sagas: MemorySagaStore::new(),
878            actions: MemorySagaActionStore::new(),
879        }
880    }
881
882    /// Returns the saga-state view.
883    pub const fn saga_store(&self) -> &MemorySagaStore<S> {
884        &self.sagas
885    }
886
887    /// Returns the action and timer view.
888    pub const fn action_store(&self) -> &MemorySagaActionStore<C> {
889        &self.actions
890    }
891}
892
893impl<S, C> Default for MemorySagaOutbox<S, C>
894where
895    C: Command<Output = ()>,
896{
897    fn default() -> Self {
898        Self::new()
899    }
900}
901
902impl<S, C> TransactionalSagaOutbox<S, C> for MemorySagaOutbox<S, C>
903where
904    S: Send,
905    C: Command<Output = ()>,
906{
907    fn commit_transition<'a>(
908        &'a self,
909        saga_id: &'a SagaId,
910        expected: ExpectedSagaVersion,
911        saga: S,
912        transition: soaprs_core::MessageMetadata,
913        actions: Vec<SagaAction<C>>,
914    ) -> BoxFuture<'a, SoapResult<SagaCommitResult>> {
915        Box::pin(async move {
916            let created_at = transition.created_at();
917            let mut records = Vec::with_capacity(actions.len());
918            for (index, action) in actions.into_iter().enumerate() {
919                let sequence = u32::try_from(index).map_err(|_| {
920                    SoapError::validation("saga transition contains too many actions")
921                })?;
922                let available_at = match &action {
923                    SagaAction::Schedule { delay, .. } => {
924                        created_at.checked_add(*delay).ok_or_else(|| {
925                            SoapError::validation("scheduled saga action time overflow")
926                        })?
927                    }
928                    SagaAction::Dispatch(_)
929                    | SagaAction::Compensate(_)
930                    | SagaAction::Complete
931                    | SagaAction::Fail(_) => created_at,
932                };
933                records.push(SagaActionRecord {
934                    id: SagaActionId::new(transition.id().clone(), sequence),
935                    saga_id: saga_id.clone(),
936                    action,
937                    attempts: 0,
938                    available_at,
939                    completed_at: None,
940                    dead_letter: None,
941                    last_error: None,
942                    transition: transition.clone(),
943                });
944            }
945
946            let mut sagas = self
947                .sagas
948                .sagas
949                .lock()
950                .map_err(|_| SoapError::infrastructure("saga store lock poisoned"))?;
951            let mut action_state = self
952                .actions
953                .state
954                .lock()
955                .map_err(|_| SoapError::infrastructure("saga action store lock poisoned"))?;
956            let current = sagas.get(saga_id).map(|stored| stored.version);
957            let version = next_saga_version(expected, current)?;
958            if records
959                .iter()
960                .any(|record| action_state.entries.contains_key(&record.id))
961            {
962                return Err(SoapError::conflict(
963                    "saga transition action identity already exists",
964                ));
965            }
966
967            let queued_actions = records.len();
968            for record in records {
969                action_state.order.push(record.id.clone());
970                action_state.entries.insert(
971                    record.id.clone(),
972                    MemorySagaActionEntry {
973                        record,
974                        claim: None,
975                    },
976                );
977            }
978            sagas.insert(
979                saga_id.clone(),
980                StoredSaga {
981                    version,
982                    state: saga,
983                },
984            );
985            Ok(SagaCommitResult {
986                version,
987                queued_actions,
988            })
989        })
990    }
991}
992
993#[derive(Debug, Clone, PartialEq, Eq)]
994enum InboxState {
995    Claimed {
996        claim_id: DeliveryClaimId,
997        lease_until: SystemTime,
998    },
999    Completed,
1000}
1001
1002/// In-memory idempotency records scoped by consumer name.
1003#[derive(Debug, Default)]
1004pub struct MemoryInboxStore {
1005    messages: Mutex<HashMap<(String, MessageId), InboxState>>,
1006}
1007
1008impl MemoryInboxStore {
1009    /// Creates an empty inbox store.
1010    pub fn new() -> Self {
1011        Self::default()
1012    }
1013}
1014
1015impl InboxStore for MemoryInboxStore {
1016    fn claim<'a>(
1017        &'a self,
1018        consumer: &'a str,
1019        id: &'a MessageId,
1020        claim_id: DeliveryClaimId,
1021        received_at: SystemTime,
1022        lease_until: SystemTime,
1023    ) -> BoxFuture<'a, SoapResult<InboxClaim>> {
1024        Box::pin(async move {
1025            let mut messages = self
1026                .messages
1027                .lock()
1028                .map_err(|_| SoapError::infrastructure("inbox store lock poisoned"))?;
1029            if lease_until <= received_at {
1030                return Err(SoapError::validation(
1031                    "inbox claim lease must end after the received time",
1032                ));
1033            }
1034            let key = (consumer.to_owned(), id.clone());
1035            match messages.entry(key) {
1036                std::collections::hash_map::Entry::Vacant(entry) => {
1037                    entry.insert(InboxState::Claimed {
1038                        claim_id,
1039                        lease_until,
1040                    });
1041                    Ok(InboxClaim::Acquired)
1042                }
1043                std::collections::hash_map::Entry::Occupied(mut entry) => match entry.get() {
1044                    InboxState::Claimed {
1045                        lease_until: current_lease,
1046                        ..
1047                    } if *current_lease <= received_at => {
1048                        entry.insert(InboxState::Claimed {
1049                            claim_id,
1050                            lease_until,
1051                        });
1052                        Ok(InboxClaim::Acquired)
1053                    }
1054                    InboxState::Claimed { .. } => Ok(InboxClaim::InProgress),
1055                    InboxState::Completed => Ok(InboxClaim::Completed),
1056                },
1057            }
1058        })
1059    }
1060
1061    fn complete<'a>(
1062        &'a self,
1063        consumer: &'a str,
1064        id: &'a MessageId,
1065        claim_id: &'a DeliveryClaimId,
1066        completed_at: SystemTime,
1067    ) -> BoxFuture<'a, SoapResult<()>> {
1068        Box::pin(async move {
1069            let mut messages = self
1070                .messages
1071                .lock()
1072                .map_err(|_| SoapError::infrastructure("inbox store lock poisoned"))?;
1073            let key = (consumer.to_owned(), id.clone());
1074            let Some(state) = messages.get_mut(&key) else {
1075                return Err(SoapError::not_found("inbox message claim"));
1076            };
1077            if !matches!(state, InboxState::Claimed { claim_id: current, .. } if current == claim_id)
1078            {
1079                return Err(SoapError::conflict(
1080                    "inbox message claim is not owned by this worker",
1081                ));
1082            }
1083            if matches!(state, InboxState::Claimed { lease_until, .. } if *lease_until <= completed_at)
1084            {
1085                return Err(SoapError::conflict("inbox message claim lease has expired"));
1086            }
1087            *state = InboxState::Completed;
1088            Ok(())
1089        })
1090    }
1091
1092    fn release<'a>(
1093        &'a self,
1094        consumer: &'a str,
1095        id: &'a MessageId,
1096        claim_id: &'a DeliveryClaimId,
1097        released_at: SystemTime,
1098    ) -> BoxFuture<'a, SoapResult<()>> {
1099        Box::pin(async move {
1100            let mut messages = self
1101                .messages
1102                .lock()
1103                .map_err(|_| SoapError::infrastructure("inbox store lock poisoned"))?;
1104            let key = (consumer.to_owned(), id.clone());
1105            match messages.get(&key) {
1106                Some(InboxState::Claimed {
1107                    claim_id: current, ..
1108                }) if current == claim_id => {
1109                    if matches!(messages.get(&key), Some(InboxState::Claimed { lease_until, .. }) if *lease_until <= released_at)
1110                    {
1111                        return Err(SoapError::conflict("inbox message claim lease has expired"));
1112                    }
1113                }
1114                Some(InboxState::Claimed { .. } | InboxState::Completed) => {
1115                    return Err(SoapError::conflict(
1116                        "inbox message claim is not owned by this worker",
1117                    ));
1118                }
1119                None => return Err(SoapError::not_found("inbox message claim")),
1120            }
1121            messages.remove(&key);
1122            Ok(())
1123        })
1124    }
1125}
1126
1127#[derive(Debug, Clone)]
1128struct MemoryOutboxEntry<M> {
1129    record: OutboxRecord<M>,
1130    claim: Option<(DeliveryClaimId, SystemTime)>,
1131}
1132
1133#[derive(Debug)]
1134struct OutboxState<M> {
1135    order: Vec<MessageId>,
1136    entries: HashMap<MessageId, MemoryOutboxEntry<M>>,
1137}
1138
1139impl<M> Default for OutboxState<M> {
1140    fn default() -> Self {
1141        Self {
1142            order: Vec::new(),
1143            entries: HashMap::new(),
1144        }
1145    }
1146}
1147
1148/// In-memory outbox with exclusive, expiring worker claims.
1149#[derive(Debug)]
1150pub struct MemoryOutboxStore<M> {
1151    state: Mutex<OutboxState<M>>,
1152}
1153
1154impl<M> MemoryOutboxStore<M> {
1155    /// Creates an empty outbox store.
1156    pub fn new() -> Self {
1157        Self {
1158            state: Mutex::new(OutboxState::default()),
1159        }
1160    }
1161}
1162
1163impl<M> Default for MemoryOutboxStore<M> {
1164    fn default() -> Self {
1165        Self::new()
1166    }
1167}
1168
1169impl<M> OutboxStore<M> for MemoryOutboxStore<M>
1170where
1171    M: IntegrationEvent + Clone,
1172{
1173    fn enqueue(&self, messages: Vec<OutboxRecord<M>>) -> BoxFuture<'_, SoapResult<()>> {
1174        Box::pin(async move {
1175            let mut state = self
1176                .state
1177                .lock()
1178                .map_err(|_| SoapError::infrastructure("outbox store lock poisoned"))?;
1179            for message in &messages {
1180                if state.entries.contains_key(message.envelope.metadata.id()) {
1181                    return Err(SoapError::conflict("duplicate outbox message identity"));
1182                }
1183            }
1184            for message in messages {
1185                let id = message.envelope.metadata.id().clone();
1186                state.order.push(id.clone());
1187                state.entries.insert(
1188                    id,
1189                    MemoryOutboxEntry {
1190                        record: message,
1191                        claim: None,
1192                    },
1193                );
1194            }
1195            Ok(())
1196        })
1197    }
1198
1199    fn claim_pending(
1200        &self,
1201        claim_id: DeliveryClaimId,
1202        now: SystemTime,
1203        lease_until: SystemTime,
1204        limit: usize,
1205    ) -> BoxFuture<'_, SoapResult<Vec<ClaimedOutboxRecord<M>>>> {
1206        Box::pin(async move {
1207            if limit == 0 {
1208                return Err(SoapError::validation(
1209                    "outbox claim limit must be greater than zero",
1210                ));
1211            }
1212            if lease_until <= now {
1213                return Err(SoapError::validation(
1214                    "outbox claim lease must end after the claim time",
1215                ));
1216            }
1217            let mut state = self
1218                .state
1219                .lock()
1220                .map_err(|_| SoapError::infrastructure("outbox store lock poisoned"))?;
1221            let order = state.order.clone();
1222            let mut claimed = Vec::new();
1223            for id in order {
1224                if claimed.len() == limit {
1225                    break;
1226                }
1227                let Some(entry) = state.entries.get_mut(&id) else {
1228                    continue;
1229                };
1230                if entry
1231                    .claim
1232                    .as_ref()
1233                    .is_none_or(|(_, current_lease)| *current_lease <= now)
1234                    && entry.record.delivered_at.is_none()
1235                    && entry.record.dead_letter.is_none()
1236                    && entry.record.available_at <= now
1237                {
1238                    entry.claim = Some((claim_id.clone(), lease_until));
1239                    claimed.push(ClaimedOutboxRecord {
1240                        record: entry.record.clone(),
1241                        claim_id: claim_id.clone(),
1242                        lease_until,
1243                    });
1244                }
1245            }
1246            Ok(claimed)
1247        })
1248    }
1249
1250    fn mark_delivered<'a>(
1251        &'a self,
1252        id: &'a MessageId,
1253        claim_id: &'a DeliveryClaimId,
1254        delivered_at: SystemTime,
1255    ) -> BoxFuture<'a, SoapResult<()>> {
1256        Box::pin(async move {
1257            let mut state = self
1258                .state
1259                .lock()
1260                .map_err(|_| SoapError::infrastructure("outbox store lock poisoned"))?;
1261            let Some(entry) = state.entries.get_mut(id) else {
1262                return Err(SoapError::not_found("outbox message"));
1263            };
1264            if !matches!(&entry.claim, Some((current, _)) if current == claim_id) {
1265                return Err(SoapError::conflict(
1266                    "outbox message claim is not owned by this worker",
1267                ));
1268            }
1269            if matches!(&entry.claim, Some((_, lease_until)) if *lease_until <= delivered_at) {
1270                return Err(SoapError::conflict(
1271                    "outbox message claim lease has expired",
1272                ));
1273            }
1274            entry.record.delivered_at = Some(delivered_at);
1275            entry.claim = None;
1276            Ok(())
1277        })
1278    }
1279
1280    fn mark_failed<'a>(
1281        &'a self,
1282        id: &'a MessageId,
1283        claim_id: &'a DeliveryClaimId,
1284        safe_error: &'a str,
1285        failed_at: SystemTime,
1286        available_at: SystemTime,
1287    ) -> BoxFuture<'a, SoapResult<()>> {
1288        Box::pin(async move {
1289            let mut state = self
1290                .state
1291                .lock()
1292                .map_err(|_| SoapError::infrastructure("outbox store lock poisoned"))?;
1293            let Some(entry) = state.entries.get_mut(id) else {
1294                return Err(SoapError::not_found("outbox message"));
1295            };
1296            if !matches!(&entry.claim, Some((current, _)) if current == claim_id) {
1297                return Err(SoapError::conflict(
1298                    "outbox message claim is not owned by this worker",
1299                ));
1300            }
1301            if matches!(&entry.claim, Some((_, lease_until)) if *lease_until <= failed_at) {
1302                return Err(SoapError::conflict(
1303                    "outbox message claim lease has expired",
1304                ));
1305            }
1306            entry.record.attempts = entry.record.attempts.saturating_add(1);
1307            entry.record.available_at = available_at;
1308            entry.record.last_error = Some(safe_error.to_owned());
1309            entry.claim = None;
1310            Ok(())
1311        })
1312    }
1313
1314    fn mark_dead_lettered<'a>(
1315        &'a self,
1316        id: &'a MessageId,
1317        claim_id: &'a DeliveryClaimId,
1318        safe_error: &'a str,
1319        failed_at: SystemTime,
1320    ) -> BoxFuture<'a, SoapResult<()>> {
1321        Box::pin(async move {
1322            let mut state = self
1323                .state
1324                .lock()
1325                .map_err(|_| SoapError::infrastructure("outbox store lock poisoned"))?;
1326            let Some(entry) = state.entries.get_mut(id) else {
1327                return Err(SoapError::not_found("outbox message"));
1328            };
1329            if !matches!(&entry.claim, Some((current, _)) if current == claim_id) {
1330                return Err(SoapError::conflict(
1331                    "outbox message claim is not owned by this worker",
1332                ));
1333            }
1334            if matches!(&entry.claim, Some((_, lease_until)) if *lease_until <= failed_at) {
1335                return Err(SoapError::conflict(
1336                    "outbox message claim lease has expired",
1337                ));
1338            }
1339            entry.record.attempts = entry.record.attempts.saturating_add(1);
1340            entry.record.last_error = Some(safe_error.to_owned());
1341            entry.record.dead_letter = Some(DeadLetter {
1342                failed_at,
1343                safe_error: safe_error.to_owned(),
1344            });
1345            entry.claim = None;
1346            Ok(())
1347        })
1348    }
1349}
1350
1351/// In-memory event store and outbox sharing one atomic commit boundary.
1352#[derive(Debug)]
1353pub struct MemoryEventOutbox<E, M> {
1354    events: MemoryEventStore<E>,
1355    outbox: MemoryOutboxStore<M>,
1356}
1357
1358impl<E, M> MemoryEventOutbox<E, M> {
1359    /// Creates an empty combined store.
1360    pub fn new() -> Self {
1361        Self {
1362            events: MemoryEventStore::new(),
1363            outbox: MemoryOutboxStore::new(),
1364        }
1365    }
1366
1367    /// Returns the event-store view used to read committed events.
1368    pub const fn event_store(&self) -> &MemoryEventStore<E> {
1369        &self.events
1370    }
1371
1372    /// Returns the outbox view used by a publisher worker.
1373    pub const fn outbox(&self) -> &MemoryOutboxStore<M> {
1374        &self.outbox
1375    }
1376}
1377
1378impl<E, M> Default for MemoryEventOutbox<E, M> {
1379    fn default() -> Self {
1380        Self::new()
1381    }
1382}
1383
1384impl<E, M> TransactionalEventOutbox<E, M> for MemoryEventOutbox<E, M>
1385where
1386    E: DomainEvent + Clone,
1387    M: IntegrationEvent,
1388{
1389    fn append_with_outbox<'a>(
1390        &'a self,
1391        stream_id: &'a StreamId,
1392        expected: ExpectedVersion,
1393        events: Vec<soaprs_events::EventEnvelope<E>>,
1394        messages: Vec<OutboxRecord<M>>,
1395    ) -> BoxFuture<'a, SoapResult<AppendResult>> {
1396        Box::pin(async move {
1397            if events.is_empty() {
1398                return Err(SoapError::validation(
1399                    "event store append requires at least one event",
1400                ));
1401            }
1402
1403            let mut event_state =
1404                self.events.state.lock().map_err(|_| {
1405                    SoapError::infrastructure("in-memory event store lock poisoned")
1406                })?;
1407            let mut outbox_state = self
1408                .outbox
1409                .state
1410                .lock()
1411                .map_err(|_| SoapError::infrastructure("outbox store lock poisoned"))?;
1412
1413            let current = event_state
1414                .streams
1415                .get(stream_id)
1416                .and_then(|stream| stream.last())
1417                .map(|event| event.stream_version);
1418            validate_expected_version(expected, current)?;
1419
1420            let mut new_ids = HashSet::new();
1421            for message in &messages {
1422                let id = message.envelope.metadata.id();
1423                if outbox_state.entries.contains_key(id) || !new_ids.insert(id.clone()) {
1424                    return Err(SoapError::conflict("duplicate outbox message identity"));
1425                }
1426            }
1427
1428            let mut next_global_position = event_state.next_global_position;
1429            let mut stream_version = current
1430                .map_or(Some(StreamVersion::FIRST), StreamVersion::checked_next)
1431                .ok_or_else(|| SoapError::infrastructure("event stream version overflow"))?;
1432            let event_count = events.len();
1433            let mut records = Vec::with_capacity(event_count);
1434            for (index, envelope) in events.into_iter().enumerate() {
1435                let global_position = GlobalPosition::new(next_global_position);
1436                next_global_position = next_global_position
1437                    .checked_add(1)
1438                    .ok_or_else(|| SoapError::infrastructure("global event position overflow"))?;
1439                records.push(RecordedEvent {
1440                    stream_id: stream_id.clone(),
1441                    stream_version,
1442                    global_position,
1443                    envelope,
1444                });
1445                if index + 1 < event_count {
1446                    stream_version = stream_version.checked_next().ok_or_else(|| {
1447                        SoapError::infrastructure("event stream version overflow")
1448                    })?;
1449                }
1450            }
1451            let Some(last) = records.last() else {
1452                return Err(SoapError::infrastructure(
1453                    "event append lost its non-empty record set",
1454                ));
1455            };
1456            let result = AppendResult {
1457                stream_version: last.stream_version,
1458                global_position: last.global_position,
1459            };
1460
1461            for message in messages {
1462                let id = message.envelope.metadata.id().clone();
1463                outbox_state.order.push(id.clone());
1464                outbox_state.entries.insert(
1465                    id,
1466                    MemoryOutboxEntry {
1467                        record: message,
1468                        claim: None,
1469                    },
1470                );
1471            }
1472            event_state.next_global_position = next_global_position;
1473            event_state
1474                .streams
1475                .entry(stream_id.clone())
1476                .or_default()
1477                .extend(records.iter().cloned());
1478            event_state.global.extend(records);
1479            Ok(result)
1480        })
1481    }
1482}
1483
1484#[cfg(test)]
1485mod tests {
1486    use std::{
1487        sync::{Arc, Mutex},
1488        time::{Duration, UNIX_EPOCH},
1489    };
1490
1491    use soaprs_contract_tests::{
1492        TransactionalEventOutboxFixture, TransactionalSagaOutboxFixture, block_on,
1493        verify_event_store_contract, verify_inbox_contract, verify_outbox_contract,
1494        verify_projection_checkpoint_contract, verify_saga_store_contract,
1495        verify_snapshot_contract, verify_stored_event_store_contract,
1496        verify_transactional_event_outbox_contract, verify_transactional_projection_contract,
1497        verify_transactional_saga_outbox_contract,
1498    };
1499    use soaprs_core::{
1500        BoxFuture, Command, CommandHandler, MessageEnvelope, MessageMetadata, SoapError, SoapResult,
1501    };
1502    use soaprs_cqrs::{
1503        AggregateCommitResult, CodecEventStore, DeliveryClaimId, EncodedEvent, EventPayloadCodec,
1504        EventReplayHandler, EventReplayer, EventSourcedAggregate, EventSourcedRepository,
1505        EventStore, EventUpcaster, ExpectedSagaVersion, ExpectedVersion, GlobalPosition,
1506        IdempotentProjection, InboxClaim, InboxProcessingOutcome, InboxProcessor, InboxStore,
1507        OutboxProcessingReport, OutboxProcessor, OutboxRecord, OutboxStore, Projection,
1508        ProjectionId, ProjectionRunner, RecordedEvent, ReplayOptions, Saga, SagaAction,
1509        SagaActionProcessingReport, SagaActionProcessor, SagaActionStore, SagaCoordinator, SagaId,
1510        SagaStatus, Snapshot, StoredEventStore, StreamId, StreamVersion,
1511        TransactionalProjectionRunner, TransactionalSagaOutbox, TransientRetryPolicy,
1512        UpcasterChain, VersionedPayload,
1513    };
1514    use soaprs_events::{DomainEvent, Event, EventHandler, EventPublisher, IntegrationEvent};
1515
1516    use super::{
1517        MemoryEventOutbox, MemoryEventStore, MemoryInboxStore, MemoryOutboxStore,
1518        MemoryProjectionCheckpointStore, MemorySagaOutbox, MemorySnapshotStore,
1519        MemoryStoredEventStore, MemoryTransactionalProjection,
1520    };
1521
1522    #[derive(Debug, Clone, PartialEq, Eq)]
1523    enum TestEvent {
1524        Opened,
1525        Renamed(String),
1526        Closed,
1527    }
1528
1529    impl Event for TestEvent {
1530        fn event_type(&self) -> &'static str {
1531            match self {
1532                Self::Opened => "contract.opened",
1533                Self::Renamed(_) => "contract.renamed",
1534                Self::Closed => "contract.closed",
1535            }
1536        }
1537    }
1538
1539    impl DomainEvent for TestEvent {}
1540
1541    #[derive(Debug, Clone, PartialEq, Eq)]
1542    enum CounterEvent {
1543        Added(i32),
1544    }
1545
1546    impl Event for CounterEvent {
1547        fn event_type(&self) -> &'static str {
1548            "contract.counter-added"
1549        }
1550    }
1551
1552    impl DomainEvent for CounterEvent {}
1553
1554    #[derive(Debug, Clone, PartialEq, Eq)]
1555    struct CounterAggregate {
1556        stream_id: StreamId,
1557        version: Option<StreamVersion>,
1558        value: i32,
1559        uncommitted: Vec<MessageEnvelope<CounterEvent>>,
1560    }
1561
1562    impl CounterAggregate {
1563        fn pristine(stream_id: StreamId) -> Self {
1564            Self {
1565                stream_id,
1566                version: None,
1567                value: 0,
1568                uncommitted: Vec::new(),
1569            }
1570        }
1571    }
1572
1573    impl EventSourcedAggregate for CounterAggregate {
1574        type Event = CounterEvent;
1575
1576        fn stream_id(&self) -> &StreamId {
1577            &self.stream_id
1578        }
1579
1580        fn version(&self) -> Option<StreamVersion> {
1581            self.version
1582        }
1583
1584        fn apply(&mut self, event: &Self::Event) -> SoapResult<()> {
1585            match event {
1586                CounterEvent::Added(amount) => {
1587                    self.value = self
1588                        .value
1589                        .checked_add(*amount)
1590                        .ok_or_else(|| SoapError::domain("counter overflow"))?;
1591                }
1592            }
1593            Ok(())
1594        }
1595
1596        fn uncommitted_events(&self) -> &[MessageEnvelope<Self::Event>] {
1597            &self.uncommitted
1598        }
1599
1600        fn record(&mut self, event: MessageEnvelope<Self::Event>) -> SoapResult<()> {
1601            self.apply(&event.message)?;
1602            self.uncommitted.push(event);
1603            Ok(())
1604        }
1605
1606        fn mark_committed(&mut self, version: StreamVersion) {
1607            self.version = Some(version);
1608            self.uncommitted.clear();
1609        }
1610    }
1611
1612    #[derive(Debug, Clone, PartialEq, Eq)]
1613    enum VersionedTestEvent {
1614        Created { name: String, email: String },
1615    }
1616
1617    impl Event for VersionedTestEvent {
1618        fn event_type(&self) -> &'static str {
1619            "contract.user-created"
1620        }
1621
1622        fn schema_version(&self) -> u32 {
1623            2
1624        }
1625    }
1626
1627    impl DomainEvent for VersionedTestEvent {}
1628
1629    struct VersionedTestCodec;
1630
1631    impl EventPayloadCodec<VersionedTestEvent, String> for VersionedTestCodec {
1632        fn encode(&self, event: &VersionedTestEvent) -> SoapResult<String> {
1633            match event {
1634                VersionedTestEvent::Created { name, email } => Ok(format!("{name}|{email}")),
1635            }
1636        }
1637
1638        fn decode(&self, event: VersionedPayload<String>) -> SoapResult<VersionedTestEvent> {
1639            if event.event_type != "contract.user-created" || event.schema_version != 2 {
1640                return Err(SoapError::validation(
1641                    "versioned test codec received an unsupported schema",
1642                ));
1643            }
1644            let Some((name, email)) = event.payload.split_once('|') else {
1645                return Err(SoapError::validation(
1646                    "versioned test payload is missing email",
1647                ));
1648            };
1649            Ok(VersionedTestEvent::Created {
1650                name: name.to_owned(),
1651                email: email.to_owned(),
1652            })
1653        }
1654
1655        fn current_schema_version(&self, event_type: &str) -> SoapResult<u32> {
1656            if event_type == "contract.user-created" {
1657                Ok(2)
1658            } else {
1659                Err(SoapError::unsupported("unknown versioned test event type"))
1660            }
1661        }
1662    }
1663
1664    struct AddEmailUpcaster;
1665
1666    impl EventUpcaster<String> for AddEmailUpcaster {
1667        fn event_type(&self) -> &str {
1668            "contract.user-created"
1669        }
1670
1671        fn source_version(&self) -> u32 {
1672            1
1673        }
1674
1675        fn target_version(&self) -> u32 {
1676            2
1677        }
1678
1679        fn upcast(&self, payload: String) -> SoapResult<String> {
1680            Ok(format!("{payload}|unknown@example.test"))
1681        }
1682    }
1683
1684    #[derive(Debug, Clone, PartialEq, Eq)]
1685    struct Outgoing(String);
1686
1687    impl Event for Outgoing {
1688        fn event_type(&self) -> &'static str {
1689            "contract.outgoing"
1690        }
1691    }
1692
1693    impl IntegrationEvent for Outgoing {}
1694
1695    struct SelectivePublisher;
1696
1697    impl EventPublisher<Outgoing> for SelectivePublisher {
1698        fn publish(&self, event: MessageEnvelope<Outgoing>) -> BoxFuture<'_, SoapResult<()>> {
1699            Box::pin(async move {
1700                match event.message.0.as_str() {
1701                    "retry" => Err(SoapError::timeout("publisher timed out")),
1702                    "dead-letter" => Err(SoapError::validation("invalid integration event")),
1703                    _ => Ok(()),
1704                }
1705            })
1706        }
1707    }
1708
1709    #[derive(Default)]
1710    struct SelectiveHandler {
1711        seen: Mutex<Vec<String>>,
1712    }
1713
1714    impl EventHandler<Outgoing> for SelectiveHandler {
1715        fn handle<'a>(
1716            &'a self,
1717            event: &'a MessageEnvelope<Outgoing>,
1718        ) -> BoxFuture<'a, SoapResult<()>> {
1719            Box::pin(async move {
1720                self.seen
1721                    .lock()
1722                    .map_err(|_| SoapError::infrastructure("test inbox handler lock poisoned"))?
1723                    .push(event.message.0.clone());
1724                if event.message.0 == "fail" {
1725                    Err(SoapError::domain("incoming handler failed"))
1726                } else {
1727                    Ok(())
1728                }
1729            })
1730        }
1731    }
1732
1733    #[derive(Debug, Clone, PartialEq, Eq)]
1734    enum TestCommand {
1735        Continue,
1736        Retry,
1737        Reject,
1738    }
1739
1740    impl Command for TestCommand {
1741        type Output = ();
1742    }
1743
1744    #[derive(Default)]
1745    struct TestCommandHandler {
1746        seen: Mutex<Vec<TestCommand>>,
1747    }
1748
1749    impl CommandHandler<TestCommand> for TestCommandHandler {
1750        fn command(&self, command: TestCommand) -> BoxFuture<'_, SoapResult<()>> {
1751            Box::pin(async move {
1752                self.seen
1753                    .lock()
1754                    .map_err(|_| SoapError::infrastructure("test command handler lock poisoned"))?
1755                    .push(command.clone());
1756                match command {
1757                    TestCommand::Continue => Ok(()),
1758                    TestCommand::Retry => Err(SoapError::timeout("command timed out")),
1759                    TestCommand::Reject => Err(SoapError::validation("command was rejected")),
1760                }
1761            })
1762        }
1763    }
1764
1765    #[derive(Debug, Clone)]
1766    struct TestSaga {
1767        id: SagaId,
1768        status: SagaStatus,
1769    }
1770
1771    impl Saga<TestEvent> for TestSaga {
1772        type Command = TestCommand;
1773
1774        fn id(&self) -> &SagaId {
1775            &self.id
1776        }
1777
1778        fn status(&self) -> SagaStatus {
1779            self.status
1780        }
1781
1782        fn react(
1783            &mut self,
1784            _event: &MessageEnvelope<TestEvent>,
1785        ) -> SoapResult<Vec<SagaAction<Self::Command>>> {
1786            self.status = SagaStatus::Completed;
1787            Ok(vec![
1788                SagaAction::Dispatch(TestCommand::Continue),
1789                SagaAction::Complete,
1790            ])
1791        }
1792    }
1793
1794    fn event(id: &str, message: TestEvent) -> MessageEnvelope<TestEvent> {
1795        MessageEnvelope::new(message, MessageMetadata::new(id, UNIX_EPOCH))
1796    }
1797
1798    fn recorded_event(position: u64, message: TestEvent) -> RecordedEvent<TestEvent> {
1799        RecordedEvent {
1800            stream_id: StreamId::new("transactional-projection-contract"),
1801            stream_version: StreamVersion::new(position),
1802            global_position: GlobalPosition::new(position),
1803            envelope: event(&format!("projection-contract-{position}"), message),
1804        }
1805    }
1806
1807    fn encoded(id: &str, payload: &str) -> EncodedEvent<String> {
1808        EncodedEvent {
1809            event: VersionedPayload {
1810                event_type: "contract.raw-event".to_owned(),
1811                schema_version: 1,
1812                payload: payload.to_owned(),
1813            },
1814            metadata: MessageMetadata::new(id, UNIX_EPOCH),
1815        }
1816    }
1817
1818    #[test]
1819    fn event_store_passes_shared_contract() {
1820        let store = MemoryEventStore::new();
1821        let result = block_on(verify_event_store_contract(
1822            &store,
1823            StreamId::new("order-1"),
1824            StreamId::new("order-2"),
1825            event("event-1", TestEvent::Opened),
1826            event("event-2", TestEvent::Renamed("new".into())),
1827            event("event-3", TestEvent::Closed),
1828        ));
1829        assert!(result.is_ok(), "{result:?}");
1830    }
1831
1832    #[test]
1833    fn event_sourced_repository_rehydrates_and_preserves_conflicting_changes() {
1834        let store = MemoryEventStore::new();
1835        let factory = |stream_id| Ok(CounterAggregate::pristine(stream_id));
1836        let repository = EventSourcedRepository::new(&store, &factory);
1837        let stream = StreamId::new("counter-1");
1838        let aggregate: SoapResult<CounterAggregate> = repository.new_aggregate(stream.clone());
1839        let Some(mut aggregate) = aggregate.ok() else {
1840            panic!("counter aggregate factory was rejected");
1841        };
1842        let recorded = aggregate.record(MessageEnvelope::new(
1843            CounterEvent::Added(1),
1844            MessageMetadata::new("counter-event-1", UNIX_EPOCH),
1845        ));
1846        assert!(recorded.is_ok(), "{recorded:?}");
1847        let first_commit = block_on(repository.commit(&mut aggregate));
1848        assert!(matches!(
1849            first_commit,
1850            Ok(AggregateCommitResult::Appended { event_count: 1, .. })
1851        ));
1852        assert_eq!(aggregate.version, Some(StreamVersion::FIRST));
1853        assert!(aggregate.uncommitted.is_empty());
1854        let unchanged = block_on(repository.commit(&mut aggregate));
1855        assert_eq!(
1856            unchanged.ok(),
1857            Some(AggregateCommitResult::Unchanged {
1858                stream_version: Some(StreamVersion::FIRST),
1859            })
1860        );
1861
1862        let first: SoapResult<Option<CounterAggregate>> = block_on(repository.load(&stream));
1863        let second: SoapResult<Option<CounterAggregate>> = block_on(repository.load(&stream));
1864        let Some(mut first) = first.ok().flatten() else {
1865            panic!("first counter copy was not rehydrated");
1866        };
1867        let Some(mut second) = second.ok().flatten() else {
1868            panic!("second counter copy was not rehydrated");
1869        };
1870        assert_eq!(first.value, 1);
1871        assert_eq!(first.version, Some(StreamVersion::FIRST));
1872        let first_recorded = first.record(MessageEnvelope::new(
1873            CounterEvent::Added(2),
1874            MessageMetadata::new("counter-event-2", UNIX_EPOCH),
1875        ));
1876        let second_recorded = second.record(MessageEnvelope::new(
1877            CounterEvent::Added(4),
1878            MessageMetadata::new("counter-event-conflict", UNIX_EPOCH),
1879        ));
1880        assert!(first_recorded.is_ok(), "{first_recorded:?}");
1881        assert!(second_recorded.is_ok(), "{second_recorded:?}");
1882        let winning_commit = block_on(repository.commit(&mut first));
1883        assert!(winning_commit.is_ok(), "{winning_commit:?}");
1884        let conflicting_commit = block_on(repository.commit(&mut second));
1885        assert!(conflicting_commit.is_err());
1886        assert_eq!(second.version, Some(StreamVersion::FIRST));
1887        assert_eq!(second.uncommitted.len(), 1);
1888
1889        let reloaded: SoapResult<Option<CounterAggregate>> = block_on(repository.load(&stream));
1890        assert!(matches!(reloaded, Ok(Some(current)) if current.value == 3
1891            && current.version == Some(StreamVersion::new(2))
1892            && current.uncommitted.is_empty()));
1893    }
1894
1895    #[test]
1896    fn stored_event_store_passes_shared_contract() {
1897        let store = MemoryStoredEventStore::new();
1898        let result = block_on(verify_stored_event_store_contract(
1899            &store,
1900            StreamId::new("raw-stream-1"),
1901            StreamId::new("raw-stream-2"),
1902            encoded("raw-event-1", "first"),
1903            encoded("raw-event-2", "second"),
1904            encoded("raw-event-3", "other"),
1905        ));
1906        assert!(result.is_ok(), "{result:?}");
1907    }
1908
1909    #[test]
1910    fn codec_event_store_upcasts_old_payloads_and_preserves_metadata() {
1911        let raw = MemoryStoredEventStore::new();
1912        let stream = StreamId::new("versioned-user-1");
1913        let seeded = block_on(raw.append(
1914            &stream,
1915            ExpectedVersion::NoStream,
1916            vec![EncodedEvent {
1917                event: VersionedPayload {
1918                    event_type: "contract.user-created".to_owned(),
1919                    schema_version: 1,
1920                    payload: "Ada".to_owned(),
1921                },
1922                metadata: MessageMetadata::new("versioned-event-1", UNIX_EPOCH)
1923                    .with_correlation_id("registration-1"),
1924            }],
1925        ));
1926        assert!(seeded.is_ok(), "{seeded:?}");
1927
1928        let mut upcasters = UpcasterChain::new();
1929        let registered = upcasters.register(Arc::new(AddEmailUpcaster));
1930        assert!(registered.is_ok(), "{registered:?}");
1931        let store = CodecEventStore::new(raw, VersionedTestCodec, upcasters);
1932        let loaded: SoapResult<Vec<RecordedEvent<VersionedTestEvent>>> =
1933            block_on(store.load(&stream, None));
1934        assert!(matches!(loaded, Ok(events) if events.len() == 1
1935            && events[0].envelope.message == VersionedTestEvent::Created {
1936                name: "Ada".to_owned(),
1937                email: "unknown@example.test".to_owned(),
1938            }
1939            && events[0]
1940                .envelope
1941                .metadata
1942                .correlation_id()
1943                .is_some_and(|id| id.as_str() == "registration-1")));
1944
1945        let appended = block_on(store.append(
1946            &stream,
1947            ExpectedVersion::Exact(StreamVersion::FIRST),
1948            vec![MessageEnvelope::new(
1949                VersionedTestEvent::Created {
1950                    name: "Grace".to_owned(),
1951                    email: "grace@example.test".to_owned(),
1952                },
1953                MessageMetadata::new("versioned-event-2", UNIX_EPOCH),
1954            )],
1955        ));
1956        assert!(appended.is_ok(), "{appended:?}");
1957        let raw_events = block_on(store.store().load(&stream, Some(StreamVersion::FIRST)));
1958        assert!(matches!(raw_events, Ok(events) if events.len() == 1
1959            && events[0].encoded.event.schema_version == 2
1960            && events[0].encoded.event.payload == "Grace|grace@example.test"));
1961    }
1962
1963    #[test]
1964    fn checkpoint_inbox_and_snapshot_pass_shared_contracts() {
1965        let checkpoint_result = block_on(verify_projection_checkpoint_contract(
1966            &MemoryProjectionCheckpointStore::new(),
1967        ));
1968        assert!(checkpoint_result.is_ok(), "{checkpoint_result:?}");
1969
1970        let inbox_result = block_on(verify_inbox_contract(
1971            &MemoryInboxStore::new(),
1972            MessageMetadata::new("incoming-1", UNIX_EPOCH).id(),
1973            UNIX_EPOCH,
1974        ));
1975        assert!(inbox_result.is_ok(), "{inbox_result:?}");
1976
1977        let snapshots = MemorySnapshotStore::new();
1978        let first = Snapshot {
1979            stream_id: StreamId::new("order-1"),
1980            stream_version: StreamVersion::new(1),
1981            state: "first".to_owned(),
1982            captured_at: UNIX_EPOCH,
1983        };
1984        let second = Snapshot {
1985            stream_id: StreamId::new("order-1"),
1986            stream_version: StreamVersion::new(2),
1987            state: "second".to_owned(),
1988            captured_at: UNIX_EPOCH + Duration::from_secs(1),
1989        };
1990        let snapshot_result = block_on(verify_snapshot_contract(&snapshots, first, second));
1991        assert!(snapshot_result.is_ok(), "{snapshot_result:?}");
1992    }
1993
1994    #[test]
1995    fn outbox_claims_once_until_failure_releases_the_message() {
1996        let store = MemoryOutboxStore::new();
1997        let record = OutboxRecord::pending(
1998            MessageEnvelope::new(
1999                Outgoing("payload".to_owned()),
2000                MessageMetadata::new("outgoing-1", UNIX_EPOCH),
2001            ),
2002            UNIX_EPOCH,
2003        );
2004        let dead_letter_record = OutboxRecord::pending(
2005            MessageEnvelope::new(
2006                Outgoing("dead-letter".to_owned()),
2007                MessageMetadata::new("outgoing-dead-letter", UNIX_EPOCH),
2008            ),
2009            UNIX_EPOCH,
2010        );
2011        let result = block_on(verify_outbox_contract(
2012            &store,
2013            record,
2014            dead_letter_record,
2015            UNIX_EPOCH,
2016        ));
2017        assert!(result.is_ok(), "{result:?}");
2018    }
2019
2020    #[test]
2021    fn outbox_processor_delivers_retries_and_dead_letters_with_one_policy() {
2022        let store = MemoryOutboxStore::new();
2023        let now = UNIX_EPOCH + Duration::from_secs(10);
2024        let record = |id: &str, payload: &str| {
2025            OutboxRecord::pending(
2026                MessageEnvelope::new(Outgoing(payload.to_owned()), MessageMetadata::new(id, now)),
2027                now,
2028            )
2029        };
2030        let enqueued = block_on(store.enqueue(vec![
2031            record("delivery-success", "success"),
2032            record("delivery-retry", "retry"),
2033            record("delivery-dead-letter", "dead-letter"),
2034        ]));
2035        assert!(enqueued.is_ok(), "{enqueued:?}");
2036
2037        let policy = TransientRetryPolicy::new(3, Duration::from_secs(5), Duration::from_secs(30));
2038        let Some(policy) = policy.ok() else {
2039            panic!("valid retry policy was rejected");
2040        };
2041        let clock = move || now;
2042        let publisher = SelectivePublisher;
2043        let processor = OutboxProcessor::new(&store, &publisher, &policy, &clock);
2044        let report = block_on(processor.process_batch::<Outgoing>(
2045            DeliveryClaimId::new("processor-claim"),
2046            Duration::from_secs(60),
2047            10,
2048        ));
2049        assert_eq!(
2050            report.ok(),
2051            Some(OutboxProcessingReport {
2052                claimed: 3,
2053                delivered: 1,
2054                retry_scheduled: 1,
2055                dead_lettered: 1,
2056            })
2057        );
2058
2059        let retry = block_on(store.claim_pending(
2060            DeliveryClaimId::new("retry-claim"),
2061            now + Duration::from_secs(5),
2062            now + Duration::from_secs(65),
2063            10,
2064        ));
2065        assert!(matches!(retry, Ok(records) if records.len() == 1
2066                && records[0].record.envelope.metadata.id().as_str() == "delivery-retry"
2067                && records[0].record.attempts == 1));
2068    }
2069
2070    #[test]
2071    fn inbox_processor_completes_duplicates_and_releases_failures() {
2072        let store = MemoryInboxStore::new();
2073        let handler = SelectiveHandler::default();
2074        let now = UNIX_EPOCH + Duration::from_secs(10);
2075        let clock = move || now;
2076        let processor = InboxProcessor::new(&store, &handler, &clock);
2077        let successful = MessageEnvelope::new(
2078            Outgoing("success".to_owned()),
2079            MessageMetadata::new("incoming-success", now),
2080        );
2081
2082        let first = block_on(processor.process(
2083            "orders",
2084            successful.clone(),
2085            DeliveryClaimId::new("inbox-success-1"),
2086            Duration::from_secs(60),
2087        ));
2088        assert_eq!(first.ok(), Some(InboxProcessingOutcome::Processed));
2089        let duplicate = block_on(processor.process(
2090            "orders",
2091            successful,
2092            DeliveryClaimId::new("inbox-success-2"),
2093            Duration::from_secs(60),
2094        ));
2095        assert_eq!(
2096            duplicate.ok(),
2097            Some(InboxProcessingOutcome::AlreadyProcessed)
2098        );
2099
2100        let active = MessageEnvelope::new(
2101            Outgoing("active".to_owned()),
2102            MessageMetadata::new("incoming-active", now),
2103        );
2104        let active_claim = block_on(store.claim(
2105            "orders",
2106            active.metadata.id(),
2107            DeliveryClaimId::new("inbox-active-owner"),
2108            now,
2109            now + Duration::from_secs(60),
2110        ));
2111        assert!(matches!(active_claim, Ok(InboxClaim::Acquired)));
2112        let in_progress = block_on(processor.process(
2113            "orders",
2114            active,
2115            DeliveryClaimId::new("inbox-active-duplicate"),
2116            Duration::from_secs(60),
2117        ));
2118        assert_eq!(in_progress.ok(), Some(InboxProcessingOutcome::InProgress));
2119
2120        let failing = || {
2121            MessageEnvelope::new(
2122                Outgoing("fail".to_owned()),
2123                MessageMetadata::new("incoming-failure", now),
2124            )
2125        };
2126        let first_failure = block_on(processor.process(
2127            "orders",
2128            failing(),
2129            DeliveryClaimId::new("inbox-failure-1"),
2130            Duration::from_secs(60),
2131        ));
2132        let second_failure = block_on(processor.process(
2133            "orders",
2134            failing(),
2135            DeliveryClaimId::new("inbox-failure-2"),
2136            Duration::from_secs(60),
2137        ));
2138        assert!(first_failure.is_err());
2139        assert!(second_failure.is_err());
2140        assert_eq!(
2141            handler.seen.lock().ok().map(|seen| seen.clone()),
2142            Some(vec![
2143                "success".to_owned(),
2144                "fail".to_owned(),
2145                "fail".to_owned(),
2146            ])
2147        );
2148    }
2149
2150    #[test]
2151    fn transactional_outbox_does_not_commit_messages_after_version_conflict() {
2152        let store = MemoryEventOutbox::new();
2153        let stream = StreamId::new("order-atomic");
2154        let outgoing = |id: &str| {
2155            OutboxRecord::pending(
2156                MessageEnvelope::new(
2157                    Outgoing(id.to_owned()),
2158                    MessageMetadata::new(id, UNIX_EPOCH),
2159                ),
2160                UNIX_EPOCH,
2161            )
2162        };
2163
2164        let result = block_on(verify_transactional_event_outbox_contract(
2165            &store,
2166            store.event_store(),
2167            store.outbox(),
2168            TransactionalEventOutboxFixture {
2169                stream,
2170                first_event: event("atomic-event-1", TestEvent::Opened),
2171                conflicting_event: event("atomic-event-2", TestEvent::Closed),
2172                first_message: outgoing("atomic-message-1"),
2173                conflicting_message: outgoing("atomic-message-2"),
2174                now: UNIX_EPOCH,
2175            },
2176        ));
2177        assert!(result.is_ok(), "{result:?}");
2178    }
2179
2180    struct RecordingReplay {
2181        seen: Mutex<Vec<u64>>,
2182    }
2183
2184    impl EventReplayHandler<TestEvent> for RecordingReplay {
2185        fn handle_batch<'a>(
2186            &'a self,
2187            events: &'a [RecordedEvent<TestEvent>],
2188        ) -> BoxFuture<'a, SoapResult<()>> {
2189            Box::pin(async move {
2190                let mut seen = self
2191                    .seen
2192                    .lock()
2193                    .map_err(|_| SoapError::infrastructure("replay test lock poisoned"))?;
2194                seen.extend(events.iter().map(|item| item.global_position.get()));
2195                Ok(())
2196            })
2197        }
2198    }
2199
2200    struct RecordingProjection {
2201        id: ProjectionId,
2202        seen: Mutex<Vec<u64>>,
2203    }
2204
2205    impl Projection<TestEvent> for RecordingProjection {
2206        fn id(&self) -> &ProjectionId {
2207            &self.id
2208        }
2209
2210        fn project<'a>(
2211            &'a self,
2212            event: &'a RecordedEvent<TestEvent>,
2213        ) -> BoxFuture<'a, SoapResult<()>> {
2214            Box::pin(async move {
2215                self.seen
2216                    .lock()
2217                    .map_err(|_| SoapError::infrastructure("projection test lock poisoned"))?
2218                    .push(event.global_position.get());
2219                Ok(())
2220            })
2221        }
2222    }
2223
2224    impl IdempotentProjection<TestEvent> for RecordingProjection {}
2225
2226    #[test]
2227    fn replay_and_projection_resume_from_durable_positions() {
2228        let store = MemoryEventStore::new();
2229        let stream = StreamId::new("replay-stream");
2230        let append = block_on(store.append(
2231            &stream,
2232            ExpectedVersion::NoStream,
2233            vec![
2234                event("replay-1", TestEvent::Opened),
2235                event("replay-2", TestEvent::Closed),
2236            ],
2237        ));
2238        assert!(append.is_ok(), "{append:?}");
2239
2240        let handler = RecordingReplay {
2241            seen: Mutex::new(Vec::new()),
2242        };
2243        let replayer = EventReplayer::new(&store);
2244        let Some(options) = ReplayOptions::new(1).ok() else {
2245            panic!("valid replay options were rejected");
2246        };
2247        let replay = block_on(replayer.replay::<TestEvent, _>(options, &handler));
2248        assert_eq!(replay.ok().map(|report| report.processed), Some(2));
2249        assert_eq!(
2250            handler.seen.lock().ok().map(|seen| seen.clone()),
2251            Some(vec![1, 2])
2252        );
2253
2254        let checkpoints = MemoryProjectionCheckpointStore::new();
2255        let projection = RecordingProjection {
2256            id: ProjectionId::new("orders"),
2257            seen: Mutex::new(Vec::new()),
2258        };
2259        let runner = ProjectionRunner::new(&store, &checkpoints);
2260        assert_eq!(
2261            block_on(runner.run_once::<TestEvent, _>(&projection, 10)).ok(),
2262            Some(2)
2263        );
2264        assert_eq!(
2265            block_on(runner.run_once::<TestEvent, _>(&projection, 10)).ok(),
2266            Some(0)
2267        );
2268    }
2269
2270    #[test]
2271    fn transactional_projection_passes_shared_contract() {
2272        let projection = MemoryTransactionalProjection::new(
2273            ProjectionId::new("transactional-contract"),
2274            Vec::<u64>::new(),
2275            |model: &mut Vec<u64>, event: &RecordedEvent<TestEvent>| {
2276                model.push(event.global_position.get());
2277                Ok(())
2278            },
2279        );
2280        let first = recorded_event(1, TestEvent::Opened);
2281        let second = recorded_event(2, TestEvent::Closed);
2282
2283        let result = block_on(verify_transactional_projection_contract(
2284            &projection,
2285            &first,
2286            &second,
2287        ));
2288        assert!(result.is_ok(), "{result:?}");
2289        assert_eq!(
2290            projection.snapshot().ok(),
2291            Some((vec![1, 2], Some(GlobalPosition::new(2))))
2292        );
2293    }
2294
2295    #[test]
2296    fn transactional_projection_rolls_back_model_and_checkpoint_on_failure() {
2297        let store = MemoryEventStore::new();
2298        let stream = StreamId::new("transactional-projection-stream");
2299        let append = block_on(store.append(
2300            &stream,
2301            ExpectedVersion::NoStream,
2302            vec![
2303                event("projection-1", TestEvent::Opened),
2304                event("projection-2", TestEvent::Renamed("fail".to_owned())),
2305                event("projection-3", TestEvent::Closed),
2306            ],
2307        ));
2308        assert!(append.is_ok(), "{append:?}");
2309
2310        let projection = MemoryTransactionalProjection::new(
2311            ProjectionId::new("transactional-rollback"),
2312            Vec::<String>::new(),
2313            |model: &mut Vec<String>, event: &RecordedEvent<TestEvent>| {
2314                match &event.envelope.message {
2315                    TestEvent::Opened => model.push("opened".to_owned()),
2316                    TestEvent::Renamed(name) => {
2317                        model.push(name.clone());
2318                        return Err(SoapError::domain("projection update failed"));
2319                    }
2320                    TestEvent::Closed => model.push("closed".to_owned()),
2321                }
2322                Ok(())
2323            },
2324        );
2325        let runner = TransactionalProjectionRunner::new(&store);
2326
2327        let result = block_on(runner.run_once::<TestEvent, _>(&projection, 10));
2328        assert_eq!(
2329            result.as_ref().map_err(SoapError::kind),
2330            Err(soaprs_core::SoapErrorKind::Domain)
2331        );
2332        assert_eq!(
2333            projection.snapshot().ok(),
2334            Some((vec!["opened".to_owned()], Some(GlobalPosition::new(1))))
2335        );
2336    }
2337
2338    #[test]
2339    fn saga_store_round_trips_application_state() {
2340        let store = super::MemorySagaStore::new();
2341        let id = SagaId::new("checkout-1");
2342        let result = block_on(verify_saga_store_contract(
2343            &store,
2344            &id,
2345            "waiting-for-payment".to_owned(),
2346            "completed".to_owned(),
2347        ));
2348        assert!(result.is_ok(), "{result:?}");
2349    }
2350
2351    #[test]
2352    fn saga_outbox_passes_atomic_state_action_and_timer_contract() {
2353        let store = MemorySagaOutbox::new();
2354        let later = UNIX_EPOCH + Duration::from_secs(60);
2355        let result = block_on(verify_transactional_saga_outbox_contract(
2356            &store,
2357            store.saga_store(),
2358            store.action_store(),
2359            TransactionalSagaOutboxFixture {
2360                saga_id: SagaId::new("contract-durable-saga"),
2361                first_state: "running".to_owned(),
2362                conflicting_state: "stale".to_owned(),
2363                first_transition: MessageMetadata::new("saga-transition-1", UNIX_EPOCH),
2364                first_actions: vec![
2365                    SagaAction::Dispatch(TestCommand::Continue),
2366                    SagaAction::Schedule {
2367                        delay: Duration::from_secs(60),
2368                        command: TestCommand::Continue,
2369                    },
2370                    SagaAction::Complete,
2371                ],
2372                conflicting_transition: MessageMetadata::new(
2373                    "saga-transition-conflict",
2374                    UNIX_EPOCH,
2375                ),
2376                conflicting_actions: vec![SagaAction::Dispatch(TestCommand::Reject)],
2377                now: UNIX_EPOCH,
2378                later,
2379                expected_due_now: 2,
2380                expected_due_later: 1,
2381            },
2382        ));
2383        assert!(result.is_ok(), "{result:?}");
2384    }
2385
2386    #[test]
2387    fn saga_coordinator_commits_actions_instead_of_returning_them() {
2388        let store = MemorySagaOutbox::new();
2389        let coordinator = SagaCoordinator::new(&store);
2390        let saga = TestSaga {
2391            id: SagaId::new("durable-coordinated-saga"),
2392            status: SagaStatus::Pending,
2393        };
2394        let triggering_event = event("durable-saga-event", TestEvent::Opened);
2395        let transition = block_on(coordinator.transition(
2396            saga,
2397            ExpectedSagaVersion::NoSaga,
2398            &triggering_event,
2399            MessageMetadata::new("durable-saga-transition", UNIX_EPOCH),
2400        ));
2401        assert_eq!(
2402            transition
2403                .as_ref()
2404                .ok()
2405                .map(|result| result.commit.queued_actions),
2406            Some(2)
2407        );
2408        let claimed = block_on(store.action_store().claim_pending(
2409            DeliveryClaimId::new("durable-saga-worker"),
2410            UNIX_EPOCH,
2411            UNIX_EPOCH + Duration::from_secs(60),
2412            10,
2413        ));
2414        assert!(matches!(claimed, Ok(actions) if actions.len() == 2));
2415    }
2416
2417    #[test]
2418    fn saga_action_processor_dispatches_retries_dead_letters_and_waits_for_timers() {
2419        let store = MemorySagaOutbox::new();
2420        let committed = block_on(store.commit_transition(
2421            &SagaId::new("processed-saga"),
2422            ExpectedSagaVersion::NoSaga,
2423            "running".to_owned(),
2424            MessageMetadata::new("processed-saga-transition", UNIX_EPOCH),
2425            vec![
2426                SagaAction::Dispatch(TestCommand::Continue),
2427                SagaAction::Dispatch(TestCommand::Retry),
2428                SagaAction::Compensate(TestCommand::Reject),
2429                SagaAction::Complete,
2430                SagaAction::Schedule {
2431                    delay: Duration::from_secs(60),
2432                    command: TestCommand::Continue,
2433                },
2434            ],
2435        ));
2436        assert!(committed.is_ok(), "{committed:?}");
2437
2438        let policy = TransientRetryPolicy::new(3, Duration::from_secs(5), Duration::from_secs(30));
2439        let Some(policy) = policy.ok() else {
2440            panic!("valid retry policy was rejected");
2441        };
2442        let handler = TestCommandHandler::default();
2443        let clock = || UNIX_EPOCH;
2444        let processor = SagaActionProcessor::new(store.action_store(), &handler, &policy, &clock);
2445        let report = block_on(processor.process_batch::<TestCommand>(
2446            DeliveryClaimId::new("saga-action-processor"),
2447            Duration::from_secs(30),
2448            10,
2449        ));
2450        assert_eq!(
2451            report.ok(),
2452            Some(SagaActionProcessingReport {
2453                claimed: 4,
2454                dispatched: 1,
2455                markers_completed: 1,
2456                retry_scheduled: 1,
2457                dead_lettered: 1,
2458            })
2459        );
2460        let before_timer = block_on(store.action_store().claim_pending(
2461            DeliveryClaimId::new("saga-before-timer"),
2462            UNIX_EPOCH + Duration::from_secs(4),
2463            UNIX_EPOCH + Duration::from_secs(30),
2464            10,
2465        ));
2466        assert!(matches!(before_timer, Ok(actions) if actions.is_empty()));
2467        let retry_claim = DeliveryClaimId::new("saga-retry");
2468        let retry = block_on(store.action_store().claim_pending(
2469            retry_claim.clone(),
2470            UNIX_EPOCH + Duration::from_secs(5),
2471            UNIX_EPOCH + Duration::from_secs(30),
2472            10,
2473        ));
2474        let Ok(retry) = retry else {
2475            panic!("retryable saga action could not be claimed");
2476        };
2477        assert_eq!(retry.len(), 1);
2478        assert_eq!(retry[0].record.attempts, 1);
2479        let retry_completed = block_on(store.action_store().mark_completed(
2480            &retry[0].record.id,
2481            &retry_claim,
2482            UNIX_EPOCH + Duration::from_secs(5),
2483        ));
2484        assert!(retry_completed.is_ok(), "{retry_completed:?}");
2485        let timer = block_on(store.action_store().claim_pending(
2486            DeliveryClaimId::new("saga-timer"),
2487            UNIX_EPOCH + Duration::from_secs(60),
2488            UNIX_EPOCH + Duration::from_secs(90),
2489            10,
2490        ));
2491        assert!(matches!(timer, Ok(actions) if actions.len() == 1
2492            && matches!(actions[0].record.action, SagaAction::Schedule { .. })));
2493    }
2494}