Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "fmodel-decider-rust"
version = "0.2.0"
version = "0.3.0"
edition = "2024"
description = "Pure, type-safe, event-driven decision engine with progressive type refinement for event-sourced and state-stored systems"
license = "Apache-2.0"
Expand Down
167 changes: 147 additions & 20 deletions src/application/event_repository.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
use crate::EventComputationTrait;
use crate::{CommandQueries, EventComputationTrait, EventMeta, IdempotencyKey};

// ================================================================================================
// EventRepository Trait
Expand All @@ -12,9 +12,16 @@ use crate::EventComputationTrait;
///
/// # Type Parameters
///
/// - `C`: Command type that triggers state changes
/// - `Ei`: Input event type (events loaded from storage)
/// - `Eo`: Output event type (events to be persisted)
/// - `C`: Command type that triggers state changes. Must implement [`IdempotencyKey`] so
/// repository implementations can deduplicate retried commands, and [`CommandQueries`] so
/// DCB-style repositories know which events to fetch. In multi-threaded mode, must also
/// be `Send + Sync` since it is moved into the `Send` future returned by `execute`.
/// - `Ei`: Input event type (events loaded from storage). Must implement [`EventMeta`] so
/// repository implementations can build secondary indexes. In multi-threaded mode, must
/// also be `Send + Sync`.
/// - `Eo`: Output event type (events to be persisted). Must implement [`EventMeta`] so
/// repository implementations can index newly persisted events. In multi-threaded mode,
/// must also be `Send + Sync`.
///
/// # Associated Types
///
Expand Down Expand Up @@ -43,12 +50,41 @@ use crate::EventComputationTrait;
/// ```rust,no_run
/// use std::collections::HashMap;
/// use std::sync::{Arc, Mutex};
/// # use fmodel_decider_rust::{EventComputationTrait, AggregateDecider};
/// # use fmodel_decider_rust::{EventComputationTrait, AggregateDecider, EventMeta, IdempotencyKey, CommandQueries, QueryTuple, Tag};
///
/// # #[derive(Clone, Debug)]
/// # enum Command { OpenAccount { id: String } }
/// # enum Command { OpenAccount { id: String, idempotency_key: String } }
/// # impl IdempotencyKey for Command {
/// # fn idempotency_key(&self) -> &str {
/// # match self {
/// # Command::OpenAccount { idempotency_key, .. } => idempotency_key,
/// # }
/// # }
/// # }
/// # impl CommandQueries for Command {
/// # fn queries(&self) -> Vec<QueryTuple> {
/// # match self {
/// # Command::OpenAccount { id, .. } => vec![QueryTuple {
/// # event_type: "AccountOpened".to_string(),
/// # tags: vec![Tag::new("id", id.clone())],
/// # }],
/// # }
/// # }
/// # }
/// # #[derive(Clone, Debug)]
/// # enum Event { AccountOpened { id: String } }
/// # impl EventMeta for Event {
/// # fn event_type(&self) -> &str {
/// # match self {
/// # Event::AccountOpened { .. } => "AccountOpened",
/// # }
/// # }
/// # fn tags(&self) -> Vec<Tag> {
/// # match self {
/// # Event::AccountOpened { id } => vec![Tag::new("id", id.clone())],
/// # }
/// # }
/// # }
/// # #[derive(Clone, Debug, Default)]
/// # struct State { opened: bool }
///
Expand All @@ -58,7 +94,12 @@ use crate::EventComputationTrait;
/// }
///
/// # #[cfg(not(feature = "single-threaded"))]
/// # trait EventRepository<C, Ei, Eo>: Send + Sync {
/// # trait EventRepository<C, Ei, Eo>: Send + Sync
/// # where
/// # C: IdempotencyKey + CommandQueries,
/// # Ei: EventMeta,
/// # Eo: EventMeta,
/// # {
/// # type Error;
/// # async fn execute<D>(&self, command: C, decider: &D) -> Result<Vec<Eo>, Self::Error>
/// # where D: EventComputationTrait<C, Ei, Eo> + Send + Sync, D::Error: std::fmt::Debug;
Expand All @@ -79,7 +120,7 @@ use crate::EventComputationTrait;
/// {
/// // 1. FETCH: Extract stream ID and load events
/// let stream_id = match &command {
/// Command::OpenAccount { id } => id.clone(),
/// Command::OpenAccount { id, .. } => id.clone(),
/// };
/// let mut events = self.events.lock().unwrap();
/// let current_events = events.get(&stream_id).cloned().unwrap_or_default();
Expand All @@ -105,7 +146,7 @@ use crate::EventComputationTrait;
/// # let decider = AggregateDecider::new(
/// # |c: &Command, _s: &State| -> Result<Vec<Event>, String> {
/// # match c {
/// # Command::OpenAccount { id } => Ok(vec![Event::AccountOpened { id: id.clone() }]),
/// # Command::OpenAccount { id, .. } => Ok(vec![Event::AccountOpened { id: id.clone() }]),
/// # }
/// # },
/// # |s: &State, _e: &Event| {
Expand All @@ -118,7 +159,7 @@ use crate::EventComputationTrait;
/// # let repository = InMemoryEventRepository {
/// # events: Arc::new(Mutex::new(HashMap::new())),
/// # };
/// let command = Command::OpenAccount { id: "acc-123".to_string() };
/// let command = Command::OpenAccount { id: "acc-123".to_string(), idempotency_key: "req-1".to_string() };
///
/// // Execute command through repository
/// let events = repository.execute(command, &decider).await?;
Expand Down Expand Up @@ -148,7 +189,12 @@ use crate::EventComputationTrait;
/// }
/// ```
#[cfg(not(feature = "single-threaded"))]
pub trait EventRepository<C, Ei, Eo>: Send + Sync {
pub trait EventRepository<C, Ei, Eo>: Send + Sync
where
C: IdempotencyKey + CommandQueries + Send + Sync,
Ei: EventMeta + Send + Sync,
Eo: EventMeta + Send + Sync,
{
/// Error type for repository operations.
///
/// This should capture both infrastructure errors (fetch/save failures) and domain
Expand Down Expand Up @@ -184,19 +230,57 @@ pub trait EventRepository<C, Ei, Eo>: Send + Sync {
/// The exact mechanism depends on the underlying storage (transactions, optimistic
/// locking, etc.).
///
/// # Idempotency
///
/// Implementations should use `command.idempotency_key()` to detect retried commands:
/// when a command with a previously seen key is executed again, the original output
/// events should be returned without re-running the decider.
///
/// # DCB Queries
///
/// DCB-style implementations should use `command.queries()` to determine which events
/// to fetch for the FETCH stage, instead of assuming a single fixed stream.
///
/// # Example
///
/// ```rust,no_run
/// # use fmodel_decider_rust::{EventComputationTrait, AggregateDecider};
/// # use fmodel_decider_rust::{EventComputationTrait, AggregateDecider, EventMeta, IdempotencyKey, CommandQueries, QueryTuple, Tag};
/// # #[derive(Clone, Debug)]
/// # enum Command { Deposit(u32) }
/// # enum Command { Deposit { amount: u32, idempotency_key: String } }
/// # impl IdempotencyKey for Command {
/// # fn idempotency_key(&self) -> &str {
/// # match self {
/// # Command::Deposit { idempotency_key, .. } => idempotency_key,
/// # }
/// # }
/// # }
/// # impl CommandQueries for Command {
/// # fn queries(&self) -> Vec<QueryTuple> {
/// # Vec::new()
/// # }
/// # }
/// # #[derive(Clone, Debug)]
/// # enum Event { Deposited(u32) }
/// # impl EventMeta for Event {
/// # fn event_type(&self) -> &str {
/// # match self {
/// # Event::Deposited(_) => "Deposited",
/// # }
/// # }
/// # fn tags(&self) -> Vec<Tag> {
/// # Vec::new()
/// # }
/// # }
/// # #[derive(Clone, Debug, Default)]
/// # struct State { balance: u32 }
/// # struct MyRepository;
/// # #[cfg(not(feature = "single-threaded"))]
/// # trait EventRepository<C, Ei, Eo>: Send + Sync {
/// # trait EventRepository<C, Ei, Eo>: Send + Sync
/// # where
/// # C: IdempotencyKey + CommandQueries,
/// # Ei: EventMeta,
/// # Eo: EventMeta,
/// # {
/// # type Error;
/// # async fn execute<D>(&self, command: C, decider: &D) -> Result<Vec<Eo>, Self::Error>
/// # where D: EventComputationTrait<C, Ei, Eo> + Send + Sync;
Expand All @@ -213,7 +297,7 @@ pub trait EventRepository<C, Ei, Eo>: Send + Sync {
/// # let decider = AggregateDecider::new(
/// # |c: &Command, _s: &State| -> Result<Vec<Event>, String> {
/// # match c {
/// # Command::Deposit(amount) => Ok(vec![Event::Deposited(*amount)]),
/// # Command::Deposit { amount, .. } => Ok(vec![Event::Deposited(*amount)]),
/// # }
/// # },
/// # |s: &State, e: &Event| {
Expand All @@ -225,7 +309,7 @@ pub trait EventRepository<C, Ei, Eo>: Send + Sync {
/// # },
/// # || State::default(),
/// # );
/// let command = Command::Deposit(100);
/// let command = Command::Deposit { amount: 100, idempotency_key: "req-1".to_string() };
/// let events = repository.execute(command, &decider).await?;
/// # Ok(())
/// # }
Expand Down Expand Up @@ -256,6 +340,17 @@ pub trait EventRepository<C, Ei, Eo>: Send + Sync {
/// succeed and their events are persisted, or none are. The exact mechanism depends on the
/// underlying storage (transactions, optimistic locking, etc.).
///
/// # Idempotency
///
/// As with [`execute`](Self::execute), implementations should use each command's
/// `idempotency_key()` to detect and skip re-running previously handled commands within
/// the batch.
///
/// # DCB Queries
///
/// As with [`execute`](Self::execute), DCB-style implementations should use each
/// command's `queries()` to determine which events to fetch for it.
///
/// # Type Parameters
///
/// - `D`: The decider implementing `EventComputationTrait<C, Ei, Eo>`
Expand Down Expand Up @@ -306,19 +401,46 @@ pub trait EventRepository<C, Ei, Eo>: Send + Sync {
/// use std::rc::Rc;
/// use std::cell::RefCell;
/// use std::collections::HashMap;
/// # use fmodel_decider_rust::{EventComputationTrait, AggregateDecider};
/// # use fmodel_decider_rust::{EventComputationTrait, AggregateDecider, EventMeta, IdempotencyKey, CommandQueries, QueryTuple, Tag};
///
/// # #[derive(Clone, Debug)]
/// # enum Command { Deposit(u32) }
/// # enum Command { Deposit { amount: u32, idempotency_key: String } }
/// # impl IdempotencyKey for Command {
/// # fn idempotency_key(&self) -> &str {
/// # match self {
/// # Command::Deposit { idempotency_key, .. } => idempotency_key,
/// # }
/// # }
/// # }
/// # impl CommandQueries for Command {
/// # fn queries(&self) -> Vec<QueryTuple> {
/// # Vec::new()
/// # }
/// # }
/// # #[derive(Clone, Debug)]
/// # enum Event { Deposited(u32) }
/// # impl EventMeta for Event {
/// # fn event_type(&self) -> &str {
/// # match self {
/// # Event::Deposited(_) => "Deposited",
/// # }
/// # }
/// # fn tags(&self) -> Vec<Tag> {
/// # Vec::new()
/// # }
/// # }
///
/// // Single-threaded repository using Rc instead of Arc
/// struct SingleThreadedEventRepository {
/// events: Rc<RefCell<HashMap<String, Vec<Event>>>>,
/// }
///
/// # trait EventRepository<C, Ei, Eo> {
/// # trait EventRepository<C, Ei, Eo>
/// # where
/// # C: IdempotencyKey + CommandQueries,
/// # Ei: EventMeta,
/// # Eo: EventMeta,
/// # {
/// # type Error;
/// # async fn execute<D>(&self, command: C, decider: &D) -> Result<Vec<Eo>, Self::Error>
/// # where D: EventComputationTrait<C, Ei, Eo>;
Expand All @@ -344,7 +466,12 @@ pub trait EventRepository<C, Ei, Eo>: Send + Sync {
/// # }
/// ```
#[cfg(feature = "single-threaded")]
pub trait EventRepository<C, Ei, Eo> {
pub trait EventRepository<C, Ei, Eo>
where
C: IdempotencyKey + CommandQueries,
Ei: EventMeta,
Eo: EventMeta,
{
/// Error type for repository operations.
type Error;

Expand Down
Loading