From 9cf2cf54bab119c40f0a73c7e2f7ed70c316aa57 Mon Sep 17 00:00:00 2001 From: Patrick Lee Scott Date: Tue, 19 May 2026 22:14:16 -0500 Subject: [PATCH 01/18] fix: harden event record compatibility Implements [[tasks/harden-event-record-compatibility]] --- docs/postgres-event-store.md | 45 ++++++++++++ docs/research-and-roadmap.md | 2 +- sourced_rust_macros/src/lib.rs | 55 +++++++++++++++ src/aggregate/aggregate.rs | 5 +- src/entity/entity.rs | 109 +++++++++++++++++++++++++---- src/entity/event_record.rs | 9 +-- src/entity/mod.rs | 2 +- src/entity/upcaster.rs | 121 ++++++++++++++++++++++++++++++++- src/lib.rs | 5 +- src/read_model/in_memory.rs | 52 ++++++++++---- src/snapshot/repository.rs | 5 +- tests/sourced/main.rs | 62 ++++++++++++++++- tests/upcasting/main.rs | 60 +++++++++++++++- 13 files changed, 489 insertions(+), 43 deletions(-) create mode 100644 docs/postgres-event-store.md diff --git a/docs/postgres-event-store.md b/docs/postgres-event-store.md new file mode 100644 index 000000000..0ff07c130 --- /dev/null +++ b/docs/postgres-event-store.md @@ -0,0 +1,45 @@ +# Postgres Event Store Contract + +This is the storage contract for the future Postgres repository. It narrows the durable representation without requiring the Postgres implementation to land in the same change. + +## Event Records + +The event table stores aggregate-sourced replay records. Published domain events and integration messages should use the outbox/message tables. + +Recommended table shape: + +| Column | Type | Notes | +| --- | --- | --- | +| `aggregate_type` | `text` | Stable aggregate type name. | +| `aggregate_id` | `text` | Aggregate stream identifier. | +| `sequence` | `bigint` | One-based stream position. | +| `event_name` | `text` | Stable replay event name. | +| `event_version` | `bigint` | Payload schema version, default `1`. | +| `payload` | `bytea` | Raw encoded event payload bytes. | +| `payload_codec` | `text` | Required codec label, initially `bitcode`. | +| `payload_codec_version` | `integer` | Required codec metadata, initially `1`. | +| `metadata` | `jsonb` | Event metadata, default `{}`. | +| `recorded_at` | `timestamptz` | UTC instant for the event record. | + +Required constraints and indexes: + +- `PRIMARY KEY (aggregate_type, aggregate_id, sequence)`. +- Index `(aggregate_type, aggregate_id, sequence)` for stream hydration. +- Optional index `(event_name, event_version)` for migrations or diagnostics. + +## Timestamp Representation + +Rust `EventRecord::timestamp` remains `SystemTime` in the in-memory API, but Postgres must not persist serde's `SystemTime` JSON shape. The database representation is `recorded_at timestamptz NOT NULL`, bound as a UTC instant. Implementations should round or truncate to the database/driver's supported precision, normally microseconds, and convert back to `SystemTime` at the repository boundary. + +## Payload Codec Metadata + +Event payload bytes are currently bitcode-encoded. Postgres rows must carry codec metadata beside the payload so future codecs or bitcode compatibility changes can be handled explicitly: + +- `payload_codec = 'bitcode'` +- `payload_codec_version = 1` + +The repository should reject unknown codec labels or versions unless an explicit decoder/upcaster path exists. Payload schema changes still use `event_version` and aggregate upcasters; codec metadata describes the byte encoding, not the domain event version. + +## Backward Compatibility + +Rows or imported JSON records without event metadata deserialize with empty metadata. Postgres migrations should still write `metadata jsonb NOT NULL DEFAULT '{}'` so newly stored rows are explicit. diff --git a/docs/research-and-roadmap.md b/docs/research-and-roadmap.md index 7b9be5a3b..8feb1d986 100644 --- a/docs/research-and-roadmap.md +++ b/docs/research-and-roadmap.md @@ -18,7 +18,7 @@ Approach: Add async versions of the core traits. Could be feature-gated or a par ### Later -- **Postgres backend** — proves the trait design, makes the library production-usable. Use sqlx. +- **Postgres backend** — proves the trait design, makes the library production-usable. Use sqlx. The event-record storage contract is outlined in [Postgres Event Store Contract](postgres-event-store.md). - **API docs** — `cargo doc` with doc comments on all public traits/types. - **Publish to crates.io** — after the above items stabilize. - **Domain service** — TBD whether to keep. Not documenting further until decided. diff --git a/sourced_rust_macros/src/lib.rs b/sourced_rust_macros/src/lib.rs index 4d4b62fa2..48ed62f16 100644 --- a/sourced_rust_macros/src/lib.rs +++ b/sourced_rust_macros/src/lib.rs @@ -74,6 +74,37 @@ fn generate_digest_call( } } +/// Generate a fallible digest call token stream. +fn generate_try_digest_call( + entity_field: &Ident, + event_name: &LitStr, + param_names: &[&Ident], + version: Option<&syn::LitInt>, +) -> proc_macro2::TokenStream { + match version { + Some(ver) => { + if param_names.is_empty() { + quote! { self.#entity_field.try_digest_v(#event_name, #ver, &())?; } + } else if param_names.len() == 1 { + let param = param_names[0]; + quote! { self.#entity_field.try_digest_v(#event_name, #ver, &(#param.clone(),))?; } + } else { + quote! { self.#entity_field.try_digest_v(#event_name, #ver, &(#(#param_names.clone()),*))?; } + } + } + None => { + if param_names.is_empty() { + quote! { self.#entity_field.try_digest(#event_name, &())?; } + } else if param_names.len() == 1 { + let param = param_names[0]; + quote! { self.#entity_field.try_digest(#event_name, &(#param.clone(),))?; } + } else { + quote! { self.#entity_field.try_digest(#event_name, &(#(#param_names.clone()),*))?; } + } + } + } +} + /// Wrap a method body with an optional guard condition and prepended statements. fn wrap_body_with_guard( guard: Option<&Expr>, @@ -333,6 +364,30 @@ pub fn digest(attr: TokenStream, item: TokenStream) -> TokenStream { TokenStream::from(quote! { #func }) } +/// Attribute macro that inserts a fallible digest call at the beginning of a method. +/// +/// The annotated method must return `Result<_, sourced_rust::PayloadError>` or +/// another `Result` whose error type can be constructed from `PayloadError`. +#[proc_macro_attribute] +pub fn try_digest(attr: TokenStream, item: TokenStream) -> TokenStream { + let args = parse_macro_input!(attr with parse_digest_args); + let mut func = parse_macro_input!(item as ItemFn); + + let param_names = extract_param_names(&func.sig); + let digest_call = generate_try_digest_call( + &args.entity_field, + &args.event_name, + ¶m_names, + args.version.as_ref(), + ); + + let original_stmts = &func.block.stmts; + let new_body = wrap_body_with_guard(args.guard.as_ref(), digest_call, original_stmts); + func.block = Box::new(new_body); + + TokenStream::from(quote! { #func }) +} + struct DigestArgs { entity_field: syn::Ident, event_name: LitStr, diff --git a/src/aggregate/aggregate.rs b/src/aggregate/aggregate.rs index d1fa77289..16c4ba7db 100644 --- a/src/aggregate/aggregate.rs +++ b/src/aggregate/aggregate.rs @@ -1,7 +1,7 @@ use std::fmt; use std::marker::PhantomData; -use crate::entity::{upcast_events, Entity, EventRecord, EventUpcaster}; +use crate::entity::{try_upcast_events, Entity, EventRecord, EventUpcaster}; use crate::queued_repo::{GetAllWithOpts, GetWithOpts, ReadOpts, UnlockableRepository}; use crate::repository::{ Commit, CommitBatch, Find, Get, Repository, RepositoryError, TransactionalCommit, @@ -66,7 +66,8 @@ pub fn hydrate(entity: Entity) -> Result { let events = if upcasters.is_empty() { agg.entity().events().to_vec() } else { - upcast_events(agg.entity().events().to_vec(), upcasters) + try_upcast_events(agg.entity().events().to_vec(), upcasters) + .map_err(|err| RepositoryError::Replay(err.to_string()))? }; agg.entity_mut().set_replaying(true); diff --git a/src/entity/entity.rs b/src/entity/entity.rs index 55d29c4f7..2432edde9 100644 --- a/src/entity/entity.rs +++ b/src/entity/entity.rs @@ -4,7 +4,7 @@ use std::time::SystemTime; use serde::{Deserialize, Serialize}; -use super::EventRecord; +use super::{EventRecord, PayloadError}; #[derive(Serialize, Deserialize)] pub struct Entity { @@ -172,19 +172,34 @@ impl Entity { /// The payload is serialized using bitcode for compact, fast storage. /// Any metadata set on the entity is attached to the event. pub fn digest(&mut self, name: impl Into, payload: &T) { + self.try_digest(name, payload) + .expect("failed to serialize payload"); + } + + /// Fallible form of [`Entity::digest`]. + /// + /// This is the production-safe path for command handlers that need to + /// return serialization failures instead of panicking. If serialization + /// fails, the entity is left unchanged. + pub fn try_digest( + &mut self, + name: impl Into, + payload: &T, + ) -> Result<(), PayloadError> { if self.replaying { - return; + return Ok(()); } - let bytes = bitcode::serialize(payload).expect("failed to serialize payload"); + let bytes = bitcode::serialize(payload).map_err(|e| PayloadError { + message: e.to_string(), + })?; let sequence = self.events.len() as u64 + 1; let mut record = EventRecord::new(name, bytes, sequence); if !self.metadata.is_empty() { record.metadata = self.metadata.clone(); } - self.events.push(record); - self.version = self.events.len() as u64; - self.timestamp = SystemTime::now(); + self.push_new_event(record); + Ok(()) } /// Record a versioned event. @@ -194,19 +209,33 @@ impl Entity { version: u64, payload: &T, ) { + self.try_digest_v(name, version, payload) + .expect("failed to serialize payload"); + } + + /// Fallible form of [`Entity::digest_v`]. + /// + /// If serialization fails, the entity is left unchanged. + pub fn try_digest_v( + &mut self, + name: impl Into, + version: u64, + payload: &T, + ) -> Result<(), PayloadError> { if self.replaying { - return; + return Ok(()); } - let bytes = bitcode::serialize(payload).expect("failed to serialize payload"); + let bytes = bitcode::serialize(payload).map_err(|e| PayloadError { + message: e.to_string(), + })?; let sequence = self.events.len() as u64 + 1; let mut record = EventRecord::new_versioned(name, bytes, sequence, version); if !self.metadata.is_empty() { record.metadata = self.metadata.clone(); } - self.events.push(record); - self.version = self.events.len() as u64; - self.timestamp = SystemTime::now(); + self.push_new_event(record); + Ok(()) } /// Record an event with no payload. @@ -214,6 +243,12 @@ impl Entity { self.digest(name, &()); } + fn push_new_event(&mut self, record: EventRecord) { + self.events.push(record); + self.version = self.events.len() as u64; + self.timestamp = SystemTime::now(); + } + pub fn load_from_history(&mut self, history: Vec) { self.events = history; self.version = self.events.len() as u64; @@ -244,20 +279,44 @@ impl Entity { /// Replace all events with a single snapshot event. /// Used by read models to store current state. pub fn set_snapshot(&mut self, data: &T) { - let payload = bitcode::serialize(data).expect("failed to serialize snapshot"); + self.try_set_snapshot(data) + .expect("failed to serialize snapshot"); + } + + /// Fallible form of [`Entity::set_snapshot`]. + /// + /// If serialization fails, the entity is left unchanged. + pub fn try_set_snapshot(&mut self, data: &T) -> Result<(), PayloadError> { + let payload = bitcode::serialize(data).map_err(|e| PayloadError { + message: e.to_string(), + })?; self.events.clear(); let record = EventRecord::new("Snapshot", payload, 1); self.events.push(record); self.version = 1; self.timestamp = SystemTime::now(); + Ok(()) } } #[cfg(test)] mod tests { use super::*; + use serde::ser::Error as _; use serde_json; + #[derive(Clone)] + struct FailingSerialize; + + impl Serialize for FailingSerialize { + fn serialize(&self, _serializer: S) -> Result + where + S: serde::Serializer, + { + Err(S::Error::custom("intentional serialization failure")) + } + } + #[test] fn new() { let entity = Entity::new(); @@ -282,6 +341,32 @@ mod tests { assert_eq!(entity.events()[0].sequence, 1); } + #[test] + fn try_digest_returns_serialization_errors_without_mutating_entity() { + let mut entity = Entity::new(); + + let err = entity + .try_digest("bad_event", &FailingSerialize) + .unwrap_err(); + + assert!(err.message.contains("intentional serialization failure")); + assert_eq!(entity.version(), 0); + assert!(entity.events().is_empty()); + } + + #[test] + fn try_digest_v_returns_serialization_errors_without_mutating_entity() { + let mut entity = Entity::new(); + + let err = entity + .try_digest_v("bad_event", 2, &FailingSerialize) + .unwrap_err(); + + assert!(err.message.contains("intentional serialization failure")); + assert_eq!(entity.version(), 0); + assert!(entity.events().is_empty()); + } + #[test] fn rehydrate() { let mut entity = Entity::new(); diff --git a/src/entity/event_record.rs b/src/entity/event_record.rs index 2a7547bda..5f48ef960 100644 --- a/src/entity/event_record.rs +++ b/src/entity/event_record.rs @@ -3,7 +3,7 @@ use std::collections::HashMap; use std::fmt; use std::time::SystemTime; -/// Error when deserializing event payload. +/// Error when serializing or deserializing event payloads. #[derive(Clone, Debug, PartialEq, Eq)] pub struct PayloadError { pub message: String, @@ -36,6 +36,7 @@ pub struct EventRecord { pub event_version: u64, pub sequence: u64, pub timestamp: SystemTime, + #[serde(default)] pub metadata: HashMap, } @@ -220,9 +221,9 @@ mod tests { } #[test] - fn deserialize_without_metadata_field_fails() { + fn deserialize_without_metadata_field_defaults_to_empty() { let json = r#"{"event_name":"old_event","payload":"","sequence":1,"timestamp":{"secs_since_epoch":0,"nanos_since_epoch":0}}"#; - let result: Result = serde_json::from_str(json); - assert!(result.is_err()); + let record: EventRecord = serde_json::from_str(json).unwrap(); + assert!(record.metadata.is_empty()); } } diff --git a/src/entity/mod.rs b/src/entity/mod.rs index 5097026e1..325441b40 100644 --- a/src/entity/mod.rs +++ b/src/entity/mod.rs @@ -10,4 +10,4 @@ pub use entity::Entity; pub use event::Event; pub use event_record::{EventRecord, PayloadError}; pub use local_event::LocalEvent; -pub use upcaster::{upcast_events, EventUpcaster}; +pub use upcaster::{try_upcast_events, upcast_events, EventUpcaster, UpcastError}; diff --git a/src/entity/upcaster.rs b/src/entity/upcaster.rs index aee1982df..421109d85 100644 --- a/src/entity/upcaster.rs +++ b/src/entity/upcaster.rs @@ -1,3 +1,6 @@ +use std::collections::HashSet; +use std::fmt; + use super::EventRecord; /// A stateless, pure transformation that converts an event payload from one version to another. @@ -11,21 +14,83 @@ pub struct EventUpcaster { pub transform: fn(payload: &[u8]) -> Vec, } +/// Error returned when an upcaster chain cannot make safe forward progress. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum UpcastError { + SameVersionTransition { event_type: String, version: u64 }, + CycleDetected { event_type: String, version: u64 }, +} + +impl fmt::Display for UpcastError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + UpcastError::SameVersionTransition { + event_type, + version, + } => write!( + f, + "upcaster for event {event_type} does not advance version {version}" + ), + UpcastError::CycleDetected { + event_type, + version, + } => write!( + f, + "upcaster chain for event {event_type} cycles back to version {version}" + ), + } + } +} + +impl std::error::Error for UpcastError {} + /// Apply upcasters to a list of events. Chains automatically (v1->v2->v3). +/// +/// This compatibility helper panics when invalid upcaster configuration is +/// detected. Hydration paths use [`try_upcast_events`] so repository reads can +/// return the error instead. pub fn upcast_events(events: Vec, upcasters: &[EventUpcaster]) -> Vec { + try_upcast_events(events, upcasters).expect("invalid upcaster chain") +} + +/// Fallible form of [`upcast_events`]. +pub fn try_upcast_events( + events: Vec, + upcasters: &[EventUpcaster], +) -> Result, UpcastError> { events .into_iter() .map(|event| upcast_one(event, upcasters)) .collect() } -fn upcast_one(mut event: EventRecord, upcasters: &[EventUpcaster]) -> EventRecord { +fn upcast_one( + mut event: EventRecord, + upcasters: &[EventUpcaster], +) -> Result { + let mut seen_versions = HashSet::new(); + seen_versions.insert(event.event_version); + loop { let mut applied = false; for u in upcasters { if u.event_type == event.event_name && u.from_version == event.event_version { + if u.to_version == event.event_version { + return Err(UpcastError::SameVersionTransition { + event_type: event.event_name, + version: event.event_version, + }); + } + + let next_version = u.to_version; event.payload = (u.transform)(&event.payload); - event.event_version = u.to_version; + event.event_version = next_version; + if !seen_versions.insert(next_version) { + return Err(UpcastError::CycleDetected { + event_type: event.event_name, + version: next_version, + }); + } applied = true; break; // restart loop to handle chaining } @@ -34,7 +99,7 @@ fn upcast_one(mut event: EventRecord, upcasters: &[EventUpcaster]) -> EventRecor break; } } - event + Ok(event) } #[cfg(test)] @@ -139,4 +204,54 @@ mod tests { assert_eq!(result[2].payload, vec![10, 99]); assert_eq!(result[2].event_version, 2); } + + #[test] + fn try_upcast_events_rejects_same_version_transition() { + let event = EventRecord::new("A", vec![10], 1); + let upcasters = [EventUpcaster { + event_type: "A", + from_version: 1, + to_version: 1, + transform: |payload| payload.to_vec(), + }]; + + let err = try_upcast_events(vec![event], &upcasters).unwrap_err(); + + assert_eq!( + err, + UpcastError::SameVersionTransition { + event_type: "A".to_string(), + version: 1 + } + ); + } + + #[test] + fn try_upcast_events_rejects_cycles() { + let event = EventRecord::new("A", vec![10], 1); + let upcasters = [ + EventUpcaster { + event_type: "A", + from_version: 1, + to_version: 2, + transform: |payload| payload.to_vec(), + }, + EventUpcaster { + event_type: "A", + from_version: 2, + to_version: 1, + transform: |payload| payload.to_vec(), + }, + ]; + + let err = try_upcast_events(vec![event], &upcasters).unwrap_err(); + + assert_eq!( + err, + UpcastError::CycleDetected { + event_type: "A".to_string(), + version: 1 + } + ); + } } diff --git a/src/lib.rs b/src/lib.rs index 68d3fb630..b2b983894 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -21,7 +21,8 @@ pub mod snapshot; // Re-export entity types at crate root for convenience pub use entity::{ - upcast_events, Committable, Entity, Event, EventRecord, EventUpcaster, LocalEvent, PayloadError, + try_upcast_events, upcast_events, Committable, Entity, Event, EventRecord, EventUpcaster, + LocalEvent, PayloadError, UpcastError, }; // Re-export repository traits at crate root for convenience @@ -107,7 +108,7 @@ pub use snapshot::{ pub use event_emitter_rs::EventEmitter; // Re-export proc macros -pub use sourced_rust_macros::{aggregate, digest, sourced, ReadModel, Snapshot}; +pub use sourced_rust_macros::{aggregate, digest, sourced, try_digest, ReadModel, Snapshot}; // Re-export enqueue macro (requires "emitter" feature) #[cfg(feature = "emitter")] diff --git a/src/read_model/in_memory.rs b/src/read_model/in_memory.rs index 41e92bae4..69c060d79 100644 --- a/src/read_model/in_memory.rs +++ b/src/read_model/in_memory.rs @@ -201,13 +201,13 @@ impl ReadModelStore for InMemoryReadModelStore { for (key, stored) in storage.iter() { if key.starts_with(&prefix) { - if let Ok(data) = serde_json::from_slice::(&stored.bytes) { - if predicate(&data) { - results.push(Versioned { - data, - version: stored.version, - }); - } + let data = serde_json::from_slice::(&stored.bytes) + .map_err(|e| ReadModelError::Serde(e.to_string()))?; + if predicate(&data) { + results.push(Versioned { + data, + version: stored.version, + }); } } } @@ -228,13 +228,13 @@ impl ReadModelStore for InMemoryReadModelStore { for (key, stored) in storage.iter() { if key.starts_with(&prefix) { - if let Ok(data) = serde_json::from_slice::(&stored.bytes) { - if predicate(&data) { - return Ok(Some(Versioned { - data, - version: stored.version, - })); - } + let data = serde_json::from_slice::(&stored.bytes) + .map_err(|e| ReadModelError::Serde(e.to_string()))?; + if predicate(&data) { + return Ok(Some(Versioned { + data, + version: stored.version, + })); } } } @@ -432,6 +432,30 @@ mod tests { assert!(none.is_none()); } + #[test] + fn find_models_returns_error_for_corrupted_rows() { + let store = InMemoryReadModelStore::new(); + store + .save_raw("test_models:bad", b"not valid json".to_vec()) + .unwrap(); + + let err = store.find_models::(&|_| true).unwrap_err(); + + assert!(matches!(err, ReadModelError::Serde(_))); + } + + #[test] + fn find_one_model_returns_error_for_corrupted_rows() { + let store = InMemoryReadModelStore::new(); + store + .save_raw("test_models:bad", b"not valid json".to_vec()) + .unwrap(); + + let err = store.find_one_model::(&|_| true).unwrap_err(); + + assert!(matches!(err, ReadModelError::Serde(_))); + } + #[test] fn clone_shares_storage() { let store = InMemoryReadModelStore::new(); diff --git a/src/snapshot/repository.rs b/src/snapshot/repository.rs index ed92d0462..6c369c235 100644 --- a/src/snapshot/repository.rs +++ b/src/snapshot/repository.rs @@ -1,5 +1,5 @@ use crate::aggregate::{hydrate, AggregateRepository}; -use crate::entity::{upcast_events, Entity}; +use crate::entity::{try_upcast_events, Entity}; use crate::queued_repo::{GetAllWithOpts, GetWithOpts, ReadOpts, UnlockableRepository}; use crate::repository::{ CommitBatch, Find, Get, RepositoryError, SnapshotWrite, TransactionalCommit, @@ -38,7 +38,8 @@ pub fn hydrate_from_snapshot( let events = if upcasters.is_empty() { post_snapshot } else { - upcast_events(post_snapshot, upcasters) + try_upcast_events(post_snapshot, upcasters) + .map_err(|err| RepositoryError::Replay(err.to_string()))? }; agg.entity_mut().set_replaying(true); diff --git a/tests/sourced/main.rs b/tests/sourced/main.rs index 91d616f3f..8366b6a2c 100644 --- a/tests/sourced/main.rs +++ b/tests/sourced/main.rs @@ -1,7 +1,44 @@ mod aggregate; use aggregate::{Todo, TodoEvent}; -use sourced_rust::{Aggregate, AggregateBuilder, EventRecord, HashMapRepository, Queueable}; +use serde::ser::Error as _; +use serde::Serialize; +use sourced_rust::{ + Aggregate, AggregateBuilder, Entity, EventRecord, HashMapRepository, PayloadError, Queueable, +}; + +#[derive(Clone)] +struct FailingSerialize; + +impl Serialize for FailingSerialize { + fn serialize(&self, _serializer: S) -> Result + where + S: serde::Serializer, + { + Err(S::Error::custom("intentional serialization failure")) + } +} + +#[derive(Default)] +struct SafeRecorder { + entity: Entity, + applied: bool, +} + +impl SafeRecorder { + #[sourced_rust::try_digest("Recorded")] + fn record(&mut self, _payload: FailingSerialize) -> Result<(), PayloadError> { + self.applied = true; + Ok(()) + } + + #[sourced_rust::try_digest("Recorded", version = 2)] + fn record_ok(&mut self, payload: String) -> Result<(), PayloadError> { + self.applied = true; + assert_eq!(payload, "ok"); + Ok(()) + } +} #[test] fn enum_variants_exist_and_compile() { @@ -18,6 +55,29 @@ fn enum_variants_exist_and_compile() { let _ = format!("{:?}", completed); } +#[test] +fn try_digest_macro_returns_payload_errors_without_running_body() { + let mut recorder = SafeRecorder::default(); + + let err = recorder.record(FailingSerialize).unwrap_err(); + + assert!(err.message.contains("intentional serialization failure")); + assert!(!recorder.applied); + assert!(recorder.entity.events().is_empty()); +} + +#[test] +fn try_digest_macro_records_successful_versioned_events() { + let mut recorder = SafeRecorder::default(); + + recorder.record_ok("ok".to_string()).unwrap(); + + assert!(recorder.applied); + assert_eq!(recorder.entity.events().len(), 1); + assert_eq!(recorder.entity.events()[0].event_name, "Recorded"); + assert_eq!(recorder.entity.events()[0].event_version, 2); +} + #[test] fn event_name_returns_correct_strings() { let init = TodoEvent::Initialized { diff --git a/tests/upcasting/main.rs b/tests/upcasting/main.rs index 735964a0f..8dfae8a0c 100644 --- a/tests/upcasting/main.rs +++ b/tests/upcasting/main.rs @@ -3,9 +3,44 @@ mod aggregate; use aggregate::{TodoV1, TodoV2, TodoV3}; use sourced_rust::{ hydrate, upcast_events, Aggregate, AggregateBuilder, Commit, Entity, EventRecord, - EventUpcaster, HashMapRepository, SnapshotStore, + EventUpcaster, HashMapRepository, RepositoryError, SnapshotStore, }; +fn identity_payload(payload: &[u8]) -> Vec { + payload.to_vec() +} + +#[derive(Debug, Default)] +struct SameVersionUpcasterAggregate { + entity: Entity, +} + +impl Aggregate for SameVersionUpcasterAggregate { + type ReplayError = String; + + fn entity(&self) -> &Entity { + &self.entity + } + + fn entity_mut(&mut self) -> &mut Entity { + &mut self.entity + } + + fn replay_event(&mut self, _event: &EventRecord) -> Result<(), Self::ReplayError> { + Ok(()) + } + + fn upcasters() -> &'static [EventUpcaster] { + static UPCASTERS: &[EventUpcaster] = &[EventUpcaster { + event_type: "Loop", + from_version: 1, + to_version: 1, + transform: identity_payload, + }]; + UPCASTERS + } +} + // ============================================================================= // EventRecord version field // ============================================================================= @@ -45,6 +80,14 @@ fn old_events_without_event_version_deserialize_as_v1() { assert_eq!(record.event_version, 1); } +#[test] +fn old_events_without_metadata_deserialize_with_empty_metadata() { + let json = r#"{"event_name":"old_event","payload":"","sequence":1,"timestamp":{"secs_since_epoch":0,"nanos_since_epoch":0}}"#; + let record: EventRecord = serde_json::from_str(json).unwrap(); + assert_eq!(record.event_version, 1); + assert!(record.metadata.is_empty()); +} + #[test] fn event_version_round_trips_through_serde() { let record = EventRecord::new_versioned("TestEvent", vec![1, 2, 3], 1, 5); @@ -253,6 +296,21 @@ fn upcast_events_standalone() { assert_eq!(priority, 0); } +#[test] +fn hydrate_rejects_invalid_same_version_upcaster() { + let mut entity = Entity::new(); + entity.load_from_history(vec![EventRecord::new("Loop", vec![], 1)]); + + let err = hydrate::(entity).unwrap_err(); + + match err { + RepositoryError::Replay(message) => { + assert!(message.contains("does not advance version 1")); + } + other => panic!("expected replay error, got {other:?}"), + } +} + // ============================================================================= // Snapshot + upcasting // ============================================================================= From 5f204e03038b669a2e1d56e05fb6faa8527d3a58 Mon Sep 17 00:00:00 2001 From: Patrick Lee Scott Date: Wed, 20 May 2026 00:24:39 -0500 Subject: [PATCH 02/18] refactor: make event recording API fallible --- sourced_rust_macros/src/lib.rs | 246 ++++++++++-------- src/commit_builder/mod.rs | 8 +- src/entity/entity.rs | 112 +++----- src/entity/event_record.rs | 118 ++++++++- src/entity/mod.rs | 5 +- src/hashmap_repo/repository.rs | 28 +- src/lib.rs | 7 +- src/outbox/commit.rs | 6 +- src/outbox/message.rs | 171 ++++++------ src/outbox_worker/repository_ext.rs | 8 +- src/outbox_worker/worker.rs | 86 +++--- src/repository/error.rs | 7 + src/snapshot/repository.rs | 2 +- tests/blob_game/aggregate.rs | 15 +- tests/blob_game/main.rs | 120 +++++---- tests/bomberman/commands.rs | 52 ++-- tests/bomberman/domain/bomb.rs | 13 +- tests/bomberman/domain/explosion.rs | 6 +- tests/bomberman/domain/game_map.rs | 16 +- tests/bomberman/domain/player.rs | 21 +- tests/bomberman/domain/tick_saga.rs | 25 +- tests/bomberman/error.rs | 8 +- tests/enqueue/aggregate.rs | 16 +- tests/enqueue/main.rs | 50 ++-- tests/event_store/main.rs | 50 ++-- tests/microsvc/basic.rs | 6 +- tests/microsvc/handlers/counter_create.rs | 2 +- tests/microsvc/handlers/counter_increment.rs | 2 +- tests/microsvc/models/counter.rs | 6 +- tests/read_models/aggregate.rs | 11 +- tests/read_models/main.rs | 46 ++-- tests/sagas/distributed.rs | 124 +++++---- tests/sagas/handlers/inventory/init.rs | 2 +- tests/sagas/handlers/inventory/reserve.rs | 2 +- tests/sagas/handlers/messages.rs | 2 +- tests/sagas/handlers/orders/complete.rs | 6 +- tests/sagas/handlers/orders/create.rs | 4 +- tests/sagas/handlers/payments/process.rs | 18 +- .../handlers/saga/on_inventory_reserved.rs | 2 +- .../sagas/handlers/saga/on_order_completed.rs | 2 +- .../handlers/saga/on_payment_succeeded.rs | 2 +- tests/sagas/handlers/saga/start.rs | 3 +- tests/sagas/microsvc_saga.rs | 2 +- tests/sagas/orchestration.rs | 188 +++++++------ tests/sagas/order/inventory.rs | 22 +- tests/sagas/order/order.rs | 15 +- tests/sagas/order/payment.rs | 18 +- tests/sagas/order/saga.rs | 20 +- tests/snapshots/aggregate.rs | 9 +- tests/snapshots/main.rs | 57 ++-- tests/sourced/aggregate.rs | 9 +- tests/sourced/main.rs | 32 +-- tests/sourced_enqueue/aggregate.rs | 16 +- tests/sourced_enqueue/main.rs | 26 +- tests/sourced_snapshot/aggregates.rs | 42 ++- tests/sourced_snapshot/main.rs | 39 +-- tests/sourced_upcasting/aggregate.rs | 23 +- tests/sourced_upcasting/main.rs | 22 +- tests/todos/aggregate.rs | 9 +- tests/todos/main.rs | 162 +++++++----- tests/upcasting/aggregate.rs | 23 +- tests/upcasting/main.rs | 42 +-- 62 files changed, 1333 insertions(+), 879 deletions(-) diff --git a/sourced_rust_macros/src/lib.rs b/sourced_rust_macros/src/lib.rs index 48ed62f16..230c37064 100644 --- a/sourced_rust_macros/src/lib.rs +++ b/sourced_rust_macros/src/lib.rs @@ -6,7 +6,7 @@ use quote::{format_ident, quote}; use syn::{ braced, parse::{Parse, ParseStream}, - parse_macro_input, Expr, FnArg, Ident, ItemFn, ItemImpl, LitStr, Pat, Token, + parse_macro_input, Expr, FnArg, Ident, ItemFn, ItemImpl, LitStr, Pat, ReturnType, Token, Type, }; // ============================================================================ @@ -43,39 +43,42 @@ fn extract_params_with_types(sig: &syn::Signature) -> Vec<(Ident, syn::Type)> { .collect() } -/// Generate a digest call token stream. -fn generate_digest_call( - entity_field: &Ident, - event_name: &LitStr, - param_names: &[&Ident], - version: Option<&syn::LitInt>, -) -> proc_macro2::TokenStream { - match version { - Some(ver) => { - if param_names.is_empty() { - quote! { self.#entity_field.digest_v(#event_name, #ver, &()); } - } else if param_names.len() == 1 { - let param = param_names[0]; - quote! { self.#entity_field.digest_v(#event_name, #ver, &(#param.clone(),)); } - } else { - quote! { self.#entity_field.digest_v(#event_name, #ver, &(#(#param_names.clone()),*)); } - } - } - None => { - if param_names.is_empty() { - quote! { self.#entity_field.digest_empty(#event_name); } - } else if param_names.len() == 1 { - let param = param_names[0]; - quote! { self.#entity_field.digest(#event_name, &(#param.clone(),)); } - } else { - quote! { self.#entity_field.digest(#event_name, &(#(#param_names.clone()),*)); } - } - } +fn returns_result(sig: &syn::Signature) -> bool { + match &sig.output { + ReturnType::Default => false, + ReturnType::Type(_, ty) => match ty.as_ref() { + Type::Path(path) => path + .path + .segments + .last() + .map(|segment| segment.ident == "Result") + .unwrap_or(false), + _ => false, + }, + } +} + +fn block_returns_result(block: &syn::Block) -> bool { + let Some(syn::Stmt::Expr(expr, None)) = block.stmts.last() else { + return false; + }; + + match expr { + Expr::Call(call) => match &*call.func { + Expr::Path(path) => path + .path + .segments + .last() + .map(|segment| segment.ident == "Ok" || segment.ident == "Err") + .unwrap_or(false), + _ => false, + }, + _ => false, } } -/// Generate a fallible digest call token stream. -fn generate_try_digest_call( +/// Generate a digest call token stream. +fn generate_digest_call( entity_field: &Ident, event_name: &LitStr, param_names: &[&Ident], @@ -84,47 +87,75 @@ fn generate_try_digest_call( match version { Some(ver) => { if param_names.is_empty() { - quote! { self.#entity_field.try_digest_v(#event_name, #ver, &())?; } + quote! { self.#entity_field.digest_v(#event_name, #ver, &())?; } } else if param_names.len() == 1 { let param = param_names[0]; - quote! { self.#entity_field.try_digest_v(#event_name, #ver, &(#param.clone(),))?; } + quote! { self.#entity_field.digest_v(#event_name, #ver, &(#param.clone(),))?; } } else { - quote! { self.#entity_field.try_digest_v(#event_name, #ver, &(#(#param_names.clone()),*))?; } + quote! { self.#entity_field.digest_v(#event_name, #ver, &(#(#param_names.clone()),*))?; } } } None => { if param_names.is_empty() { - quote! { self.#entity_field.try_digest(#event_name, &())?; } + quote! { self.#entity_field.digest_empty(#event_name)?; } } else if param_names.len() == 1 { let param = param_names[0]; - quote! { self.#entity_field.try_digest(#event_name, &(#param.clone(),))?; } + quote! { self.#entity_field.digest(#event_name, &(#param.clone(),))?; } } else { - quote! { self.#entity_field.try_digest(#event_name, &(#(#param_names.clone()),*))?; } + quote! { self.#entity_field.digest(#event_name, &(#(#param_names.clone()),*))?; } } } } } -/// Wrap a method body with an optional guard condition and prepended statements. -fn wrap_body_with_guard( +/// Wrap a `Result<(), E>` command method with an optional guard and fallible prelude. +fn wrap_result_body_with_guard( guard: Option<&Expr>, prepend: proc_macro2::TokenStream, - original_stmts: &[syn::Stmt], + original_block: &syn::Block, ) -> syn::Block { - if let Some(guard) = guard { - syn::parse_quote! { - { - if #guard { + let original_stmts = &original_block.stmts; + let original_returns_result = block_returns_result(original_block); + + match (guard, original_returns_result) { + (Some(guard), true) => { + syn::parse_quote! { + { + if #guard { + #prepend + (|| #original_block)()?; + } + Ok(()) + } + } + } + (Some(guard), false) => { + syn::parse_quote! { + { + if #guard { + #prepend + #(#original_stmts)* + } + Ok(()) + } + } + } + (None, true) => { + syn::parse_quote! { + { #prepend - #(#original_stmts)* + (|| #original_block)()?; + Ok(()) } } } - } else { - syn::parse_quote! { - { - #prepend - #(#original_stmts)* + (None, false) => { + syn::parse_quote! { + { + #prepend + #(#original_stmts)* + Ok(()) + } } } } @@ -148,7 +179,7 @@ fn generate_enqueue_call( quote! { if !self.#entity_field.is_replaying() { #enqueue_expr - } + }; } } @@ -223,7 +254,7 @@ pub fn enqueue(attr: TokenStream, item: TokenStream) -> TokenStream { quote! { if !self.#entity_field.is_replaying() { self.#emitter_field.enqueue(#event_name, ""); - } + }; } } else if param_names.len() == 1 { // Single-element tuple needs trailing comma: (x,) not (x) @@ -231,37 +262,40 @@ pub fn enqueue(attr: TokenStream, item: TokenStream) -> TokenStream { quote! { if !self.#entity_field.is_replaying() { self.#emitter_field.enqueue_with(#event_name, &(#param.clone(),)); - } + }; } } else { // Multi-element tuple quote! { if !self.#entity_field.is_replaying() { self.#emitter_field.enqueue_with(#event_name, &(#(#param_names.clone()),*)); - } + }; } }; - // Build the new function body - let original_stmts = &func.block.stmts; - let new_body = if let Some(guard) = &args.guard { - // Wrap everything in the guard condition - syn::parse_quote! { - { - if #guard { + let new_body = if returns_result(&func.sig) { + wrap_result_body_with_guard(args.guard.as_ref(), enqueue_call, &func.block) + } else { + let original_stmts = &func.block.stmts; + if let Some(guard) = &args.guard { + // Wrap everything in the guard condition + syn::parse_quote! { + { + if #guard { + #enqueue_call + #(#original_stmts)* + } + } + } + } else { + // No guard - just prepend enqueue + syn::parse_quote! { + { #enqueue_call #(#original_stmts)* } } } - } else { - // No guard - just prepend enqueue - syn::parse_quote! { - { - #enqueue_call - #(#original_stmts)* - } - } }; func.block = Box::new(new_body); @@ -315,20 +349,24 @@ fn parse_enqueue_args(input: syn::parse::ParseStream) -> syn::Result` where `E` can be constructed +/// from `sourced_rust::EventRecordError`. +/// /// # Usage /// /// Basic usage with function parameters (automatically captured): /// ```ignore /// #[digest("Initialized")] -/// fn initialize(&mut self, id: String, user_id: String) { +/// fn initialize(&mut self, id: String, user_id: String) -> Result<(), sourced_rust::EventRecordError> { /// // digest call auto-inserted, params serialized as tuple +/// Ok(()) /// } /// ``` /// /// With guard condition: /// ```ignore /// #[digest("Completed", when = !self.completed)] -/// fn complete(&mut self) { +/// fn complete(&mut self) -> Result<(), sourced_rust::EventRecordError> { /// self.completed = true; /// } /// ``` @@ -336,7 +374,7 @@ fn parse_enqueue_args(input: syn::parse::ParseStream) -> syn::Result Result<(), sourced_rust::EventRecordError> { /// // uses self.my_entity instead of self.entity /// } /// ``` @@ -349,40 +387,24 @@ pub fn digest(attr: TokenStream, item: TokenStream) -> TokenStream { let args = parse_macro_input!(attr with parse_digest_args); let mut func = parse_macro_input!(item as ItemFn); - let param_names = extract_param_names(&func.sig); - let digest_call = generate_digest_call( - &args.entity_field, - &args.event_name, - ¶m_names, - args.version.as_ref(), - ); - - let original_stmts = &func.block.stmts; - let new_body = wrap_body_with_guard(args.guard.as_ref(), digest_call, original_stmts); - func.block = Box::new(new_body); - - TokenStream::from(quote! { #func }) -} - -/// Attribute macro that inserts a fallible digest call at the beginning of a method. -/// -/// The annotated method must return `Result<_, sourced_rust::PayloadError>` or -/// another `Result` whose error type can be constructed from `PayloadError`. -#[proc_macro_attribute] -pub fn try_digest(attr: TokenStream, item: TokenStream) -> TokenStream { - let args = parse_macro_input!(attr with parse_digest_args); - let mut func = parse_macro_input!(item as ItemFn); + if !returns_result(&func.sig) { + return syn::Error::new_spanned( + &func.sig.ident, + "#[digest] methods must return Result<(), E> where E can be constructed from sourced_rust::EventRecordError", + ) + .to_compile_error() + .into(); + } let param_names = extract_param_names(&func.sig); - let digest_call = generate_try_digest_call( + let digest_call = generate_digest_call( &args.entity_field, &args.event_name, ¶m_names, args.version.as_ref(), ); - let original_stmts = &func.block.stmts; - let new_body = wrap_body_with_guard(args.guard.as_ref(), digest_call, original_stmts); + let new_body = wrap_result_body_with_guard(args.guard.as_ref(), digest_call, &func.block); func.block = Box::new(new_body); TokenStream::from(quote! { #func }) @@ -481,14 +503,14 @@ pub fn aggregate(input: TokenStream) -> TokenStream { // No payload quote! { #event_name => { - self.#method_name(); + self.#method_name().map_err(|e| e.to_string())?; } } } else if call_args.is_empty() { // Event has payload but method takes no args quote! { #event_name => { - self.#method_name(); + self.#method_name().map_err(|e| e.to_string())?; } } } else if args.len() == 1 { @@ -498,7 +520,7 @@ pub fn aggregate(input: TokenStream) -> TokenStream { quote! { #event_name => { let (#arg,) = event.decode().map_err(|e| e.to_string())?; - self.#method_name(#call_arg); + self.#method_name(#call_arg).map_err(|e| e.to_string())?; } } } else { @@ -506,7 +528,7 @@ pub fn aggregate(input: TokenStream) -> TokenStream { quote! { #event_name => { let (#(#args),*) = event.decode().map_err(|e| e.to_string())?; - self.#method_name(#(#call_args),*); + self.#method_name(#(#call_args),*).map_err(|e| e.to_string())?; } } } @@ -873,6 +895,15 @@ pub fn sourced(attr: TokenStream, item: TokenStream) -> TokenStream { if let syn::ImplItem::Fn(method) = item { match find_and_remove_event_attr(&mut method.attrs) { Ok(Some(event_attr)) => { + if !returns_result(&method.sig) { + return syn::Error::new_spanned( + &method.sig.ident, + "#[event] methods must return Result<(), E> where E can be constructed from sourced_rust::EventRecordError", + ) + .to_compile_error() + .into(); + } + let params = extract_params_with_types(&method.sig); let param_name_refs: Vec<&Ident> = params.iter().map(|(name, _)| name).collect(); @@ -897,11 +928,10 @@ pub fn sourced(attr: TokenStream, item: TokenStream) -> TokenStream { #digest_call }; - let original_stmts = &method.block.stmts; - let new_body = wrap_body_with_guard( + let new_body = wrap_result_body_with_guard( event_attr.guard.as_ref(), prepend, - original_stmts, + &method.block, ); method.block = new_body; @@ -1010,7 +1040,7 @@ pub fn sourced(attr: TokenStream, item: TokenStream) -> TokenStream { if e.params.is_empty() { quote! { #event_name_str => { - self.#method_name(); + self.#method_name().map_err(|e| e.to_string())?; } } } else if e.params.len() == 1 { @@ -1018,7 +1048,7 @@ pub fn sourced(attr: TokenStream, item: TokenStream) -> TokenStream { quote! { #event_name_str => { let (#name,) = event.decode().map_err(|e| e.to_string())?; - self.#method_name(#name); + self.#method_name(#name).map_err(|e| e.to_string())?; } } } else { @@ -1026,7 +1056,7 @@ pub fn sourced(attr: TokenStream, item: TokenStream) -> TokenStream { quote! { #event_name_str => { let (#(#names),*) = event.decode().map_err(|e| e.to_string())?; - self.#method_name(#(#names),*); + self.#method_name(#(#names),*).map_err(|e| e.to_string())?; } } } diff --git a/src/commit_builder/mod.rs b/src/commit_builder/mod.rs index 520dbb22b..f19226f1c 100644 --- a/src/commit_builder/mod.rs +++ b/src/commit_builder/mod.rs @@ -134,7 +134,7 @@ mod tests { if self.entity.id().is_empty() { self.entity.set_id("agg-1"); } - self.entity.digest_empty("Touched"); + self.entity.digest_empty("Touched").unwrap(); } fn replay(&mut self, _event: &EventRecord) -> Result<(), String> { @@ -244,7 +244,7 @@ mod tests { counter: 42, }; - let outbox = OutboxMessage::create("msg-1", "TestEvent", b"{}".to_vec()); + let outbox = OutboxMessage::create("msg-1", "TestEvent", b"{}".to_vec()).unwrap(); let mut agg = TestAggregate::default(); agg.touch(); @@ -269,7 +269,7 @@ mod tests { counter: 99, }; - let outbox = OutboxMessage::create("msg-2", "TestEvent", b"{}".to_vec()); + let outbox = OutboxMessage::create("msg-2", "TestEvent", b"{}".to_vec()).unwrap(); let mut agg = TestAggregate::default(); agg.touch(); @@ -364,7 +364,7 @@ mod tests { id: "rollback".into(), counter: 1, }; - let outbox = OutboxMessage::create("msg-rollback", "TestEvent", b"{}".to_vec()); + let outbox = OutboxMessage::create("msg-rollback", "TestEvent", b"{}".to_vec()).unwrap(); let mut agg = TestAggregate::default(); agg.touch(); diff --git a/src/entity/entity.rs b/src/entity/entity.rs index 2432edde9..9e2684c15 100644 --- a/src/entity/entity.rs +++ b/src/entity/entity.rs @@ -4,7 +4,7 @@ use std::time::SystemTime; use serde::{Deserialize, Serialize}; -use super::{EventRecord, PayloadError}; +use super::{BitcodePayloadCodec, EventRecord, EventRecordError, PayloadCodec}; #[derive(Serialize, Deserialize)] pub struct Entity { @@ -169,30 +169,19 @@ impl Entity { } /// Record an event with a serializable payload. - /// The payload is serialized using bitcode for compact, fast storage. - /// Any metadata set on the entity is attached to the event. - pub fn digest(&mut self, name: impl Into, payload: &T) { - self.try_digest(name, payload) - .expect("failed to serialize payload"); - } - - /// Fallible form of [`Entity::digest`]. /// - /// This is the production-safe path for command handlers that need to - /// return serialization failures instead of panicking. If serialization - /// fails, the entity is left unchanged. - pub fn try_digest( + /// If serialization fails, the entity is left unchanged. + /// Any metadata set on the entity is attached to the event. + pub fn digest( &mut self, name: impl Into, payload: &T, - ) -> Result<(), PayloadError> { + ) -> Result<(), EventRecordError> { if self.replaying { return Ok(()); } - let bytes = bitcode::serialize(payload).map_err(|e| PayloadError { - message: e.to_string(), - })?; + let bytes = BitcodePayloadCodec::encode(payload).map_err(EventRecordError::encode)?; let sequence = self.events.len() as u64 + 1; let mut record = EventRecord::new(name, bytes, sequence); if !self.metadata.is_empty() { @@ -203,32 +192,19 @@ impl Entity { } /// Record a versioned event. - pub fn digest_v( - &mut self, - name: impl Into, - version: u64, - payload: &T, - ) { - self.try_digest_v(name, version, payload) - .expect("failed to serialize payload"); - } - - /// Fallible form of [`Entity::digest_v`]. /// /// If serialization fails, the entity is left unchanged. - pub fn try_digest_v( + pub fn digest_v( &mut self, name: impl Into, version: u64, payload: &T, - ) -> Result<(), PayloadError> { + ) -> Result<(), EventRecordError> { if self.replaying { return Ok(()); } - let bytes = bitcode::serialize(payload).map_err(|e| PayloadError { - message: e.to_string(), - })?; + let bytes = BitcodePayloadCodec::encode(payload).map_err(EventRecordError::encode)?; let sequence = self.events.len() as u64 + 1; let mut record = EventRecord::new_versioned(name, bytes, sequence, version); if !self.metadata.is_empty() { @@ -239,8 +215,8 @@ impl Entity { } /// Record an event with no payload. - pub fn digest_empty(&mut self, name: impl Into) { - self.digest(name, &()); + pub fn digest_empty(&mut self, name: impl Into) -> Result<(), EventRecordError> { + self.digest(name, &()) } fn push_new_event(&mut self, record: EventRecord) { @@ -278,18 +254,8 @@ impl Entity { /// Replace all events with a single snapshot event. /// Used by read models to store current state. - pub fn set_snapshot(&mut self, data: &T) { - self.try_set_snapshot(data) - .expect("failed to serialize snapshot"); - } - - /// Fallible form of [`Entity::set_snapshot`]. - /// - /// If serialization fails, the entity is left unchanged. - pub fn try_set_snapshot(&mut self, data: &T) -> Result<(), PayloadError> { - let payload = bitcode::serialize(data).map_err(|e| PayloadError { - message: e.to_string(), - })?; + pub fn set_snapshot(&mut self, data: &T) -> Result<(), EventRecordError> { + let payload = BitcodePayloadCodec::encode(data).map_err(EventRecordError::encode)?; self.events.clear(); let record = EventRecord::new("Snapshot", payload, 1); self.events.push(record); @@ -331,7 +297,7 @@ mod tests { #[test] fn digest() { let mut entity = Entity::new(); - entity.digest("test_event", &("arg1", "arg2")); + entity.digest("test_event", &("arg1", "arg2")).unwrap(); assert_eq!(entity.version(), 1); assert_eq!(entity.events().len(), 1); @@ -342,12 +308,10 @@ mod tests { } #[test] - fn try_digest_returns_serialization_errors_without_mutating_entity() { + fn digest_returns_serialization_errors_without_mutating_entity() { let mut entity = Entity::new(); - let err = entity - .try_digest("bad_event", &FailingSerialize) - .unwrap_err(); + let err = entity.digest("bad_event", &FailingSerialize).unwrap_err(); assert!(err.message.contains("intentional serialization failure")); assert_eq!(entity.version(), 0); @@ -355,11 +319,11 @@ mod tests { } #[test] - fn try_digest_v_returns_serialization_errors_without_mutating_entity() { + fn digest_v_returns_serialization_errors_without_mutating_entity() { let mut entity = Entity::new(); let err = entity - .try_digest_v("bad_event", 2, &FailingSerialize) + .digest_v("bad_event", 2, &FailingSerialize) .unwrap_err(); assert!(err.message.contains("intentional serialization failure")); @@ -370,8 +334,8 @@ mod tests { #[test] fn rehydrate() { let mut entity = Entity::new(); - entity.digest("test_event1", &"arg1"); - entity.digest("test_event2", &"arg2"); + entity.digest("test_event1", &"arg1").unwrap(); + entity.digest("test_event2", &"arg2").unwrap(); let mut replayed = Vec::new(); let result = entity.rehydrate(|event| { @@ -412,7 +376,7 @@ mod tests { #[test] fn serialize_deserialize() { let mut entity = Entity::new(); - entity.digest("test_event1", &"arg1"); + entity.digest("test_event1", &"arg1").unwrap(); let serialized: String = serde_json::to_string(&entity).unwrap(); let deserialized: Entity = serde_json::from_str(&serialized).unwrap(); @@ -431,7 +395,7 @@ mod tests { let mut entity = Entity::new(); entity.replaying = true; - entity.digest("test_event", &"arg1"); + entity.digest("test_event", &"arg1").unwrap(); assert!(entity.events().is_empty()); } @@ -441,8 +405,8 @@ mod tests { assert_eq!(entity.committed_version(), 0); let mut source = Entity::new(); - source.digest("e1", &"a"); - source.digest("e2", &"b"); + source.digest("e1", &"a").unwrap(); + source.digest("e2", &"b").unwrap(); entity.load_from_history(source.events().to_vec()); assert_eq!(entity.version(), 2); @@ -455,8 +419,8 @@ mod tests { let mut entity = Entity::new(); assert!(entity.new_events().is_empty()); - entity.digest("e1", &"a"); - entity.digest("e2", &"b"); + entity.digest("e1", &"a").unwrap(); + entity.digest("e2", &"b").unwrap(); assert_eq!(entity.new_events().len(), 2); assert_eq!(entity.new_events()[0].event_name, "e1"); assert_eq!(entity.new_events()[1].event_name, "e2"); @@ -465,14 +429,14 @@ mod tests { #[test] fn new_events_after_load_and_digest() { let mut source = Entity::new(); - source.digest("e1", &"a"); - source.digest("e2", &"b"); + source.digest("e1", &"a").unwrap(); + source.digest("e2", &"b").unwrap(); let mut entity = Entity::new(); entity.load_from_history(source.events().to_vec()); assert!(entity.new_events().is_empty()); - entity.digest("e3", &"c"); + entity.digest("e3", &"c").unwrap(); assert_eq!(entity.new_events().len(), 1); assert_eq!(entity.new_events()[0].event_name, "e3"); } @@ -480,8 +444,8 @@ mod tests { #[test] fn mark_committed_resets_new_events() { let mut entity = Entity::new(); - entity.digest("e1", &"a"); - entity.digest("e2", &"b"); + entity.digest("e1", &"a").unwrap(); + entity.digest("e2", &"b").unwrap(); assert_eq!(entity.new_events().len(), 2); entity.mark_committed(); @@ -499,7 +463,7 @@ mod tests { entity.set_causation_id("cmd-xyz"); entity.set_meta("user_id", "u-42"); - entity.digest("e1", &"payload"); + entity.digest("e1", &"payload").unwrap(); let record = &entity.events()[0]; assert_eq!(record.correlation_id(), Some("req-abc")); @@ -510,7 +474,7 @@ mod tests { #[test] fn digest_without_metadata_leaves_event_record_empty() { let mut entity = Entity::new(); - entity.digest("e1", &"payload"); + entity.digest("e1", &"payload").unwrap(); let record = &entity.events()[0]; assert!(record.metadata.is_empty()); @@ -521,7 +485,7 @@ mod tests { fn metadata_is_transient_not_serialized() { let mut entity = Entity::new(); entity.set_correlation_id("req-abc"); - entity.digest("e1", &"payload"); + entity.digest("e1", &"payload").unwrap(); let serialized = serde_json::to_string(&entity).unwrap(); let deserialized: Entity = serde_json::from_str(&serialized).unwrap(); @@ -536,10 +500,10 @@ mod tests { fn clear_metadata_stops_propagation() { let mut entity = Entity::new(); entity.set_correlation_id("req-abc"); - entity.digest("e1", &"first"); + entity.digest("e1", &"first").unwrap(); entity.clear_metadata(); - entity.digest("e2", &"second"); + entity.digest("e2", &"second").unwrap(); assert_eq!(entity.events()[0].correlation_id(), Some("req-abc")); assert!(entity.events()[1].metadata.is_empty()); @@ -551,8 +515,8 @@ mod tests { assert_eq!(entity.snapshot_version(), 0); // digest doesn't touch snapshot_version - entity.digest("e1", &"a"); - entity.digest("e2", &"b"); + entity.digest("e1", &"a").unwrap(); + entity.digest("e2", &"b").unwrap(); assert_eq!(entity.snapshot_version(), 0); // mark_committed doesn't touch snapshot_version diff --git a/src/entity/event_record.rs b/src/entity/event_record.rs index 5f48ef960..ede8aae3d 100644 --- a/src/entity/event_record.rs +++ b/src/entity/event_record.rs @@ -1,21 +1,88 @@ -use serde::{de::DeserializeOwned, Deserialize, Serialize}; use std::collections::HashMap; use std::fmt; use std::time::SystemTime; -/// Error when serializing or deserializing event payloads. +use serde::{de::DeserializeOwned, Deserialize, Serialize}; + +pub const BITCODE_PAYLOAD_CODEC: &str = "bitcode"; +pub const BITCODE_PAYLOAD_CODEC_VERSION: u16 = 1; + +/// Codec used to serialize and deserialize event payload bytes. +pub trait PayloadCodec { + const NAME: &'static str; + const VERSION: u16; + + type Error: std::error::Error + Send + Sync + 'static; + + fn encode(value: &T) -> Result, Self::Error>; + fn decode(bytes: &[u8]) -> Result; +} + +/// Default payload codec. +pub struct BitcodePayloadCodec; + +impl PayloadCodec for BitcodePayloadCodec { + const NAME: &'static str = BITCODE_PAYLOAD_CODEC; + const VERSION: u16 = BITCODE_PAYLOAD_CODEC_VERSION; + + type Error = bitcode::Error; + + fn encode(value: &T) -> Result, Self::Error> { + bitcode::serialize(value) + } + + fn decode(bytes: &[u8]) -> Result { + bitcode::deserialize(bytes) + } +} + +/// Error when serializing or deserializing event records. #[derive(Clone, Debug, PartialEq, Eq)] -pub struct PayloadError { +pub struct EventRecordError { pub message: String, } -impl fmt::Display for PayloadError { +impl EventRecordError { + pub fn encode(source: impl fmt::Display) -> Self { + Self { + message: format!( + "failed to encode payload with codec `{}` version {}: {}", + BITCODE_PAYLOAD_CODEC, BITCODE_PAYLOAD_CODEC_VERSION, source + ), + } + } + + pub fn decode( + payload_type: impl fmt::Display, + codec: impl fmt::Display, + codec_version: u16, + source: impl fmt::Display, + ) -> Self { + Self { + message: format!( + "failed to decode payload `{}` with codec `{}` version {}: {}", + payload_type, codec, codec_version, source + ), + } + } + + pub fn unsupported_codec(codec: impl fmt::Display, codec_version: u16) -> Self { + Self { + message: format!( + "unsupported payload codec `{}` version {}", + codec, codec_version + ), + } + } +} + +impl fmt::Display for EventRecordError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "payload error: {}", self.message) + write!(f, "event record error: {}", self.message) } } -impl std::error::Error for PayloadError {} +impl std::error::Error for EventRecordError {} fn default_event_version() -> u64 { 1 @@ -27,6 +94,8 @@ fn is_version_one(v: &u64) -> bool { #[derive(Clone, Serialize, Deserialize, Debug, PartialEq)] pub struct EventRecord { pub event_name: String, + pub payload_codec: String, + pub payload_codec_version: u16, #[serde(with = "payload_serde")] pub payload: Vec, #[serde( @@ -64,6 +133,8 @@ impl EventRecord { pub fn new(event_name: impl Into, payload: Vec, sequence: u64) -> Self { EventRecord { event_name: event_name.into(), + payload_codec: BITCODE_PAYLOAD_CODEC.to_string(), + payload_codec_version: BITCODE_PAYLOAD_CODEC_VERSION, payload, event_version: 1, sequence, @@ -81,6 +152,8 @@ impl EventRecord { ) -> Self { EventRecord { event_name: event_name.into(), + payload_codec: BITCODE_PAYLOAD_CODEC.to_string(), + payload_codec_version: BITCODE_PAYLOAD_CODEC_VERSION, payload, event_version: version, sequence, @@ -98,6 +171,8 @@ impl EventRecord { ) -> Self { EventRecord { event_name: event_name.into(), + payload_codec: BITCODE_PAYLOAD_CODEC.to_string(), + payload_codec_version: BITCODE_PAYLOAD_CODEC_VERSION, payload, event_version: 1, sequence, @@ -107,9 +182,23 @@ impl EventRecord { } /// Deserialize the payload into the specified type. - pub fn decode(&self) -> Result { - bitcode::deserialize(&self.payload).map_err(|e| PayloadError { - message: e.to_string(), + pub fn decode(&self) -> Result { + if self.payload_codec != BITCODE_PAYLOAD_CODEC + || self.payload_codec_version != BITCODE_PAYLOAD_CODEC_VERSION + { + return Err(EventRecordError::unsupported_codec( + &self.payload_codec, + self.payload_codec_version, + )); + } + + BitcodePayloadCodec::decode(&self.payload).map_err(|e| { + EventRecordError::decode( + &self.event_name, + &self.payload_codec, + self.payload_codec_version, + e, + ) }) } @@ -187,6 +276,15 @@ mod tests { assert_eq!(decoded, ("hello".to_string(), 42, true)); } + #[test] + fn decode_unknown_codec_returns_error() { + let mut event_record = EventRecord::new("test_event", vec![], 1); + event_record.payload_codec = "json".to_string(); + + let err = event_record.decode::<()>().unwrap_err(); + assert!(err.message.contains("unsupported payload codec `json`")); + } + #[test] fn payload_bytes() { let payload = vec![0xff, 0x00, 0xab]; @@ -222,7 +320,7 @@ mod tests { #[test] fn deserialize_without_metadata_field_defaults_to_empty() { - let json = r#"{"event_name":"old_event","payload":"","sequence":1,"timestamp":{"secs_since_epoch":0,"nanos_since_epoch":0}}"#; + let json = r#"{"event_name":"old_event","payload_codec":"bitcode","payload_codec_version":1,"payload":"","sequence":1,"timestamp":{"secs_since_epoch":0,"nanos_since_epoch":0}}"#; let record: EventRecord = serde_json::from_str(json).unwrap(); assert!(record.metadata.is_empty()); } diff --git a/src/entity/mod.rs b/src/entity/mod.rs index 325441b40..599e94ab4 100644 --- a/src/entity/mod.rs +++ b/src/entity/mod.rs @@ -8,6 +8,9 @@ mod upcaster; pub use committable::Committable; pub use entity::Entity; pub use event::Event; -pub use event_record::{EventRecord, PayloadError}; +pub use event_record::{ + BitcodePayloadCodec, EventRecord, EventRecordError, PayloadCodec, BITCODE_PAYLOAD_CODEC, + BITCODE_PAYLOAD_CODEC_VERSION, +}; pub use local_event::LocalEvent; pub use upcaster::{try_upcast_events, upcast_events, EventUpcaster, UpcastError}; diff --git a/src/hashmap_repo/repository.rs b/src/hashmap_repo/repository.rs index e68a615bb..10873be47 100644 --- a/src/hashmap_repo/repository.rs +++ b/src/hashmap_repo/repository.rs @@ -349,7 +349,7 @@ mod tests { let id = "test_id"; let mut entity = Entity::with_id(id); - entity.digest("test_event", &("arg1", "arg2")); + entity.digest("test_event", &("arg1", "arg2")).unwrap(); repo.commit(&mut entity).unwrap(); @@ -363,10 +363,10 @@ mod tests { let repo = HashMapRepository::new(); let mut entity1 = Entity::with_id("id_1"); - entity1.digest("event1", &"arg1"); + entity1.digest("event1", &"arg1").unwrap(); let mut entity2 = Entity::with_id("id_2"); - entity2.digest("event2", &"arg2"); + entity2.digest("event2", &"arg2").unwrap(); // Commit multiple entities using array syntax repo.commit(&mut [&mut entity1, &mut entity2]).unwrap(); @@ -380,10 +380,10 @@ mod tests { let repo = HashMapRepository::new(); let mut entity1 = Entity::with_id("same-id"); - entity1.digest("event1", &"arg1"); + entity1.digest("event1", &"arg1").unwrap(); let mut entity2 = Entity::with_id("same-id"); - entity2.digest("event2", &"arg2"); + entity2.digest("event2", &"arg2").unwrap(); let err = repo.commit(&mut [&mut entity1, &mut entity2]).unwrap_err(); assert_eq!( @@ -405,13 +405,13 @@ mod tests { let repo = HashMapRepository::new(); let mut todo1 = Entity::with_id("todo-1"); - todo1.digest("Created", &"todo-1"); + todo1.digest("Created", &"todo-1").unwrap(); let mut todo2 = Entity::with_id("todo-2"); - todo2.digest("Created", &"todo-2"); + todo2.digest("Created", &"todo-2").unwrap(); let mut user1 = Entity::with_id("user-1"); - user1.digest("Created", &"user-1"); + user1.digest("Created", &"user-1").unwrap(); repo.commit(&mut [&mut todo1, &mut todo2, &mut user1]) .unwrap(); @@ -431,10 +431,10 @@ mod tests { let repo = HashMapRepository::new(); let mut entity1 = Entity::with_id("item-1"); - entity1.digest("Created", &"item-1"); + entity1.digest("Created", &"item-1").unwrap(); let mut entity2 = Entity::with_id("item-2"); - entity2.digest("Created", &"item-2"); + entity2.digest("Created", &"item-2").unwrap(); repo.commit(&mut [&mut entity1, &mut entity2]).unwrap(); @@ -462,7 +462,7 @@ mod tests { let repo = HashMapRepository::new(); let mut entity = Entity::with_id("todo-1"); - entity.digest("Created", &"todo-1"); + entity.digest("Created", &"todo-1").unwrap(); repo.commit(&mut entity).unwrap(); assert!(repo.exists(|e| e.id() == "todo-1").unwrap()); @@ -474,13 +474,13 @@ mod tests { let repo = HashMapRepository::new(); let mut todo1 = Entity::with_id("todo-1"); - todo1.digest("Created", &"todo-1"); + todo1.digest("Created", &"todo-1").unwrap(); let mut todo2 = Entity::with_id("todo-2"); - todo2.digest("Created", &"todo-2"); + todo2.digest("Created", &"todo-2").unwrap(); let mut user1 = Entity::with_id("user-1"); - user1.digest("Created", &"user-1"); + user1.digest("Created", &"user-1").unwrap(); repo.commit(&mut [&mut todo1, &mut todo2, &mut user1]) .unwrap(); diff --git a/src/lib.rs b/src/lib.rs index b2b983894..ffcdda94b 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -21,8 +21,9 @@ pub mod snapshot; // Re-export entity types at crate root for convenience pub use entity::{ - try_upcast_events, upcast_events, Committable, Entity, Event, EventRecord, EventUpcaster, - LocalEvent, PayloadError, UpcastError, + try_upcast_events, upcast_events, BitcodePayloadCodec, Committable, Entity, Event, EventRecord, + EventRecordError, EventUpcaster, LocalEvent, PayloadCodec, UpcastError, BITCODE_PAYLOAD_CODEC, + BITCODE_PAYLOAD_CODEC_VERSION, }; // Re-export repository traits at crate root for convenience @@ -108,7 +109,7 @@ pub use snapshot::{ pub use event_emitter_rs::EventEmitter; // Re-export proc macros -pub use sourced_rust_macros::{aggregate, digest, sourced, try_digest, ReadModel, Snapshot}; +pub use sourced_rust_macros::{aggregate, digest, sourced, ReadModel, Snapshot}; // Re-export enqueue macro (requires "emitter" feature) #[cfg(feature = "emitter")] diff --git a/src/outbox/commit.rs b/src/outbox/commit.rs index 08e3e6170..b1586ebdc 100644 --- a/src/outbox/commit.rs +++ b/src/outbox/commit.rs @@ -62,7 +62,7 @@ mod tests { if self.entity.id().is_empty() { self.entity.set_id("dummy-1"); } - self.entity.digest_empty("Touched"); + self.entity.digest_empty("Touched").unwrap(); } fn replay(&mut self, _event: &EventRecord) -> Result<(), String> { @@ -96,7 +96,7 @@ mod tests { let mut aggregate = Dummy::default(); aggregate.touch(); - let mut event = OutboxMessage::create("msg-1", "DummyTouched", b"{}".to_vec()); + let mut event = OutboxMessage::create("msg-1", "DummyTouched", b"{}".to_vec()).unwrap(); repo.outbox(&mut event).commit(&mut aggregate).unwrap(); @@ -114,7 +114,7 @@ mod tests { let mut aggregate = Dummy::default(); aggregate.touch(); - let mut event = OutboxMessage::create("msg-fail", "DummyTouched", b"{}".to_vec()); + let mut event = OutboxMessage::create("msg-fail", "DummyTouched", b"{}".to_vec()).unwrap(); let err = repo.outbox(&mut event).commit(&mut aggregate).unwrap_err(); diff --git a/src/outbox/message.rs b/src/outbox/message.rs index e55005300..115a4d7cd 100644 --- a/src/outbox/message.rs +++ b/src/outbox/message.rs @@ -4,7 +4,7 @@ use std::time::{Duration, SystemTime}; use serde::{Deserialize, Serialize}; use crate::digest; -use crate::entity::Entity; +use crate::entity::{BitcodePayloadCodec, Entity, EventRecordError, PayloadCodec}; /// Status of an outbox message. #[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] @@ -62,10 +62,14 @@ impl OutboxMessage { } /// Create a new outbox message with raw bytes payload. - pub fn create(id: impl Into, event_type: impl Into, payload: Vec) -> Self { + pub fn create( + id: impl Into, + event_type: impl Into, + payload: Vec, + ) -> Result { let mut message = Self::new(); - message.initialize(id.into(), event_type.into(), payload, None, HashMap::new()); - message + message.initialize(id.into(), event_type.into(), payload, None, HashMap::new())?; + Ok(message) } /// Create a new outbox message with raw bytes payload and a destination queue. @@ -77,7 +81,7 @@ impl OutboxMessage { event_type: impl Into, destination: impl Into, payload: Vec, - ) -> Self { + ) -> Result { let mut message = Self::new(); message.initialize( id.into(), @@ -85,8 +89,8 @@ impl OutboxMessage { payload, Some(destination.into()), HashMap::new(), - ); - message + )?; + Ok(message) } /// Create a new outbox message with bitcode (fast binary) serialization. @@ -94,9 +98,9 @@ impl OutboxMessage { id: impl Into, event_type: impl Into, payload: &T, - ) -> Result { - let bytes = bitcode::serialize(payload)?; - Ok(Self::create(id, event_type, bytes)) + ) -> Result { + let bytes = BitcodePayloadCodec::encode(payload).map_err(EventRecordError::encode)?; + Self::create(id, event_type, bytes) } /// Create a new outbox message with bitcode serialization and a destination queue. @@ -108,9 +112,9 @@ impl OutboxMessage { event_type: impl Into, destination: impl Into, payload: &T, - ) -> Result { - let bytes = bitcode::serialize(payload)?; - Ok(Self::create_to(id, event_type, destination, bytes)) + ) -> Result { + let bytes = BitcodePayloadCodec::encode(payload).map_err(EventRecordError::encode)?; + Self::create_to(id, event_type, destination, bytes) } /// Create a message with metadata and raw bytes payload. @@ -119,10 +123,10 @@ impl OutboxMessage { event_type: impl Into, payload: Vec, metadata: HashMap, - ) -> Self { + ) -> Result { let mut message = Self::new(); - message.initialize(id.into(), event_type.into(), payload, None, metadata); - message + message.initialize(id.into(), event_type.into(), payload, None, metadata)?; + Ok(message) } /// Create a message with metadata and bitcode-serialized payload. @@ -131,9 +135,9 @@ impl OutboxMessage { event_type: impl Into, payload: &T, metadata: HashMap, - ) -> Result { - let bytes = bitcode::serialize(payload)?; - Ok(Self::create_with_metadata(id, event_type, bytes, metadata)) + ) -> Result { + let bytes = BitcodePayloadCodec::encode(payload).map_err(EventRecordError::encode)?; + Self::create_with_metadata(id, event_type, bytes, metadata) } /// Create a message that inherits metadata from an entity's context. @@ -146,14 +150,9 @@ impl OutboxMessage { event_type: impl Into, payload: &T, entity: &Entity, - ) -> Result { - let bytes = bitcode::serialize(payload)?; - Ok(Self::create_with_metadata( - id, - event_type, - bytes, - entity.metadata().clone(), - )) + ) -> Result { + let bytes = BitcodePayloadCodec::encode(payload).map_err(EventRecordError::encode)?; + Self::create_with_metadata(id, event_type, bytes, entity.metadata().clone()) } /// Create a message from a `Snapshottable` aggregate. @@ -171,23 +170,25 @@ impl OutboxMessage { pub fn domain_event( event_type: impl Into, aggregate: &A, - ) -> Result { + ) -> Result { let event_type = event_type.into(); let entity = aggregate.entity(); let id = format!("{}:{}:{}", entity.id(), event_type, entity.version()); let snapshot = aggregate.create_snapshot(); - let bytes = bitcode::serialize(&snapshot)?; - Ok(Self::create_with_metadata( - id, - event_type, - bytes, - entity.metadata().clone(), - )) + let bytes = BitcodePayloadCodec::encode(&snapshot).map_err(EventRecordError::encode)?; + Self::create_with_metadata(id, event_type, bytes, entity.metadata().clone()) } - /// Decode the payload from bitcode binary format. - pub fn decode(&self) -> Result { - bitcode::deserialize(&self.payload) + /// Decode the payload from the default binary codec. + pub fn decode(&self) -> Result { + BitcodePayloadCodec::decode(&self.payload).map_err(|e| { + EventRecordError::decode( + &self.event_type, + BitcodePayloadCodec::NAME, + BitcodePayloadCodec::VERSION, + e, + ) + }) } // Getters @@ -224,7 +225,7 @@ impl OutboxMessage { payload: Vec, destination: Option, metadata: HashMap, - ) { + ) -> Result<(), EventRecordError> { let normalized_id = Self::normalize_id(id); self.entity.set_id(&normalized_id); self.event_type = event_type; @@ -233,49 +234,58 @@ impl OutboxMessage { self.metadata = metadata; self.status = OutboxMessageStatus::Pending; self.created_at = SystemTime::now(); + Ok(()) } #[digest("MessageClaimed", when = self.is_pending())] - pub fn claim(&mut self, worker_id: String, until_secs: u64) { + pub fn claim(&mut self, worker_id: String, until_secs: u64) -> Result<(), EventRecordError> { let until_time = SystemTime::UNIX_EPOCH + Duration::from_secs(until_secs); self.status = OutboxMessageStatus::InFlight; self.attempts += 1; self.worker_id = Some(worker_id); self.leased_until = Some(until_time); + Ok(()) } /// Claim with a Duration (convenience method that computes until_secs) - pub fn claim_for(&mut self, worker_id: impl Into, lease: Duration) { + pub fn claim_for( + &mut self, + worker_id: impl Into, + lease: Duration, + ) -> Result<(), EventRecordError> { let now = SystemTime::now(); let until = now + lease; let until_secs = until .duration_since(SystemTime::UNIX_EPOCH) .unwrap_or_default() .as_secs(); - self.claim(worker_id.into(), until_secs); + self.claim(worker_id.into(), until_secs) } #[digest("MessagePublished", when = self.is_in_flight())] - pub fn complete(&mut self) { + pub fn complete(&mut self) -> Result<(), EventRecordError> { self.status = OutboxMessageStatus::Published; self.worker_id = None; self.leased_until = None; + Ok(()) } #[digest("MessageReleased", when = self.is_in_flight())] - pub fn release(&mut self, error: String) { + pub fn release(&mut self, error: String) -> Result<(), EventRecordError> { self.status = OutboxMessageStatus::Pending; self.last_error = if error.is_empty() { None } else { Some(error) }; self.worker_id = None; self.leased_until = None; + Ok(()) } #[digest("MessageFailed", when = self.can_fail())] - pub fn fail(&mut self, error: String) { + pub fn fail(&mut self, error: String) -> Result<(), EventRecordError> { self.status = OutboxMessageStatus::Failed; self.last_error = if error.is_empty() { None } else { Some(error) }; self.worker_id = None; self.leased_until = None; + Ok(()) } fn can_fail(&self) -> bool { @@ -347,13 +357,15 @@ mod tests { #[test] fn new_message_is_pending() { let mut message = OutboxMessage::new(); - message.initialize( - "msg-1".into(), - "UserCreated".into(), - br#"{"id":"123"}"#.to_vec(), - None, - HashMap::new(), - ); + message + .initialize( + "msg-1".into(), + "UserCreated".into(), + br#"{"id":"123"}"#.to_vec(), + None, + HashMap::new(), + ) + .unwrap(); assert_eq!(message.event_type, "UserCreated"); assert!(message.is_pending()); } @@ -361,18 +373,22 @@ mod tests { #[test] fn claim_and_complete() { let mut message = OutboxMessage::new(); - message.initialize( - "msg-1".into(), - "Event1".into(), - b"{}".to_vec(), - None, - HashMap::new(), - ); - message.claim_for("worker-1", Duration::from_secs(60)); + message + .initialize( + "msg-1".into(), + "Event1".into(), + b"{}".to_vec(), + None, + HashMap::new(), + ) + .unwrap(); + message + .claim_for("worker-1", Duration::from_secs(60)) + .unwrap(); assert!(message.is_in_flight()); assert_eq!(message.attempts, 1); - message.complete(); + message.complete().unwrap(); assert!(message.is_published()); } @@ -383,7 +399,8 @@ mod tests { meta.insert("trace_id".to_string(), "t-999".to_string()); let message = - OutboxMessage::create_with_metadata("msg-1", "UserCreated", b"{}".to_vec(), meta); + OutboxMessage::create_with_metadata("msg-1", "UserCreated", b"{}".to_vec(), meta) + .unwrap(); assert_eq!(message.correlation_id(), Some("req-abc")); assert_eq!(message.meta("trace_id"), Some("t-999")); } @@ -404,7 +421,7 @@ mod tests { #[test] fn set_metadata_individually() { - let mut message = OutboxMessage::create("msg-1", "Event", b"{}".to_vec()); + let mut message = OutboxMessage::create("msg-1", "Event", b"{}".to_vec()).unwrap(); message.set_correlation_id("req-abc"); message.set_causation_id("evt-prior"); message.set_meta("tenant", "acme"); @@ -417,21 +434,27 @@ mod tests { #[test] fn release_and_fail() { let mut message = OutboxMessage::new(); - message.initialize( - "msg-1".into(), - "Event1".into(), - b"{}".to_vec(), - None, - HashMap::new(), - ); - message.claim_for("worker-1", Duration::from_secs(60)); + message + .initialize( + "msg-1".into(), + "Event1".into(), + b"{}".to_vec(), + None, + HashMap::new(), + ) + .unwrap(); + message + .claim_for("worker-1", Duration::from_secs(60)) + .unwrap(); - message.release("timeout".into()); + message.release("timeout".into()).unwrap(); assert!(message.is_pending()); assert_eq!(message.last_error.as_deref(), Some("timeout")); - message.claim_for("worker-1", Duration::from_secs(60)); - message.fail("max retries".into()); + message + .claim_for("worker-1", Duration::from_secs(60)) + .unwrap(); + message.fail("max retries".into()).unwrap(); assert!(message.is_failed()); } } diff --git a/src/outbox_worker/repository_ext.rs b/src/outbox_worker/repository_ext.rs index c7e1e03b3..724125861 100644 --- a/src/outbox_worker/repository_ext.rs +++ b/src/outbox_worker/repository_ext.rs @@ -87,7 +87,7 @@ impl OutboxRepositoryExt for HashMapRepository { let mut message = hydrate::(entity)?; if message.is_pending() { - message.claim_for(worker_id, lease); + message.claim_for(worker_id, lease)?; *events = message.entity.events().to_vec(); message.entity.mark_committed(); claimed.push(message); @@ -119,7 +119,7 @@ impl OutboxRepositoryExt for HashMapRepository { let mut message = hydrate::(entity)?; if message.is_in_flight() { - message.complete(); + message.complete()?; *events = message.entity.events().to_vec(); message.entity.mark_committed(); } @@ -146,7 +146,7 @@ impl OutboxRepositoryExt for HashMapRepository { let mut message = hydrate::(entity)?; if message.is_in_flight() { - message.release(error.to_string()); + message.release(error.to_string())?; *events = message.entity.events().to_vec(); message.entity.mark_committed(); } @@ -172,7 +172,7 @@ impl OutboxRepositoryExt for HashMapRepository { entity.load_from_history(events.clone()); let mut message = hydrate::(entity)?; - message.fail(error.to_string()); + message.fail(error.to_string())?; *events = message.entity.events().to_vec(); message.entity.mark_committed(); } diff --git a/src/outbox_worker/worker.rs b/src/outbox_worker/worker.rs index 9f47401dc..10b257bba 100644 --- a/src/outbox_worker/worker.rs +++ b/src/outbox_worker/worker.rs @@ -1,6 +1,7 @@ use std::time::Duration; use super::publisher::OutboxPublisher; +use crate::entity::EventRecordError; use crate::outbox::OutboxMessage; /// Result of a batch drain operation. @@ -90,58 +91,67 @@ impl OutboxWorker

{ /// If the message is pending, it will be claimed by this worker before /// publishing. The caller is responsible for persisting the updated /// message entity after processing. - pub fn process_message(&mut self, message: &mut OutboxMessage) -> ProcessOneResult { + pub fn process_message( + &mut self, + message: &mut OutboxMessage, + ) -> Result { if message.is_published() || message.is_failed() { - return ProcessOneResult::default(); + return Ok(ProcessOneResult::default()); } if message.is_pending() { - message.claim_for(&self.worker_id, self.lease); + message.claim_for(&self.worker_id, self.lease)?; } if !message.is_in_flight() { - return ProcessOneResult::default(); + return Ok(ProcessOneResult::default()); } - match self - .publisher - .publish(&message.event_type, &message.payload, &message.metadata) - { - Ok(()) => { - message.complete(); - ProcessOneResult { - did_work: true, - completed: true, - ..Default::default() - } - } - Err(err) => { - let error_msg = err.to_string(); - if message.attempts >= self.max_attempts { - message.fail(error_msg); + let result = + match self + .publisher + .publish(&message.event_type, &message.payload, &message.metadata) + { + Ok(()) => { + message.complete()?; ProcessOneResult { did_work: true, - failed: true, + completed: true, ..Default::default() } - } else { - message.release(error_msg); - ProcessOneResult { - did_work: true, - released: true, - ..Default::default() + } + Err(err) => { + let error_msg = err.to_string(); + if message.attempts >= self.max_attempts { + message.fail(error_msg)?; + ProcessOneResult { + did_work: true, + failed: true, + ..Default::default() + } + } else { + message.release(error_msg)?; + ProcessOneResult { + did_work: true, + released: true, + ..Default::default() + } } } - } - } + }; + + Ok(result) } /// Process a batch of outbox messages. - pub fn process_batch(&mut self, messages: &mut [OutboxMessage]) -> DrainResult { + pub fn process_batch( + &mut self, + messages: &mut [OutboxMessage], + ) -> Result { let mut result = DrainResult::default(); for message in messages.iter_mut().take(self.batch_size) { - let processed = self.process_message(message); + let processed = self.process_message(message)?; if processed.did_work { result.claimed += 1; } @@ -156,7 +166,7 @@ impl OutboxWorker

{ } } - result + Ok(result) } } @@ -181,12 +191,12 @@ mod tests { #[test] fn process_message_noop_for_published() { - let mut message = OutboxMessage::create("msg-1", "Event", b"{}".to_vec()); - message.claim_for("worker", Duration::from_secs(1)); - message.complete(); + let mut message = OutboxMessage::create("msg-1", "Event", b"{}".to_vec()).unwrap(); + message.claim_for("worker", Duration::from_secs(1)).unwrap(); + message.complete().unwrap(); let mut worker = OutboxWorker::new(LogPublisher::default()); - let result = worker.process_message(&mut message); + let result = worker.process_message(&mut message).unwrap(); assert!(!result.did_work); } @@ -198,10 +208,10 @@ mod tests { let publisher = LogPublisher::with_buffer(buffer.clone()); let mut worker = OutboxWorker::new(publisher); - let mut message = OutboxMessage::create("msg-1", "UserCreated", b"{}".to_vec()); + let mut message = OutboxMessage::create("msg-1", "UserCreated", b"{}".to_vec()).unwrap(); message.set_correlation_id("req-abc"); - let result = worker.process_message(&mut message); + let result = worker.process_message(&mut message).unwrap(); assert!(result.completed); let logs = buffer.lock().unwrap(); diff --git a/src/repository/error.rs b/src/repository/error.rs index 7e426b0b9..02614e044 100644 --- a/src/repository/error.rs +++ b/src/repository/error.rs @@ -2,6 +2,7 @@ use std::fmt; use crate::lock::LockError; use crate::read_model::ReadModelError; +use crate::EventRecordError; #[derive(Debug, Clone, PartialEq, Eq)] pub enum RepositoryError { @@ -57,3 +58,9 @@ impl From for RepositoryError { RepositoryError::Model(err.to_string()) } } + +impl From for RepositoryError { + fn from(err: EventRecordError) -> Self { + RepositoryError::Model(err.to_string()) + } +} diff --git a/src/snapshot/repository.rs b/src/snapshot/repository.rs index 6c369c235..8f550bbbf 100644 --- a/src/snapshot/repository.rs +++ b/src/snapshot/repository.rs @@ -381,7 +381,7 @@ mod tests { self.entity.set_id("snap-1"); } self.value += 1; - self.entity.digest_empty("Touched"); + self.entity.digest_empty("Touched").unwrap(); } fn replay(&mut self, _event: &EventRecord) -> Result<(), String> { diff --git a/tests/blob_game/aggregate.rs b/tests/blob_game/aggregate.rs index e225050db..cd357432c 100644 --- a/tests/blob_game/aggregate.rs +++ b/tests/blob_game/aggregate.rs @@ -200,7 +200,7 @@ impl BlobGame { minigame_id: String, timed: bool, started_at: Option, - ) { + ) -> Result<(), sourced_rust::EventRecordError> { self.entity.set_id(&id); self.address = address; self.minigame_id = minigame_id; @@ -211,7 +211,10 @@ impl BlobGame { } #[digest("NextLevelStarted", when = self.current_level_completed && !self.player_dead)] - pub fn start_next_level(&mut self, map: Vec>) { + pub fn start_next_level( + &mut self, + map: Vec>, + ) -> Result<(), sourced_rust::EventRecordError> { self.current_level += 1; let level = Level { id: self.current_level, @@ -223,7 +226,7 @@ impl BlobGame { } #[digest("MovedUp", when = self.can_move_up())] - pub fn up(&mut self, time: Option) { + pub fn up(&mut self, time: Option) -> Result<(), sourced_rust::EventRecordError> { if let Some(pos) = self.player_position() { let new_pos = Coordinate { column: pos.column, @@ -234,7 +237,7 @@ impl BlobGame { } #[digest("MovedDown", when = self.can_move_down())] - pub fn down(&mut self, time: Option) { + pub fn down(&mut self, time: Option) -> Result<(), sourced_rust::EventRecordError> { if let Some(pos) = self.player_position() { let new_pos = Coordinate { column: pos.column, @@ -245,7 +248,7 @@ impl BlobGame { } #[digest("MovedLeft", when = self.can_move_left())] - pub fn left(&mut self, time: Option) { + pub fn left(&mut self, time: Option) -> Result<(), sourced_rust::EventRecordError> { if let Some(pos) = self.player_position() { let new_pos = Coordinate { column: pos.column - 1, @@ -256,7 +259,7 @@ impl BlobGame { } #[digest("MovedRight", when = self.can_move_right())] - pub fn right(&mut self, time: Option) { + pub fn right(&mut self, time: Option) -> Result<(), sourced_rust::EventRecordError> { if let Some(pos) = self.player_position() { let new_pos = Coordinate { column: pos.column + 1, diff --git a/tests/blob_game/main.rs b/tests/blob_game/main.rs index 2d99bf0e1..8f45495b9 100644 --- a/tests/blob_game/main.rs +++ b/tests/blob_game/main.rs @@ -62,17 +62,17 @@ fn win_no_holes_level(game: &mut BlobGame) { if col % 2 == 0 { // Go down for _ in 0..(height - 1) { - game.down(None); + game.down(None).unwrap(); } } else { // Go up for _ in 0..(height - 1) { - game.up(None); + game.up(None).unwrap(); } } // Move right (except on last column) if col < width - 1 { - game.right(None); + game.right(None).unwrap(); } } } @@ -95,14 +95,15 @@ fn should_not_move_before_game_initialized() { "minigame-1".into(), false, None, - ); + ) + .unwrap(); // Can't move without a level started let events_before = game.entity.events().len(); - game.up(None); - game.down(None); - game.left(None); - game.right(None); + game.up(None).unwrap(); + game.down(None).unwrap(); + game.left(None).unwrap(); + game.right(None).unwrap(); assert_eq!(game.entity.events().len(), events_before); } @@ -113,7 +114,7 @@ fn should_not_start_level_before_initialized() { // Try to start level without initializing - guard should prevent it // (current_level_completed is true but we haven't set an id) let events_before = game.entity.events().len(); - game.start_next_level(easy_test_level()); + game.start_next_level(easy_test_level()).unwrap(); // The guard allows it because current_level_completed is true by default // But let's check it works after proper init assert!(game.entity.events().len() > events_before); @@ -129,50 +130,51 @@ fn should_work_with_normal_gameplay_simulation() { "test-minigame-id-gameplay".into(), false, None, - ); + ) + .unwrap(); assert_eq!(game.address(), "0x0000test0000"); assert_eq!(game.minigame_id(), "test-minigame-id-gameplay"); assert_eq!(game.score(), 0); - game.start_next_level(easy_test_level()); + game.start_next_level(easy_test_level()).unwrap(); assert_eq!(game.score(), 0); assert!(!game.is_current_level_completed()); // Can't move up or left from (0,0) let events_before = game.entity.events().len(); - game.up(None); - game.left(None); + game.up(None).unwrap(); + game.left(None).unwrap(); assert_eq!(game.entity.events().len(), events_before); assert_eq!(game.score(), 0); // Move down 9 times for i in 0..9 { - game.down(None); + game.down(None).unwrap(); assert_eq!(game.score(), (i + 1) as u32); } // Can't move down anymore (at row 9) let events_before = game.entity.events().len(); - game.down(None); + game.down(None).unwrap(); assert_eq!(game.entity.events().len(), events_before); // Move right 9 times for _ in 0..9 { - game.right(None); + game.right(None).unwrap(); } // Can't move right anymore (at column 9) let events_before = game.entity.events().len(); - game.right(None); + game.right(None).unwrap(); assert_eq!(game.entity.events().len(), events_before); // Move up and left - game.up(None); - game.left(None); + game.up(None).unwrap(); + game.left(None).unwrap(); // Move down - this revisits a tile (suicide) - game.down(None); + game.down(None).unwrap(); assert!(game.is_player_dead()); assert_eq!(game.score(), 20); } @@ -187,13 +189,14 @@ fn should_die_when_moving_into_self() { "test-minigame-id-suicide".into(), false, None, - ); + ) + .unwrap(); - game.start_next_level(easy_test_level()); + game.start_next_level(easy_test_level()).unwrap(); assert!(!game.is_player_dead()); - game.down(None); - game.up(None); // Revisit starting tile + game.down(None).unwrap(); + game.up(None).unwrap(); // Revisit starting tile assert!(game.is_player_dead()); assert!(!game.is_current_level_completed()); } @@ -208,9 +211,10 @@ fn should_win_when_all_blocks_visited_except_holes() { "test-minigame-id-win".into(), false, None, - ); + ) + .unwrap(); - game.start_next_level(no_holes_level()); + game.start_next_level(no_holes_level()).unwrap(); assert!(!game.is_current_level_completed()); win_no_holes_level(&mut game); @@ -229,11 +233,12 @@ fn should_die_when_falling_in_hole() { "test-minigame-id-hole".into(), false, None, - ); + ) + .unwrap(); - game.start_next_level(die_mother_clucka()); + game.start_next_level(die_mother_clucka()).unwrap(); - game.right(None); // Fall into hole + game.right(None).unwrap(); // Fall into hole assert!(game.is_player_dead()); assert!(!game.is_current_level_completed()); assert_eq!(game.score(), 0); @@ -249,22 +254,23 @@ fn should_be_able_to_add_new_levels_each_time_you_win() { "test-minigame-id-multi".into(), false, None, - ); + ) + .unwrap(); // Level 1 - game.start_next_level(no_holes_level()); + game.start_next_level(no_holes_level()).unwrap(); assert!(!game.is_current_level_completed()); win_no_holes_level(&mut game); assert!(game.is_current_level_completed()); // Level 2 - game.start_next_level(no_holes_level()); + game.start_next_level(no_holes_level()).unwrap(); assert!(!game.is_current_level_completed()); win_no_holes_level(&mut game); assert!(game.is_current_level_completed()); // Level 3 - game.start_next_level(no_holes_level()); + game.start_next_level(no_holes_level()).unwrap(); assert!(!game.is_current_level_completed()); win_no_holes_level(&mut game); assert!(game.is_current_level_completed()); @@ -272,9 +278,9 @@ fn should_be_able_to_add_new_levels_each_time_you_win() { assert_eq!(game.score(), 99 * 3); // Level 4 - die immediately - game.start_next_level(die_mother_clucka()); + game.start_next_level(die_mother_clucka()).unwrap(); assert!(!game.is_current_level_completed()); - game.right(None); + game.right(None).unwrap(); assert!(!game.is_current_level_completed()); assert!(game.is_player_dead()); assert_eq!(game.score(), 99 * 3); // Score unchanged @@ -290,14 +296,15 @@ fn should_not_start_next_level_in_middle_of_current_level() { "test-minigame-id-mid".into(), false, None, - ); + ) + .unwrap(); - game.start_next_level(no_holes_level()); + game.start_next_level(no_holes_level()).unwrap(); assert!(!game.is_current_level_completed()); // Try to start another level - should be blocked by guard let events_before = game.entity.events().len(); - game.start_next_level(no_holes_level()); + game.start_next_level(no_holes_level()).unwrap(); assert_eq!(game.entity.events().len(), events_before); assert!(!game.is_current_level_completed()); } @@ -314,21 +321,22 @@ fn should_work_with_timer_mode() { "test-minigame-id-timed".into(), true, Some(start_time), - ); + ) + .unwrap(); - game.start_next_level(no_holes_level()); + game.start_next_level(no_holes_level()).unwrap(); // Move within time limit - game.right(Some(start_time + 1000)); - game.right(Some(start_time + 2000)); - game.right(Some(start_time + 3000)); - game.right(Some(start_time + 4000)); + game.right(Some(start_time + 1000)).unwrap(); + game.right(Some(start_time + 2000)).unwrap(); + game.right(Some(start_time + 3000)).unwrap(); + game.right(Some(start_time + 4000)).unwrap(); assert_eq!(game.score(), 4); assert!(!game.is_player_dead()); // Move after 5+ minutes - should die let six_minutes_ms = 6 * 60 * 1000; - game.right(Some(start_time + six_minutes_ms)); + game.right(Some(start_time + six_minutes_ms)).unwrap(); assert!(game.is_player_dead()); assert!(!game.is_current_level_completed()); assert_eq!(game.score(), 4); // Score unchanged from timeout death @@ -346,13 +354,14 @@ fn replay_restores_game_state() { "mg-1".into(), false, None, - ); - game.start_next_level(no_holes_level()); + ) + .unwrap(); + game.start_next_level(no_holes_level()).unwrap(); // Make some moves - game.down(None); - game.down(None); - game.right(None); + game.down(None).unwrap(); + game.down(None).unwrap(); + game.right(None).unwrap(); // Commit to repository repo.commit(&mut game).unwrap(); @@ -365,7 +374,7 @@ fn replay_restores_game_state() { // Verify we can continue playing let mut restored = restored; - restored.down(None); + restored.down(None).unwrap(); assert_eq!(restored.score(), 4); } @@ -378,10 +387,11 @@ fn snapshot_captures_full_state() { "mg-1".into(), false, None, - ); - game.start_next_level(no_holes_level()); - game.right(None); - game.down(None); + ) + .unwrap(); + game.start_next_level(no_holes_level()).unwrap(); + game.right(None).unwrap(); + game.down(None).unwrap(); let snapshot = game.snapshot(); assert_eq!(snapshot.id, "game-snap"); diff --git a/tests/bomberman/commands.rs b/tests/bomberman/commands.rs index 1837a3161..c6c1d69e5 100644 --- a/tests/bomberman/commands.rs +++ b/tests/bomberman/commands.rs @@ -44,7 +44,7 @@ pub fn create_game( let (width, height, tiles, spawn_points) = GameMap::from_ascii(ascii_map); let mut map = GameMap::default(); - map.create(game_id.into(), width, height, tiles.clone(), spawn_points); + map.create(game_id.into(), width, height, tiles.clone(), spawn_points)?; let board = BoardView::new(game_id, width, height, tiles); repo.readmodel(&board).commit(&mut map)?; @@ -73,7 +73,7 @@ pub fn tick = find_by_prefix(repo, "explosion:", |_: &Explosion| true)?; @@ -97,18 +97,18 @@ pub fn tick = players.iter().collect(); @@ -208,7 +208,7 @@ pub fn tick, -) -> (Vec<(i32, i32)>, Vec, Vec) { +) -> Result<(Vec<(i32, i32)>, Vec, Vec), GameError> { let mut blocks_destroyed = Vec::new(); let mut players_killed = Vec::new(); let mut chain_detonations = Vec::new(); @@ -246,14 +246,14 @@ fn apply_damage( for &(cx, cy) in cells { // Destroy blocks if map.is_in_bounds(cx, cy) && *map.tile_at(cx, cy) == Tile::Block { - map.destroy_block(cx, cy); + map.destroy_block(cx, cy)?; blocks_destroyed.push((cx, cy)); } // Kill players for player in players.iter_mut() { if player.alive && player.x == cx && player.y == cy { - player.kill(); + player.kill()?; players_killed.push(player.entity.id().to_string()); } } @@ -270,7 +270,7 @@ fn apply_damage( } } - (blocks_destroyed, players_killed, chain_detonations) + Ok((blocks_destroyed, players_killed, chain_detonations)) } // ── Player commands ── @@ -289,7 +289,7 @@ pub fn join_game = find_by_prefix(repo, "player:", |_: &Player| true)?; @@ -336,12 +336,12 @@ pub fn move_player = find_by_prefix(repo, "player:", |p: &Player| { diff --git a/tests/bomberman/domain/bomb.rs b/tests/bomberman/domain/bomb.rs index 40dc68cd1..018e47b5d 100644 --- a/tests/bomberman/domain/bomb.rs +++ b/tests/bomberman/domain/bomb.rs @@ -13,7 +13,14 @@ pub struct Bomb { impl Bomb { #[digest("BombCreated")] - pub fn create(&mut self, id: String, owner_id: String, x: i32, y: i32, blast_radius: u8) { + pub fn create( + &mut self, + id: String, + owner_id: String, + x: i32, + y: i32, + blast_radius: u8, + ) -> Result<(), sourced_rust::EventRecordError> { self.entity.set_id(&id); self.owner_id = owner_id; self.x = x; @@ -24,12 +31,12 @@ impl Bomb { } #[digest("BombTicked", when = !self.exploded && self.ticks_remaining > 0)] - pub fn tick(&mut self) { + pub fn tick(&mut self) -> Result<(), sourced_rust::EventRecordError> { self.ticks_remaining -= 1; } #[digest("BombExploded", when = !self.exploded)] - pub fn explode(&mut self) { + pub fn explode(&mut self) -> Result<(), sourced_rust::EventRecordError> { self.exploded = true; } diff --git a/tests/bomberman/domain/explosion.rs b/tests/bomberman/domain/explosion.rs index 68b3cb724..56e25e81e 100644 --- a/tests/bomberman/domain/explosion.rs +++ b/tests/bomberman/domain/explosion.rs @@ -22,7 +22,7 @@ impl Explosion { center: (i32, i32), blast_radius: u8, rings: Vec>, - ) { + ) -> Result<(), sourced_rust::EventRecordError> { self.entity.set_id(&id); self.bomb_id = bomb_id; self.owner = owner; @@ -34,12 +34,12 @@ impl Explosion { } #[digest("ExplosionExpanded", when = self.active && !self.is_fully_expanded())] - pub fn expand(&mut self) { + pub fn expand(&mut self) -> Result<(), sourced_rust::EventRecordError> { self.current_ring += 1; } #[digest("ExplosionDissipated", when = self.active)] - pub fn dissipate(&mut self) { + pub fn dissipate(&mut self) -> Result<(), sourced_rust::EventRecordError> { self.active = false; } diff --git a/tests/bomberman/domain/game_map.rs b/tests/bomberman/domain/game_map.rs index 64c2b54e1..033282778 100644 --- a/tests/bomberman/domain/game_map.rs +++ b/tests/bomberman/domain/game_map.rs @@ -21,7 +21,7 @@ impl GameMap { height: usize, tiles: Vec>, spawn_points: Vec<(i32, i32)>, - ) { + ) -> Result<(), sourced_rust::EventRecordError> { self.entity.set_id(&id); self.width = width; self.height = height; @@ -31,7 +31,7 @@ impl GameMap { } #[digest("BlockDestroyed", when = self.tile_at(x, y) == &Tile::Block)] - pub fn destroy_block(&mut self, x: i32, y: i32) { + pub fn destroy_block(&mut self, x: i32, y: i32) -> Result<(), sourced_rust::EventRecordError> { self.tiles[y as usize][x as usize] = Tile::Floor; // 50% chance to reveal a power-up based on position parity if (x + y) % 2 == 0 { @@ -41,17 +41,21 @@ impl GameMap { } } - #[digest("PowerUpCollected")] - pub fn collect_power_up(&mut self, x: i32, y: i32) -> Option { + pub fn collect_power_up( + &mut self, + x: i32, + y: i32, + ) -> Result, sourced_rust::EventRecordError> { + self.entity.digest("PowerUpCollected", &(x, y))?; if let Some(idx) = self .power_ups .iter() .position(|((px, py), _)| *px == x && *py == y) { let (_, power_up) = self.power_ups.remove(idx); - Some(power_up) + Ok(Some(power_up)) } else { - None + Ok(None) } } diff --git a/tests/bomberman/domain/player.rs b/tests/bomberman/domain/player.rs index 8b3061c3f..254e08904 100644 --- a/tests/bomberman/domain/player.rs +++ b/tests/bomberman/domain/player.rs @@ -16,7 +16,13 @@ pub struct Player { impl Player { #[digest("PlayerJoined")] - pub fn join(&mut self, id: String, name: String, x: i32, y: i32) { + pub fn join( + &mut self, + id: String, + name: String, + x: i32, + y: i32, + ) -> Result<(), sourced_rust::EventRecordError> { self.entity.set_id(&id); self.name = name; self.x = x; @@ -28,30 +34,33 @@ impl Player { } #[digest("PlayerMoved", when = self.alive)] - pub fn move_to(&mut self, x: i32, y: i32) { + pub fn move_to(&mut self, x: i32, y: i32) -> Result<(), sourced_rust::EventRecordError> { self.x = x; self.y = y; } #[digest("PlayerKilled", when = self.alive)] - pub fn kill(&mut self) { + pub fn kill(&mut self) -> Result<(), sourced_rust::EventRecordError> { self.alive = false; } #[digest("BombPlaced", when = self.alive && self.active_bombs < self.max_bombs)] - pub fn place_bomb(&mut self) { + pub fn place_bomb(&mut self) -> Result<(), sourced_rust::EventRecordError> { self.active_bombs += 1; } #[digest("BombReturned")] - pub fn return_bomb(&mut self) { + pub fn return_bomb(&mut self) -> Result<(), sourced_rust::EventRecordError> { if self.active_bombs > 0 { self.active_bombs -= 1; } } #[digest("PowerUpApplied")] - pub fn apply_power_up(&mut self, power_up: PowerUp) { + pub fn apply_power_up( + &mut self, + power_up: PowerUp, + ) -> Result<(), sourced_rust::EventRecordError> { match power_up { PowerUp::BombUp => self.max_bombs += 1, PowerUp::FireUp => self.blast_radius += 1, diff --git a/tests/bomberman/domain/tick_saga.rs b/tests/bomberman/domain/tick_saga.rs index 9abefc7d3..52e9cc979 100644 --- a/tests/bomberman/domain/tick_saga.rs +++ b/tests/bomberman/domain/tick_saga.rs @@ -24,14 +24,22 @@ pub struct TickSaga { impl TickSaga { #[digest("TickStarted")] - pub fn start(&mut self, saga_id: String, game_id: String, bombs_ticked: usize) { + pub fn start( + &mut self, + saga_id: String, + game_id: String, + bombs_ticked: usize, + ) -> Result<(), sourced_rust::EventRecordError> { self.entity.set_id(&saga_id); self.game_id = game_id; self.bombs_ticked = bombs_ticked; } #[digest("DetonationRecorded")] - pub fn record_detonation(&mut self, detonation: Detonation) { + pub fn record_detonation( + &mut self, + detonation: Detonation, + ) -> Result<(), sourced_rust::EventRecordError> { self.detonations.push(detonation); } @@ -41,19 +49,26 @@ impl TickSaga { blocks_destroyed: Vec<(i32, i32)>, players_killed: Vec, chain_detonations: Vec, - ) { + ) -> Result<(), sourced_rust::EventRecordError> { self.blocks_destroyed.extend(blocks_destroyed); self.players_killed.extend(players_killed); self.chain_detonations.extend(chain_detonations); } #[digest("DissipationRecorded")] - pub fn record_dissipation(&mut self, explosion_id: String) { + pub fn record_dissipation( + &mut self, + explosion_id: String, + ) -> Result<(), sourced_rust::EventRecordError> { self.explosions_dissipated.push(explosion_id); } #[digest("TickCompleted")] - pub fn complete(&mut self, game_over: bool, winner: Option) { + pub fn complete( + &mut self, + game_over: bool, + winner: Option, + ) -> Result<(), sourced_rust::EventRecordError> { self.game_over = game_over; self.winner = winner; } diff --git a/tests/bomberman/error.rs b/tests/bomberman/error.rs index 75a469165..db34826ca 100644 --- a/tests/bomberman/error.rs +++ b/tests/bomberman/error.rs @@ -1,6 +1,6 @@ use std::fmt; -use sourced_rust::RepositoryError; +use sourced_rust::{EventRecordError, RepositoryError}; #[derive(Debug)] pub enum GameError { @@ -32,3 +32,9 @@ impl From for GameError { GameError::Repository(e) } } + +impl From for GameError { + fn from(e: EventRecordError) -> Self { + GameError::Repository(e.into()) + } +} diff --git a/tests/enqueue/aggregate.rs b/tests/enqueue/aggregate.rs index 21d29b2f1..2719273d1 100644 --- a/tests/enqueue/aggregate.rs +++ b/tests/enqueue/aggregate.rs @@ -27,7 +27,11 @@ impl Default for Order { impl Order { #[digest("OrderCreated")] #[enqueue("OrderCreated")] - pub fn create(&mut self, order_id: String, customer: String) { + pub fn create( + &mut self, + order_id: String, + customer: String, + ) -> Result<(), sourced_rust::EventRecordError> { self.entity.set_id(&order_id); self.order_id = order_id; self.customer = customer; @@ -36,13 +40,13 @@ impl Order { #[digest("OrderConfirmed", when = self.status == "created")] #[enqueue("OrderConfirmed", when = self.status == "created")] - pub fn confirm(&mut self) { + pub fn confirm(&mut self) -> Result<(), sourced_rust::EventRecordError> { self.status = "confirmed".into(); } #[digest("OrderShipped", when = self.status == "confirmed")] #[enqueue("OrderShipped", when = self.status == "confirmed")] - pub fn ship(&mut self) { + pub fn ship(&mut self) -> Result<(), sourced_rust::EventRecordError> { self.status = "shipped".into(); } } @@ -73,7 +77,11 @@ impl Default for Notifier { impl Notifier { #[digest("NotificationSent")] #[enqueue(my_emitter, "NotificationSent")] - pub fn send(&mut self, id: String, message: String) { + pub fn send( + &mut self, + id: String, + message: String, + ) -> Result<(), sourced_rust::EventRecordError> { self.entity.set_id(&id); self.message = message; } diff --git a/tests/enqueue/main.rs b/tests/enqueue/main.rs index 4ca4a688a..ddca57707 100644 --- a/tests/enqueue/main.rs +++ b/tests/enqueue/main.rs @@ -13,7 +13,7 @@ use aggregate::{Ephemeral, Notifier, Order}; #[test] fn enqueue_queues_events_during_method_call() { let mut order = Order::default(); - order.create("order-1".into(), "alice".into()); + order.create("order-1".into(), "alice".into()).unwrap(); assert_eq!(order.emitter.queued_len(), 1); } @@ -21,8 +21,8 @@ fn enqueue_queues_events_during_method_call() { #[test] fn enqueue_queues_multiple_events_across_calls() { let mut order = Order::default(); - order.create("order-1".into(), "alice".into()); - order.confirm(); + order.create("order-1".into(), "alice".into()).unwrap(); + order.confirm().unwrap(); assert_eq!(order.emitter.queued_len(), 2); } @@ -36,7 +36,7 @@ fn emit_queued_fires_registered_listeners() { tx.send(payload).unwrap(); }); - order.create("order-1".into(), "alice".into()); + order.create("order-1".into(), "alice".into()).unwrap(); order.emitter.emit_queued(); let payload = rx @@ -48,7 +48,7 @@ fn emit_queued_fires_registered_listeners() { #[test] fn emit_queued_drains_the_queue() { let mut order = Order::default(); - order.create("order-1".into(), "alice".into()); + order.create("order-1".into(), "alice".into()).unwrap(); assert_eq!(order.emitter.queued_len(), 1); order.emitter.emit_queued(); @@ -69,8 +69,8 @@ fn emit_queued_fires_correct_event_types() { tx_confirmed.send("OrderConfirmed").unwrap(); }); - order.create("order-1".into(), "alice".into()); - order.confirm(); + order.create("order-1".into(), "alice".into()).unwrap(); + order.confirm().unwrap(); order.emitter.emit_queued(); rx_created @@ -88,10 +88,10 @@ fn emit_queued_fires_correct_event_types() { #[test] fn enqueue_guard_prevents_event_when_condition_false() { let mut order = Order::default(); - order.create("order-1".into(), "alice".into()); + order.create("order-1".into(), "alice".into()).unwrap(); // Try to ship without confirming first — guard blocks it - order.ship(); + order.ship().unwrap(); // Only OrderCreated should be queued, not OrderShipped assert_eq!(order.emitter.queued_len(), 1); @@ -101,9 +101,9 @@ fn enqueue_guard_prevents_event_when_condition_false() { #[test] fn enqueue_guard_allows_event_when_condition_true() { let mut order = Order::default(); - order.create("order-1".into(), "alice".into()); - order.confirm(); - order.ship(); + order.create("order-1".into(), "alice".into()).unwrap(); + order.confirm().unwrap(); + order.ship().unwrap(); assert_eq!(order.emitter.queued_len(), 3); assert_eq!(order.status, "shipped"); @@ -130,7 +130,7 @@ fn enqueue_guard_on_empty_value() { #[test] fn enqueue_with_custom_field_name() { let mut notifier = Notifier::default(); - notifier.send("n-1".into(), "Hello world".into()); + notifier.send("n-1".into(), "Hello world".into()).unwrap(); assert_eq!(notifier.my_emitter.queued_len(), 1); } @@ -146,7 +146,7 @@ fn custom_field_emit_fires_listener() { tx.send(()).unwrap(); }); - notifier.send("n-1".into(), "Hello".into()); + notifier.send("n-1".into(), "Hello".into()).unwrap(); notifier.my_emitter.emit_queued(); rx.recv_timeout(Duration::from_secs(1)) @@ -160,7 +160,7 @@ fn custom_field_emit_fires_listener() { #[test] fn digest_and_enqueue_both_record() { let mut order = Order::default(); - order.create("order-1".into(), "alice".into()); + order.create("order-1".into(), "alice".into()).unwrap(); // digest records to entity event stream assert_eq!(order.entity.version(), 1); @@ -172,9 +172,9 @@ fn digest_and_enqueue_both_record() { #[test] fn digest_and_enqueue_full_lifecycle() { let mut order = Order::default(); - order.create("order-1".into(), "alice".into()); - order.confirm(); - order.ship(); + order.create("order-1".into(), "alice".into()).unwrap(); + order.confirm().unwrap(); + order.ship().unwrap(); // 3 events digested assert_eq!(order.entity.version(), 3); @@ -188,11 +188,11 @@ fn digest_and_enqueue_full_lifecycle() { #[test] fn digest_and_enqueue_guards_stay_in_sync() { let mut order = Order::default(); - order.create("order-1".into(), "alice".into()); + order.create("order-1".into(), "alice".into()).unwrap(); // Try to confirm twice — guard blocks second call for both digest and enqueue - order.confirm(); - order.confirm(); + order.confirm().unwrap(); + order.confirm().unwrap(); assert_eq!(order.entity.version(), 2); // OrderCreated + OrderConfirmed assert_eq!(order.emitter.queued_len(), 2); @@ -207,8 +207,8 @@ fn enqueue_events_survive_commit_and_emit_after() { let repo = HashMapRepository::new().queued().aggregate::(); let mut order = Order::default(); - order.create("order-1".into(), "alice".into()); - order.confirm(); + order.create("order-1".into(), "alice".into()).unwrap(); + order.confirm().unwrap(); // Events queued before commit assert_eq!(order.emitter.queued_len(), 2); @@ -235,8 +235,8 @@ fn replay_does_not_enqueue_events() { let repo = HashMapRepository::new().queued().aggregate::(); let mut order = Order::default(); - order.create("order-1".into(), "alice".into()); - order.confirm(); + order.create("order-1".into(), "alice".into()).unwrap(); + order.confirm().unwrap(); order.emitter.emit_queued(); repo.commit(&mut order).unwrap(); diff --git a/tests/event_store/main.rs b/tests/event_store/main.rs index 9116d0fe2..570bcf499 100644 --- a/tests/event_store/main.rs +++ b/tests/event_store/main.rs @@ -5,9 +5,9 @@ use sourced_rust::{Commit, Entity, GetOne, HashMapRepository}; #[test] fn digest_adds_events_with_correct_sequences() { let mut entity = Entity::with_id("e1"); - entity.digest("Created", &"data1"); - entity.digest("Updated", &"data2"); - entity.digest("Updated", &"data3"); + entity.digest("Created", &"data1").unwrap(); + entity.digest("Updated", &"data2").unwrap(); + entity.digest("Updated", &"data3").unwrap(); assert_eq!(entity.events().len(), 3); assert_eq!(entity.events()[0].sequence, 1); @@ -21,19 +21,19 @@ fn multiple_load_modify_commit_cycles_accumulate_all_events() { // Cycle 1: create and commit let mut entity = Entity::with_id("e1"); - entity.digest("Created", &"v1"); + entity.digest("Created", &"v1").unwrap(); repo.commit(&mut entity).unwrap(); // Cycle 2: load, modify, commit let mut entity = repo.get_one("e1").unwrap().unwrap(); assert_eq!(entity.events().len(), 1); - entity.digest("Updated", &"v2"); + entity.digest("Updated", &"v2").unwrap(); repo.commit(&mut entity).unwrap(); // Cycle 3: load, modify, commit let mut entity = repo.get_one("e1").unwrap().unwrap(); assert_eq!(entity.events().len(), 2); - entity.digest("Updated", &"v3"); + entity.digest("Updated", &"v3").unwrap(); repo.commit(&mut entity).unwrap(); // Verify all events accumulated @@ -52,12 +52,12 @@ fn commit_appends_only_new_events() { let repo = HashMapRepository::new(); let mut entity = Entity::with_id("e1"); - entity.digest("Created", &"v1"); + entity.digest("Created", &"v1").unwrap(); repo.commit(&mut entity).unwrap(); // Reload and add one more event let mut entity = repo.get_one("e1").unwrap().unwrap(); - entity.digest("Updated", &"v2"); + entity.digest("Updated", &"v2").unwrap(); repo.commit(&mut entity).unwrap(); // Verify via get_one: exactly 2 events @@ -73,7 +73,7 @@ fn empty_commit_is_idempotent() { // Create initial state let mut entity = Entity::with_id("e1"); - entity.digest("Created", &"v1"); + entity.digest("Created", &"v1").unwrap(); repo.commit(&mut entity).unwrap(); // Load and commit without changes @@ -96,7 +96,7 @@ fn events_grow_monotonically() { } else { repo.get_one("e1").unwrap().unwrap() }; - entity.digest("Event", &format!("v{}", i)); + entity.digest("Event", &format!("v{}", i)).unwrap(); repo.commit(&mut entity).unwrap(); let loaded = repo.get_one("e1").unwrap().unwrap(); @@ -112,7 +112,7 @@ fn concurrent_writes_detected() { // Create initial state let mut entity = Entity::with_id("e1"); - entity.digest("Created", &"v1"); + entity.digest("Created", &"v1").unwrap(); repo.commit(&mut entity).unwrap(); // Two readers load the same version @@ -123,8 +123,8 @@ fn concurrent_writes_detected() { assert_eq!(reader2.committed_version(), 1); // Both modify - reader1.digest("UpdatedByR1", &"r1"); - reader2.digest("UpdatedByR2", &"r2"); + reader1.digest("UpdatedByR1", &"r1").unwrap(); + reader2.digest("UpdatedByR2", &"r2").unwrap(); // First commit succeeds repo.commit(&mut reader1).unwrap(); @@ -151,9 +151,9 @@ fn partial_conflict_rolls_back_entire_commit() { // Create two entities let mut e1 = Entity::with_id("e1"); - e1.digest("Created", &"v1"); + e1.digest("Created", &"v1").unwrap(); let mut e2 = Entity::with_id("e2"); - e2.digest("Created", &"v1"); + e2.digest("Created", &"v1").unwrap(); repo.commit(&mut [&mut e1, &mut e2]).unwrap(); // Load both entities at version 1 @@ -162,13 +162,13 @@ fn partial_conflict_rolls_back_entire_commit() { // Concurrently modify e2 from another "session" let mut e2_b = repo.get_one("e2").unwrap().unwrap(); - e2_b.digest("Conflict", &"b"); + e2_b.digest("Conflict", &"b").unwrap(); repo.commit(&mut e2_b).unwrap(); // Try to commit both e1_a and e2_a together // e1 would be fine, but e2 has a version conflict - e1_a.digest("Update", &"a"); - e2_a.digest("Update", &"a"); + e1_a.digest("Update", &"a").unwrap(); + e2_a.digest("Update", &"a").unwrap(); let err = repo.commit(&mut [&mut e1_a, &mut e2_a]).unwrap_err(); match err { sourced_rust::RepositoryError::ConcurrentWrite { id, .. } => { @@ -199,8 +199,8 @@ fn load_from_history_sets_committed_version() { let repo = HashMapRepository::new(); let mut entity = Entity::with_id("e1"); - entity.digest("Created", &"v1"); - entity.digest("Updated", &"v2"); + entity.digest("Created", &"v1").unwrap(); + entity.digest("Updated", &"v2").unwrap(); repo.commit(&mut entity).unwrap(); let loaded = repo.get_one("e1").unwrap().unwrap(); @@ -215,7 +215,7 @@ fn commit_updates_committed_version() { let repo = HashMapRepository::new(); let mut entity = Entity::with_id("e1"); - entity.digest("Created", &"v1"); + entity.digest("Created", &"v1").unwrap(); assert_eq!(entity.committed_version(), 0); repo.commit(&mut entity).unwrap(); @@ -224,7 +224,7 @@ fn commit_updates_committed_version() { assert!(entity.new_events().is_empty()); // Add more events and commit again - entity.digest("Updated", &"v2"); + entity.digest("Updated", &"v2").unwrap(); assert_eq!(entity.new_events().len(), 1); repo.commit(&mut entity).unwrap(); @@ -238,14 +238,14 @@ fn new_events_returns_only_uncommitted() { let repo = HashMapRepository::new(); let mut entity = Entity::with_id("e1"); - entity.digest("e1", &"a"); - entity.digest("e2", &"b"); + entity.digest("e1", &"a").unwrap(); + entity.digest("e2", &"b").unwrap(); assert_eq!(entity.new_events().len(), 2); repo.commit(&mut entity).unwrap(); assert!(entity.new_events().is_empty()); - entity.digest("e3", &"c"); + entity.digest("e3", &"c").unwrap(); assert_eq!(entity.new_events().len(), 1); assert_eq!(entity.new_events()[0].event_name, "e3"); } diff --git a/tests/microsvc/basic.rs b/tests/microsvc/basic.rs index ddcf91c16..bb2b43925 100644 --- a/tests/microsvc/basic.rs +++ b/tests/microsvc/basic.rs @@ -13,7 +13,7 @@ fn full_lifecycle() { let input = ctx.input::()?; let counter_repo = ctx.repo().clone().aggregate::(); let mut counter = Counter::default(); - counter.create(input.id.clone()); + counter.create(input.id.clone()).unwrap(); counter_repo.commit(&mut counter)?; Ok(json!({ "id": input.id })) }) @@ -23,7 +23,7 @@ fn full_lifecycle() { let mut counter: Counter = counter_repo .get(&input.id)? .ok_or_else(|| HandlerError::NotFound(input.id.clone()))?; - counter.increment(input.amount); + counter.increment(input.amount).unwrap(); counter_repo.commit(&mut counter)?; Ok(json!({ "value": counter.value })) }) @@ -33,7 +33,7 @@ fn full_lifecycle() { let mut counter: Counter = counter_repo .get(&input.id)? .ok_or_else(|| HandlerError::NotFound(input.id.clone()))?; - counter.decrement(input.amount); + counter.decrement(input.amount).unwrap(); counter_repo.commit(&mut counter)?; Ok(json!({ "value": counter.value })) }); diff --git a/tests/microsvc/handlers/counter_create.rs b/tests/microsvc/handlers/counter_create.rs index 6a121a463..79c2c34c4 100644 --- a/tests/microsvc/handlers/counter_create.rs +++ b/tests/microsvc/handlers/counter_create.rs @@ -35,7 +35,7 @@ pub fn handle(ctx: &Context) -> Result { } let mut counter = Counter::default(); - counter.create(input.id.clone()); + counter.create(input.id.clone()).unwrap(); let mut message = OutboxMessage::domain_event("CounterCreated", &counter) .map_err(|e| HandlerError::Other(Box::new(e)))?; diff --git a/tests/microsvc/handlers/counter_increment.rs b/tests/microsvc/handlers/counter_increment.rs index 6189b675e..310a48d60 100644 --- a/tests/microsvc/handlers/counter_increment.rs +++ b/tests/microsvc/handlers/counter_increment.rs @@ -28,7 +28,7 @@ pub fn handle(ctx: &Context) -> Result { .get(&input.id)? .ok_or_else(|| HandlerError::NotFound(input.id.clone()))?; - counter.increment(input.amount); + counter.increment(input.amount).unwrap(); let mut message = OutboxMessage::domain_event("CounterIncremented", &counter) .map_err(|e| HandlerError::Other(Box::new(e)))?; diff --git a/tests/microsvc/models/counter.rs b/tests/microsvc/models/counter.rs index fabb59235..e27f954d9 100644 --- a/tests/microsvc/models/counter.rs +++ b/tests/microsvc/models/counter.rs @@ -13,18 +13,18 @@ pub struct Counter { #[sourced(entity)] impl Counter { #[event("Created")] - pub fn create(&mut self, id: String) { + pub fn create(&mut self, id: String) -> Result<(), sourced_rust::EventRecordError> { self.entity.set_id(&id); self.value = 0; } #[event("Incremented")] - pub fn increment(&mut self, amount: i64) { + pub fn increment(&mut self, amount: i64) -> Result<(), sourced_rust::EventRecordError> { self.value += amount; } #[event("Decremented", when = self.value >= amount)] - pub fn decrement(&mut self, amount: i64) { + pub fn decrement(&mut self, amount: i64) -> Result<(), sourced_rust::EventRecordError> { self.value -= amount; } } diff --git a/tests/read_models/aggregate.rs b/tests/read_models/aggregate.rs index 09f28de87..04c4ee106 100644 --- a/tests/read_models/aggregate.rs +++ b/tests/read_models/aggregate.rs @@ -16,7 +16,12 @@ impl Counter { } #[digest("CounterCreated")] - pub fn create(&mut self, id: String, name: String, user_id: String) { + pub fn create( + &mut self, + id: String, + name: String, + user_id: String, + ) -> Result<(), sourced_rust::EventRecordError> { self.entity.set_id(&id); self.name = name; self.user_id = user_id; @@ -24,12 +29,12 @@ impl Counter { } #[digest("CounterIncremented")] - pub fn increment(&mut self, amount: i32) { + pub fn increment(&mut self, amount: i32) -> Result<(), sourced_rust::EventRecordError> { self.value += amount; } #[digest("CounterDecremented")] - pub fn decrement(&mut self, amount: i32) { + pub fn decrement(&mut self, amount: i32) -> Result<(), sourced_rust::EventRecordError> { self.value -= amount; } diff --git a/tests/read_models/main.rs b/tests/read_models/main.rs index 25cadb288..71fe955c5 100644 --- a/tests/read_models/main.rs +++ b/tests/read_models/main.rs @@ -21,8 +21,10 @@ fn readmodel_commits_with_aggregate() { // Create and modify aggregate let mut counter = Counter::new(); - counter.create("counter-1".into(), "Page Views".into(), "user-1".into()); - counter.increment(5); + counter + .create("counter-1".into(), "Page Views".into(), "user-1".into()) + .unwrap(); + counter.increment(5).unwrap(); // Create read model from aggregate state let mut view = CounterView::new("counter-1", "Page Views", "user-1"); @@ -59,8 +61,10 @@ fn multiple_readmodels_commit_together() { // Create aggregate let mut counter = Counter::new(); - counter.create("counter-2".into(), "Clicks".into(), "user-abc".into()); - counter.increment(10); + counter + .create("counter-2".into(), "Clicks".into(), "user-abc".into()) + .unwrap(); + counter.increment(10).unwrap(); // Create counter view read model let mut counter_view = CounterView::new("counter-2", "Clicks", "user-abc"); @@ -104,7 +108,9 @@ fn readmodel_update_with_outbox() { // Initial creation let mut counter = Counter::new(); - counter.create("counter-3".into(), "Downloads".into(), "user-xyz".into()); + counter + .create("counter-3".into(), "Downloads".into(), "user-xyz".into()) + .unwrap(); let view = CounterView::new("counter-3", "Downloads", "user-xyz"); @@ -116,7 +122,7 @@ fn readmodel_update_with_outbox() { .unwrap(); // Now increment and update - counter.increment(3); + counter.increment(3).unwrap(); let mut loaded_view = repo .read_models::() @@ -149,7 +155,9 @@ fn readmodel_load_and_update() { // Initial commit let mut counter = Counter::new(); - counter.create("counter-4".into(), "Likes".into(), "user-456".into()); + counter + .create("counter-4".into(), "Likes".into(), "user-456".into()) + .unwrap(); let view = CounterView::new("counter-4", "Likes", "user-456"); @@ -170,7 +178,7 @@ fn readmodel_load_and_update() { assert_eq!(loaded.version, 1); // Modify aggregate and update read model - counter.increment(7); + counter.increment(7).unwrap(); let mut updated_view = loaded.data; updated_view.set_value(counter.value()); @@ -236,8 +244,10 @@ fn outbox_then_readmodel_order() { let repo = HashMapRepository::new(); let mut counter = Counter::new(); - counter.create("counter-5".into(), "Shares".into(), "user-999".into()); - counter.increment(42); + counter + .create("counter-5".into(), "Shares".into(), "user-999".into()) + .unwrap(); + counter.increment(42).unwrap(); let mut view = CounterView::new("counter-5", "Shares", "user-999"); view.set_value(counter.value()); @@ -310,8 +320,10 @@ fn queued_readmodel_get_locks_commit_unlocks() { // Create aggregate and seed read model let mut counter = Counter::new(); - counter.create("q-1".into(), "Queued".into(), "user-q".into()); - counter.increment(5); + counter + .create("q-1".into(), "Queued".into(), "user-q".into()) + .unwrap(); + counter.increment(5).unwrap(); let mut view = CounterView::new("q-1", "Queued", "user-q"); view.set_value(counter.value()); @@ -327,7 +339,7 @@ fn queued_readmodel_get_locks_commit_unlocks() { assert_eq!(loaded.data.value, 5); // Modify and commit — releases the lock - counter.increment(3); + counter.increment(3).unwrap(); let mut updated = loaded.data; updated.set_value(counter.value()); @@ -504,8 +516,10 @@ fn queued_readmodel_full_lifecycle_with_aggregate_and_outbox() { // ── Step 1: Create new aggregate, read model, and outbox ── let mut counter = Counter::new(); - counter.create("life-1".into(), "Lifecycle".into(), "user-life".into()); - counter.increment(10); + counter + .create("life-1".into(), "Lifecycle".into(), "user-life".into()) + .unwrap(); + counter.increment(10).unwrap(); let mut view = CounterView::new("life-1", "Lifecycle", "user-life"); view.set_value(counter.value()); @@ -578,7 +592,7 @@ fn queued_readmodel_full_lifecycle_with_aggregate_and_outbox() { ); // ── Step 5: Update the aggregate and read models ── - counter.increment(5); + counter.increment(5).unwrap(); let mut updated_view = loaded_view.data; updated_view.set_value(counter.value()); diff --git a/tests/sagas/distributed.rs b/tests/sagas/distributed.rs index 01cbfd79c..040fdf30f 100644 --- a/tests/sagas/distributed.rs +++ b/tests/sagas/distributed.rs @@ -69,13 +69,15 @@ fn distributed_saga_with_threads() { }]; let mut order_fulfillment_saga = OrderFulfillmentSaga::new(); - order_fulfillment_saga.start( - saga_id.clone(), - order_id.clone(), - "customer-001".to_string(), - items.clone(), - 5000, - ); + order_fulfillment_saga + .start( + saga_id.clone(), + order_id.clone(), + "customer-001".to_string(), + items.clone(), + 5000, + ) + .unwrap(); let mut outbox = OutboxMessage::encode( &format!("{}:started", saga_id), @@ -123,7 +125,7 @@ fn distributed_saga_with_threads() { println!("[Saga Orchestrator] Inventory reserved, advancing saga..."); let mut order_fulfillment_saga = order_fulfillment_saga_repo.get(&saga_id).unwrap().unwrap(); - order_fulfillment_saga.inventory_reserved(); + order_fulfillment_saga.inventory_reserved().unwrap(); order_fulfillment_saga_repo .commit(&mut order_fulfillment_saga) .unwrap(); @@ -135,7 +137,7 @@ fn distributed_saga_with_threads() { println!("[Saga Orchestrator] Payment succeeded, advancing saga..."); let mut order_fulfillment_saga = order_fulfillment_saga_repo.get(&saga_id).unwrap().unwrap(); - order_fulfillment_saga.payment_succeeded(); + order_fulfillment_saga.payment_succeeded().unwrap(); order_fulfillment_saga_repo .commit(&mut order_fulfillment_saga) .unwrap(); @@ -147,7 +149,7 @@ fn distributed_saga_with_threads() { println!("[Saga Orchestrator] Order completed, completing saga..."); let mut order_fulfillment_saga = order_fulfillment_saga_repo.get(&saga_id).unwrap().unwrap(); - order_fulfillment_saga.complete(); + order_fulfillment_saga.complete().unwrap(); let mut outbox = OutboxMessage::encode( &format!("{}:completed", saga_id), @@ -214,11 +216,13 @@ fn distributed_saga_with_threads() { println!("[Order Service] Received SagaStarted, creating order..."); let mut order = Order::new(); - order.create( - data.order_id.clone(), - data.customer_id.clone(), - data.items.clone(), - ); + order + .create( + data.order_id.clone(), + data.customer_id.clone(), + data.items.clone(), + ) + .unwrap(); let mut outbox = OutboxMessage::encode( &format!("{}:created", data.order_id), @@ -246,9 +250,9 @@ fn distributed_saga_with_threads() { ); let mut order = order_repo.get(&data.order_id).unwrap().unwrap(); - order.mark_inventory_reserved(); - order.mark_payment_processed(); - order.complete(); + order.mark_inventory_reserved().unwrap(); + order.mark_payment_processed().unwrap(); + order.complete().unwrap(); let mut outbox = OutboxMessage::encode( &format!("{}:completed", data.order_id), @@ -289,7 +293,7 @@ fn distributed_saga_with_threads() { // Initialize inventory first let mut inv = Inventory::new(); - inv.initialize("WIDGET-001".to_string(), 100); + inv.initialize("WIDGET-001".to_string(), 100).unwrap(); inventory_repo.commit(&mut inv).unwrap(); println!( @@ -309,7 +313,7 @@ fn distributed_saga_with_threads() { let mut inv = inventory_repo.get(&item.sku).unwrap().unwrap(); if inv.can_reserve(item.quantity) { - inv.reserve(data.order_id.clone(), item.quantity); + inv.reserve(data.order_id.clone(), item.quantity).unwrap(); let mut outbox = OutboxMessage::encode( &format!("{}:reserved", data.order_id), @@ -362,9 +366,11 @@ fn distributed_saga_with_threads() { let mut payment = Payment::new(); let payment_id = format!("pay-{}", data.order_id); - payment.initiate(payment_id.clone(), data.order_id.clone(), 5000); - payment.authorize("txn-123".to_string()); - payment.capture(); + payment + .initiate(payment_id.clone(), data.order_id.clone(), 5000) + .unwrap(); + payment.authorize("txn-123".to_string()).unwrap(); + payment.capture().unwrap(); let mut outbox = OutboxMessage::encode( &format!("{}:paid", data.order_id), @@ -474,13 +480,15 @@ fn distributed_saga_with_send_listen() { }]; let mut order_fulfillment_saga = OrderFulfillmentSaga::new(); - order_fulfillment_saga.start( - saga_id.clone(), - order_id.clone(), - "customer-001".to_string(), - items.clone(), - 5000, - ); + order_fulfillment_saga + .start( + saga_id.clone(), + order_id.clone(), + "customer-001".to_string(), + items.clone(), + 5000, + ) + .unwrap(); // Send to the "orders" queue (point-to-point) let mut outbox = OutboxMessage::encode_to( @@ -524,7 +532,7 @@ fn distributed_saga_with_send_listen() { println!("[Saga/SendListen] Inventory reserved, advancing saga..."); let mut order_fulfillment_saga = order_fulfillment_saga_repo.get(&saga_id).unwrap().unwrap(); - order_fulfillment_saga.inventory_reserved(); + order_fulfillment_saga.inventory_reserved().unwrap(); order_fulfillment_saga_repo .commit(&mut order_fulfillment_saga) .unwrap(); @@ -536,7 +544,7 @@ fn distributed_saga_with_send_listen() { println!("[Saga/SendListen] Payment succeeded, advancing saga..."); let mut order_fulfillment_saga = order_fulfillment_saga_repo.get(&saga_id).unwrap().unwrap(); - order_fulfillment_saga.payment_succeeded(); + order_fulfillment_saga.payment_succeeded().unwrap(); order_fulfillment_saga_repo .commit(&mut order_fulfillment_saga) .unwrap(); @@ -548,7 +556,7 @@ fn distributed_saga_with_send_listen() { println!("[Saga/SendListen] Order completed, completing saga..."); let mut order_fulfillment_saga = order_fulfillment_saga_repo.get(&saga_id).unwrap().unwrap(); - order_fulfillment_saga.complete(); + order_fulfillment_saga.complete().unwrap(); // SagaCompleted has no specific destination, but we can // still route it to a queue (or use publish for fan-out) @@ -619,11 +627,13 @@ fn distributed_saga_with_send_listen() { println!("[Order/SendListen] Received SagaStarted, creating order..."); let mut order = Order::new(); - order.create( - data.order_id.clone(), - data.customer_id.clone(), - data.items.clone(), - ); + order + .create( + data.order_id.clone(), + data.customer_id.clone(), + data.items.clone(), + ) + .unwrap(); // Send OrderCreated to both "saga" and "inventory" queues let payload = OrderCreatedPayload { @@ -667,9 +677,9 @@ fn distributed_saga_with_send_listen() { ); let mut order = order_repo.get(&data.order_id).unwrap().unwrap(); - order.mark_inventory_reserved(); - order.mark_payment_processed(); - order.complete(); + order.mark_inventory_reserved().unwrap(); + order.mark_payment_processed().unwrap(); + order.complete().unwrap(); let mut outbox = OutboxMessage::encode_to( &format!("{}:completed", data.order_id), @@ -712,7 +722,7 @@ fn distributed_saga_with_send_listen() { // Initialize inventory let mut inv = Inventory::new(); - inv.initialize("WIDGET-001".to_string(), 100); + inv.initialize("WIDGET-001".to_string(), 100).unwrap(); inventory_repo.commit(&mut inv).unwrap(); println!( @@ -730,7 +740,7 @@ fn distributed_saga_with_send_listen() { let mut inv = inventory_repo.get(&item.sku).unwrap().unwrap(); if inv.can_reserve(item.quantity) { - inv.reserve(data.order_id.clone(), item.quantity); + inv.reserve(data.order_id.clone(), item.quantity).unwrap(); // Send InventoryReserved to both "saga" and "payments" queues let payload = InventoryReservedPayload { @@ -796,9 +806,11 @@ fn distributed_saga_with_send_listen() { let mut payment = Payment::new(); let payment_id = format!("pay-{}", data.order_id); - payment.initiate(payment_id.clone(), data.order_id.clone(), 5000); - payment.authorize("txn-123".to_string()); - payment.capture(); + payment + .initiate(payment_id.clone(), data.order_id.clone(), 5000) + .unwrap(); + payment.authorize("txn-123".to_string()).unwrap(); + payment.capture().unwrap(); // Send PaymentSucceeded to both "saga" and "orders" queues let payload = PaymentSucceededPayload { @@ -911,15 +923,17 @@ fn metadata_propagates_across_bus_to_subscriber() { order.entity.set_correlation_id("req-distributed-001"); order.entity.set_causation_id("cmd-create-order"); order.entity.set_meta("user_id", "u-99"); - order.create( - "order-meta-001".to_string(), - "customer-meta-001".to_string(), - vec![OrderItem { - sku: "WIDGET-META".to_string(), - quantity: 1, - price_cents: 500, - }], - ); + order + .create( + "order-meta-001".to_string(), + "customer-meta-001".to_string(), + vec![OrderItem { + sku: "WIDGET-META".to_string(), + quantity: 1, + price_cents: 500, + }], + ) + .unwrap(); // Metadata propagates automatically from entity context let mut outbox = OutboxMessage::encode_for_entity( diff --git a/tests/sagas/handlers/inventory/init.rs b/tests/sagas/handlers/inventory/init.rs index f407e5ce7..ee6b00db2 100644 --- a/tests/sagas/handlers/inventory/init.rs +++ b/tests/sagas/handlers/inventory/init.rs @@ -10,7 +10,7 @@ pub fn handle(ctx: &Context) -> Result { let input = ctx.input::()?; let mut inv = Inventory::new(); - inv.initialize(input.sku.clone(), input.stock); + inv.initialize(input.sku.clone(), input.stock).unwrap(); ctx.repo().commit(&mut inv)?; Ok(json!({ "sku": input.sku, "stock": input.stock })) diff --git a/tests/sagas/handlers/inventory/reserve.rs b/tests/sagas/handlers/inventory/reserve.rs index 202975ae6..228495718 100644 --- a/tests/sagas/handlers/inventory/reserve.rs +++ b/tests/sagas/handlers/inventory/reserve.rs @@ -17,7 +17,7 @@ pub fn handle(ctx: &Context) -> Result { if !inv.can_reserve(input.quantity) { return Err(HandlerError::Rejected("insufficient stock".into())); } - inv.reserve(input.order_id.clone(), input.quantity); + inv.reserve(input.order_id.clone(), input.quantity).unwrap(); let mut msg = json_outbox_to( &format!("{}-inventory-reserved", input.order_id), diff --git a/tests/sagas/handlers/messages.rs b/tests/sagas/handlers/messages.rs index d559b53e8..62d9fd796 100644 --- a/tests/sagas/handlers/messages.rs +++ b/tests/sagas/handlers/messages.rs @@ -20,7 +20,7 @@ pub fn json_outbox_to( payload: &T, ) -> OutboxMessage { let bytes = serde_json::to_vec(payload).expect("JSON serialization should not fail"); - OutboxMessage::create_to(id, event_type, destination, bytes) + OutboxMessage::create_to(id, event_type, destination, bytes).unwrap() } // === Saga → Order Service === diff --git a/tests/sagas/handlers/orders/complete.rs b/tests/sagas/handlers/orders/complete.rs index 96df09546..ac1bdd4d3 100644 --- a/tests/sagas/handlers/orders/complete.rs +++ b/tests/sagas/handlers/orders/complete.rs @@ -13,9 +13,9 @@ pub fn handle(ctx: &Context) -> Result { .repo() .get(&input.order_id)? .ok_or_else(|| HandlerError::NotFound(input.order_id.clone()))?; - order.mark_inventory_reserved(); - order.mark_payment_processed(); - order.complete(); + order.mark_inventory_reserved().unwrap(); + order.mark_payment_processed().unwrap(); + order.complete().unwrap(); let mut msg = json_outbox_to( &format!("{}-order-completed", input.order_id), diff --git a/tests/sagas/handlers/orders/create.rs b/tests/sagas/handlers/orders/create.rs index 11d86d2d2..beebc5992 100644 --- a/tests/sagas/handlers/orders/create.rs +++ b/tests/sagas/handlers/orders/create.rs @@ -10,7 +10,9 @@ pub fn handle(ctx: &Context) -> Result { let input = ctx.input::()?; let mut order = Order::new(); - order.create(input.order_id.clone(), input.customer_id, input.items); + order + .create(input.order_id.clone(), input.customer_id, input.items) + .unwrap(); let mut msg = json_outbox_to( &format!("{}-order-created", input.order_id), diff --git a/tests/sagas/handlers/payments/process.rs b/tests/sagas/handlers/payments/process.rs index c20634f07..0ca63f021 100644 --- a/tests/sagas/handlers/payments/process.rs +++ b/tests/sagas/handlers/payments/process.rs @@ -11,13 +11,17 @@ pub fn handle(ctx: &Context) -> Result { let payment_id = format!("pay-{}", input.order_id); let mut payment = Payment::new(); - payment.initiate( - payment_id.clone(), - input.order_id.clone(), - input.amount_cents, - ); - payment.authorize("txn-distributed-001".to_string()); - payment.capture(); + payment + .initiate( + payment_id.clone(), + input.order_id.clone(), + input.amount_cents, + ) + .unwrap(); + payment + .authorize("txn-distributed-001".to_string()) + .unwrap(); + payment.capture().unwrap(); let mut msg = json_outbox_to( &format!("{}-payment-succeeded", input.order_id), diff --git a/tests/sagas/handlers/saga/on_inventory_reserved.rs b/tests/sagas/handlers/saga/on_inventory_reserved.rs index 5ea611492..067a98f29 100644 --- a/tests/sagas/handlers/saga/on_inventory_reserved.rs +++ b/tests/sagas/handlers/saga/on_inventory_reserved.rs @@ -13,7 +13,7 @@ pub fn handle(ctx: &Context) -> Result { .repo() .get(&input.saga_id)? .ok_or_else(|| HandlerError::NotFound(input.saga_id.clone()))?; - saga.inventory_reserved(); + saga.inventory_reserved().unwrap(); let mut msg = json_outbox_to( &format!("{}-process-payment", input.saga_id), diff --git a/tests/sagas/handlers/saga/on_order_completed.rs b/tests/sagas/handlers/saga/on_order_completed.rs index 3876d1556..e87379470 100644 --- a/tests/sagas/handlers/saga/on_order_completed.rs +++ b/tests/sagas/handlers/saga/on_order_completed.rs @@ -13,7 +13,7 @@ pub fn handle(ctx: &Context) -> Result { .repo() .get(&input.saga_id)? .ok_or_else(|| HandlerError::NotFound(input.saga_id.clone()))?; - saga.complete(); + saga.complete().unwrap(); ctx.repo().commit(&mut saga)?; Ok(json!({ "saga_id": input.saga_id, "status": "completed" })) diff --git a/tests/sagas/handlers/saga/on_payment_succeeded.rs b/tests/sagas/handlers/saga/on_payment_succeeded.rs index 8221c3926..dd4c7af77 100644 --- a/tests/sagas/handlers/saga/on_payment_succeeded.rs +++ b/tests/sagas/handlers/saga/on_payment_succeeded.rs @@ -13,7 +13,7 @@ pub fn handle(ctx: &Context) -> Result { .repo() .get(&input.saga_id)? .ok_or_else(|| HandlerError::NotFound(input.saga_id.clone()))?; - saga.payment_succeeded(); + saga.payment_succeeded().unwrap(); let mut msg = json_outbox_to( &format!("{}-complete-order", input.saga_id), diff --git a/tests/sagas/handlers/saga/start.rs b/tests/sagas/handlers/saga/start.rs index 3c9c18a00..244c837f6 100644 --- a/tests/sagas/handlers/saga/start.rs +++ b/tests/sagas/handlers/saga/start.rs @@ -16,7 +16,8 @@ pub fn handle(ctx: &Context) -> Result { input.customer_id.clone(), input.items.clone(), input.total_cents, - ); + ) + .unwrap(); let mut msg = json_outbox_to( &format!("{}-create-order", input.saga_id), diff --git a/tests/sagas/microsvc_saga.rs b/tests/sagas/microsvc_saga.rs index 10f2e409b..8b34d23ac 100644 --- a/tests/sagas/microsvc_saga.rs +++ b/tests/sagas/microsvc_saga.rs @@ -269,7 +269,7 @@ fn saga_distributed() { { let tmp = inventory_repo.clone().aggregate::(); let mut inv = Inventory::new(); - inv.initialize("WIDGET-001".to_string(), 100); + inv.initialize("WIDGET-001".to_string(), 100).unwrap(); tmp.commit(&mut inv).unwrap(); } diff --git a/tests/sagas/orchestration.rs b/tests/sagas/orchestration.rs index ce05f1f56..ea48c9d63 100644 --- a/tests/sagas/orchestration.rs +++ b/tests/sagas/orchestration.rs @@ -30,7 +30,9 @@ fn saga_happy_path_completes_order() { // === Setup: Initialize inventory === let mut widget_inventory = Inventory::new(); - widget_inventory.initialize("WIDGET-001".to_string(), 100); + widget_inventory + .initialize("WIDGET-001".to_string(), 100) + .unwrap(); inventory_repo.commit(&mut widget_inventory).unwrap(); // === Step 1: Create Order === @@ -42,30 +44,34 @@ fn saga_happy_path_completes_order() { }]; let mut order = Order::new(); - order.create(order_id.clone(), "customer-456".to_string(), items.clone()); + order + .create(order_id.clone(), "customer-456".to_string(), items.clone()) + .unwrap(); order_repo.commit(&mut order).unwrap(); // === Step 2: Start Saga === let mut order_fulfillment_saga = OrderFulfillmentSaga::new(); - order_fulfillment_saga.start( - "saga-123".to_string(), - order_id.clone(), - "customer-456".to_string(), - items, - 5000, // 5 widgets * 1000 cents - ); + order_fulfillment_saga + .start( + "saga-123".to_string(), + order_id.clone(), + "customer-456".to_string(), + items, + 5000, // 5 widgets * 1000 cents + ) + .unwrap(); assert_eq!(order_fulfillment_saga.status(), SagaStatus::Started); saga_repo.commit(&mut order_fulfillment_saga).unwrap(); // === Step 3: Reserve Inventory === let mut inventory = inventory_repo.get("WIDGET-001").unwrap().unwrap(); assert!(inventory.can_reserve(5)); - inventory.reserve(order_id.clone(), 5); + inventory.reserve(order_id.clone(), 5).unwrap(); inventory_repo.commit(&mut inventory).unwrap(); // Update saga state let mut order_fulfillment_saga = saga_repo.get("saga-123").unwrap().unwrap(); - order_fulfillment_saga.inventory_reserved(); + order_fulfillment_saga.inventory_reserved().unwrap(); assert_eq!( order_fulfillment_saga.status(), SagaStatus::InventoryReserved @@ -75,20 +81,22 @@ fn saga_happy_path_completes_order() { // Update order state let mut order = order_repo.get(&order_id).unwrap().unwrap(); - order.mark_inventory_reserved(); + order.mark_inventory_reserved().unwrap(); order_repo.commit(&mut order).unwrap(); // === Step 4: Process Payment === let mut payment = Payment::new(); - payment.initiate("payment-789".to_string(), order_id.clone(), 5000); - payment.authorize("txn-abc123".to_string()); - payment.capture(); + payment + .initiate("payment-789".to_string(), order_id.clone(), 5000) + .unwrap(); + payment.authorize("txn-abc123".to_string()).unwrap(); + payment.capture().unwrap(); assert!(payment.is_successful()); payment_repo.commit(&mut payment).unwrap(); // Update saga state let mut order_fulfillment_saga = saga_repo.get("saga-123").unwrap().unwrap(); - order_fulfillment_saga.payment_succeeded(); + order_fulfillment_saga.payment_succeeded().unwrap(); assert_eq!( order_fulfillment_saga.status(), SagaStatus::PaymentProcessed @@ -98,23 +106,23 @@ fn saga_happy_path_completes_order() { // Update order state let mut order = order_repo.get(&order_id).unwrap().unwrap(); - order.mark_payment_processed(); + order.mark_payment_processed().unwrap(); order_repo.commit(&mut order).unwrap(); // === Step 5: Complete Saga === let mut order_fulfillment_saga = saga_repo.get("saga-123").unwrap().unwrap(); - order_fulfillment_saga.complete(); + order_fulfillment_saga.complete().unwrap(); assert_eq!(order_fulfillment_saga.status(), SagaStatus::Completed); saga_repo.commit(&mut order_fulfillment_saga).unwrap(); // Commit the inventory reservation (no longer reversible) let mut inventory = inventory_repo.get("WIDGET-001").unwrap().unwrap(); - inventory.commit_reservation(order_id.clone()); + inventory.commit_reservation(order_id.clone()).unwrap(); inventory_repo.commit(&mut inventory).unwrap(); // Complete the order let mut order = order_repo.get(&order_id).unwrap().unwrap(); - order.complete(); + order.complete().unwrap(); order_repo.commit(&mut order).unwrap(); // === Verify Final State === @@ -141,7 +149,9 @@ fn saga_compensates_on_payment_failure() { // === Setup === let mut widget_inventory = Inventory::new(); - widget_inventory.initialize("WIDGET-002".to_string(), 50); + widget_inventory + .initialize("WIDGET-002".to_string(), 50) + .unwrap(); inventory_repo.commit(&mut widget_inventory).unwrap(); let order_id = "order-fail-456".to_string(); @@ -152,31 +162,35 @@ fn saga_compensates_on_payment_failure() { }]; let mut order = Order::new(); - order.create(order_id.clone(), "customer-789".to_string(), items.clone()); + order + .create(order_id.clone(), "customer-789".to_string(), items.clone()) + .unwrap(); order_repo.commit(&mut order).unwrap(); // === Start Saga === let mut order_fulfillment_saga = OrderFulfillmentSaga::new(); - order_fulfillment_saga.start( - "saga-fail-456".to_string(), - order_id.clone(), - "customer-789".to_string(), - items, - 5000, - ); + order_fulfillment_saga + .start( + "saga-fail-456".to_string(), + order_id.clone(), + "customer-789".to_string(), + items, + 5000, + ) + .unwrap(); saga_repo.commit(&mut order_fulfillment_saga).unwrap(); // === Reserve Inventory (succeeds) === let mut inventory = inventory_repo.get("WIDGET-002").unwrap().unwrap(); - inventory.reserve(order_id.clone(), 10); + inventory.reserve(order_id.clone(), 10).unwrap(); inventory_repo.commit(&mut inventory).unwrap(); let mut order_fulfillment_saga = saga_repo.get("saga-fail-456").unwrap().unwrap(); - order_fulfillment_saga.inventory_reserved(); + order_fulfillment_saga.inventory_reserved().unwrap(); saga_repo.commit(&mut order_fulfillment_saga).unwrap(); let mut order = order_repo.get(&order_id).unwrap().unwrap(); - order.mark_inventory_reserved(); + order.mark_inventory_reserved().unwrap(); order_repo.commit(&mut order).unwrap(); // Verify inventory is reserved @@ -186,15 +200,19 @@ fn saga_compensates_on_payment_failure() { // === Payment Fails === let mut payment = Payment::new(); - payment.initiate("payment-fail-xyz".to_string(), order_id.clone(), 5000); - payment.fail("Insufficient funds".to_string()); + payment + .initiate("payment-fail-xyz".to_string(), order_id.clone(), 5000) + .unwrap(); + payment.fail("Insufficient funds".to_string()).unwrap(); assert!(!payment.is_successful()); assert_eq!(payment.status(), PaymentStatus::Failed); payment_repo.commit(&mut payment).unwrap(); // === Saga enters compensation mode === let mut order_fulfillment_saga = saga_repo.get("saga-fail-456").unwrap().unwrap(); - order_fulfillment_saga.step_failed("Payment".to_string(), "Insufficient funds".to_string()); + order_fulfillment_saga + .step_failed("Payment".to_string(), "Insufficient funds".to_string()) + .unwrap(); assert_eq!(order_fulfillment_saga.status(), SagaStatus::Compensating); assert!(order_fulfillment_saga.needs_inventory_compensation()); assert!(!order_fulfillment_saga.needs_payment_compensation()); // Payment wasn't successful @@ -202,22 +220,24 @@ fn saga_compensates_on_payment_failure() { // === Compensate: Release Inventory === let mut inventory = inventory_repo.get("WIDGET-002").unwrap().unwrap(); - inventory.release_reservation(order_id.clone()); + inventory.release_reservation(order_id.clone()).unwrap(); inventory_repo.commit(&mut inventory).unwrap(); let mut order_fulfillment_saga = saga_repo.get("saga-fail-456").unwrap().unwrap(); - order_fulfillment_saga.inventory_compensated(); + order_fulfillment_saga.inventory_compensated().unwrap(); assert!(!order_fulfillment_saga.needs_inventory_compensation()); saga_repo.commit(&mut order_fulfillment_saga).unwrap(); // === Cancel Order === let mut order = order_repo.get(&order_id).unwrap().unwrap(); - order.cancel("Payment failed: Insufficient funds".to_string()); + order + .cancel("Payment failed: Insufficient funds".to_string()) + .unwrap(); order_repo.commit(&mut order).unwrap(); // === Mark Saga as Failed === let mut order_fulfillment_saga = saga_repo.get("saga-fail-456").unwrap().unwrap(); - order_fulfillment_saga.mark_failed(); + order_fulfillment_saga.mark_failed().unwrap(); assert_eq!(order_fulfillment_saga.status(), SagaStatus::Failed); assert!(order_fulfillment_saga.is_complete()); saga_repo.commit(&mut order_fulfillment_saga).unwrap(); @@ -248,7 +268,9 @@ fn saga_compensates_on_inventory_failure() { // === Setup: Low inventory === let mut widget_inventory = Inventory::new(); - widget_inventory.initialize("WIDGET-003".to_string(), 5); // Only 5 available + widget_inventory + .initialize("WIDGET-003".to_string(), 5) + .unwrap(); // Only 5 available inventory_repo.commit(&mut widget_inventory).unwrap(); let order_id = "order-inv-fail-789".to_string(); @@ -259,18 +281,22 @@ fn saga_compensates_on_inventory_failure() { }]; let mut order = Order::new(); - order.create(order_id.clone(), "customer-xyz".to_string(), items.clone()); + order + .create(order_id.clone(), "customer-xyz".to_string(), items.clone()) + .unwrap(); order_repo.commit(&mut order).unwrap(); // === Start Saga === let mut order_fulfillment_saga = OrderFulfillmentSaga::new(); - order_fulfillment_saga.start( - "saga-inv-fail".to_string(), - order_id.clone(), - "customer-xyz".to_string(), - items, - 5000, - ); + order_fulfillment_saga + .start( + "saga-inv-fail".to_string(), + order_id.clone(), + "customer-xyz".to_string(), + items, + 5000, + ) + .unwrap(); saga_repo.commit(&mut order_fulfillment_saga).unwrap(); // === Try to Reserve Inventory (fails - not enough stock) === @@ -279,10 +305,12 @@ fn saga_compensates_on_inventory_failure() { // Saga fails at first step let mut order_fulfillment_saga = saga_repo.get("saga-inv-fail").unwrap().unwrap(); - order_fulfillment_saga.step_failed( - "Inventory".to_string(), - "Insufficient stock for WIDGET-003".to_string(), - ); + order_fulfillment_saga + .step_failed( + "Inventory".to_string(), + "Insufficient stock for WIDGET-003".to_string(), + ) + .unwrap(); assert_eq!(order_fulfillment_saga.status(), SagaStatus::Compensating); // No compensation needed - nothing was reserved yet assert!(!order_fulfillment_saga.needs_inventory_compensation()); @@ -291,12 +319,12 @@ fn saga_compensates_on_inventory_failure() { // Cancel order let mut order = order_repo.get(&order_id).unwrap().unwrap(); - order.cancel("Insufficient stock".to_string()); + order.cancel("Insufficient stock".to_string()).unwrap(); order_repo.commit(&mut order).unwrap(); // Mark saga as failed (no compensation needed) let mut order_fulfillment_saga = saga_repo.get("saga-inv-fail").unwrap().unwrap(); - order_fulfillment_saga.mark_failed(); + order_fulfillment_saga.mark_failed().unwrap(); saga_repo.commit(&mut order_fulfillment_saga).unwrap(); // === Verify Final State === @@ -325,15 +353,17 @@ fn saga_is_replayable_from_events() { // Create and progress a saga let mut order_fulfillment_saga = OrderFulfillmentSaga::new(); - order_fulfillment_saga.start( - "saga-replay".to_string(), - "order-replay".to_string(), - "customer-replay".to_string(), - items, - 4500, - ); - order_fulfillment_saga.inventory_reserved(); - order_fulfillment_saga.payment_succeeded(); + order_fulfillment_saga + .start( + "saga-replay".to_string(), + "order-replay".to_string(), + "customer-replay".to_string(), + items, + 4500, + ) + .unwrap(); + order_fulfillment_saga.inventory_reserved().unwrap(); + order_fulfillment_saga.payment_succeeded().unwrap(); // Commit to repository saga_repo.commit(&mut order_fulfillment_saga).unwrap(); @@ -350,7 +380,7 @@ fn saga_is_replayable_from_events() { // Can continue from restored state let mut restored = restored; - restored.complete(); + restored.complete().unwrap(); saga_repo.commit(&mut restored).unwrap(); let final_order_fulfillment_saga = saga_repo.get("saga-replay").unwrap().unwrap(); @@ -369,46 +399,50 @@ fn saga_tracks_compensation_state_correctly() { // Progress saga through multiple steps let mut order_fulfillment_saga = OrderFulfillmentSaga::new(); - order_fulfillment_saga.start( - "saga-comp".to_string(), - "order-comp".to_string(), - "customer-comp".to_string(), - items, - 100, - ); + order_fulfillment_saga + .start( + "saga-comp".to_string(), + "order-comp".to_string(), + "customer-comp".to_string(), + items, + 100, + ) + .unwrap(); // Initially no compensation needed assert!(!order_fulfillment_saga.compensation().inventory_reserved); assert!(!order_fulfillment_saga.compensation().payment_processed); // After inventory reserved - order_fulfillment_saga.inventory_reserved(); + order_fulfillment_saga.inventory_reserved().unwrap(); assert!(order_fulfillment_saga.compensation().inventory_reserved); assert!(!order_fulfillment_saga.compensation().payment_processed); // After payment processed - order_fulfillment_saga.payment_succeeded(); + order_fulfillment_saga.payment_succeeded().unwrap(); assert!(order_fulfillment_saga.compensation().inventory_reserved); assert!(order_fulfillment_saga.compensation().payment_processed); // Fail after both steps completed - order_fulfillment_saga.step_failed("FinalStep".to_string(), "Something went wrong".to_string()); + order_fulfillment_saga + .step_failed("FinalStep".to_string(), "Something went wrong".to_string()) + .unwrap(); assert_eq!(order_fulfillment_saga.status(), SagaStatus::Compensating); assert!(order_fulfillment_saga.needs_inventory_compensation()); assert!(order_fulfillment_saga.needs_payment_compensation()); // Compensate payment first - order_fulfillment_saga.payment_compensated(); + order_fulfillment_saga.payment_compensated().unwrap(); assert!(order_fulfillment_saga.needs_inventory_compensation()); assert!(!order_fulfillment_saga.needs_payment_compensation()); // Compensate inventory - order_fulfillment_saga.inventory_compensated(); + order_fulfillment_saga.inventory_compensated().unwrap(); assert!(!order_fulfillment_saga.needs_inventory_compensation()); assert!(!order_fulfillment_saga.needs_payment_compensation()); // Now can mark as failed - order_fulfillment_saga.mark_failed(); + order_fulfillment_saga.mark_failed().unwrap(); assert_eq!(order_fulfillment_saga.status(), SagaStatus::Failed); assert!(order_fulfillment_saga.is_complete()); diff --git a/tests/sagas/order/inventory.rs b/tests/sagas/order/inventory.rs index f5c5bef76..41427ceec 100644 --- a/tests/sagas/order/inventory.rs +++ b/tests/sagas/order/inventory.rs @@ -47,21 +47,32 @@ impl Inventory { } #[digest("InventoryInitialized")] - pub fn initialize(&mut self, sku: String, initial_stock: u32) { + pub fn initialize( + &mut self, + sku: String, + initial_stock: u32, + ) -> Result<(), sourced_rust::EventRecordError> { self.entity.set_id(&sku); self.sku = sku; self.available = initial_stock; } #[digest("StockReserved", when = self.can_reserve(quantity))] - pub fn reserve(&mut self, order_id: String, quantity: u32) { + pub fn reserve( + &mut self, + order_id: String, + quantity: u32, + ) -> Result<(), sourced_rust::EventRecordError> { self.available -= quantity; self.reserved += quantity; self.reservations.insert(order_id, quantity); } #[digest("ReservationReleased", when = self.reservations.contains_key(&order_id))] - pub fn release_reservation(&mut self, order_id: String) { + pub fn release_reservation( + &mut self, + order_id: String, + ) -> Result<(), sourced_rust::EventRecordError> { if let Some(quantity) = self.reservations.remove(&order_id) { self.available += quantity; self.reserved -= quantity; @@ -69,7 +80,10 @@ impl Inventory { } #[digest("ReservationCommitted", when = self.reservations.contains_key(&order_id))] - pub fn commit_reservation(&mut self, order_id: String) { + pub fn commit_reservation( + &mut self, + order_id: String, + ) -> Result<(), sourced_rust::EventRecordError> { if let Some(quantity) = self.reservations.remove(&order_id) { self.reserved -= quantity; // Stock is now sold, no longer available or reserved diff --git a/tests/sagas/order/order.rs b/tests/sagas/order/order.rs index ee0812330..951fdbe88 100644 --- a/tests/sagas/order/order.rs +++ b/tests/sagas/order/order.rs @@ -64,7 +64,12 @@ impl Order { } #[digest("OrderCreated")] - pub fn create(&mut self, id: String, customer_id: String, items: Vec) { + pub fn create( + &mut self, + id: String, + customer_id: String, + items: Vec, + ) -> Result<(), sourced_rust::EventRecordError> { self.entity.set_id(&id); self.customer_id = customer_id; self.total_cents = items.iter().map(|i| i.price_cents * i.quantity).sum(); @@ -73,22 +78,22 @@ impl Order { } #[digest("InventoryReserved", when = self.status == OrderStatus::Pending)] - pub fn mark_inventory_reserved(&mut self) { + pub fn mark_inventory_reserved(&mut self) -> Result<(), sourced_rust::EventRecordError> { self.status = OrderStatus::InventoryReserved; } #[digest("PaymentProcessed", when = self.status == OrderStatus::InventoryReserved)] - pub fn mark_payment_processed(&mut self) { + pub fn mark_payment_processed(&mut self) -> Result<(), sourced_rust::EventRecordError> { self.status = OrderStatus::PaymentProcessed; } #[digest("OrderCompleted", when = self.status == OrderStatus::PaymentProcessed)] - pub fn complete(&mut self) { + pub fn complete(&mut self) -> Result<(), sourced_rust::EventRecordError> { self.status = OrderStatus::Completed; } #[digest("OrderCancelled", when = self.status != OrderStatus::Completed && self.status != OrderStatus::Cancelled)] - pub fn cancel(&mut self, reason: String) { + pub fn cancel(&mut self, reason: String) -> Result<(), sourced_rust::EventRecordError> { self.status = OrderStatus::Cancelled; self.failure_reason = Some(reason); } diff --git a/tests/sagas/order/payment.rs b/tests/sagas/order/payment.rs index d99d9c7c6..c70927615 100644 --- a/tests/sagas/order/payment.rs +++ b/tests/sagas/order/payment.rs @@ -58,7 +58,12 @@ impl Payment { } #[digest("PaymentInitiated")] - pub fn initiate(&mut self, id: String, order_id: String, amount_cents: u32) { + pub fn initiate( + &mut self, + id: String, + order_id: String, + amount_cents: u32, + ) -> Result<(), sourced_rust::EventRecordError> { self.entity.set_id(&id); self.order_id = order_id; self.amount_cents = amount_cents; @@ -66,24 +71,27 @@ impl Payment { } #[digest("PaymentAuthorized", when = self.status == PaymentStatus::Pending)] - pub fn authorize(&mut self, transaction_id: String) { + pub fn authorize( + &mut self, + transaction_id: String, + ) -> Result<(), sourced_rust::EventRecordError> { self.status = PaymentStatus::Authorized; self.transaction_id = Some(transaction_id); } #[digest("PaymentCaptured", when = self.status == PaymentStatus::Authorized)] - pub fn capture(&mut self) { + pub fn capture(&mut self) -> Result<(), sourced_rust::EventRecordError> { self.status = PaymentStatus::Captured; } #[digest("PaymentFailed", when = self.status == PaymentStatus::Pending || self.status == PaymentStatus::Authorized)] - pub fn fail(&mut self, reason: String) { + pub fn fail(&mut self, reason: String) -> Result<(), sourced_rust::EventRecordError> { self.status = PaymentStatus::Failed; self.failure_reason = Some(reason); } #[digest("PaymentRefunded", when = self.status == PaymentStatus::Captured)] - pub fn refund(&mut self) { + pub fn refund(&mut self) -> Result<(), sourced_rust::EventRecordError> { self.status = PaymentStatus::Refunded; } diff --git a/tests/sagas/order/saga.rs b/tests/sagas/order/saga.rs index 0035dd088..c62c704ba 100644 --- a/tests/sagas/order/saga.rs +++ b/tests/sagas/order/saga.rs @@ -108,7 +108,7 @@ impl OrderFulfillmentSaga { customer_id: String, items: Vec, total_cents: u32, - ) { + ) -> Result<(), sourced_rust::EventRecordError> { self.entity.set_id(&saga_id); self.order_id = order_id; self.customer_id = customer_id; @@ -118,42 +118,46 @@ impl OrderFulfillmentSaga { } #[digest("InventoryReservationSucceeded", when = self.status == SagaStatus::Started)] - pub fn inventory_reserved(&mut self) { + pub fn inventory_reserved(&mut self) -> Result<(), sourced_rust::EventRecordError> { self.status = SagaStatus::InventoryReserved; self.compensation.inventory_reserved = true; } #[digest("PaymentSucceeded", when = self.status == SagaStatus::InventoryReserved)] - pub fn payment_succeeded(&mut self) { + pub fn payment_succeeded(&mut self) -> Result<(), sourced_rust::EventRecordError> { self.status = SagaStatus::PaymentProcessed; self.compensation.payment_processed = true; } #[digest("SagaCompleted", when = self.status == SagaStatus::PaymentProcessed)] - pub fn complete(&mut self) { + pub fn complete(&mut self) -> Result<(), sourced_rust::EventRecordError> { self.status = SagaStatus::Completed; } // === Failure and Compensation === #[digest("StepFailed", when = !self.is_complete())] - pub fn step_failed(&mut self, step: String, reason: String) { + pub fn step_failed( + &mut self, + step: String, + reason: String, + ) -> Result<(), sourced_rust::EventRecordError> { self.status = SagaStatus::Compensating; self.failure_reason = Some(format!("{}: {}", step, reason)); } #[digest("InventoryCompensated", when = self.needs_inventory_compensation())] - pub fn inventory_compensated(&mut self) { + pub fn inventory_compensated(&mut self) -> Result<(), sourced_rust::EventRecordError> { self.compensation.inventory_reserved = false; } #[digest("PaymentCompensated", when = self.needs_payment_compensation())] - pub fn payment_compensated(&mut self) { + pub fn payment_compensated(&mut self) -> Result<(), sourced_rust::EventRecordError> { self.compensation.payment_processed = false; } #[digest("SagaFailed", when = self.status == SagaStatus::Compensating && !self.compensation.inventory_reserved && !self.compensation.payment_processed)] - pub fn mark_failed(&mut self) { + pub fn mark_failed(&mut self) -> Result<(), sourced_rust::EventRecordError> { self.status = SagaStatus::Failed; } diff --git a/tests/snapshots/aggregate.rs b/tests/snapshots/aggregate.rs index b231f46f0..23ef1441a 100644 --- a/tests/snapshots/aggregate.rs +++ b/tests/snapshots/aggregate.rs @@ -14,14 +14,19 @@ impl Todo { } #[digest("Initialized")] - pub fn initialize(&mut self, id: String, user_id: String, task: String) { + pub fn initialize( + &mut self, + id: String, + user_id: String, + task: String, + ) -> Result<(), sourced_rust::EventRecordError> { self.entity.set_id(&id); self.user_id = user_id; self.task = task; } #[digest("Completed", when = !self.completed)] - pub fn complete(&mut self) { + pub fn complete(&mut self) -> Result<(), sourced_rust::EventRecordError> { self.completed = true; } } diff --git a/tests/snapshots/main.rs b/tests/snapshots/main.rs index 92e28d4fa..1d75b5ed0 100644 --- a/tests/snapshots/main.rs +++ b/tests/snapshots/main.rs @@ -10,7 +10,8 @@ fn snapshot_created_at_frequency_threshold() { .with_snapshots(2); let mut todo = Todo::new(); - todo.initialize("t1".into(), "alice".into(), "Buy milk".into()); + todo.initialize("t1".into(), "alice".into(), "Buy milk".into()) + .unwrap(); repo.commit(&mut todo).unwrap(); // Version 1 — below threshold of 2, no snapshot yet @@ -18,7 +19,7 @@ fn snapshot_created_at_frequency_threshold() { // Load, add another event to reach version 2 let mut todo = repo.get("t1").unwrap().unwrap(); - todo.complete(); + todo.complete().unwrap(); repo.commit(&mut todo).unwrap(); // Version 2 >= 0 + 2 — snapshot should now exist @@ -45,7 +46,8 @@ fn no_snapshot_before_threshold() { .with_snapshots(5); let mut todo = Todo::new(); - todo.initialize("t1".into(), "alice".into(), "Buy milk".into()); + todo.initialize("t1".into(), "alice".into(), "Buy milk".into()) + .unwrap(); repo.commit(&mut todo).unwrap(); // Only 1 event, threshold is 5 @@ -59,8 +61,9 @@ fn load_from_snapshot_produces_correct_state() { .with_snapshots(2); let mut todo = Todo::new(); - todo.initialize("t1".into(), "alice".into(), "Buy milk".into()); - todo.complete(); + todo.initialize("t1".into(), "alice".into(), "Buy milk".into()) + .unwrap(); + todo.complete().unwrap(); repo.commit(&mut todo).unwrap(); // Snapshot at version 2 @@ -83,11 +86,12 @@ fn snapshot_plus_newer_events() { // Create and commit 2 events (triggers snapshot at version 2) let mut todo = Todo::new(); - todo.initialize("t1".into(), "alice".into(), "Buy milk".into()); + todo.initialize("t1".into(), "alice".into(), "Buy milk".into()) + .unwrap(); repo.commit(&mut todo).unwrap(); let mut todo = repo.get("t1").unwrap().unwrap(); - todo.complete(); + todo.complete().unwrap(); repo.commit(&mut todo).unwrap(); // Snapshot exists at version 2, completed = true @@ -110,8 +114,9 @@ fn no_snapshot_falls_back_to_full_replay() { .with_snapshots(2); let mut todo = Todo::new(); - todo.initialize("t1".into(), "alice".into(), "Buy milk".into()); - todo.complete(); + todo.initialize("t1".into(), "alice".into(), "Buy milk".into()) + .unwrap(); + todo.complete().unwrap(); repo.commit(&mut todo).unwrap(); // Snapshot exists @@ -136,7 +141,8 @@ fn snapshot_version_advances_on_second_snapshot() { .with_snapshots(1); // snapshot every event let mut todo = Todo::new(); - todo.initialize("t1".into(), "alice".into(), "Buy milk".into()); + todo.initialize("t1".into(), "alice".into(), "Buy milk".into()) + .unwrap(); repo.commit(&mut todo).unwrap(); // First snapshot at version 1 @@ -145,7 +151,7 @@ fn snapshot_version_advances_on_second_snapshot() { // Add another event let mut todo = repo.get("t1").unwrap().unwrap(); - todo.complete(); + todo.complete().unwrap(); repo.commit(&mut todo).unwrap(); // Second snapshot at version 2 @@ -166,11 +172,12 @@ fn with_queued_repo() { .with_snapshots(2); let mut todo = Todo::new(); - todo.initialize("t1".into(), "alice".into(), "Buy milk".into()); + todo.initialize("t1".into(), "alice".into(), "Buy milk".into()) + .unwrap(); repo.commit(&mut todo).unwrap(); let mut todo = repo.get("t1").unwrap().unwrap(); - todo.complete(); + todo.complete().unwrap(); repo.commit(&mut todo).unwrap(); // Snapshot should exist through the queued + snapshot chain @@ -192,13 +199,17 @@ fn find_with_snapshots() { // Create two todos, both past snapshot threshold let mut todo1 = Todo::new(); - todo1.initialize("t1".into(), "alice".into(), "Buy milk".into()); - todo1.complete(); + todo1 + .initialize("t1".into(), "alice".into(), "Buy milk".into()) + .unwrap(); + todo1.complete().unwrap(); repo.commit(&mut todo1).unwrap(); let mut todo2 = Todo::new(); - todo2.initialize("t2".into(), "bob".into(), "Walk dog".into()); - todo2.complete(); + todo2 + .initialize("t2".into(), "bob".into(), "Walk dog".into()) + .unwrap(); + todo2.complete().unwrap(); repo.commit(&mut todo2).unwrap(); // Find all completed @@ -218,12 +229,16 @@ fn commit_all_with_snapshots() { .with_snapshots(2); let mut todo1 = Todo::new(); - todo1.initialize("t1".into(), "alice".into(), "Task 1".into()); - todo1.complete(); // version 2 + todo1 + .initialize("t1".into(), "alice".into(), "Task 1".into()) + .unwrap(); + todo1.complete().unwrap(); // version 2 let mut todo2 = Todo::new(); - todo2.initialize("t2".into(), "bob".into(), "Task 2".into()); - todo2.complete(); // version 2 + todo2 + .initialize("t2".into(), "bob".into(), "Task 2".into()) + .unwrap(); + todo2.complete().unwrap(); // version 2 repo.commit_all(&mut [&mut todo1, &mut todo2]).unwrap(); diff --git a/tests/sourced/aggregate.rs b/tests/sourced/aggregate.rs index 6cbc6b86d..1996321ac 100644 --- a/tests/sourced/aggregate.rs +++ b/tests/sourced/aggregate.rs @@ -11,14 +11,19 @@ pub struct Todo { #[sourced(entity)] impl Todo { #[event("Initialized")] - pub fn initialize(&mut self, id: String, user_id: String, task: String) { + pub fn initialize( + &mut self, + id: String, + user_id: String, + task: String, + ) -> Result<(), sourced_rust::EventRecordError> { self.entity.set_id(&id); self.user_id = user_id; self.task = task; } #[event("Completed", when = !self.completed)] - pub fn complete(&mut self) { + pub fn complete(&mut self) -> Result<(), sourced_rust::EventRecordError> { self.completed = true; } diff --git a/tests/sourced/main.rs b/tests/sourced/main.rs index 8366b6a2c..17130c32f 100644 --- a/tests/sourced/main.rs +++ b/tests/sourced/main.rs @@ -4,7 +4,8 @@ use aggregate::{Todo, TodoEvent}; use serde::ser::Error as _; use serde::Serialize; use sourced_rust::{ - Aggregate, AggregateBuilder, Entity, EventRecord, HashMapRepository, PayloadError, Queueable, + Aggregate, AggregateBuilder, Entity, EventRecord, EventRecordError, HashMapRepository, + Queueable, }; #[derive(Clone)] @@ -26,17 +27,15 @@ struct SafeRecorder { } impl SafeRecorder { - #[sourced_rust::try_digest("Recorded")] - fn record(&mut self, _payload: FailingSerialize) -> Result<(), PayloadError> { + #[sourced_rust::digest("Recorded")] + fn record(&mut self, _payload: FailingSerialize) -> Result<(), EventRecordError> { self.applied = true; - Ok(()) } - #[sourced_rust::try_digest("Recorded", version = 2)] - fn record_ok(&mut self, payload: String) -> Result<(), PayloadError> { + #[sourced_rust::digest("Recorded", version = 2)] + fn record_ok(&mut self, payload: String) -> Result<(), EventRecordError> { self.applied = true; assert_eq!(payload, "ok"); - Ok(()) } } @@ -56,7 +55,7 @@ fn enum_variants_exist_and_compile() { } #[test] -fn try_digest_macro_returns_payload_errors_without_running_body() { +fn digest_macro_returns_payload_errors_without_running_body() { let mut recorder = SafeRecorder::default(); let err = recorder.record(FailingSerialize).unwrap_err(); @@ -67,7 +66,7 @@ fn try_digest_macro_returns_payload_errors_without_running_body() { } #[test] -fn try_digest_macro_records_successful_versioned_events() { +fn digest_macro_records_successful_versioned_events() { let mut recorder = SafeRecorder::default(); recorder.record_ok("ok".to_string()).unwrap(); @@ -131,8 +130,9 @@ fn aggregate_hydration_roundtrip() { let repo = HashMapRepository::new().queued().aggregate::(); let mut todo = Todo::default(); - todo.initialize("t1".into(), "alice".into(), "Buy milk".into()); - todo.complete(); + todo.initialize("t1".into(), "alice".into(), "Buy milk".into()) + .unwrap(); + todo.complete().unwrap(); repo.commit(&mut todo).unwrap(); @@ -146,10 +146,11 @@ fn aggregate_hydration_roundtrip() { #[test] fn guard_condition_works() { let mut todo = Todo::default(); - todo.initialize("t1".into(), "alice".into(), "Test".into()); - todo.complete(); + todo.initialize("t1".into(), "alice".into(), "Test".into()) + .unwrap(); + todo.complete().unwrap(); // Second complete should be no-op (guard: !self.completed) - todo.complete(); + todo.complete().unwrap(); assert_eq!(todo.entity.version(), 2); // only Initialized + Completed } @@ -157,7 +158,8 @@ fn guard_condition_works() { #[test] fn non_event_methods_pass_through() { let mut todo = Todo::default(); - todo.initialize("t1".into(), "alice".into(), "Test".into()); + todo.initialize("t1".into(), "alice".into(), "Test".into()) + .unwrap(); let snap = todo.snapshot(); assert_eq!(snap.id, "t1"); assert_eq!(snap.user_id, "alice"); diff --git a/tests/sourced_enqueue/aggregate.rs b/tests/sourced_enqueue/aggregate.rs index 32a657da6..9e3de3395 100644 --- a/tests/sourced_enqueue/aggregate.rs +++ b/tests/sourced_enqueue/aggregate.rs @@ -24,7 +24,11 @@ impl Default for Order { #[sourced(entity, enqueue)] impl Order { #[event("OrderCreated")] - pub fn create(&mut self, order_id: String, customer: String) { + pub fn create( + &mut self, + order_id: String, + customer: String, + ) -> Result<(), sourced_rust::EventRecordError> { self.entity.set_id(&order_id); self.order_id = order_id; self.customer = customer; @@ -32,12 +36,12 @@ impl Order { } #[event("OrderConfirmed", when = self.status == "created")] - pub fn confirm(&mut self) { + pub fn confirm(&mut self) -> Result<(), sourced_rust::EventRecordError> { self.status = "confirmed".into(); } #[event("OrderShipped", when = self.status == "confirmed")] - pub fn ship(&mut self) { + pub fn ship(&mut self) -> Result<(), sourced_rust::EventRecordError> { self.status = "shipped".into(); } } @@ -62,7 +66,11 @@ impl Default for Notifier { #[sourced(entity, enqueue(my_emitter))] impl Notifier { #[event("NotificationSent")] - pub fn send(&mut self, id: String, message: String) { + pub fn send( + &mut self, + id: String, + message: String, + ) -> Result<(), sourced_rust::EventRecordError> { self.entity.set_id(&id); self.message = message; } diff --git a/tests/sourced_enqueue/main.rs b/tests/sourced_enqueue/main.rs index bdf969ae4..d0c03691f 100644 --- a/tests/sourced_enqueue/main.rs +++ b/tests/sourced_enqueue/main.rs @@ -12,7 +12,7 @@ use std::time::Duration; #[test] fn digest_and_enqueue_both_fire() { let mut order = Order::default(); - order.create("order-1".into(), "alice".into()); + order.create("order-1".into(), "alice".into()).unwrap(); assert_eq!(order.entity.version(), 1); assert_eq!(order.emitter.queued_len(), 1); @@ -21,9 +21,9 @@ fn digest_and_enqueue_both_fire() { #[test] fn full_lifecycle_digest_and_enqueue() { let mut order = Order::default(); - order.create("order-1".into(), "alice".into()); - order.confirm(); - order.ship(); + order.create("order-1".into(), "alice".into()).unwrap(); + order.confirm().unwrap(); + order.ship().unwrap(); assert_eq!(order.entity.version(), 3); assert_eq!(order.emitter.queued_len(), 3); @@ -39,8 +39,8 @@ fn replay_does_not_re_enqueue() { let repo = HashMapRepository::new().queued().aggregate::(); let mut order = Order::default(); - order.create("order-1".into(), "alice".into()); - order.confirm(); + order.create("order-1".into(), "alice".into()).unwrap(); + order.confirm().unwrap(); order.emitter.emit_queued(); repo.commit(&mut order).unwrap(); @@ -64,7 +64,7 @@ fn emit_fires_listeners() { tx.send(()).unwrap(); }); - order.create("order-1".into(), "alice".into()); + order.create("order-1".into(), "alice".into()).unwrap(); order.emitter.emit_queued(); rx.recv_timeout(Duration::from_secs(1)) @@ -78,9 +78,9 @@ fn emit_fires_listeners() { #[test] fn guards_stay_in_sync_between_digest_and_enqueue() { let mut order = Order::default(); - order.create("order-1".into(), "alice".into()); - order.confirm(); - order.confirm(); // second confirm blocked by guard + order.create("order-1".into(), "alice".into()).unwrap(); + order.confirm().unwrap(); + order.confirm().unwrap(); // second confirm blocked by guard assert_eq!(order.entity.version(), 2); assert_eq!(order.emitter.queued_len(), 2); @@ -108,7 +108,7 @@ fn typed_event_enum_exists() { #[test] fn custom_emitter_field_enqueues() { let mut notifier = Notifier::default(); - notifier.send("n-1".into(), "Hello world".into()); + notifier.send("n-1".into(), "Hello world".into()).unwrap(); assert_eq!(notifier.entity.version(), 1); assert_eq!(notifier.my_emitter.queued_len(), 1); @@ -125,7 +125,7 @@ fn custom_emitter_field_emits() { tx.send(()).unwrap(); }); - notifier.send("n-1".into(), "Hello".into()); + notifier.send("n-1".into(), "Hello".into()).unwrap(); notifier.my_emitter.emit_queued(); rx.recv_timeout(Duration::from_secs(1)) @@ -137,7 +137,7 @@ fn custom_emitter_replay_does_not_enqueue() { let repo = HashMapRepository::new().queued().aggregate::(); let mut notifier = Notifier::default(); - notifier.send("n-1".into(), "Hello".into()); + notifier.send("n-1".into(), "Hello".into()).unwrap(); notifier.my_emitter.emit_queued(); repo.commit(&mut notifier).unwrap(); diff --git a/tests/sourced_snapshot/aggregates.rs b/tests/sourced_snapshot/aggregates.rs index 3c8b68cbc..460d592f2 100644 --- a/tests/sourced_snapshot/aggregates.rs +++ b/tests/sourced_snapshot/aggregates.rs @@ -19,14 +19,19 @@ impl Todo { } #[digest("Initialized")] - pub fn initialize(&mut self, id: String, user_id: String, task: String) { + pub fn initialize( + &mut self, + id: String, + user_id: String, + task: String, + ) -> Result<(), sourced_rust::EventRecordError> { self.entity.set_id(&id); self.user_id = user_id; self.task = task; } #[digest("Completed", when = !self.completed)] - pub fn complete(&mut self) { + pub fn complete(&mut self) -> Result<(), sourced_rust::EventRecordError> { self.completed = true; } } @@ -54,14 +59,19 @@ impl Inventory { } #[digest("Created")] - pub fn create(&mut self, id: String, sku: String, available: u32) { + pub fn create( + &mut self, + id: String, + sku: String, + available: u32, + ) -> Result<(), sourced_rust::EventRecordError> { self.entity.set_id(&id); self.sku = sku; self.available = available; } #[digest("Restocked")] - pub fn restock(&mut self, qty: u32) { + pub fn restock(&mut self, qty: u32) -> Result<(), sourced_rust::EventRecordError> { self.available += qty; } } @@ -90,7 +100,12 @@ impl Order { } #[digest("Placed")] - pub fn place(&mut self, id: String, customer: String, total: u64) { + pub fn place( + &mut self, + id: String, + customer: String, + total: u64, + ) -> Result<(), sourced_rust::EventRecordError> { self.entity.set_id(&id); self.customer = customer; self.total = total; @@ -119,12 +134,12 @@ impl Counter { } #[event("Initialized")] - pub fn initialize(&mut self, id: String) { + pub fn initialize(&mut self, id: String) -> Result<(), sourced_rust::EventRecordError> { self.entity.set_id(&id); } #[event("Incremented")] - pub fn increment(&mut self, amount: i64) { + pub fn increment(&mut self, amount: i64) -> Result<(), sourced_rust::EventRecordError> { self.count += amount; } } @@ -147,7 +162,12 @@ impl Widget { } #[digest(my_entity, "Created")] - pub fn create(&mut self, id: String, name: String, weight: f64) { + pub fn create( + &mut self, + id: String, + name: String, + weight: f64, + ) -> Result<(), sourced_rust::EventRecordError> { self.my_entity.set_id(&id); self.name = name; self.weight = weight; @@ -180,7 +200,11 @@ impl Notifier { } #[digest("Sent")] - pub fn send(&mut self, id: String, message: String) { + pub fn send( + &mut self, + id: String, + message: String, + ) -> Result<(), sourced_rust::EventRecordError> { self.entity.set_id(&id); self.message = message; } diff --git a/tests/sourced_snapshot/main.rs b/tests/sourced_snapshot/main.rs index 2a3be81b8..66ff37341 100644 --- a/tests/sourced_snapshot/main.rs +++ b/tests/sourced_snapshot/main.rs @@ -13,8 +13,9 @@ use sourced_rust::{ #[test] fn default_snapshot_has_id_and_all_fields() { let mut todo = Todo::new(); - todo.initialize("t1".into(), "alice".into(), "Buy milk".into()); - todo.complete(); + todo.initialize("t1".into(), "alice".into(), "Buy milk".into()) + .unwrap(); + todo.complete().unwrap(); let snap = todo.snapshot(); assert_eq!(snap.id, "t1"); @@ -30,7 +31,8 @@ fn default_snapshot_roundtrip_via_snapshottable() { .with_snapshots(1); let mut todo = Todo::new(); - todo.initialize("t1".into(), "alice".into(), "Buy milk".into()); + todo.initialize("t1".into(), "alice".into(), "Buy milk".into()) + .unwrap(); repo.commit(&mut todo).unwrap(); let loaded = repo.get("t1").unwrap().unwrap(); @@ -64,7 +66,7 @@ fn default_restore_from_snapshot() { #[test] fn custom_id_snapshot_uses_field_as_key() { let mut inv = Inventory::new(); - inv.create("inv-1".into(), "WIDGET-42".into(), 100); + inv.create("inv-1".into(), "WIDGET-42".into(), 100).unwrap(); let snap = inv.snapshot(); // The snapshot should have `sku` as the id field, not a separate `id` @@ -92,7 +94,7 @@ fn custom_id_roundtrip_via_repo() { .with_snapshots(1); let mut inv = Inventory::new(); - inv.create("inv-1".into(), "SKU-A".into(), 10); + inv.create("inv-1".into(), "SKU-A".into(), 10).unwrap(); repo.commit(&mut inv).unwrap(); let loaded = repo.get("inv-1").unwrap().unwrap(); @@ -108,7 +110,7 @@ fn custom_id_roundtrip_via_repo() { #[test] fn serde_skip_fields_excluded_from_snapshot() { let mut order = Order::new(); - order.place("o1".into(), "alice".into(), 999); + order.place("o1".into(), "alice".into(), 999).unwrap(); let snap = order.snapshot(); assert_eq!(snap.id, "o1"); @@ -120,7 +122,7 @@ fn serde_skip_fields_excluded_from_snapshot() { #[test] fn serde_skip_default_excluded_from_snapshot() { let mut notifier = Notifier::new(); - notifier.send("n1".into(), "hello".into()); + notifier.send("n1".into(), "hello".into()).unwrap(); let snap = notifier.snapshot(); assert_eq!(snap.id, "n1"); @@ -135,7 +137,7 @@ fn serde_skip_restore_roundtrip() { .with_snapshots(1); let mut order = Order::new(); - order.place("o1".into(), "alice".into(), 500); + order.place("o1".into(), "alice".into(), 500).unwrap(); repo.commit(&mut order).unwrap(); let loaded = repo.get("o1").unwrap().unwrap(); @@ -152,9 +154,9 @@ fn serde_skip_restore_roundtrip() { #[test] fn sourced_attr_with_snapshot_derive() { let mut counter = Counter::new(); - counter.initialize("c1".into()); - counter.increment(5); - counter.increment(3); + counter.initialize("c1".into()).unwrap(); + counter.increment(5).unwrap(); + counter.increment(3).unwrap(); let snap = counter.snapshot(); assert_eq!(snap.id, "c1"); @@ -168,8 +170,8 @@ fn sourced_attr_snapshot_roundtrip_via_repo() { .with_snapshots(2); let mut counter = Counter::new(); - counter.initialize("c1".into()); - counter.increment(10); + counter.initialize("c1".into()).unwrap(); + counter.increment(10).unwrap(); repo.commit(&mut counter).unwrap(); // At version 2, should have a snapshot @@ -187,7 +189,7 @@ fn sourced_attr_snapshot_roundtrip_via_repo() { #[test] fn custom_entity_field_snapshot() { let mut widget = Widget::new(); - widget.create("w1".into(), "Sprocket".into(), 2.5); + widget.create("w1".into(), "Sprocket".into(), 2.5).unwrap(); let snap = widget.snapshot(); assert_eq!(snap.id, "w1"); @@ -216,7 +218,8 @@ fn custom_entity_field_restore() { #[test] fn domain_event_derives_id_and_payload() { let mut todo = Todo::new(); - todo.initialize("t1".into(), "alice".into(), "Buy milk".into()); + todo.initialize("t1".into(), "alice".into(), "Buy milk".into()) + .unwrap(); let outbox = OutboxMessage::domain_event("TodoInitialized", &todo).unwrap(); assert_eq!(outbox.id(), "outbox:t1:TodoInitialized:1"); @@ -234,7 +237,8 @@ fn domain_event_propagates_metadata() { todo.entity.set_correlation_id("req-abc"); todo.entity.set_causation_id("cmd-create"); todo.entity.set_meta("user_id", "u-42"); - todo.initialize("t1".into(), "alice".into(), "Buy milk".into()); + todo.initialize("t1".into(), "alice".into(), "Buy milk".into()) + .unwrap(); let outbox = OutboxMessage::domain_event("TodoInitialized", &todo).unwrap(); assert_eq!(outbox.correlation_id(), Some("req-abc")); @@ -247,7 +251,8 @@ fn domain_event_commits_with_outbox() { let repo = HashMapRepository::new().aggregate::(); let mut todo = Todo::new(); - todo.initialize("t1".into(), "alice".into(), "Ship it".into()); + todo.initialize("t1".into(), "alice".into(), "Ship it".into()) + .unwrap(); let mut outbox = OutboxMessage::domain_event("TodoInitialized", &todo).unwrap(); repo.outbox(&mut outbox).commit(&mut todo).unwrap(); diff --git a/tests/sourced_upcasting/aggregate.rs b/tests/sourced_upcasting/aggregate.rs index 39879fa6f..3d7bbd216 100644 --- a/tests/sourced_upcasting/aggregate.rs +++ b/tests/sourced_upcasting/aggregate.rs @@ -15,14 +15,19 @@ pub struct TodoV1 { #[sourced(entity)] impl TodoV1 { #[event("Initialized")] - pub fn initialize(&mut self, id: String, user_id: String, task: String) { + pub fn initialize( + &mut self, + id: String, + user_id: String, + task: String, + ) -> Result<(), sourced_rust::EventRecordError> { self.entity.set_id(&id); self.user_id = user_id; self.task = task; } #[event("Completed", when = !self.completed)] - pub fn complete(&mut self) { + pub fn complete(&mut self) -> Result<(), sourced_rust::EventRecordError> { self.completed = true; } } @@ -50,7 +55,13 @@ pub struct TodoV2 { ))] impl TodoV2 { #[event("Initialized", version = 2)] - pub fn initialize(&mut self, id: String, user_id: String, task: String, priority: u8) { + pub fn initialize( + &mut self, + id: String, + user_id: String, + task: String, + priority: u8, + ) -> Result<(), sourced_rust::EventRecordError> { self.entity.set_id(&id); self.user_id = user_id; self.task = task; @@ -58,7 +69,7 @@ impl TodoV2 { } #[event("Completed", when = !self.completed)] - pub fn complete(&mut self) { + pub fn complete(&mut self) -> Result<(), sourced_rust::EventRecordError> { self.completed = true; } } @@ -96,7 +107,7 @@ impl TodoV3 { task: String, priority: u8, due_date: String, - ) { + ) -> Result<(), sourced_rust::EventRecordError> { self.entity.set_id(&id); self.user_id = user_id; self.task = task; @@ -105,7 +116,7 @@ impl TodoV3 { } #[event("Completed", when = !self.completed)] - pub fn complete(&mut self) { + pub fn complete(&mut self) -> Result<(), sourced_rust::EventRecordError> { self.completed = true; } } diff --git a/tests/sourced_upcasting/main.rs b/tests/sourced_upcasting/main.rs index 9daea36ad..bce0842b2 100644 --- a/tests/sourced_upcasting/main.rs +++ b/tests/sourced_upcasting/main.rs @@ -30,8 +30,9 @@ fn v3_has_two_upcasters() { #[test] fn hydrate_v2_from_v1_events() { let mut v1 = TodoV1::default(); - v1.initialize("t1".into(), "alice".into(), "Buy milk".into()); - v1.complete(); + v1.initialize("t1".into(), "alice".into(), "Buy milk".into()) + .unwrap(); + v1.complete().unwrap(); let mut entity = Entity::new(); entity.load_from_history(v1.entity.events().to_vec()); @@ -46,7 +47,8 @@ fn hydrate_v2_from_v1_events() { #[test] fn hydrate_v3_from_v1_events_chains_upcasters() { let mut v1 = TodoV1::default(); - v1.initialize("t1".into(), "bob".into(), "Walk dog".into()); + v1.initialize("t1".into(), "bob".into(), "Walk dog".into()) + .unwrap(); let mut entity = Entity::new(); entity.load_from_history(v1.entity.events().to_vec()); @@ -59,7 +61,8 @@ fn hydrate_v3_from_v1_events_chains_upcasters() { #[test] fn hydrate_v3_from_v2_events() { let mut v2 = TodoV2::default(); - v2.initialize("t1".into(), "carol".into(), "Read".into(), 5); + v2.initialize("t1".into(), "carol".into(), "Read".into(), 5) + .unwrap(); let mut entity = Entity::new(); entity.load_from_history(v2.entity.events().to_vec()); @@ -78,7 +81,8 @@ fn hydrate_v3_native_no_upcasting() { "Cook".into(), 2, "2025-12-31".into(), - ); + ) + .unwrap(); let mut entity = Entity::new(); entity.load_from_history(v3.entity.events().to_vec()); @@ -92,7 +96,8 @@ fn hydrate_v3_native_no_upcasting() { fn repo_roundtrip_v1_to_v2() { let repo = HashMapRepository::new(); let mut v1 = TodoV1::default(); - v1.initialize("t1".into(), "frank".into(), "Shop".into()); + v1.initialize("t1".into(), "frank".into(), "Shop".into()) + .unwrap(); repo.commit(&mut v1.entity).unwrap(); let v2_repo = repo.aggregate::(); @@ -145,8 +150,9 @@ fn v2_try_from_event_record() { #[test] fn mixed_v1_events_upcasted_to_v2() { let mut v1 = TodoV1::default(); - v1.initialize("t1".into(), "eve".into(), "Test".into()); - v1.complete(); + v1.initialize("t1".into(), "eve".into(), "Test".into()) + .unwrap(); + v1.complete().unwrap(); let mut entity = Entity::new(); entity.load_from_history(v1.entity.events().to_vec()); diff --git a/tests/todos/aggregate.rs b/tests/todos/aggregate.rs index edbe46d6c..a9b1f7f7e 100644 --- a/tests/todos/aggregate.rs +++ b/tests/todos/aggregate.rs @@ -15,14 +15,19 @@ impl Todo { } #[digest("Initialized")] - pub fn initialize(&mut self, id: String, user_id: String, task: String) { + pub fn initialize( + &mut self, + id: String, + user_id: String, + task: String, + ) -> Result<(), sourced_rust::EventRecordError> { self.entity.set_id(&id); self.user_id = user_id; self.task = task; } #[digest("Completed", when = !self.completed)] - pub fn complete(&mut self) { + pub fn complete(&mut self) -> Result<(), sourced_rust::EventRecordError> { self.completed = true; } diff --git a/tests/todos/main.rs b/tests/todos/main.rs index acfafca65..b04dd9c57 100644 --- a/tests/todos/main.rs +++ b/tests/todos/main.rs @@ -29,7 +29,8 @@ fn todos() { id1.clone(), "user1".to_string(), "Buy groceries".to_string(), - ); + ) + .unwrap(); // Add an outbox event for the initialization let mut init_message = @@ -48,7 +49,7 @@ fn todos() { // Retrieve the Todo from the repository and complete it, then commit again if let Some(mut retrieved_todo) = repo.get(&id1).unwrap() { - retrieved_todo.complete(); + retrieved_todo.complete().unwrap(); // Add an outbox event for the completion let mut complete_message = OutboxMessage::encode( @@ -88,15 +89,19 @@ fn todos() { let mut todo2 = Todo::new(); let id2 = next_id(); - todo2.initialize(id2.clone(), "user1".to_string(), "Buy Sauna".to_string()); + todo2 + .initialize(id2.clone(), "user1".to_string(), "Buy Sauna".to_string()) + .unwrap(); let mut todo3 = Todo::new(); let id3 = next_id(); - todo3.initialize( - id3.clone(), - "user2".to_string(), - "Chew bubblegum".to_string(), - ); + todo3 + .initialize( + id3.clone(), + "user2".to_string(), + "Chew bubblegum".to_string(), + ) + .unwrap(); // Commit multiple Todos to the repository let _ = repo.commit_all(&mut [&mut todo2, &mut todo3]); @@ -115,7 +120,8 @@ fn get_commit_roundtrip() { let repo = HashMapRepository::new().queued().aggregate::(); let mut todo = Todo::new(); let id = next_id(); - todo.initialize(id.clone(), "user1".to_string(), "Roundtrip".to_string()); + todo.initialize(id.clone(), "user1".to_string(), "Roundtrip".to_string()) + .unwrap(); repo.commit(&mut todo).unwrap(); @@ -132,11 +138,15 @@ fn get_all_commit_all_roundtrip() { let mut todo1 = Todo::new(); let id1 = next_id(); - todo1.initialize(id1.clone(), "user1".to_string(), "First".to_string()); + todo1 + .initialize(id1.clone(), "user1".to_string(), "First".to_string()) + .unwrap(); let mut todo2 = Todo::new(); let id2 = next_id(); - todo2.initialize(id2.clone(), "user2".to_string(), "Second".to_string()); + todo2 + .initialize(id2.clone(), "user2".to_string(), "Second".to_string()) + .unwrap(); repo.commit_all(&mut [&mut todo1, &mut todo2]).unwrap(); @@ -151,8 +161,8 @@ fn get_all_commit_all_roundtrip() { let mut todo1v2 = iter.next().unwrap(); let mut todo2v2 = iter.next().unwrap(); - todo1v2.complete(); - todo2v2.complete(); + todo1v2.complete().unwrap(); + todo2v2.complete().unwrap(); repo.commit_all(&mut [&mut todo1v2, &mut todo2v2]).unwrap(); @@ -170,7 +180,8 @@ fn outbox_records_persisted() { let repo = HashMapRepository::new(); let mut todo = Todo::new(); let id = next_id(); - todo.initialize(id.clone(), "user1".to_string(), "Outbox demo".to_string()); + todo.initialize(id.clone(), "user1".to_string(), "Outbox demo".to_string()) + .unwrap(); let snapshot = todo.snapshot(); let mut message = OutboxMessage::encode(format!("{}:init", id), "TodoInitialized", &snapshot).unwrap(); @@ -199,7 +210,8 @@ fn outbox_worker_log_publisher() { id.clone(), "user1".to_string(), "Outbox log publisher".to_string(), - ); + ) + .unwrap(); let snapshot = todo.snapshot(); let mut message = OutboxMessage::encode(format!("{}:init", id), "TodoInitialized", &snapshot).unwrap(); @@ -219,7 +231,7 @@ fn outbox_worker_log_publisher() { let mut claimed = repo .claim_outbox_messages("logger-1", 10, Duration::from_secs(30)) .unwrap(); - let result = worker.process_batch(&mut claimed); + let result = worker.process_batch(&mut claimed).unwrap(); assert_eq!(result.completed, 1); for message in &mut claimed { repo.commit(&mut message.entity).unwrap(); @@ -246,7 +258,8 @@ fn outbox_worker_local_emitter_publisher() { id.clone(), "user1".to_string(), "Outbox local emitter".to_string(), - ); + ) + .unwrap(); let snapshot = todo.snapshot(); let mut message = OutboxMessage::encode(format!("{}:init", id), "TodoInitialized", &snapshot).unwrap(); @@ -269,7 +282,7 @@ fn outbox_worker_local_emitter_publisher() { let mut claimed = repo .claim_outbox_messages("emitter-1", 10, Duration::from_secs(30)) .unwrap(); - let result = worker.process_batch(&mut claimed); + let result = worker.process_batch(&mut claimed).unwrap(); assert_eq!(result.completed, 1); for message in &mut claimed { repo.commit(&mut message.entity).unwrap(); @@ -285,7 +298,8 @@ fn abort_releases_lock_after_get() { let repo = Arc::new(HashMapRepository::new().queued().aggregate::()); let mut todo = Todo::new(); let id = next_id(); - todo.initialize(id.clone(), "user1".to_string(), "Abort get".to_string()); + todo.initialize(id.clone(), "user1".to_string(), "Abort get".to_string()) + .unwrap(); repo.commit(&mut todo).unwrap(); let locked = repo.get(&id).unwrap().unwrap(); @@ -312,20 +326,24 @@ fn abort_releases_lock_after_get_all() { let repo = Arc::new(HashMapRepository::new().queued().aggregate::()); let mut todo1 = Todo::new(); let id1 = next_id(); - todo1.initialize( - id1.clone(), - "user1".to_string(), - "Abort get_all 1".to_string(), - ); + todo1 + .initialize( + id1.clone(), + "user1".to_string(), + "Abort get_all 1".to_string(), + ) + .unwrap(); repo.commit(&mut todo1).unwrap(); let mut todo2 = Todo::new(); let id2 = next_id(); - todo2.initialize( - id2.clone(), - "user2".to_string(), - "Abort get_all 2".to_string(), - ); + todo2 + .initialize( + id2.clone(), + "user2".to_string(), + "Abort get_all 2".to_string(), + ) + .unwrap(); repo.commit(&mut todo2).unwrap(); let locked = repo.get_all(&[&id1, &id2]).unwrap(); @@ -355,16 +373,19 @@ fn queued_repo_blocks_get_until_commit() { let repo = Arc::new(HashMapRepository::new().queued().aggregate::()); let mut todo = Todo::new(); let id = next_id(); - todo.initialize(id.clone(), "user1".to_string(), "Queue test".to_string()); + todo.initialize(id.clone(), "user1".to_string(), "Queue test".to_string()) + .unwrap(); repo.commit(&mut todo).unwrap(); let mut other_todo = Todo::new(); let other_id = next_id(); - other_todo.initialize( - other_id.clone(), - "user2".to_string(), - "Independent queue".to_string(), - ); + other_todo + .initialize( + other_id.clone(), + "user2".to_string(), + "Independent queue".to_string(), + ) + .unwrap(); repo.commit(&mut other_todo).unwrap(); let (tx_started, rx_started) = mpsc::channel(); @@ -443,7 +464,8 @@ fn outbox_worker_process_next_with_commit() { id.clone(), "user1".to_string(), "Process next test".to_string(), - ); + ) + .unwrap(); let snapshot = todo.snapshot(); // Queue 3 messages @@ -482,7 +504,7 @@ fn outbox_worker_process_next_with_commit() { if claimed.is_empty() { break; } - let result = worker.process_batch(&mut claimed); + let result = worker.process_batch(&mut claimed).unwrap(); processed += result.completed + result.released + result.failed; for message in &mut claimed { repo.commit(&mut message.entity).unwrap(); @@ -507,19 +529,25 @@ fn find_returns_matching_aggregates() { // Create todos for different users let mut todo1 = Todo::new(); let id1 = next_id(); - todo1.initialize( - id1.clone(), - "alice".to_string(), - "Buy groceries".to_string(), - ); + todo1 + .initialize( + id1.clone(), + "alice".to_string(), + "Buy groceries".to_string(), + ) + .unwrap(); let mut todo2 = Todo::new(); let id2 = next_id(); - todo2.initialize(id2.clone(), "alice".to_string(), "Walk the dog".to_string()); + todo2 + .initialize(id2.clone(), "alice".to_string(), "Walk the dog".to_string()) + .unwrap(); let mut todo3 = Todo::new(); let id3 = next_id(); - todo3.initialize(id3.clone(), "bob".to_string(), "Write code".to_string()); + todo3 + .initialize(id3.clone(), "bob".to_string(), "Write code".to_string()) + .unwrap(); repo.commit_all(&mut [&mut todo1, &mut todo2, &mut todo3]) .unwrap(); @@ -544,11 +572,15 @@ fn find_one_returns_first_matching_aggregate() { let mut todo1 = Todo::new(); let id1 = next_id(); - todo1.initialize(id1.clone(), "alice".to_string(), "First task".to_string()); + todo1 + .initialize(id1.clone(), "alice".to_string(), "First task".to_string()) + .unwrap(); let mut todo2 = Todo::new(); let id2 = next_id(); - todo2.initialize(id2.clone(), "alice".to_string(), "Second task".to_string()); + todo2 + .initialize(id2.clone(), "alice".to_string(), "Second task".to_string()) + .unwrap(); repo.commit_all(&mut [&mut todo1, &mut todo2]).unwrap(); @@ -569,7 +601,8 @@ fn exists_returns_true_when_aggregate_matches() { let mut todo = Todo::new(); let id = next_id(); - todo.initialize(id.clone(), "alice".to_string(), "Test task".to_string()); + todo.initialize(id.clone(), "alice".to_string(), "Test task".to_string()) + .unwrap(); repo.commit(&mut todo).unwrap(); assert!(repo.exists(|t| t.snapshot().user_id == "alice").unwrap()); @@ -582,15 +615,21 @@ fn count_returns_matching_aggregate_count() { let mut todo1 = Todo::new(); let id1 = next_id(); - todo1.initialize(id1.clone(), "alice".to_string(), "Task 1".to_string()); + todo1 + .initialize(id1.clone(), "alice".to_string(), "Task 1".to_string()) + .unwrap(); let mut todo2 = Todo::new(); let id2 = next_id(); - todo2.initialize(id2.clone(), "alice".to_string(), "Task 2".to_string()); + todo2 + .initialize(id2.clone(), "alice".to_string(), "Task 2".to_string()) + .unwrap(); let mut todo3 = Todo::new(); let id3 = next_id(); - todo3.initialize(id3.clone(), "bob".to_string(), "Task 3".to_string()); + todo3 + .initialize(id3.clone(), "bob".to_string(), "Task 3".to_string()) + .unwrap(); repo.commit_all(&mut [&mut todo1, &mut todo2, &mut todo3]) .unwrap(); @@ -610,16 +649,20 @@ fn find_by_completed_status() { let mut todo1 = Todo::new(); let id1 = next_id(); - todo1.initialize( - id1.clone(), - "alice".to_string(), - "Completed task".to_string(), - ); - todo1.complete(); + todo1 + .initialize( + id1.clone(), + "alice".to_string(), + "Completed task".to_string(), + ) + .unwrap(); + todo1.complete().unwrap(); let mut todo2 = Todo::new(); let id2 = next_id(); - todo2.initialize(id2.clone(), "alice".to_string(), "Pending task".to_string()); + todo2 + .initialize(id2.clone(), "alice".to_string(), "Pending task".to_string()) + .unwrap(); repo.commit_all(&mut [&mut todo1, &mut todo2]).unwrap(); @@ -645,7 +688,8 @@ fn metadata_flows_from_entity_through_outbox_to_publisher() { todo.entity.set_correlation_id("req-abc-123"); todo.entity.set_causation_id("cmd-create-todo"); todo.entity.set_meta("user_id", "u-42"); - todo.initialize(id.clone(), "user1".to_string(), "Metadata test".to_string()); + todo.initialize(id.clone(), "user1".to_string(), "Metadata test".to_string()) + .unwrap(); // 2. Verify metadata propagated to event records let new_events = todo.entity.new_events(); @@ -678,7 +722,7 @@ fn metadata_flows_from_entity_through_outbox_to_publisher() { .repo() .claim_outbox_messages("meta-worker", 10, Duration::from_secs(30)) .unwrap(); - let result = worker.process_batch(&mut claimed); + let result = worker.process_batch(&mut claimed).unwrap(); assert_eq!(result.completed, 1); // 6. Verify the publisher received metadata diff --git a/tests/upcasting/aggregate.rs b/tests/upcasting/aggregate.rs index e76c9cf1f..c86915110 100644 --- a/tests/upcasting/aggregate.rs +++ b/tests/upcasting/aggregate.rs @@ -16,14 +16,19 @@ pub struct TodoV1 { impl TodoV1 { #[digest("Initialized")] - pub fn initialize(&mut self, id: String, user_id: String, task: String) { + pub fn initialize( + &mut self, + id: String, + user_id: String, + task: String, + ) -> Result<(), sourced_rust::EventRecordError> { self.entity.set_id(&id); self.user_id = user_id; self.task = task; } #[digest("Completed", when = !self.completed)] - pub fn complete(&mut self) { + pub fn complete(&mut self) -> Result<(), sourced_rust::EventRecordError> { self.completed = true; } } @@ -54,7 +59,13 @@ pub struct TodoV2 { impl TodoV2 { #[digest("Initialized", version = 2)] - pub fn initialize(&mut self, id: String, user_id: String, task: String, priority: u8) { + pub fn initialize( + &mut self, + id: String, + user_id: String, + task: String, + priority: u8, + ) -> Result<(), sourced_rust::EventRecordError> { self.entity.set_id(&id); self.user_id = user_id; self.task = task; @@ -62,7 +73,7 @@ impl TodoV2 { } #[digest("Completed", when = !self.completed)] - pub fn complete(&mut self) { + pub fn complete(&mut self) -> Result<(), sourced_rust::EventRecordError> { self.completed = true; } } @@ -105,7 +116,7 @@ impl TodoV3 { task: String, priority: u8, due_date: String, - ) { + ) -> Result<(), sourced_rust::EventRecordError> { self.entity.set_id(&id); self.user_id = user_id; self.task = task; @@ -114,7 +125,7 @@ impl TodoV3 { } #[digest("Completed", when = !self.completed)] - pub fn complete(&mut self) { + pub fn complete(&mut self) -> Result<(), sourced_rust::EventRecordError> { self.completed = true; } } diff --git a/tests/upcasting/main.rs b/tests/upcasting/main.rs index 8dfae8a0c..2822d6aed 100644 --- a/tests/upcasting/main.rs +++ b/tests/upcasting/main.rs @@ -75,14 +75,14 @@ fn event_version_serializes_when_not_v1() { #[test] fn old_events_without_event_version_deserialize_as_v1() { - let json = r#"{"event_name":"old_event","payload":"","sequence":1,"timestamp":{"secs_since_epoch":0,"nanos_since_epoch":0},"metadata":{}}"#; + let json = r#"{"event_name":"old_event","payload_codec":"bitcode","payload_codec_version":1,"payload":"","sequence":1,"timestamp":{"secs_since_epoch":0,"nanos_since_epoch":0},"metadata":{}}"#; let record: EventRecord = serde_json::from_str(json).unwrap(); assert_eq!(record.event_version, 1); } #[test] fn old_events_without_metadata_deserialize_with_empty_metadata() { - let json = r#"{"event_name":"old_event","payload":"","sequence":1,"timestamp":{"secs_since_epoch":0,"nanos_since_epoch":0}}"#; + let json = r#"{"event_name":"old_event","payload_codec":"bitcode","payload_codec_version":1,"payload":"","sequence":1,"timestamp":{"secs_since_epoch":0,"nanos_since_epoch":0}}"#; let record: EventRecord = serde_json::from_str(json).unwrap(); assert_eq!(record.event_version, 1); assert!(record.metadata.is_empty()); @@ -105,7 +105,8 @@ fn event_version_round_trips_through_serde() { #[test] fn digest_v_creates_events_at_specified_version() { let mut todo = TodoV2::default(); - todo.initialize("t1".into(), "alice".into(), "Buy milk".into(), 3); + todo.initialize("t1".into(), "alice".into(), "Buy milk".into(), 3) + .unwrap(); let events = todo.entity.events(); assert_eq!(events.len(), 1); @@ -116,7 +117,8 @@ fn digest_v_creates_events_at_specified_version() { #[test] fn digest_without_version_creates_v1_events() { let mut todo = TodoV1::default(); - todo.initialize("t1".into(), "alice".into(), "Buy milk".into()); + todo.initialize("t1".into(), "alice".into(), "Buy milk".into()) + .unwrap(); let events = todo.entity.events(); assert_eq!(events.len(), 1); @@ -131,8 +133,9 @@ fn digest_without_version_creates_v1_events() { fn hydrate_v2_from_v1_events() { // Create events using the v1 aggregate let mut v1 = TodoV1::default(); - v1.initialize("t1".into(), "alice".into(), "Buy milk".into()); - v1.complete(); + v1.initialize("t1".into(), "alice".into(), "Buy milk".into()) + .unwrap(); + v1.complete().unwrap(); // Simulate loading into the v2 aggregate by transferring the entity let mut entity = Entity::new(); @@ -169,7 +172,8 @@ fn v1_aggregate_has_no_upcasters() { fn hydrate_v3_from_v1_events_chains_upcasters() { // Create events using v1 aggregate let mut v1 = TodoV1::default(); - v1.initialize("t1".into(), "bob".into(), "Walk dog".into()); + v1.initialize("t1".into(), "bob".into(), "Walk dog".into()) + .unwrap(); let mut entity = Entity::new(); entity.load_from_history(v1.entity.events().to_vec()); @@ -187,7 +191,8 @@ fn hydrate_v3_from_v1_events_chains_upcasters() { fn hydrate_v3_from_v2_events_applies_single_upcaster() { // Create events using v2 aggregate let mut v2 = TodoV2::default(); - v2.initialize("t1".into(), "carol".into(), "Read book".into(), 5); + v2.initialize("t1".into(), "carol".into(), "Read book".into(), 5) + .unwrap(); let mut entity = Entity::new(); entity.load_from_history(v2.entity.events().to_vec()); @@ -209,7 +214,8 @@ fn hydrate_v3_from_native_v3_events_no_upcasting_needed() { "Cook dinner".into(), 2, "2025-12-31".into(), - ); + ) + .unwrap(); let mut entity = Entity::new(); entity.load_from_history(v3.entity.events().to_vec()); @@ -237,8 +243,9 @@ fn v3_aggregate_has_two_upcasters() { fn mixed_events_v1_init_and_v1_complete() { // Completed events are always v1 — no upcaster needed let mut v1 = TodoV1::default(); - v1.initialize("t1".into(), "eve".into(), "Test".into()); - v1.complete(); + v1.initialize("t1".into(), "eve".into(), "Test".into()) + .unwrap(); + v1.complete().unwrap(); let mut entity = Entity::new(); entity.load_from_history(v1.entity.events().to_vec()); @@ -257,7 +264,8 @@ fn repo_roundtrip_v1_to_v2() { // Store using v1 let v1_repo = HashMapRepository::new(); let mut v1 = TodoV1::default(); - v1.initialize("t1".into(), "frank".into(), "Shop".into()); + v1.initialize("t1".into(), "frank".into(), "Shop".into()) + .unwrap(); v1_repo.commit(&mut v1.entity).unwrap(); // Load using v2 (same storage) @@ -324,7 +332,8 @@ fn snapshot_plus_upcasting_post_snapshot_events() { // Create using native v2 with a specific priority let mut todo = TodoV2::default(); - todo.initialize("t1".into(), "grace".into(), "Run".into(), 7); + todo.initialize("t1".into(), "grace".into(), "Run".into(), 7) + .unwrap(); repo.commit(&mut todo).unwrap(); // Snapshot should now exist at version 1 @@ -332,7 +341,7 @@ fn snapshot_plus_upcasting_post_snapshot_events() { // Add another event; this triggers snapshot + partial replay path let mut todo = repo.get("t1").unwrap().unwrap(); - todo.complete(); + todo.complete().unwrap(); repo.commit(&mut todo).unwrap(); let loaded = repo.get("t1").unwrap().unwrap(); @@ -347,8 +356,9 @@ fn snapshot_repo_with_v1_events_upcasted_on_hydrate() { // Create a v1 todo let mut v1 = TodoV1::default(); - v1.initialize("t1".into(), "hank".into(), "Sweep".into()); - v1.complete(); + v1.initialize("t1".into(), "hank".into(), "Sweep".into()) + .unwrap(); + v1.complete().unwrap(); base_repo.commit(&mut v1.entity).unwrap(); // Load via a v2 snapshot-aware repo (no snapshot exists, so full replay with upcasting) From 1832f908d81d37b19332548f0e103288e317877b Mon Sep 17 00:00:00 2001 From: Patrick Lee Scott Date: Wed, 20 May 2026 11:56:05 -0500 Subject: [PATCH 03/18] refactor: infer sourced result for event macros --- sourced_rust_macros/src/lib.rs | 49 +++++++++++++++++----------- src/entity/entity.rs | 10 +++--- src/lib.rs | 2 ++ src/outbox/message.rs | 40 +++++++++-------------- src/outbox_worker/worker.rs | 9 ++--- tests/blob_game/aggregate.rs | 15 ++++----- tests/bomberman/domain/bomb.rs | 13 ++------ tests/bomberman/domain/explosion.rs | 6 ++-- tests/bomberman/domain/game_map.rs | 12 +++---- tests/bomberman/domain/player.rs | 21 ++++-------- tests/bomberman/domain/tick_saga.rs | 25 +++----------- tests/enqueue/aggregate.rs | 16 +++------ tests/microsvc/models/counter.rs | 6 ++-- tests/read_models/aggregate.rs | 11 ++----- tests/sagas/order/inventory.rs | 22 +++---------- tests/sagas/order/order.rs | 15 +++------ tests/sagas/order/payment.rs | 18 +++------- tests/sagas/order/saga.rs | 20 +++++------- tests/snapshots/aggregate.rs | 9 ++--- tests/sourced/aggregate.rs | 9 ++--- tests/sourced/main.rs | 7 ++-- tests/sourced_enqueue/aggregate.rs | 16 +++------ tests/sourced_snapshot/aggregates.rs | 42 +++++------------------- tests/sourced_upcasting/aggregate.rs | 23 ++++--------- tests/todos/aggregate.rs | 9 ++--- tests/upcasting/aggregate.rs | 23 ++++--------- 26 files changed, 150 insertions(+), 298 deletions(-) diff --git a/sourced_rust_macros/src/lib.rs b/sourced_rust_macros/src/lib.rs index 230c37064..cc5913d74 100644 --- a/sourced_rust_macros/src/lib.rs +++ b/sourced_rust_macros/src/lib.rs @@ -51,13 +51,33 @@ fn returns_result(sig: &syn::Signature) -> bool { .path .segments .last() - .map(|segment| segment.ident == "Result") + .map(|segment| segment.ident == "Result" || segment.ident == "SourcedResult") .unwrap_or(false), _ => false, }, } } +fn ensure_sourced_result_signature( + sig: &mut syn::Signature, + attr_name: &str, +) -> Result<(), syn::Error> { + match &sig.output { + ReturnType::Default => { + sig.output = syn::parse_quote!(-> sourced_rust::SourcedResult<()>); + Ok(()) + } + ReturnType::Type(_, _) if returns_result(sig) => Ok(()), + ReturnType::Type(_, ty) => Err(syn::Error::new_spanned( + ty, + format!( + "#[{}] methods must return Result<(), E>, SourcedResult, or omit the return type", + attr_name + ), + )), + } +} + fn block_returns_result(block: &syn::Block) -> bool { let Some(syn::Stmt::Expr(expr, None)) = block.stmts.last() else { return false; @@ -349,17 +369,18 @@ fn parse_enqueue_args(input: syn::parse::ParseStream) -> syn::Result` where `E` can be constructed -/// from `sourced_rust::EventRecordError`. +/// If the annotated method omits a return type, it expands to +/// `sourced_rust::SourcedResult<()>`. Methods may also explicitly return +/// `Result<(), E>` where `E` can be constructed from +/// `sourced_rust::EventRecordError`. /// /// # Usage /// /// Basic usage with function parameters (automatically captured): /// ```ignore /// #[digest("Initialized")] -/// fn initialize(&mut self, id: String, user_id: String) -> Result<(), sourced_rust::EventRecordError> { +/// fn initialize(&mut self, id: String, user_id: String) { /// // digest call auto-inserted, params serialized as tuple -/// Ok(()) /// } /// ``` /// @@ -387,13 +408,8 @@ pub fn digest(attr: TokenStream, item: TokenStream) -> TokenStream { let args = parse_macro_input!(attr with parse_digest_args); let mut func = parse_macro_input!(item as ItemFn); - if !returns_result(&func.sig) { - return syn::Error::new_spanned( - &func.sig.ident, - "#[digest] methods must return Result<(), E> where E can be constructed from sourced_rust::EventRecordError", - ) - .to_compile_error() - .into(); + if let Err(err) = ensure_sourced_result_signature(&mut func.sig, "digest") { + return err.to_compile_error().into(); } let param_names = extract_param_names(&func.sig); @@ -895,13 +911,8 @@ pub fn sourced(attr: TokenStream, item: TokenStream) -> TokenStream { if let syn::ImplItem::Fn(method) = item { match find_and_remove_event_attr(&mut method.attrs) { Ok(Some(event_attr)) => { - if !returns_result(&method.sig) { - return syn::Error::new_spanned( - &method.sig.ident, - "#[event] methods must return Result<(), E> where E can be constructed from sourced_rust::EventRecordError", - ) - .to_compile_error() - .into(); + if let Err(err) = ensure_sourced_result_signature(&mut method.sig, "event") { + return err.to_compile_error().into(); } let params = extract_params_with_types(&method.sig); diff --git a/src/entity/entity.rs b/src/entity/entity.rs index 9e2684c15..794c43fc3 100644 --- a/src/entity/entity.rs +++ b/src/entity/entity.rs @@ -4,6 +4,8 @@ use std::time::SystemTime; use serde::{Deserialize, Serialize}; +use crate::SourcedResult; + use super::{BitcodePayloadCodec, EventRecord, EventRecordError, PayloadCodec}; #[derive(Serialize, Deserialize)] @@ -176,7 +178,7 @@ impl Entity { &mut self, name: impl Into, payload: &T, - ) -> Result<(), EventRecordError> { + ) -> SourcedResult { if self.replaying { return Ok(()); } @@ -199,7 +201,7 @@ impl Entity { name: impl Into, version: u64, payload: &T, - ) -> Result<(), EventRecordError> { + ) -> SourcedResult { if self.replaying { return Ok(()); } @@ -215,7 +217,7 @@ impl Entity { } /// Record an event with no payload. - pub fn digest_empty(&mut self, name: impl Into) -> Result<(), EventRecordError> { + pub fn digest_empty(&mut self, name: impl Into) -> SourcedResult { self.digest(name, &()) } @@ -254,7 +256,7 @@ impl Entity { /// Replace all events with a single snapshot event. /// Used by read models to store current state. - pub fn set_snapshot(&mut self, data: &T) -> Result<(), EventRecordError> { + pub fn set_snapshot(&mut self, data: &T) -> SourcedResult { let payload = BitcodePayloadCodec::encode(data).map_err(EventRecordError::encode)?; self.events.clear(); let record = EventRecord::new("Snapshot", payload, 1); diff --git a/src/lib.rs b/src/lib.rs index ffcdda94b..4e80bb27f 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -26,6 +26,8 @@ pub use entity::{ BITCODE_PAYLOAD_CODEC_VERSION, }; +pub type SourcedResult = std::result::Result; + // Re-export repository traits at crate root for convenience pub use repository::{ Commit, CommitBatch, Count, Exists, Find, FindOne, Get, GetMany, GetOne, Gettable, diff --git a/src/outbox/message.rs b/src/outbox/message.rs index 115a4d7cd..3ff11a912 100644 --- a/src/outbox/message.rs +++ b/src/outbox/message.rs @@ -5,6 +5,7 @@ use serde::{Deserialize, Serialize}; use crate::digest; use crate::entity::{BitcodePayloadCodec, Entity, EventRecordError, PayloadCodec}; +use crate::SourcedResult; /// Status of an outbox message. #[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] @@ -66,7 +67,7 @@ impl OutboxMessage { id: impl Into, event_type: impl Into, payload: Vec, - ) -> Result { + ) -> SourcedResult { let mut message = Self::new(); message.initialize(id.into(), event_type.into(), payload, None, HashMap::new())?; Ok(message) @@ -81,7 +82,7 @@ impl OutboxMessage { event_type: impl Into, destination: impl Into, payload: Vec, - ) -> Result { + ) -> SourcedResult { let mut message = Self::new(); message.initialize( id.into(), @@ -98,7 +99,7 @@ impl OutboxMessage { id: impl Into, event_type: impl Into, payload: &T, - ) -> Result { + ) -> SourcedResult { let bytes = BitcodePayloadCodec::encode(payload).map_err(EventRecordError::encode)?; Self::create(id, event_type, bytes) } @@ -112,7 +113,7 @@ impl OutboxMessage { event_type: impl Into, destination: impl Into, payload: &T, - ) -> Result { + ) -> SourcedResult { let bytes = BitcodePayloadCodec::encode(payload).map_err(EventRecordError::encode)?; Self::create_to(id, event_type, destination, bytes) } @@ -123,7 +124,7 @@ impl OutboxMessage { event_type: impl Into, payload: Vec, metadata: HashMap, - ) -> Result { + ) -> SourcedResult { let mut message = Self::new(); message.initialize(id.into(), event_type.into(), payload, None, metadata)?; Ok(message) @@ -135,7 +136,7 @@ impl OutboxMessage { event_type: impl Into, payload: &T, metadata: HashMap, - ) -> Result { + ) -> SourcedResult { let bytes = BitcodePayloadCodec::encode(payload).map_err(EventRecordError::encode)?; Self::create_with_metadata(id, event_type, bytes, metadata) } @@ -150,7 +151,7 @@ impl OutboxMessage { event_type: impl Into, payload: &T, entity: &Entity, - ) -> Result { + ) -> SourcedResult { let bytes = BitcodePayloadCodec::encode(payload).map_err(EventRecordError::encode)?; Self::create_with_metadata(id, event_type, bytes, entity.metadata().clone()) } @@ -170,7 +171,7 @@ impl OutboxMessage { pub fn domain_event( event_type: impl Into, aggregate: &A, - ) -> Result { + ) -> SourcedResult { let event_type = event_type.into(); let entity = aggregate.entity(); let id = format!("{}:{}:{}", entity.id(), event_type, entity.version()); @@ -180,7 +181,7 @@ impl OutboxMessage { } /// Decode the payload from the default binary codec. - pub fn decode(&self) -> Result { + pub fn decode(&self) -> SourcedResult { BitcodePayloadCodec::decode(&self.payload).map_err(|e| { EventRecordError::decode( &self.event_type, @@ -225,7 +226,7 @@ impl OutboxMessage { payload: Vec, destination: Option, metadata: HashMap, - ) -> Result<(), EventRecordError> { + ) { let normalized_id = Self::normalize_id(id); self.entity.set_id(&normalized_id); self.event_type = event_type; @@ -234,25 +235,19 @@ impl OutboxMessage { self.metadata = metadata; self.status = OutboxMessageStatus::Pending; self.created_at = SystemTime::now(); - Ok(()) } #[digest("MessageClaimed", when = self.is_pending())] - pub fn claim(&mut self, worker_id: String, until_secs: u64) -> Result<(), EventRecordError> { + pub fn claim(&mut self, worker_id: String, until_secs: u64) { let until_time = SystemTime::UNIX_EPOCH + Duration::from_secs(until_secs); self.status = OutboxMessageStatus::InFlight; self.attempts += 1; self.worker_id = Some(worker_id); self.leased_until = Some(until_time); - Ok(()) } /// Claim with a Duration (convenience method that computes until_secs) - pub fn claim_for( - &mut self, - worker_id: impl Into, - lease: Duration, - ) -> Result<(), EventRecordError> { + pub fn claim_for(&mut self, worker_id: impl Into, lease: Duration) -> SourcedResult { let now = SystemTime::now(); let until = now + lease; let until_secs = until @@ -263,29 +258,26 @@ impl OutboxMessage { } #[digest("MessagePublished", when = self.is_in_flight())] - pub fn complete(&mut self) -> Result<(), EventRecordError> { + pub fn complete(&mut self) { self.status = OutboxMessageStatus::Published; self.worker_id = None; self.leased_until = None; - Ok(()) } #[digest("MessageReleased", when = self.is_in_flight())] - pub fn release(&mut self, error: String) -> Result<(), EventRecordError> { + pub fn release(&mut self, error: String) { self.status = OutboxMessageStatus::Pending; self.last_error = if error.is_empty() { None } else { Some(error) }; self.worker_id = None; self.leased_until = None; - Ok(()) } #[digest("MessageFailed", when = self.can_fail())] - pub fn fail(&mut self, error: String) -> Result<(), EventRecordError> { + pub fn fail(&mut self, error: String) { self.status = OutboxMessageStatus::Failed; self.last_error = if error.is_empty() { None } else { Some(error) }; self.worker_id = None; self.leased_until = None; - Ok(()) } fn can_fail(&self) -> bool { diff --git a/src/outbox_worker/worker.rs b/src/outbox_worker/worker.rs index 10b257bba..bd49f9332 100644 --- a/src/outbox_worker/worker.rs +++ b/src/outbox_worker/worker.rs @@ -1,8 +1,8 @@ use std::time::Duration; use super::publisher::OutboxPublisher; -use crate::entity::EventRecordError; use crate::outbox::OutboxMessage; +use crate::SourcedResult; /// Result of a batch drain operation. #[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] @@ -94,7 +94,7 @@ impl OutboxWorker

{ pub fn process_message( &mut self, message: &mut OutboxMessage, - ) -> Result { + ) -> SourcedResult { if message.is_published() || message.is_failed() { return Ok(ProcessOneResult::default()); } @@ -144,10 +144,7 @@ impl OutboxWorker

{ } /// Process a batch of outbox messages. - pub fn process_batch( - &mut self, - messages: &mut [OutboxMessage], - ) -> Result { + pub fn process_batch(&mut self, messages: &mut [OutboxMessage]) -> SourcedResult { let mut result = DrainResult::default(); for message in messages.iter_mut().take(self.batch_size) { diff --git a/tests/blob_game/aggregate.rs b/tests/blob_game/aggregate.rs index cd357432c..e225050db 100644 --- a/tests/blob_game/aggregate.rs +++ b/tests/blob_game/aggregate.rs @@ -200,7 +200,7 @@ impl BlobGame { minigame_id: String, timed: bool, started_at: Option, - ) -> Result<(), sourced_rust::EventRecordError> { + ) { self.entity.set_id(&id); self.address = address; self.minigame_id = minigame_id; @@ -211,10 +211,7 @@ impl BlobGame { } #[digest("NextLevelStarted", when = self.current_level_completed && !self.player_dead)] - pub fn start_next_level( - &mut self, - map: Vec>, - ) -> Result<(), sourced_rust::EventRecordError> { + pub fn start_next_level(&mut self, map: Vec>) { self.current_level += 1; let level = Level { id: self.current_level, @@ -226,7 +223,7 @@ impl BlobGame { } #[digest("MovedUp", when = self.can_move_up())] - pub fn up(&mut self, time: Option) -> Result<(), sourced_rust::EventRecordError> { + pub fn up(&mut self, time: Option) { if let Some(pos) = self.player_position() { let new_pos = Coordinate { column: pos.column, @@ -237,7 +234,7 @@ impl BlobGame { } #[digest("MovedDown", when = self.can_move_down())] - pub fn down(&mut self, time: Option) -> Result<(), sourced_rust::EventRecordError> { + pub fn down(&mut self, time: Option) { if let Some(pos) = self.player_position() { let new_pos = Coordinate { column: pos.column, @@ -248,7 +245,7 @@ impl BlobGame { } #[digest("MovedLeft", when = self.can_move_left())] - pub fn left(&mut self, time: Option) -> Result<(), sourced_rust::EventRecordError> { + pub fn left(&mut self, time: Option) { if let Some(pos) = self.player_position() { let new_pos = Coordinate { column: pos.column - 1, @@ -259,7 +256,7 @@ impl BlobGame { } #[digest("MovedRight", when = self.can_move_right())] - pub fn right(&mut self, time: Option) -> Result<(), sourced_rust::EventRecordError> { + pub fn right(&mut self, time: Option) { if let Some(pos) = self.player_position() { let new_pos = Coordinate { column: pos.column + 1, diff --git a/tests/bomberman/domain/bomb.rs b/tests/bomberman/domain/bomb.rs index 018e47b5d..40dc68cd1 100644 --- a/tests/bomberman/domain/bomb.rs +++ b/tests/bomberman/domain/bomb.rs @@ -13,14 +13,7 @@ pub struct Bomb { impl Bomb { #[digest("BombCreated")] - pub fn create( - &mut self, - id: String, - owner_id: String, - x: i32, - y: i32, - blast_radius: u8, - ) -> Result<(), sourced_rust::EventRecordError> { + pub fn create(&mut self, id: String, owner_id: String, x: i32, y: i32, blast_radius: u8) { self.entity.set_id(&id); self.owner_id = owner_id; self.x = x; @@ -31,12 +24,12 @@ impl Bomb { } #[digest("BombTicked", when = !self.exploded && self.ticks_remaining > 0)] - pub fn tick(&mut self) -> Result<(), sourced_rust::EventRecordError> { + pub fn tick(&mut self) { self.ticks_remaining -= 1; } #[digest("BombExploded", when = !self.exploded)] - pub fn explode(&mut self) -> Result<(), sourced_rust::EventRecordError> { + pub fn explode(&mut self) { self.exploded = true; } diff --git a/tests/bomberman/domain/explosion.rs b/tests/bomberman/domain/explosion.rs index 56e25e81e..68b3cb724 100644 --- a/tests/bomberman/domain/explosion.rs +++ b/tests/bomberman/domain/explosion.rs @@ -22,7 +22,7 @@ impl Explosion { center: (i32, i32), blast_radius: u8, rings: Vec>, - ) -> Result<(), sourced_rust::EventRecordError> { + ) { self.entity.set_id(&id); self.bomb_id = bomb_id; self.owner = owner; @@ -34,12 +34,12 @@ impl Explosion { } #[digest("ExplosionExpanded", when = self.active && !self.is_fully_expanded())] - pub fn expand(&mut self) -> Result<(), sourced_rust::EventRecordError> { + pub fn expand(&mut self) { self.current_ring += 1; } #[digest("ExplosionDissipated", when = self.active)] - pub fn dissipate(&mut self) -> Result<(), sourced_rust::EventRecordError> { + pub fn dissipate(&mut self) { self.active = false; } diff --git a/tests/bomberman/domain/game_map.rs b/tests/bomberman/domain/game_map.rs index 033282778..36fdc5ba7 100644 --- a/tests/bomberman/domain/game_map.rs +++ b/tests/bomberman/domain/game_map.rs @@ -1,4 +1,4 @@ -use sourced_rust::{digest, Entity}; +use sourced_rust::{digest, Entity, SourcedResult}; use super::types::{PowerUp, Tile}; @@ -21,7 +21,7 @@ impl GameMap { height: usize, tiles: Vec>, spawn_points: Vec<(i32, i32)>, - ) -> Result<(), sourced_rust::EventRecordError> { + ) { self.entity.set_id(&id); self.width = width; self.height = height; @@ -31,7 +31,7 @@ impl GameMap { } #[digest("BlockDestroyed", when = self.tile_at(x, y) == &Tile::Block)] - pub fn destroy_block(&mut self, x: i32, y: i32) -> Result<(), sourced_rust::EventRecordError> { + pub fn destroy_block(&mut self, x: i32, y: i32) { self.tiles[y as usize][x as usize] = Tile::Floor; // 50% chance to reveal a power-up based on position parity if (x + y) % 2 == 0 { @@ -41,11 +41,7 @@ impl GameMap { } } - pub fn collect_power_up( - &mut self, - x: i32, - y: i32, - ) -> Result, sourced_rust::EventRecordError> { + pub fn collect_power_up(&mut self, x: i32, y: i32) -> SourcedResult> { self.entity.digest("PowerUpCollected", &(x, y))?; if let Some(idx) = self .power_ups diff --git a/tests/bomberman/domain/player.rs b/tests/bomberman/domain/player.rs index 254e08904..8b3061c3f 100644 --- a/tests/bomberman/domain/player.rs +++ b/tests/bomberman/domain/player.rs @@ -16,13 +16,7 @@ pub struct Player { impl Player { #[digest("PlayerJoined")] - pub fn join( - &mut self, - id: String, - name: String, - x: i32, - y: i32, - ) -> Result<(), sourced_rust::EventRecordError> { + pub fn join(&mut self, id: String, name: String, x: i32, y: i32) { self.entity.set_id(&id); self.name = name; self.x = x; @@ -34,33 +28,30 @@ impl Player { } #[digest("PlayerMoved", when = self.alive)] - pub fn move_to(&mut self, x: i32, y: i32) -> Result<(), sourced_rust::EventRecordError> { + pub fn move_to(&mut self, x: i32, y: i32) { self.x = x; self.y = y; } #[digest("PlayerKilled", when = self.alive)] - pub fn kill(&mut self) -> Result<(), sourced_rust::EventRecordError> { + pub fn kill(&mut self) { self.alive = false; } #[digest("BombPlaced", when = self.alive && self.active_bombs < self.max_bombs)] - pub fn place_bomb(&mut self) -> Result<(), sourced_rust::EventRecordError> { + pub fn place_bomb(&mut self) { self.active_bombs += 1; } #[digest("BombReturned")] - pub fn return_bomb(&mut self) -> Result<(), sourced_rust::EventRecordError> { + pub fn return_bomb(&mut self) { if self.active_bombs > 0 { self.active_bombs -= 1; } } #[digest("PowerUpApplied")] - pub fn apply_power_up( - &mut self, - power_up: PowerUp, - ) -> Result<(), sourced_rust::EventRecordError> { + pub fn apply_power_up(&mut self, power_up: PowerUp) { match power_up { PowerUp::BombUp => self.max_bombs += 1, PowerUp::FireUp => self.blast_radius += 1, diff --git a/tests/bomberman/domain/tick_saga.rs b/tests/bomberman/domain/tick_saga.rs index 52e9cc979..9abefc7d3 100644 --- a/tests/bomberman/domain/tick_saga.rs +++ b/tests/bomberman/domain/tick_saga.rs @@ -24,22 +24,14 @@ pub struct TickSaga { impl TickSaga { #[digest("TickStarted")] - pub fn start( - &mut self, - saga_id: String, - game_id: String, - bombs_ticked: usize, - ) -> Result<(), sourced_rust::EventRecordError> { + pub fn start(&mut self, saga_id: String, game_id: String, bombs_ticked: usize) { self.entity.set_id(&saga_id); self.game_id = game_id; self.bombs_ticked = bombs_ticked; } #[digest("DetonationRecorded")] - pub fn record_detonation( - &mut self, - detonation: Detonation, - ) -> Result<(), sourced_rust::EventRecordError> { + pub fn record_detonation(&mut self, detonation: Detonation) { self.detonations.push(detonation); } @@ -49,26 +41,19 @@ impl TickSaga { blocks_destroyed: Vec<(i32, i32)>, players_killed: Vec, chain_detonations: Vec, - ) -> Result<(), sourced_rust::EventRecordError> { + ) { self.blocks_destroyed.extend(blocks_destroyed); self.players_killed.extend(players_killed); self.chain_detonations.extend(chain_detonations); } #[digest("DissipationRecorded")] - pub fn record_dissipation( - &mut self, - explosion_id: String, - ) -> Result<(), sourced_rust::EventRecordError> { + pub fn record_dissipation(&mut self, explosion_id: String) { self.explosions_dissipated.push(explosion_id); } #[digest("TickCompleted")] - pub fn complete( - &mut self, - game_over: bool, - winner: Option, - ) -> Result<(), sourced_rust::EventRecordError> { + pub fn complete(&mut self, game_over: bool, winner: Option) { self.game_over = game_over; self.winner = winner; } diff --git a/tests/enqueue/aggregate.rs b/tests/enqueue/aggregate.rs index 2719273d1..21d29b2f1 100644 --- a/tests/enqueue/aggregate.rs +++ b/tests/enqueue/aggregate.rs @@ -27,11 +27,7 @@ impl Default for Order { impl Order { #[digest("OrderCreated")] #[enqueue("OrderCreated")] - pub fn create( - &mut self, - order_id: String, - customer: String, - ) -> Result<(), sourced_rust::EventRecordError> { + pub fn create(&mut self, order_id: String, customer: String) { self.entity.set_id(&order_id); self.order_id = order_id; self.customer = customer; @@ -40,13 +36,13 @@ impl Order { #[digest("OrderConfirmed", when = self.status == "created")] #[enqueue("OrderConfirmed", when = self.status == "created")] - pub fn confirm(&mut self) -> Result<(), sourced_rust::EventRecordError> { + pub fn confirm(&mut self) { self.status = "confirmed".into(); } #[digest("OrderShipped", when = self.status == "confirmed")] #[enqueue("OrderShipped", when = self.status == "confirmed")] - pub fn ship(&mut self) -> Result<(), sourced_rust::EventRecordError> { + pub fn ship(&mut self) { self.status = "shipped".into(); } } @@ -77,11 +73,7 @@ impl Default for Notifier { impl Notifier { #[digest("NotificationSent")] #[enqueue(my_emitter, "NotificationSent")] - pub fn send( - &mut self, - id: String, - message: String, - ) -> Result<(), sourced_rust::EventRecordError> { + pub fn send(&mut self, id: String, message: String) { self.entity.set_id(&id); self.message = message; } diff --git a/tests/microsvc/models/counter.rs b/tests/microsvc/models/counter.rs index e27f954d9..fabb59235 100644 --- a/tests/microsvc/models/counter.rs +++ b/tests/microsvc/models/counter.rs @@ -13,18 +13,18 @@ pub struct Counter { #[sourced(entity)] impl Counter { #[event("Created")] - pub fn create(&mut self, id: String) -> Result<(), sourced_rust::EventRecordError> { + pub fn create(&mut self, id: String) { self.entity.set_id(&id); self.value = 0; } #[event("Incremented")] - pub fn increment(&mut self, amount: i64) -> Result<(), sourced_rust::EventRecordError> { + pub fn increment(&mut self, amount: i64) { self.value += amount; } #[event("Decremented", when = self.value >= amount)] - pub fn decrement(&mut self, amount: i64) -> Result<(), sourced_rust::EventRecordError> { + pub fn decrement(&mut self, amount: i64) { self.value -= amount; } } diff --git a/tests/read_models/aggregate.rs b/tests/read_models/aggregate.rs index 04c4ee106..09f28de87 100644 --- a/tests/read_models/aggregate.rs +++ b/tests/read_models/aggregate.rs @@ -16,12 +16,7 @@ impl Counter { } #[digest("CounterCreated")] - pub fn create( - &mut self, - id: String, - name: String, - user_id: String, - ) -> Result<(), sourced_rust::EventRecordError> { + pub fn create(&mut self, id: String, name: String, user_id: String) { self.entity.set_id(&id); self.name = name; self.user_id = user_id; @@ -29,12 +24,12 @@ impl Counter { } #[digest("CounterIncremented")] - pub fn increment(&mut self, amount: i32) -> Result<(), sourced_rust::EventRecordError> { + pub fn increment(&mut self, amount: i32) { self.value += amount; } #[digest("CounterDecremented")] - pub fn decrement(&mut self, amount: i32) -> Result<(), sourced_rust::EventRecordError> { + pub fn decrement(&mut self, amount: i32) { self.value -= amount; } diff --git a/tests/sagas/order/inventory.rs b/tests/sagas/order/inventory.rs index 41427ceec..f5c5bef76 100644 --- a/tests/sagas/order/inventory.rs +++ b/tests/sagas/order/inventory.rs @@ -47,32 +47,21 @@ impl Inventory { } #[digest("InventoryInitialized")] - pub fn initialize( - &mut self, - sku: String, - initial_stock: u32, - ) -> Result<(), sourced_rust::EventRecordError> { + pub fn initialize(&mut self, sku: String, initial_stock: u32) { self.entity.set_id(&sku); self.sku = sku; self.available = initial_stock; } #[digest("StockReserved", when = self.can_reserve(quantity))] - pub fn reserve( - &mut self, - order_id: String, - quantity: u32, - ) -> Result<(), sourced_rust::EventRecordError> { + pub fn reserve(&mut self, order_id: String, quantity: u32) { self.available -= quantity; self.reserved += quantity; self.reservations.insert(order_id, quantity); } #[digest("ReservationReleased", when = self.reservations.contains_key(&order_id))] - pub fn release_reservation( - &mut self, - order_id: String, - ) -> Result<(), sourced_rust::EventRecordError> { + pub fn release_reservation(&mut self, order_id: String) { if let Some(quantity) = self.reservations.remove(&order_id) { self.available += quantity; self.reserved -= quantity; @@ -80,10 +69,7 @@ impl Inventory { } #[digest("ReservationCommitted", when = self.reservations.contains_key(&order_id))] - pub fn commit_reservation( - &mut self, - order_id: String, - ) -> Result<(), sourced_rust::EventRecordError> { + pub fn commit_reservation(&mut self, order_id: String) { if let Some(quantity) = self.reservations.remove(&order_id) { self.reserved -= quantity; // Stock is now sold, no longer available or reserved diff --git a/tests/sagas/order/order.rs b/tests/sagas/order/order.rs index 951fdbe88..ee0812330 100644 --- a/tests/sagas/order/order.rs +++ b/tests/sagas/order/order.rs @@ -64,12 +64,7 @@ impl Order { } #[digest("OrderCreated")] - pub fn create( - &mut self, - id: String, - customer_id: String, - items: Vec, - ) -> Result<(), sourced_rust::EventRecordError> { + pub fn create(&mut self, id: String, customer_id: String, items: Vec) { self.entity.set_id(&id); self.customer_id = customer_id; self.total_cents = items.iter().map(|i| i.price_cents * i.quantity).sum(); @@ -78,22 +73,22 @@ impl Order { } #[digest("InventoryReserved", when = self.status == OrderStatus::Pending)] - pub fn mark_inventory_reserved(&mut self) -> Result<(), sourced_rust::EventRecordError> { + pub fn mark_inventory_reserved(&mut self) { self.status = OrderStatus::InventoryReserved; } #[digest("PaymentProcessed", when = self.status == OrderStatus::InventoryReserved)] - pub fn mark_payment_processed(&mut self) -> Result<(), sourced_rust::EventRecordError> { + pub fn mark_payment_processed(&mut self) { self.status = OrderStatus::PaymentProcessed; } #[digest("OrderCompleted", when = self.status == OrderStatus::PaymentProcessed)] - pub fn complete(&mut self) -> Result<(), sourced_rust::EventRecordError> { + pub fn complete(&mut self) { self.status = OrderStatus::Completed; } #[digest("OrderCancelled", when = self.status != OrderStatus::Completed && self.status != OrderStatus::Cancelled)] - pub fn cancel(&mut self, reason: String) -> Result<(), sourced_rust::EventRecordError> { + pub fn cancel(&mut self, reason: String) { self.status = OrderStatus::Cancelled; self.failure_reason = Some(reason); } diff --git a/tests/sagas/order/payment.rs b/tests/sagas/order/payment.rs index c70927615..d99d9c7c6 100644 --- a/tests/sagas/order/payment.rs +++ b/tests/sagas/order/payment.rs @@ -58,12 +58,7 @@ impl Payment { } #[digest("PaymentInitiated")] - pub fn initiate( - &mut self, - id: String, - order_id: String, - amount_cents: u32, - ) -> Result<(), sourced_rust::EventRecordError> { + pub fn initiate(&mut self, id: String, order_id: String, amount_cents: u32) { self.entity.set_id(&id); self.order_id = order_id; self.amount_cents = amount_cents; @@ -71,27 +66,24 @@ impl Payment { } #[digest("PaymentAuthorized", when = self.status == PaymentStatus::Pending)] - pub fn authorize( - &mut self, - transaction_id: String, - ) -> Result<(), sourced_rust::EventRecordError> { + pub fn authorize(&mut self, transaction_id: String) { self.status = PaymentStatus::Authorized; self.transaction_id = Some(transaction_id); } #[digest("PaymentCaptured", when = self.status == PaymentStatus::Authorized)] - pub fn capture(&mut self) -> Result<(), sourced_rust::EventRecordError> { + pub fn capture(&mut self) { self.status = PaymentStatus::Captured; } #[digest("PaymentFailed", when = self.status == PaymentStatus::Pending || self.status == PaymentStatus::Authorized)] - pub fn fail(&mut self, reason: String) -> Result<(), sourced_rust::EventRecordError> { + pub fn fail(&mut self, reason: String) { self.status = PaymentStatus::Failed; self.failure_reason = Some(reason); } #[digest("PaymentRefunded", when = self.status == PaymentStatus::Captured)] - pub fn refund(&mut self) -> Result<(), sourced_rust::EventRecordError> { + pub fn refund(&mut self) { self.status = PaymentStatus::Refunded; } diff --git a/tests/sagas/order/saga.rs b/tests/sagas/order/saga.rs index c62c704ba..0035dd088 100644 --- a/tests/sagas/order/saga.rs +++ b/tests/sagas/order/saga.rs @@ -108,7 +108,7 @@ impl OrderFulfillmentSaga { customer_id: String, items: Vec, total_cents: u32, - ) -> Result<(), sourced_rust::EventRecordError> { + ) { self.entity.set_id(&saga_id); self.order_id = order_id; self.customer_id = customer_id; @@ -118,46 +118,42 @@ impl OrderFulfillmentSaga { } #[digest("InventoryReservationSucceeded", when = self.status == SagaStatus::Started)] - pub fn inventory_reserved(&mut self) -> Result<(), sourced_rust::EventRecordError> { + pub fn inventory_reserved(&mut self) { self.status = SagaStatus::InventoryReserved; self.compensation.inventory_reserved = true; } #[digest("PaymentSucceeded", when = self.status == SagaStatus::InventoryReserved)] - pub fn payment_succeeded(&mut self) -> Result<(), sourced_rust::EventRecordError> { + pub fn payment_succeeded(&mut self) { self.status = SagaStatus::PaymentProcessed; self.compensation.payment_processed = true; } #[digest("SagaCompleted", when = self.status == SagaStatus::PaymentProcessed)] - pub fn complete(&mut self) -> Result<(), sourced_rust::EventRecordError> { + pub fn complete(&mut self) { self.status = SagaStatus::Completed; } // === Failure and Compensation === #[digest("StepFailed", when = !self.is_complete())] - pub fn step_failed( - &mut self, - step: String, - reason: String, - ) -> Result<(), sourced_rust::EventRecordError> { + pub fn step_failed(&mut self, step: String, reason: String) { self.status = SagaStatus::Compensating; self.failure_reason = Some(format!("{}: {}", step, reason)); } #[digest("InventoryCompensated", when = self.needs_inventory_compensation())] - pub fn inventory_compensated(&mut self) -> Result<(), sourced_rust::EventRecordError> { + pub fn inventory_compensated(&mut self) { self.compensation.inventory_reserved = false; } #[digest("PaymentCompensated", when = self.needs_payment_compensation())] - pub fn payment_compensated(&mut self) -> Result<(), sourced_rust::EventRecordError> { + pub fn payment_compensated(&mut self) { self.compensation.payment_processed = false; } #[digest("SagaFailed", when = self.status == SagaStatus::Compensating && !self.compensation.inventory_reserved && !self.compensation.payment_processed)] - pub fn mark_failed(&mut self) -> Result<(), sourced_rust::EventRecordError> { + pub fn mark_failed(&mut self) { self.status = SagaStatus::Failed; } diff --git a/tests/snapshots/aggregate.rs b/tests/snapshots/aggregate.rs index 23ef1441a..b231f46f0 100644 --- a/tests/snapshots/aggregate.rs +++ b/tests/snapshots/aggregate.rs @@ -14,19 +14,14 @@ impl Todo { } #[digest("Initialized")] - pub fn initialize( - &mut self, - id: String, - user_id: String, - task: String, - ) -> Result<(), sourced_rust::EventRecordError> { + pub fn initialize(&mut self, id: String, user_id: String, task: String) { self.entity.set_id(&id); self.user_id = user_id; self.task = task; } #[digest("Completed", when = !self.completed)] - pub fn complete(&mut self) -> Result<(), sourced_rust::EventRecordError> { + pub fn complete(&mut self) { self.completed = true; } } diff --git a/tests/sourced/aggregate.rs b/tests/sourced/aggregate.rs index 1996321ac..6cbc6b86d 100644 --- a/tests/sourced/aggregate.rs +++ b/tests/sourced/aggregate.rs @@ -11,19 +11,14 @@ pub struct Todo { #[sourced(entity)] impl Todo { #[event("Initialized")] - pub fn initialize( - &mut self, - id: String, - user_id: String, - task: String, - ) -> Result<(), sourced_rust::EventRecordError> { + pub fn initialize(&mut self, id: String, user_id: String, task: String) { self.entity.set_id(&id); self.user_id = user_id; self.task = task; } #[event("Completed", when = !self.completed)] - pub fn complete(&mut self) -> Result<(), sourced_rust::EventRecordError> { + pub fn complete(&mut self) { self.completed = true; } diff --git a/tests/sourced/main.rs b/tests/sourced/main.rs index 17130c32f..9c72287ac 100644 --- a/tests/sourced/main.rs +++ b/tests/sourced/main.rs @@ -4,8 +4,7 @@ use aggregate::{Todo, TodoEvent}; use serde::ser::Error as _; use serde::Serialize; use sourced_rust::{ - Aggregate, AggregateBuilder, Entity, EventRecord, EventRecordError, HashMapRepository, - Queueable, + Aggregate, AggregateBuilder, Entity, EventRecord, HashMapRepository, Queueable, }; #[derive(Clone)] @@ -28,12 +27,12 @@ struct SafeRecorder { impl SafeRecorder { #[sourced_rust::digest("Recorded")] - fn record(&mut self, _payload: FailingSerialize) -> Result<(), EventRecordError> { + fn record(&mut self, _payload: FailingSerialize) { self.applied = true; } #[sourced_rust::digest("Recorded", version = 2)] - fn record_ok(&mut self, payload: String) -> Result<(), EventRecordError> { + fn record_ok(&mut self, payload: String) { self.applied = true; assert_eq!(payload, "ok"); } diff --git a/tests/sourced_enqueue/aggregate.rs b/tests/sourced_enqueue/aggregate.rs index 9e3de3395..32a657da6 100644 --- a/tests/sourced_enqueue/aggregate.rs +++ b/tests/sourced_enqueue/aggregate.rs @@ -24,11 +24,7 @@ impl Default for Order { #[sourced(entity, enqueue)] impl Order { #[event("OrderCreated")] - pub fn create( - &mut self, - order_id: String, - customer: String, - ) -> Result<(), sourced_rust::EventRecordError> { + pub fn create(&mut self, order_id: String, customer: String) { self.entity.set_id(&order_id); self.order_id = order_id; self.customer = customer; @@ -36,12 +32,12 @@ impl Order { } #[event("OrderConfirmed", when = self.status == "created")] - pub fn confirm(&mut self) -> Result<(), sourced_rust::EventRecordError> { + pub fn confirm(&mut self) { self.status = "confirmed".into(); } #[event("OrderShipped", when = self.status == "confirmed")] - pub fn ship(&mut self) -> Result<(), sourced_rust::EventRecordError> { + pub fn ship(&mut self) { self.status = "shipped".into(); } } @@ -66,11 +62,7 @@ impl Default for Notifier { #[sourced(entity, enqueue(my_emitter))] impl Notifier { #[event("NotificationSent")] - pub fn send( - &mut self, - id: String, - message: String, - ) -> Result<(), sourced_rust::EventRecordError> { + pub fn send(&mut self, id: String, message: String) { self.entity.set_id(&id); self.message = message; } diff --git a/tests/sourced_snapshot/aggregates.rs b/tests/sourced_snapshot/aggregates.rs index 460d592f2..3c8b68cbc 100644 --- a/tests/sourced_snapshot/aggregates.rs +++ b/tests/sourced_snapshot/aggregates.rs @@ -19,19 +19,14 @@ impl Todo { } #[digest("Initialized")] - pub fn initialize( - &mut self, - id: String, - user_id: String, - task: String, - ) -> Result<(), sourced_rust::EventRecordError> { + pub fn initialize(&mut self, id: String, user_id: String, task: String) { self.entity.set_id(&id); self.user_id = user_id; self.task = task; } #[digest("Completed", when = !self.completed)] - pub fn complete(&mut self) -> Result<(), sourced_rust::EventRecordError> { + pub fn complete(&mut self) { self.completed = true; } } @@ -59,19 +54,14 @@ impl Inventory { } #[digest("Created")] - pub fn create( - &mut self, - id: String, - sku: String, - available: u32, - ) -> Result<(), sourced_rust::EventRecordError> { + pub fn create(&mut self, id: String, sku: String, available: u32) { self.entity.set_id(&id); self.sku = sku; self.available = available; } #[digest("Restocked")] - pub fn restock(&mut self, qty: u32) -> Result<(), sourced_rust::EventRecordError> { + pub fn restock(&mut self, qty: u32) { self.available += qty; } } @@ -100,12 +90,7 @@ impl Order { } #[digest("Placed")] - pub fn place( - &mut self, - id: String, - customer: String, - total: u64, - ) -> Result<(), sourced_rust::EventRecordError> { + pub fn place(&mut self, id: String, customer: String, total: u64) { self.entity.set_id(&id); self.customer = customer; self.total = total; @@ -134,12 +119,12 @@ impl Counter { } #[event("Initialized")] - pub fn initialize(&mut self, id: String) -> Result<(), sourced_rust::EventRecordError> { + pub fn initialize(&mut self, id: String) { self.entity.set_id(&id); } #[event("Incremented")] - pub fn increment(&mut self, amount: i64) -> Result<(), sourced_rust::EventRecordError> { + pub fn increment(&mut self, amount: i64) { self.count += amount; } } @@ -162,12 +147,7 @@ impl Widget { } #[digest(my_entity, "Created")] - pub fn create( - &mut self, - id: String, - name: String, - weight: f64, - ) -> Result<(), sourced_rust::EventRecordError> { + pub fn create(&mut self, id: String, name: String, weight: f64) { self.my_entity.set_id(&id); self.name = name; self.weight = weight; @@ -200,11 +180,7 @@ impl Notifier { } #[digest("Sent")] - pub fn send( - &mut self, - id: String, - message: String, - ) -> Result<(), sourced_rust::EventRecordError> { + pub fn send(&mut self, id: String, message: String) { self.entity.set_id(&id); self.message = message; } diff --git a/tests/sourced_upcasting/aggregate.rs b/tests/sourced_upcasting/aggregate.rs index 3d7bbd216..39879fa6f 100644 --- a/tests/sourced_upcasting/aggregate.rs +++ b/tests/sourced_upcasting/aggregate.rs @@ -15,19 +15,14 @@ pub struct TodoV1 { #[sourced(entity)] impl TodoV1 { #[event("Initialized")] - pub fn initialize( - &mut self, - id: String, - user_id: String, - task: String, - ) -> Result<(), sourced_rust::EventRecordError> { + pub fn initialize(&mut self, id: String, user_id: String, task: String) { self.entity.set_id(&id); self.user_id = user_id; self.task = task; } #[event("Completed", when = !self.completed)] - pub fn complete(&mut self) -> Result<(), sourced_rust::EventRecordError> { + pub fn complete(&mut self) { self.completed = true; } } @@ -55,13 +50,7 @@ pub struct TodoV2 { ))] impl TodoV2 { #[event("Initialized", version = 2)] - pub fn initialize( - &mut self, - id: String, - user_id: String, - task: String, - priority: u8, - ) -> Result<(), sourced_rust::EventRecordError> { + pub fn initialize(&mut self, id: String, user_id: String, task: String, priority: u8) { self.entity.set_id(&id); self.user_id = user_id; self.task = task; @@ -69,7 +58,7 @@ impl TodoV2 { } #[event("Completed", when = !self.completed)] - pub fn complete(&mut self) -> Result<(), sourced_rust::EventRecordError> { + pub fn complete(&mut self) { self.completed = true; } } @@ -107,7 +96,7 @@ impl TodoV3 { task: String, priority: u8, due_date: String, - ) -> Result<(), sourced_rust::EventRecordError> { + ) { self.entity.set_id(&id); self.user_id = user_id; self.task = task; @@ -116,7 +105,7 @@ impl TodoV3 { } #[event("Completed", when = !self.completed)] - pub fn complete(&mut self) -> Result<(), sourced_rust::EventRecordError> { + pub fn complete(&mut self) { self.completed = true; } } diff --git a/tests/todos/aggregate.rs b/tests/todos/aggregate.rs index a9b1f7f7e..edbe46d6c 100644 --- a/tests/todos/aggregate.rs +++ b/tests/todos/aggregate.rs @@ -15,19 +15,14 @@ impl Todo { } #[digest("Initialized")] - pub fn initialize( - &mut self, - id: String, - user_id: String, - task: String, - ) -> Result<(), sourced_rust::EventRecordError> { + pub fn initialize(&mut self, id: String, user_id: String, task: String) { self.entity.set_id(&id); self.user_id = user_id; self.task = task; } #[digest("Completed", when = !self.completed)] - pub fn complete(&mut self) -> Result<(), sourced_rust::EventRecordError> { + pub fn complete(&mut self) { self.completed = true; } diff --git a/tests/upcasting/aggregate.rs b/tests/upcasting/aggregate.rs index c86915110..e76c9cf1f 100644 --- a/tests/upcasting/aggregate.rs +++ b/tests/upcasting/aggregate.rs @@ -16,19 +16,14 @@ pub struct TodoV1 { impl TodoV1 { #[digest("Initialized")] - pub fn initialize( - &mut self, - id: String, - user_id: String, - task: String, - ) -> Result<(), sourced_rust::EventRecordError> { + pub fn initialize(&mut self, id: String, user_id: String, task: String) { self.entity.set_id(&id); self.user_id = user_id; self.task = task; } #[digest("Completed", when = !self.completed)] - pub fn complete(&mut self) -> Result<(), sourced_rust::EventRecordError> { + pub fn complete(&mut self) { self.completed = true; } } @@ -59,13 +54,7 @@ pub struct TodoV2 { impl TodoV2 { #[digest("Initialized", version = 2)] - pub fn initialize( - &mut self, - id: String, - user_id: String, - task: String, - priority: u8, - ) -> Result<(), sourced_rust::EventRecordError> { + pub fn initialize(&mut self, id: String, user_id: String, task: String, priority: u8) { self.entity.set_id(&id); self.user_id = user_id; self.task = task; @@ -73,7 +62,7 @@ impl TodoV2 { } #[digest("Completed", when = !self.completed)] - pub fn complete(&mut self) -> Result<(), sourced_rust::EventRecordError> { + pub fn complete(&mut self) { self.completed = true; } } @@ -116,7 +105,7 @@ impl TodoV3 { task: String, priority: u8, due_date: String, - ) -> Result<(), sourced_rust::EventRecordError> { + ) { self.entity.set_id(&id); self.user_id = user_id; self.task = task; @@ -125,7 +114,7 @@ impl TodoV3 { } #[digest("Completed", when = !self.completed)] - pub fn complete(&mut self) -> Result<(), sourced_rust::EventRecordError> { + pub fn complete(&mut self) { self.completed = true; } } From 31a84dadf2a655cb63170e891e50f7d66e4981d1 Mon Sep 17 00:00:00 2001 From: Patrick Lee Scott Date: Wed, 20 May 2026 12:03:49 -0500 Subject: [PATCH 04/18] refactor: make upcast_events fallible --- src/aggregate/aggregate.rs | 4 ++-- src/entity/mod.rs | 2 +- src/entity/upcaster.rs | 29 ++++++++++------------------- src/lib.rs | 4 ++-- src/snapshot/repository.rs | 4 ++-- tests/upcasting/main.rs | 2 +- 6 files changed, 18 insertions(+), 27 deletions(-) diff --git a/src/aggregate/aggregate.rs b/src/aggregate/aggregate.rs index 16c4ba7db..c2141d7c5 100644 --- a/src/aggregate/aggregate.rs +++ b/src/aggregate/aggregate.rs @@ -1,7 +1,7 @@ use std::fmt; use std::marker::PhantomData; -use crate::entity::{try_upcast_events, Entity, EventRecord, EventUpcaster}; +use crate::entity::{upcast_events, Entity, EventRecord, EventUpcaster}; use crate::queued_repo::{GetAllWithOpts, GetWithOpts, ReadOpts, UnlockableRepository}; use crate::repository::{ Commit, CommitBatch, Find, Get, Repository, RepositoryError, TransactionalCommit, @@ -66,7 +66,7 @@ pub fn hydrate(entity: Entity) -> Result { let events = if upcasters.is_empty() { agg.entity().events().to_vec() } else { - try_upcast_events(agg.entity().events().to_vec(), upcasters) + upcast_events(agg.entity().events().to_vec(), upcasters) .map_err(|err| RepositoryError::Replay(err.to_string()))? }; diff --git a/src/entity/mod.rs b/src/entity/mod.rs index 599e94ab4..daea0383a 100644 --- a/src/entity/mod.rs +++ b/src/entity/mod.rs @@ -13,4 +13,4 @@ pub use event_record::{ BITCODE_PAYLOAD_CODEC_VERSION, }; pub use local_event::LocalEvent; -pub use upcaster::{try_upcast_events, upcast_events, EventUpcaster, UpcastError}; +pub use upcaster::{upcast_events, EventUpcaster, UpcastError}; diff --git a/src/entity/upcaster.rs b/src/entity/upcaster.rs index 421109d85..876255a3f 100644 --- a/src/entity/upcaster.rs +++ b/src/entity/upcaster.rs @@ -45,16 +45,7 @@ impl fmt::Display for UpcastError { impl std::error::Error for UpcastError {} /// Apply upcasters to a list of events. Chains automatically (v1->v2->v3). -/// -/// This compatibility helper panics when invalid upcaster configuration is -/// detected. Hydration paths use [`try_upcast_events`] so repository reads can -/// return the error instead. -pub fn upcast_events(events: Vec, upcasters: &[EventUpcaster]) -> Vec { - try_upcast_events(events, upcasters).expect("invalid upcaster chain") -} - -/// Fallible form of [`upcast_events`]. -pub fn try_upcast_events( +pub fn upcast_events( events: Vec, upcasters: &[EventUpcaster], ) -> Result, UpcastError> { @@ -109,7 +100,7 @@ mod tests { #[test] fn no_upcasters_leaves_events_unchanged() { let event = EventRecord::new("TestEvent", vec![1, 2, 3], 1); - let events = upcast_events(vec![event.clone()], &[]); + let events = upcast_events(vec![event.clone()], &[]).unwrap(); assert_eq!(events[0].payload, vec![1, 2, 3]); assert_eq!(events[0].event_version, 1); } @@ -127,7 +118,7 @@ mod tests { new }, }]; - let events = upcast_events(vec![event], &upcasters); + let events = upcast_events(vec![event], &upcasters).unwrap(); assert_eq!(events[0].payload, vec![1, 2, 99]); assert_eq!(events[0].event_version, 2); } @@ -141,7 +132,7 @@ mod tests { to_version: 2, transform: |_| vec![99], }]; - let events = upcast_events(vec![event], &upcasters); + let events = upcast_events(vec![event], &upcasters).unwrap(); assert_eq!(events[0].payload, vec![1, 2]); assert_eq!(events[0].event_version, 1); } @@ -171,7 +162,7 @@ mod tests { }, }, ]; - let events = upcast_events(vec![event], &upcasters); + let events = upcast_events(vec![event], &upcasters).unwrap(); assert_eq!(events[0].payload, vec![1, 2, 3]); assert_eq!(events[0].event_version, 3); } @@ -193,7 +184,7 @@ mod tests { new }, }]; - let result = upcast_events(events, &upcasters); + let result = upcast_events(events, &upcasters).unwrap(); // First A: upcasted from v1 to v2 assert_eq!(result[0].payload, vec![10, 99]); assert_eq!(result[0].event_version, 2); @@ -206,7 +197,7 @@ mod tests { } #[test] - fn try_upcast_events_rejects_same_version_transition() { + fn upcast_events_rejects_same_version_transition() { let event = EventRecord::new("A", vec![10], 1); let upcasters = [EventUpcaster { event_type: "A", @@ -215,7 +206,7 @@ mod tests { transform: |payload| payload.to_vec(), }]; - let err = try_upcast_events(vec![event], &upcasters).unwrap_err(); + let err = upcast_events(vec![event], &upcasters).unwrap_err(); assert_eq!( err, @@ -227,7 +218,7 @@ mod tests { } #[test] - fn try_upcast_events_rejects_cycles() { + fn upcast_events_rejects_cycles() { let event = EventRecord::new("A", vec![10], 1); let upcasters = [ EventUpcaster { @@ -244,7 +235,7 @@ mod tests { }, ]; - let err = try_upcast_events(vec![event], &upcasters).unwrap_err(); + let err = upcast_events(vec![event], &upcasters).unwrap_err(); assert_eq!( err, diff --git a/src/lib.rs b/src/lib.rs index 4e80bb27f..989366e41 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -21,8 +21,8 @@ pub mod snapshot; // Re-export entity types at crate root for convenience pub use entity::{ - try_upcast_events, upcast_events, BitcodePayloadCodec, Committable, Entity, Event, EventRecord, - EventRecordError, EventUpcaster, LocalEvent, PayloadCodec, UpcastError, BITCODE_PAYLOAD_CODEC, + upcast_events, BitcodePayloadCodec, Committable, Entity, Event, EventRecord, EventRecordError, + EventUpcaster, LocalEvent, PayloadCodec, UpcastError, BITCODE_PAYLOAD_CODEC, BITCODE_PAYLOAD_CODEC_VERSION, }; diff --git a/src/snapshot/repository.rs b/src/snapshot/repository.rs index 8f550bbbf..7491fd86e 100644 --- a/src/snapshot/repository.rs +++ b/src/snapshot/repository.rs @@ -1,5 +1,5 @@ use crate::aggregate::{hydrate, AggregateRepository}; -use crate::entity::{try_upcast_events, Entity}; +use crate::entity::{upcast_events, Entity}; use crate::queued_repo::{GetAllWithOpts, GetWithOpts, ReadOpts, UnlockableRepository}; use crate::repository::{ CommitBatch, Find, Get, RepositoryError, SnapshotWrite, TransactionalCommit, @@ -38,7 +38,7 @@ pub fn hydrate_from_snapshot( let events = if upcasters.is_empty() { post_snapshot } else { - try_upcast_events(post_snapshot, upcasters) + upcast_events(post_snapshot, upcasters) .map_err(|err| RepositoryError::Replay(err.to_string()))? }; diff --git a/tests/upcasting/main.rs b/tests/upcasting/main.rs index 2822d6aed..a19601985 100644 --- a/tests/upcasting/main.rs +++ b/tests/upcasting/main.rs @@ -293,7 +293,7 @@ fn upcast_events_standalone() { transform: aggregate::upcast_initialized_v1_v2, }]; - let result = upcast_events(vec![event], upcasters); + let result = upcast_events(vec![event], upcasters).unwrap(); assert_eq!(result[0].event_version, 2); let (id, user, task, priority): (String, String, String, u8) = From 8c3c56e9eeb10d6877b9b0ba872be614b8eec78a Mon Sep 17 00:00:00 2001 From: Patrick Lee Scott Date: Wed, 20 May 2026 12:16:23 -0500 Subject: [PATCH 05/18] refactor: propagate handler event errors --- src/microsvc/error.rs | 8 +++++++- tests/microsvc/basic.rs | 6 +++--- tests/microsvc/handlers/counter_create.rs | 5 ++--- tests/microsvc/handlers/counter_increment.rs | 5 ++--- tests/sagas/handlers/inventory/init.rs | 2 +- tests/sagas/handlers/inventory/reserve.rs | 4 ++-- tests/sagas/handlers/messages.rs | 8 +++++--- tests/sagas/handlers/orders/complete.rs | 8 ++++---- tests/sagas/handlers/orders/create.rs | 6 ++---- tests/sagas/handlers/payments/process.rs | 20 ++++++++----------- .../handlers/saga/on_inventory_reserved.rs | 4 ++-- .../sagas/handlers/saga/on_order_completed.rs | 2 +- tests/sagas/handlers/saga/on_order_created.rs | 2 +- .../handlers/saga/on_payment_succeeded.rs | 4 ++-- tests/sagas/handlers/saga/start.rs | 5 ++--- 15 files changed, 44 insertions(+), 45 deletions(-) diff --git a/src/microsvc/error.rs b/src/microsvc/error.rs index 7ad68d964..56c8c1df8 100644 --- a/src/microsvc/error.rs +++ b/src/microsvc/error.rs @@ -3,7 +3,7 @@ use std::error::Error; use std::fmt; -use crate::repository::RepositoryError; +use crate::{repository::RepositoryError, EventRecordError}; /// Error type for command handler operations. #[derive(Debug)] @@ -59,6 +59,12 @@ impl From for HandlerError { } } +impl From for HandlerError { + fn from(err: EventRecordError) -> Self { + HandlerError::Other(Box::new(err)) + } +} + impl From for HandlerError { fn from(err: serde_json::Error) -> Self { HandlerError::DecodeFailed(err.to_string()) diff --git a/tests/microsvc/basic.rs b/tests/microsvc/basic.rs index bb2b43925..3a24c45b8 100644 --- a/tests/microsvc/basic.rs +++ b/tests/microsvc/basic.rs @@ -13,7 +13,7 @@ fn full_lifecycle() { let input = ctx.input::()?; let counter_repo = ctx.repo().clone().aggregate::(); let mut counter = Counter::default(); - counter.create(input.id.clone()).unwrap(); + counter.create(input.id.clone())?; counter_repo.commit(&mut counter)?; Ok(json!({ "id": input.id })) }) @@ -23,7 +23,7 @@ fn full_lifecycle() { let mut counter: Counter = counter_repo .get(&input.id)? .ok_or_else(|| HandlerError::NotFound(input.id.clone()))?; - counter.increment(input.amount).unwrap(); + counter.increment(input.amount)?; counter_repo.commit(&mut counter)?; Ok(json!({ "value": counter.value })) }) @@ -33,7 +33,7 @@ fn full_lifecycle() { let mut counter: Counter = counter_repo .get(&input.id)? .ok_or_else(|| HandlerError::NotFound(input.id.clone()))?; - counter.decrement(input.amount).unwrap(); + counter.decrement(input.amount)?; counter_repo.commit(&mut counter)?; Ok(json!({ "value": counter.value })) }); diff --git a/tests/microsvc/handlers/counter_create.rs b/tests/microsvc/handlers/counter_create.rs index 79c2c34c4..d121b42ba 100644 --- a/tests/microsvc/handlers/counter_create.rs +++ b/tests/microsvc/handlers/counter_create.rs @@ -35,10 +35,9 @@ pub fn handle(ctx: &Context) -> Result { } let mut counter = Counter::default(); - counter.create(input.id.clone()).unwrap(); + counter.create(input.id.clone())?; - let mut message = OutboxMessage::domain_event("CounterCreated", &counter) - .map_err(|e| HandlerError::Other(Box::new(e)))?; + let mut message = OutboxMessage::domain_event("CounterCreated", &counter)?; ctx.repo().outbox(&mut message).commit(&mut counter)?; diff --git a/tests/microsvc/handlers/counter_increment.rs b/tests/microsvc/handlers/counter_increment.rs index 310a48d60..b2b788416 100644 --- a/tests/microsvc/handlers/counter_increment.rs +++ b/tests/microsvc/handlers/counter_increment.rs @@ -28,10 +28,9 @@ pub fn handle(ctx: &Context) -> Result { .get(&input.id)? .ok_or_else(|| HandlerError::NotFound(input.id.clone()))?; - counter.increment(input.amount).unwrap(); + counter.increment(input.amount)?; - let mut message = OutboxMessage::domain_event("CounterIncremented", &counter) - .map_err(|e| HandlerError::Other(Box::new(e)))?; + let mut message = OutboxMessage::domain_event("CounterIncremented", &counter)?; ctx.repo().outbox(&mut message).commit(&mut counter)?; diff --git a/tests/sagas/handlers/inventory/init.rs b/tests/sagas/handlers/inventory/init.rs index ee6b00db2..7f3279470 100644 --- a/tests/sagas/handlers/inventory/init.rs +++ b/tests/sagas/handlers/inventory/init.rs @@ -10,7 +10,7 @@ pub fn handle(ctx: &Context) -> Result { let input = ctx.input::()?; let mut inv = Inventory::new(); - inv.initialize(input.sku.clone(), input.stock).unwrap(); + inv.initialize(input.sku.clone(), input.stock)?; ctx.repo().commit(&mut inv)?; Ok(json!({ "sku": input.sku, "stock": input.stock })) diff --git a/tests/sagas/handlers/inventory/reserve.rs b/tests/sagas/handlers/inventory/reserve.rs index 228495718..329cd9acc 100644 --- a/tests/sagas/handlers/inventory/reserve.rs +++ b/tests/sagas/handlers/inventory/reserve.rs @@ -17,7 +17,7 @@ pub fn handle(ctx: &Context) -> Result { if !inv.can_reserve(input.quantity) { return Err(HandlerError::Rejected("insufficient stock".into())); } - inv.reserve(input.order_id.clone(), input.quantity).unwrap(); + inv.reserve(input.order_id.clone(), input.quantity)?; let mut msg = json_outbox_to( &format!("{}-inventory-reserved", input.order_id), @@ -27,7 +27,7 @@ pub fn handle(ctx: &Context) -> Result { saga_id: input.saga_id, order_id: input.order_id, }, - ); + )?; ctx.repo().outbox(&mut msg).commit(&mut inv)?; Ok(json!({ "reserved": input.quantity })) diff --git a/tests/sagas/handlers/messages.rs b/tests/sagas/handlers/messages.rs index 62d9fd796..3e7966be8 100644 --- a/tests/sagas/handlers/messages.rs +++ b/tests/sagas/handlers/messages.rs @@ -4,6 +4,7 @@ //! `microsvc::dispatch_event` can deserialize it on the receiving end. use serde::{Deserialize, Serialize}; +use sourced_rust::microsvc::HandlerError; use sourced_rust::OutboxMessage; use crate::order::OrderItem; @@ -18,9 +19,10 @@ pub fn json_outbox_to( event_type: &str, destination: &str, payload: &T, -) -> OutboxMessage { - let bytes = serde_json::to_vec(payload).expect("JSON serialization should not fail"); - OutboxMessage::create_to(id, event_type, destination, bytes).unwrap() +) -> Result { + let bytes = serde_json::to_vec(payload).map_err(|e| HandlerError::Other(Box::new(e)))?; + let message = OutboxMessage::create_to(id, event_type, destination, bytes)?; + Ok(message) } // === Saga → Order Service === diff --git a/tests/sagas/handlers/orders/complete.rs b/tests/sagas/handlers/orders/complete.rs index ac1bdd4d3..378da50b2 100644 --- a/tests/sagas/handlers/orders/complete.rs +++ b/tests/sagas/handlers/orders/complete.rs @@ -13,9 +13,9 @@ pub fn handle(ctx: &Context) -> Result { .repo() .get(&input.order_id)? .ok_or_else(|| HandlerError::NotFound(input.order_id.clone()))?; - order.mark_inventory_reserved().unwrap(); - order.mark_payment_processed().unwrap(); - order.complete().unwrap(); + order.mark_inventory_reserved()?; + order.mark_payment_processed()?; + order.complete()?; let mut msg = json_outbox_to( &format!("{}-order-completed", input.order_id), @@ -25,7 +25,7 @@ pub fn handle(ctx: &Context) -> Result { saga_id: input.saga_id, order_id: input.order_id.clone(), }, - ); + )?; ctx.repo().outbox(&mut msg).commit(&mut order)?; Ok(json!({ "order_id": input.order_id })) diff --git a/tests/sagas/handlers/orders/create.rs b/tests/sagas/handlers/orders/create.rs index beebc5992..3c94e204c 100644 --- a/tests/sagas/handlers/orders/create.rs +++ b/tests/sagas/handlers/orders/create.rs @@ -10,9 +10,7 @@ pub fn handle(ctx: &Context) -> Result { let input = ctx.input::()?; let mut order = Order::new(); - order - .create(input.order_id.clone(), input.customer_id, input.items) - .unwrap(); + order.create(input.order_id.clone(), input.customer_id, input.items)?; let mut msg = json_outbox_to( &format!("{}-order-created", input.order_id), @@ -22,7 +20,7 @@ pub fn handle(ctx: &Context) -> Result { saga_id: input.saga_id, order_id: input.order_id.clone(), }, - ); + )?; ctx.repo().outbox(&mut msg).commit(&mut order)?; Ok(json!({ "order_id": input.order_id })) diff --git a/tests/sagas/handlers/payments/process.rs b/tests/sagas/handlers/payments/process.rs index 0ca63f021..ec4dde212 100644 --- a/tests/sagas/handlers/payments/process.rs +++ b/tests/sagas/handlers/payments/process.rs @@ -11,17 +11,13 @@ pub fn handle(ctx: &Context) -> Result { let payment_id = format!("pay-{}", input.order_id); let mut payment = Payment::new(); - payment - .initiate( - payment_id.clone(), - input.order_id.clone(), - input.amount_cents, - ) - .unwrap(); - payment - .authorize("txn-distributed-001".to_string()) - .unwrap(); - payment.capture().unwrap(); + payment.initiate( + payment_id.clone(), + input.order_id.clone(), + input.amount_cents, + )?; + payment.authorize("txn-distributed-001".to_string())?; + payment.capture()?; let mut msg = json_outbox_to( &format!("{}-payment-succeeded", input.order_id), @@ -31,7 +27,7 @@ pub fn handle(ctx: &Context) -> Result { saga_id: input.saga_id, order_id: input.order_id, }, - ); + )?; ctx.repo().outbox(&mut msg).commit(&mut payment)?; Ok(json!({ "payment_id": payment_id })) diff --git a/tests/sagas/handlers/saga/on_inventory_reserved.rs b/tests/sagas/handlers/saga/on_inventory_reserved.rs index 067a98f29..b09b86a69 100644 --- a/tests/sagas/handlers/saga/on_inventory_reserved.rs +++ b/tests/sagas/handlers/saga/on_inventory_reserved.rs @@ -13,7 +13,7 @@ pub fn handle(ctx: &Context) -> Result { .repo() .get(&input.saga_id)? .ok_or_else(|| HandlerError::NotFound(input.saga_id.clone()))?; - saga.inventory_reserved().unwrap(); + saga.inventory_reserved()?; let mut msg = json_outbox_to( &format!("{}-process-payment", input.saga_id), @@ -24,7 +24,7 @@ pub fn handle(ctx: &Context) -> Result { order_id: input.order_id, amount_cents: saga.total_cents(), }, - ); + )?; ctx.repo().outbox(&mut msg).commit(&mut saga)?; Ok(json!({ "next": "ProcessPayment" })) diff --git a/tests/sagas/handlers/saga/on_order_completed.rs b/tests/sagas/handlers/saga/on_order_completed.rs index e87379470..f64655725 100644 --- a/tests/sagas/handlers/saga/on_order_completed.rs +++ b/tests/sagas/handlers/saga/on_order_completed.rs @@ -13,7 +13,7 @@ pub fn handle(ctx: &Context) -> Result { .repo() .get(&input.saga_id)? .ok_or_else(|| HandlerError::NotFound(input.saga_id.clone()))?; - saga.complete().unwrap(); + saga.complete()?; ctx.repo().commit(&mut saga)?; Ok(json!({ "saga_id": input.saga_id, "status": "completed" })) diff --git a/tests/sagas/handlers/saga/on_order_created.rs b/tests/sagas/handlers/saga/on_order_created.rs index d7bb6b4b5..cd266ec93 100644 --- a/tests/sagas/handlers/saga/on_order_created.rs +++ b/tests/sagas/handlers/saga/on_order_created.rs @@ -27,7 +27,7 @@ pub fn handle(ctx: &Context) -> Result { sku, quantity, }, - ); + )?; ctx.repo().outbox(&mut msg).commit(&mut saga)?; Ok(json!({ "next": "ReserveInventory" })) diff --git a/tests/sagas/handlers/saga/on_payment_succeeded.rs b/tests/sagas/handlers/saga/on_payment_succeeded.rs index dd4c7af77..0afaf8681 100644 --- a/tests/sagas/handlers/saga/on_payment_succeeded.rs +++ b/tests/sagas/handlers/saga/on_payment_succeeded.rs @@ -13,7 +13,7 @@ pub fn handle(ctx: &Context) -> Result { .repo() .get(&input.saga_id)? .ok_or_else(|| HandlerError::NotFound(input.saga_id.clone()))?; - saga.payment_succeeded().unwrap(); + saga.payment_succeeded()?; let mut msg = json_outbox_to( &format!("{}-complete-order", input.saga_id), @@ -23,7 +23,7 @@ pub fn handle(ctx: &Context) -> Result { saga_id: input.saga_id.clone(), order_id: input.order_id, }, - ); + )?; ctx.repo().outbox(&mut msg).commit(&mut saga)?; Ok(json!({ "next": "CompleteOrder" })) diff --git a/tests/sagas/handlers/saga/start.rs b/tests/sagas/handlers/saga/start.rs index 244c837f6..6232124f8 100644 --- a/tests/sagas/handlers/saga/start.rs +++ b/tests/sagas/handlers/saga/start.rs @@ -16,8 +16,7 @@ pub fn handle(ctx: &Context) -> Result { input.customer_id.clone(), input.items.clone(), input.total_cents, - ) - .unwrap(); + )?; let mut msg = json_outbox_to( &format!("{}-create-order", input.saga_id), @@ -30,7 +29,7 @@ pub fn handle(ctx: &Context) -> Result { items: input.items, total_cents: input.total_cents, }, - ); + )?; ctx.repo().outbox(&mut msg).commit(&mut saga)?; Ok(json!({ "saga_id": input.saga_id })) From 7b73aa35e5ff861ce5dfbbbbb532b5f78c6b12b2 Mon Sep 17 00:00:00 2001 From: Patrick Lee Scott Date: Wed, 20 May 2026 12:21:21 -0500 Subject: [PATCH 06/18] fix: return commit builder serialization errors --- src/commit_builder/mod.rs | 74 +++++++++++++++++++++++++++++++++++++-- 1 file changed, 72 insertions(+), 2 deletions(-) diff --git a/src/commit_builder/mod.rs b/src/commit_builder/mod.rs index f19226f1c..389b84c75 100644 --- a/src/commit_builder/mod.rs +++ b/src/commit_builder/mod.rs @@ -26,6 +26,7 @@ pub struct CommitBuilder<'a, R> { repo: &'a R, entities: Vec, models: Vec, + error: Option, } impl<'a, R> CommitBuilder<'a, R> { @@ -34,14 +35,29 @@ impl<'a, R> CommitBuilder<'a, R> { repo, entities: vec![], models: vec![], + error: None, } } /// Add a read model to the commit. + /// + /// Serialization errors are returned by the final `commit*` call so the + /// fluent builder API stays chainable without panicking. pub fn readmodel(mut self, model: &M) -> Self { + if self.error.is_some() { + return self; + } + let key = format!("{}:{}", M::COLLECTION, model.id()); - let bytes = serde_json::to_vec(model).expect("read model serialization should not fail"); - self.models.push(ReadModelWrite::new(key, bytes)); + match serde_json::to_vec(model) { + Ok(bytes) => self.models.push(ReadModelWrite::new(key, bytes)), + Err(err) => { + self.error = Some(RepositoryError::Model(format!( + "failed to serialize read model {}: {}", + key, err + ))); + } + } self } @@ -56,6 +72,8 @@ impl<'a, R> CommitBuilder<'a, R> { where R: TransactionalCommit, { + self.check_staged()?; + let mut entity_refs: Vec<&mut Entity> = self.entities.iter_mut().collect(); entity_refs.push(aggregate.entity_mut()); self.repo.commit_batch(CommitBatch { @@ -76,6 +94,8 @@ impl<'a, R> CommitBuilder<'a, R> { where R: TransactionalCommit, { + self.check_staged()?; + let mut entity_refs: Vec<&mut Entity> = self.entities.iter_mut().collect(); for e in entities.iter_mut() { entity_refs.push(&mut **e); @@ -92,6 +112,8 @@ impl<'a, R> CommitBuilder<'a, R> { where R: TransactionalCommit, { + self.check_staged()?; + let entity_refs: Vec<&mut Entity> = self.entities.iter_mut().collect(); self.repo.commit_batch(CommitBatch { entities: entity_refs, @@ -99,6 +121,13 @@ impl<'a, R> CommitBuilder<'a, R> { snapshots: Vec::new(), }) } + + fn check_staged(&mut self) -> Result<(), RepositoryError> { + if let Some(err) = self.error.take() { + return Err(err); + } + Ok(()) + } } /// Extension trait to start a commit builder chain from a read model or outbox. @@ -157,6 +186,27 @@ mod tests { } } + #[derive(Deserialize, Clone)] + struct FailingView { + id: String, + } + + impl Serialize for FailingView { + fn serialize(&self, _serializer: S) -> Result + where + S: serde::Serializer, + { + Err(serde::ser::Error::custom("injected serialize failure")) + } + } + + impl ReadModel for FailingView { + const COLLECTION: &'static str = "failing_view"; + fn id(&self) -> &str { + &self.id + } + } + #[derive(Default)] struct RecordingBatchRepo { fail: bool, @@ -389,6 +439,26 @@ mod tests { .any(|id| id == "outbox:msg-rollback")); } + #[test] + fn readmodel_serialization_failure_returns_error_without_committing() { + let repo = RecordingBatchRepo::default(); + let view = FailingView { id: "bad".into() }; + let mut agg = TestAggregate::default(); + agg.touch(); + + let err = repo.readmodel(&view).commit(&mut agg).unwrap_err(); + + assert!(matches!( + err, + RepositoryError::Model(ref message) + if message.contains("failed to serialize read model failing_view:bad") + && message.contains("injected serialize failure") + )); + assert_eq!(agg.entity().committed_version(), 0); + assert!(repo.entity_ids.borrow().is_empty()); + assert!(repo.read_model_keys.borrow().is_empty()); + } + #[test] fn commit_builder_empty_batch_succeeds() { let repo = RecordingBatchRepo::default(); From 2ed7879c4fbbd4aeec43cc3f2c925b5db066dba5 Mon Sep 17 00:00:00 2001 From: Patrick Lee Scott Date: Wed, 20 May 2026 12:31:38 -0500 Subject: [PATCH 07/18] fix: return outbox lease deadline errors --- src/outbox/message.rs | 46 +++++++++++++++++++++++++++++++++++++++---- 1 file changed, 42 insertions(+), 4 deletions(-) diff --git a/src/outbox/message.rs b/src/outbox/message.rs index 3ff11a912..af5fe1123 100644 --- a/src/outbox/message.rs +++ b/src/outbox/message.rs @@ -248,13 +248,26 @@ impl OutboxMessage { /// Claim with a Duration (convenience method that computes until_secs) pub fn claim_for(&mut self, worker_id: impl Into, lease: Duration) -> SourcedResult { - let now = SystemTime::now(); - let until = now + lease; + let until_secs = Self::lease_deadline_secs(SystemTime::now(), lease)?; + self.claim(worker_id.into(), until_secs) + } + + fn lease_deadline_secs(now: SystemTime, lease: Duration) -> SourcedResult { + let until = now.checked_add(lease).ok_or_else(|| EventRecordError { + message: "failed to compute outbox lease deadline: timestamp overflow".into(), + })?; + let until_secs = until .duration_since(SystemTime::UNIX_EPOCH) - .unwrap_or_default() + .map_err(|err| EventRecordError { + message: format!( + "failed to compute outbox lease deadline before UNIX epoch: {}", + err + ), + })? .as_secs(); - self.claim(worker_id.into(), until_secs) + + Ok(until_secs) } #[digest("MessagePublished", when = self.is_in_flight())] @@ -384,6 +397,31 @@ mod tests { assert!(message.is_published()); } + #[test] + fn claim_deadline_overflow_returns_error() { + let err = OutboxMessage::lease_deadline_secs( + SystemTime::UNIX_EPOCH, + Duration::from_secs(u64::MAX), + ) + .unwrap_err(); + + assert!(err + .message + .contains("failed to compute outbox lease deadline")); + } + + #[test] + fn claim_deadline_before_epoch_returns_error() { + let before_epoch = SystemTime::UNIX_EPOCH + .checked_sub(Duration::from_secs(1)) + .unwrap(); + let err = OutboxMessage::lease_deadline_secs(before_epoch, Duration::ZERO).unwrap_err(); + + assert!(err + .message + .contains("failed to compute outbox lease deadline before UNIX epoch")); + } + #[test] fn create_with_metadata() { let mut meta = HashMap::new(); From a97d3d23433c8e365e20c33ba8a3dbed41147efb Mon Sep 17 00:00:00 2001 From: Patrick Lee Scott Date: Wed, 20 May 2026 12:46:24 -0500 Subject: [PATCH 08/18] fix: return enqueue serialization errors --- sourced_rust_macros/src/lib.rs | 46 +++++++++--------------------- src/emitter/entity_ext.rs | 52 ++++++++++++++++++++++++++++++---- tests/enqueue/main.rs | 10 +++---- 3 files changed, 66 insertions(+), 42 deletions(-) diff --git a/sourced_rust_macros/src/lib.rs b/sourced_rust_macros/src/lib.rs index cc5913d74..f887c8820 100644 --- a/sourced_rust_macros/src/lib.rs +++ b/sourced_rust_macros/src/lib.rs @@ -51,8 +51,9 @@ fn returns_result(sig: &syn::Signature) -> bool { .path .segments .last() - .map(|segment| segment.ident == "Result" || segment.ident == "SourcedResult") - .unwrap_or(false), + .is_some_and(|segment| { + segment.ident == "Result" || segment.ident == "SourcedResult" + }), _ => false, }, } @@ -89,8 +90,7 @@ fn block_returns_result(block: &syn::Block) -> bool { .path .segments .last() - .map(|segment| segment.ident == "Ok" || segment.ident == "Err") - .unwrap_or(false), + .is_some_and(|segment| segment.ident == "Ok" || segment.ident == "Err"), _ => false, }, _ => false, @@ -192,9 +192,9 @@ fn generate_enqueue_call( quote! { self.#emitter_field.enqueue(#event_name, ""); } } else if param_names.len() == 1 { let param = param_names[0]; - quote! { self.#emitter_field.enqueue_with(#event_name, &(#param.clone(),)); } + quote! { self.#emitter_field.enqueue_with(#event_name, &(#param.clone(),))?; } } else { - quote! { self.#emitter_field.enqueue_with(#event_name, &(#(#param_names.clone()),*)); } + quote! { self.#emitter_field.enqueue_with(#event_name, &(#(#param_names.clone()),*))?; } }; quote! { if !self.#entity_field.is_replaying() { @@ -245,11 +245,16 @@ fn generate_enqueue_call( /// The macro supports: /// - Default emitter field name: `emitter` (can be overridden by specifying field name first) /// - `when = condition`: guard that wraps the entire method body +/// - Methods may omit the return type; the macro expands them to `sourced_rust::SourcedResult<()>` #[proc_macro_attribute] pub fn enqueue(attr: TokenStream, item: TokenStream) -> TokenStream { let args = parse_macro_input!(attr with parse_enqueue_args); let mut func = parse_macro_input!(item as ItemFn); + if let Err(err) = ensure_sourced_result_signature(&mut func.sig, "enqueue") { + return err.to_compile_error().into(); + } + let emitter_field = &args.emitter_field; let event_name = &args.event_name; @@ -281,42 +286,19 @@ pub fn enqueue(attr: TokenStream, item: TokenStream) -> TokenStream { let param = ¶m_names[0]; quote! { if !self.#entity_field.is_replaying() { - self.#emitter_field.enqueue_with(#event_name, &(#param.clone(),)); + self.#emitter_field.enqueue_with(#event_name, &(#param.clone(),))?; }; } } else { // Multi-element tuple quote! { if !self.#entity_field.is_replaying() { - self.#emitter_field.enqueue_with(#event_name, &(#(#param_names.clone()),*)); + self.#emitter_field.enqueue_with(#event_name, &(#(#param_names.clone()),*))?; }; } }; - let new_body = if returns_result(&func.sig) { - wrap_result_body_with_guard(args.guard.as_ref(), enqueue_call, &func.block) - } else { - let original_stmts = &func.block.stmts; - if let Some(guard) = &args.guard { - // Wrap everything in the guard condition - syn::parse_quote! { - { - if #guard { - #enqueue_call - #(#original_stmts)* - } - } - } - } else { - // No guard - just prepend enqueue - syn::parse_quote! { - { - #enqueue_call - #(#original_stmts)* - } - } - } - }; + let new_body = wrap_result_body_with_guard(args.guard.as_ref(), enqueue_call, &func.block); func.block = Box::new(new_body); TokenStream::from(quote! { #func }) diff --git a/src/emitter/entity_ext.rs b/src/emitter/entity_ext.rs index eb38db2c9..0b472575c 100644 --- a/src/emitter/entity_ext.rs +++ b/src/emitter/entity_ext.rs @@ -1,6 +1,7 @@ use event_emitter_rs::EventEmitter; -use crate::entity::{Entity, LocalEvent}; +use crate::entity::{Entity, EventRecordError, LocalEvent}; +use crate::SourcedResult; /// Extension wrapper that adds event emitter capabilities to an Entity. /// @@ -61,16 +62,18 @@ impl EntityEmitter { &mut self, event_type: impl Into, payload: &T, - ) { + ) -> SourcedResult { if self.entity.is_replaying() { - return; + return Ok(()); } - let data = serde_json::to_string(payload) - .expect("failed to serialize local event payload to JSON"); + let data = serde_json::to_string(payload).map_err(|err| EventRecordError { + message: format!("failed to serialize local event payload to JSON: {}", err), + })?; self.events_to_emit.push(LocalEvent { event_type: event_type.into(), data, }); + Ok(()) } /// Drain all queued events for external emission. @@ -172,4 +175,43 @@ mod tests { let entity = emitter.into_entity(); assert_eq!(entity.id(), "changed"); } + + #[derive(Debug)] + struct FailingSerialize; + + impl serde::Serialize for FailingSerialize { + fn serialize(&self, _serializer: S) -> Result + where + S: serde::Serializer, + { + Err(serde::ser::Error::custom("injected serialize failure")) + } + } + + #[test] + fn enqueue_with_returns_serialization_error() { + let entity = Entity::with_id("test"); + let mut emitter = entity.with_emitter(); + + let err = emitter + .enqueue_with("BadEvent", &FailingSerialize) + .unwrap_err(); + + assert!(err + .message + .contains("failed to serialize local event payload to JSON")); + assert!(err.message.contains("injected serialize failure")); + assert_eq!(emitter.queued_len(), 0); + } + + #[test] + fn enqueue_with_does_not_serialize_during_replay() { + let mut entity = Entity::with_id("test"); + entity.set_replaying(true); + let mut emitter = entity.with_emitter(); + + emitter.enqueue_with("BadEvent", &FailingSerialize).unwrap(); + + assert_eq!(emitter.queued_len(), 0); + } } diff --git a/tests/enqueue/main.rs b/tests/enqueue/main.rs index ddca57707..b5e99fb66 100644 --- a/tests/enqueue/main.rs +++ b/tests/enqueue/main.rs @@ -114,12 +114,12 @@ fn enqueue_guard_on_empty_value() { let mut eph = Ephemeral::default(); // value is empty, guard blocks clear - eph.clear(); + eph.clear().unwrap(); assert_eq!(eph.emitter.queued_len(), 0); // set a value, now clear should work - eph.set_value("hello".into()); - eph.clear(); + eph.set_value("hello".into()).unwrap(); + eph.clear().unwrap(); assert_eq!(eph.emitter.queued_len(), 2); // ValueSet + ValueCleared } @@ -255,7 +255,7 @@ fn replay_does_not_enqueue_events() { #[test] fn enqueue_only_without_digest() { let mut eph = Ephemeral::default(); - eph.set_value("test".into()); + eph.set_value("test".into()).unwrap(); // Queued for emission assert_eq!(eph.emitter.queued_len(), 1); @@ -273,7 +273,7 @@ fn enqueue_only_emits_correctly() { tx.send(payload).unwrap(); }); - eph.set_value("hello".into()); + eph.set_value("hello".into()).unwrap(); eph.emitter.emit_queued(); let payload = rx From f282d0afe2df78d504a1e9b62c3d1a17dedd9bfa Mon Sep 17 00:00:00 2001 From: Patrick Lee Scott Date: Wed, 20 May 2026 13:02:41 -0500 Subject: [PATCH 09/18] fix: handle inferred macro tail expressions Implements [[tasks/review-macro-tail-result-wrapping]] --- sourced_rust_macros/src/lib.rs | 80 +++++++++++++++++----------------- tests/enqueue/aggregate.rs | 9 ++++ tests/enqueue/main.rs | 9 ++++ tests/sourced/aggregate.rs | 10 +++++ tests/sourced/main.rs | 29 ++++++++++++ 5 files changed, 96 insertions(+), 41 deletions(-) diff --git a/sourced_rust_macros/src/lib.rs b/sourced_rust_macros/src/lib.rs index f887c8820..ed1d80366 100644 --- a/sourced_rust_macros/src/lib.rs +++ b/sourced_rust_macros/src/lib.rs @@ -62,13 +62,13 @@ fn returns_result(sig: &syn::Signature) -> bool { fn ensure_sourced_result_signature( sig: &mut syn::Signature, attr_name: &str, -) -> Result<(), syn::Error> { +) -> Result { match &sig.output { ReturnType::Default => { sig.output = syn::parse_quote!(-> sourced_rust::SourcedResult<()>); - Ok(()) + Ok(true) } - ReturnType::Type(_, _) if returns_result(sig) => Ok(()), + ReturnType::Type(_, _) if returns_result(sig) => Ok(false), ReturnType::Type(_, ty) => Err(syn::Error::new_spanned( ty, format!( @@ -79,24 +79,6 @@ fn ensure_sourced_result_signature( } } -fn block_returns_result(block: &syn::Block) -> bool { - let Some(syn::Stmt::Expr(expr, None)) = block.stmts.last() else { - return false; - }; - - match expr { - Expr::Call(call) => match &*call.func { - Expr::Path(path) => path - .path - .segments - .last() - .is_some_and(|segment| segment.ident == "Ok" || segment.ident == "Err"), - _ => false, - }, - _ => false, - } -} - /// Generate a digest call token stream. fn generate_digest_call( entity_field: &Ident, @@ -133,17 +115,15 @@ fn wrap_result_body_with_guard( guard: Option<&Expr>, prepend: proc_macro2::TokenStream, original_block: &syn::Block, + signature_synthesized: bool, ) -> syn::Block { - let original_stmts = &original_block.stmts; - let original_returns_result = block_returns_result(original_block); - - match (guard, original_returns_result) { + match (guard, signature_synthesized) { (Some(guard), true) => { syn::parse_quote! { { if #guard { #prepend - (|| #original_block)()?; + #original_block; } Ok(()) } @@ -154,7 +134,7 @@ fn wrap_result_body_with_guard( { if #guard { #prepend - #(#original_stmts)* + (|| #original_block)()?; } Ok(()) } @@ -164,7 +144,7 @@ fn wrap_result_body_with_guard( syn::parse_quote! { { #prepend - (|| #original_block)()?; + #original_block; Ok(()) } } @@ -173,7 +153,7 @@ fn wrap_result_body_with_guard( syn::parse_quote! { { #prepend - #(#original_stmts)* + (|| #original_block)()?; Ok(()) } } @@ -251,9 +231,10 @@ pub fn enqueue(attr: TokenStream, item: TokenStream) -> TokenStream { let args = parse_macro_input!(attr with parse_enqueue_args); let mut func = parse_macro_input!(item as ItemFn); - if let Err(err) = ensure_sourced_result_signature(&mut func.sig, "enqueue") { - return err.to_compile_error().into(); - } + let signature_synthesized = match ensure_sourced_result_signature(&mut func.sig, "enqueue") { + Ok(signature_synthesized) => signature_synthesized, + Err(err) => return err.to_compile_error().into(), + }; let emitter_field = &args.emitter_field; let event_name = &args.event_name; @@ -298,7 +279,12 @@ pub fn enqueue(attr: TokenStream, item: TokenStream) -> TokenStream { } }; - let new_body = wrap_result_body_with_guard(args.guard.as_ref(), enqueue_call, &func.block); + let new_body = wrap_result_body_with_guard( + args.guard.as_ref(), + enqueue_call, + &func.block, + signature_synthesized, + ); func.block = Box::new(new_body); TokenStream::from(quote! { #func }) @@ -354,7 +340,8 @@ fn parse_enqueue_args(input: syn::parse::ParseStream) -> syn::Result`. Methods may also explicitly return /// `Result<(), E>` where `E` can be constructed from -/// `sourced_rust::EventRecordError`. +/// `sourced_rust::EventRecordError`; explicit `Result` methods should return +/// `Ok(())` from the original body. /// /// # Usage /// @@ -371,6 +358,7 @@ fn parse_enqueue_args(input: syn::parse::ParseStream) -> syn::Result Result<(), sourced_rust::EventRecordError> { /// self.completed = true; +/// Ok(()) /// } /// ``` /// @@ -379,6 +367,7 @@ fn parse_enqueue_args(input: syn::parse::ParseStream) -> syn::Result Result<(), sourced_rust::EventRecordError> { /// // uses self.my_entity instead of self.entity +/// Ok(()) /// } /// ``` /// @@ -390,9 +379,10 @@ pub fn digest(attr: TokenStream, item: TokenStream) -> TokenStream { let args = parse_macro_input!(attr with parse_digest_args); let mut func = parse_macro_input!(item as ItemFn); - if let Err(err) = ensure_sourced_result_signature(&mut func.sig, "digest") { - return err.to_compile_error().into(); - } + let signature_synthesized = match ensure_sourced_result_signature(&mut func.sig, "digest") { + Ok(signature_synthesized) => signature_synthesized, + Err(err) => return err.to_compile_error().into(), + }; let param_names = extract_param_names(&func.sig); let digest_call = generate_digest_call( @@ -402,7 +392,12 @@ pub fn digest(attr: TokenStream, item: TokenStream) -> TokenStream { args.version.as_ref(), ); - let new_body = wrap_result_body_with_guard(args.guard.as_ref(), digest_call, &func.block); + let new_body = wrap_result_body_with_guard( + args.guard.as_ref(), + digest_call, + &func.block, + signature_synthesized, + ); func.block = Box::new(new_body); TokenStream::from(quote! { #func }) @@ -893,9 +888,11 @@ pub fn sourced(attr: TokenStream, item: TokenStream) -> TokenStream { if let syn::ImplItem::Fn(method) = item { match find_and_remove_event_attr(&mut method.attrs) { Ok(Some(event_attr)) => { - if let Err(err) = ensure_sourced_result_signature(&mut method.sig, "event") { - return err.to_compile_error().into(); - } + let signature_synthesized = + match ensure_sourced_result_signature(&mut method.sig, "event") { + Ok(signature_synthesized) => signature_synthesized, + Err(err) => return err.to_compile_error().into(), + }; let params = extract_params_with_types(&method.sig); let param_name_refs: Vec<&Ident> = @@ -925,6 +922,7 @@ pub fn sourced(attr: TokenStream, item: TokenStream) -> TokenStream { event_attr.guard.as_ref(), prepend, &method.block, + signature_synthesized, ); method.block = new_body; diff --git a/tests/enqueue/aggregate.rs b/tests/enqueue/aggregate.rs index 21d29b2f1..b231e3f96 100644 --- a/tests/enqueue/aggregate.rs +++ b/tests/enqueue/aggregate.rs @@ -111,4 +111,13 @@ impl Ephemeral { pub fn clear(&mut self) { self.value = String::new(); } + + #[enqueue("ValueChecked")] + pub fn check_with_tail_expression(&mut self) { + self.tail_check()? + } + + fn tail_check(&self) -> sourced_rust::SourcedResult { + Ok(()) + } } diff --git a/tests/enqueue/main.rs b/tests/enqueue/main.rs index b5e99fb66..5e9417a66 100644 --- a/tests/enqueue/main.rs +++ b/tests/enqueue/main.rs @@ -123,6 +123,15 @@ fn enqueue_guard_on_empty_value() { assert_eq!(eph.emitter.queued_len(), 2); // ValueSet + ValueCleared } +#[test] +fn enqueue_macro_accepts_inferred_tail_try_expression() { + let mut eph = Ephemeral::default(); + + eph.check_with_tail_expression().unwrap(); + + assert_eq!(eph.emitter.queued_len(), 1); +} + // ============================================================================= // #[enqueue] with custom emitter field // ============================================================================= diff --git a/tests/sourced/aggregate.rs b/tests/sourced/aggregate.rs index 6cbc6b86d..e42b02330 100644 --- a/tests/sourced/aggregate.rs +++ b/tests/sourced/aggregate.rs @@ -22,6 +22,16 @@ impl Todo { self.completed = true; } + #[event("TailValidated")] + pub fn validate_tail(&mut self) { + self.completed = true; + self.tail_check()? + } + + fn tail_check(&self) -> sourced_rust::SourcedResult { + Ok(()) + } + // Non-event method should pass through unchanged pub fn snapshot(&self) -> TodoSnapshot { TodoSnapshot { diff --git a/tests/sourced/main.rs b/tests/sourced/main.rs index 9c72287ac..4efdbbcde 100644 --- a/tests/sourced/main.rs +++ b/tests/sourced/main.rs @@ -36,6 +36,15 @@ impl SafeRecorder { self.applied = true; assert_eq!(payload, "ok"); } + + #[sourced_rust::digest("TailChecked")] + fn record_after_tail_check(&mut self) { + self.tail_check()? + } + + fn tail_check(&self) -> sourced_rust::SourcedResult { + Ok(()) + } } #[test] @@ -76,6 +85,16 @@ fn digest_macro_records_successful_versioned_events() { assert_eq!(recorder.entity.events()[0].event_version, 2); } +#[test] +fn digest_macro_accepts_inferred_tail_try_expression() { + let mut recorder = SafeRecorder::default(); + + recorder.record_after_tail_check().unwrap(); + + assert_eq!(recorder.entity.events().len(), 1); + assert_eq!(recorder.entity.events()[0].event_name, "TailChecked"); +} + #[test] fn event_name_returns_correct_strings() { let init = TodoEvent::Initialized { @@ -154,6 +173,16 @@ fn guard_condition_works() { assert_eq!(todo.entity.version(), 2); // only Initialized + Completed } +#[test] +fn sourced_event_macro_accepts_inferred_tail_try_expression() { + let mut todo = Todo::default(); + + todo.validate_tail().unwrap(); + + assert_eq!(todo.entity.events().len(), 1); + assert_eq!(todo.entity.events()[0].event_name, "TailValidated"); +} + #[test] fn non_event_methods_pass_through() { let mut todo = Todo::default(); From 5e18860e3910dbc7393076bd1582a966f6ed0c80 Mon Sep 17 00:00:00 2001 From: Patrick Lee Scott Date: Wed, 20 May 2026 13:03:44 -0500 Subject: [PATCH 10/18] fix: reset snapshot committed version Implements [[tasks/review-set-snapshot-committed-version]] --- src/entity/entity.rs | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/src/entity/entity.rs b/src/entity/entity.rs index 794c43fc3..397b15b52 100644 --- a/src/entity/entity.rs +++ b/src/entity/entity.rs @@ -262,6 +262,7 @@ impl Entity { let record = EventRecord::new("Snapshot", payload, 1); self.events.push(record); self.version = 1; + self.committed_version = self.events.len() as u64; self.timestamp = SystemTime::now(); Ok(()) } @@ -458,6 +459,24 @@ mod tests { assert_eq!(entity.events().len(), 2); } + #[test] + fn set_snapshot_resets_committed_version_to_snapshot_event_len() { + let mut source = Entity::new(); + source.digest("e1", &"a").unwrap(); + source.digest("e2", &"b").unwrap(); + + let mut entity = Entity::new(); + entity.load_from_history(source.events().to_vec()); + assert_eq!(entity.committed_version(), 2); + + entity.set_snapshot(&"snapshot").unwrap(); + + assert_eq!(entity.events().len(), 1); + assert_eq!(entity.version(), 1); + assert_eq!(entity.committed_version(), 1); + assert!(entity.new_events().is_empty()); + } + #[test] fn digest_propagates_metadata_to_event_record() { let mut entity = Entity::new(); From 0c4815645faa66778fcc76a6ac347372331af2ca Mon Sep 17 00:00:00 2001 From: Patrick Lee Scott Date: Wed, 20 May 2026 13:04:33 -0500 Subject: [PATCH 11/18] fix: default legacy event payload codec Implements [[tasks/review-event-record-codec-defaults]] --- src/entity/event_record.rs | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/src/entity/event_record.rs b/src/entity/event_record.rs index ede8aae3d..0368e942c 100644 --- a/src/entity/event_record.rs +++ b/src/entity/event_record.rs @@ -90,11 +90,19 @@ fn default_event_version() -> u64 { fn is_version_one(v: &u64) -> bool { *v == 1 } +fn default_payload_codec() -> String { + BITCODE_PAYLOAD_CODEC.to_string() +} +fn default_payload_codec_version() -> u16 { + BITCODE_PAYLOAD_CODEC_VERSION +} #[derive(Clone, Serialize, Deserialize, Debug, PartialEq)] pub struct EventRecord { pub event_name: String, + #[serde(default = "default_payload_codec")] pub payload_codec: String, + #[serde(default = "default_payload_codec_version")] pub payload_codec_version: u16, #[serde(with = "payload_serde")] pub payload: Vec, @@ -324,4 +332,13 @@ mod tests { let record: EventRecord = serde_json::from_str(json).unwrap(); assert!(record.metadata.is_empty()); } + + #[test] + fn deserialize_without_payload_codec_fields_defaults_to_bitcode() { + let json = r#"{"event_name":"old_event","payload":"","sequence":1,"timestamp":{"secs_since_epoch":0,"nanos_since_epoch":0},"metadata":{}}"#; + let record: EventRecord = serde_json::from_str(json).unwrap(); + + assert_eq!(record.payload_codec, BITCODE_PAYLOAD_CODEC); + assert_eq!(record.payload_codec_version, BITCODE_PAYLOAD_CODEC_VERSION); + } } From 62c5be25d9d339409caa1f94357adeb4b37d22b6 Mon Sep 17 00:00:00 2001 From: Patrick Lee Scott Date: Wed, 20 May 2026 13:05:42 -0500 Subject: [PATCH 12/18] fix: reject backward upcaster transitions Implements [[tasks/review-upcaster-backward-transition]] --- src/entity/upcaster.rs | 60 +++++++++++++++++++++++++++++++++++++++--- 1 file changed, 57 insertions(+), 3 deletions(-) diff --git a/src/entity/upcaster.rs b/src/entity/upcaster.rs index 876255a3f..8aa6396b5 100644 --- a/src/entity/upcaster.rs +++ b/src/entity/upcaster.rs @@ -17,8 +17,19 @@ pub struct EventUpcaster { /// Error returned when an upcaster chain cannot make safe forward progress. #[derive(Clone, Debug, PartialEq, Eq)] pub enum UpcastError { - SameVersionTransition { event_type: String, version: u64 }, - CycleDetected { event_type: String, version: u64 }, + SameVersionTransition { + event_type: String, + version: u64, + }, + BackwardTransition { + event_type: String, + from: u64, + to: u64, + }, + CycleDetected { + event_type: String, + version: u64, + }, } impl fmt::Display for UpcastError { @@ -31,6 +42,14 @@ impl fmt::Display for UpcastError { f, "upcaster for event {event_type} does not advance version {version}" ), + UpcastError::BackwardTransition { + event_type, + from, + to, + } => write!( + f, + "upcaster for event {event_type} regresses version from {from} to {to}" + ), UpcastError::CycleDetected { event_type, version, @@ -68,10 +87,23 @@ fn upcast_one( if u.event_type == event.event_name && u.from_version == event.event_version { if u.to_version == event.event_version { return Err(UpcastError::SameVersionTransition { - event_type: event.event_name, + event_type: event.event_name.clone(), version: event.event_version, }); } + if seen_versions.contains(&u.to_version) { + return Err(UpcastError::CycleDetected { + event_type: event.event_name.clone(), + version: u.to_version, + }); + } + if u.to_version < event.event_version { + return Err(UpcastError::BackwardTransition { + event_type: event.event_name.clone(), + from: event.event_version, + to: u.to_version, + }); + } let next_version = u.to_version; event.payload = (u.transform)(&event.payload); @@ -217,6 +249,28 @@ mod tests { ); } + #[test] + fn upcast_events_rejects_backward_transition() { + let event = EventRecord::new_versioned("A", vec![10], 1, 3); + let upcasters = [EventUpcaster { + event_type: "A", + from_version: 3, + to_version: 2, + transform: |payload| payload.to_vec(), + }]; + + let err = upcast_events(vec![event], &upcasters).unwrap_err(); + + assert_eq!( + err, + UpcastError::BackwardTransition { + event_type: "A".to_string(), + from: 3, + to: 2 + } + ); + } + #[test] fn upcast_events_rejects_cycles() { let event = EventRecord::new("A", vec![10], 1); From 0505e6428fd0160336509a7ce5b8e067f94e5a69 Mon Sep 17 00:00:00 2001 From: Patrick Lee Scott Date: Wed, 20 May 2026 13:06:36 -0500 Subject: [PATCH 13/18] fix: count only newly claimed outbox messages Implements [[tasks/review-outbox-claimed-count]] --- src/outbox_worker/worker.rs | 35 ++++++++++++++++++++++++++++++++++- 1 file changed, 34 insertions(+), 1 deletion(-) diff --git a/src/outbox_worker/worker.rs b/src/outbox_worker/worker.rs index bd49f9332..93c5c739d 100644 --- a/src/outbox_worker/worker.rs +++ b/src/outbox_worker/worker.rs @@ -18,6 +18,8 @@ pub struct DrainResult { pub struct ProcessOneResult { /// Whether any work was done (a message was processed). pub did_work: bool, + /// Whether this call claimed a pending message. + pub claimed: bool, /// Whether the message was successfully published. pub completed: bool, /// Whether the message was released for retry. @@ -99,8 +101,10 @@ impl OutboxWorker

{ return Ok(ProcessOneResult::default()); } + let mut claimed = false; if message.is_pending() { message.claim_for(&self.worker_id, self.lease)?; + claimed = true; } if !message.is_in_flight() { @@ -116,6 +120,7 @@ impl OutboxWorker

{ message.complete()?; ProcessOneResult { did_work: true, + claimed, completed: true, ..Default::default() } @@ -126,6 +131,7 @@ impl OutboxWorker

{ message.fail(error_msg)?; ProcessOneResult { did_work: true, + claimed, failed: true, ..Default::default() } @@ -133,6 +139,7 @@ impl OutboxWorker

{ message.release(error_msg)?; ProcessOneResult { did_work: true, + claimed, released: true, ..Default::default() } @@ -149,7 +156,7 @@ impl OutboxWorker

{ for message in messages.iter_mut().take(self.batch_size) { let processed = self.process_message(message)?; - if processed.did_work { + if processed.claimed { result.claimed += 1; } if processed.completed { @@ -215,4 +222,30 @@ mod tests { assert!(logs[0].contains("correlation_id")); assert!(logs[0].contains("req-abc")); } + + #[test] + fn process_batch_counts_pending_messages_claimed_by_this_call() { + let mut messages = vec![OutboxMessage::create("msg-1", "Event", b"{}".to_vec()).unwrap()]; + let mut worker = OutboxWorker::new(LogPublisher::default()); + + let result = worker.process_batch(&mut messages).unwrap(); + + assert_eq!(result.claimed, 1); + assert_eq!(result.completed, 1); + } + + #[test] + fn process_batch_does_not_count_already_in_flight_messages_as_claimed() { + let mut message = OutboxMessage::create("msg-1", "Event", b"{}".to_vec()).unwrap(); + message + .claim_for("other-worker", Duration::from_secs(1)) + .unwrap(); + let mut messages = vec![message]; + let mut worker = OutboxWorker::new(LogPublisher::default()); + + let result = worker.process_batch(&mut messages).unwrap(); + + assert_eq!(result.claimed, 0); + assert_eq!(result.completed, 1); + } } From 1ebb0a4420fe2014d84dfe811b5f15f9a66fed05 Mon Sep 17 00:00:00 2001 From: Patrick Lee Scott Date: Wed, 20 May 2026 13:07:49 -0500 Subject: [PATCH 14/18] fix: validate in-memory find one rows Implements [[tasks/review-in-memory-find-one-validation]] --- src/read_model/in_memory.rs | 29 +++++++++++++++++++++++++---- 1 file changed, 25 insertions(+), 4 deletions(-) diff --git a/src/read_model/in_memory.rs b/src/read_model/in_memory.rs index 69c060d79..2accc0412 100644 --- a/src/read_model/in_memory.rs +++ b/src/read_model/in_memory.rs @@ -225,21 +225,22 @@ impl ReadModelStore for InMemoryReadModelStore { .map_err(|_| ReadModelError::Storage("lock poisoned".into()))?; let prefix = format!("{}:", M::COLLECTION); + let mut matched = None; for (key, stored) in storage.iter() { if key.starts_with(&prefix) { let data = serde_json::from_slice::(&stored.bytes) .map_err(|e| ReadModelError::Serde(e.to_string()))?; - if predicate(&data) { - return Ok(Some(Versioned { + if matched.is_none() && predicate(&data) { + matched = Some(Versioned { data, version: stored.version, - })); + }); } } } - Ok(None) + Ok(matched) } fn upsert_raw(&self, key: &str, bytes: Vec) -> Result<(), ReadModelError> { @@ -456,6 +457,26 @@ mod tests { assert!(matches!(err, ReadModelError::Serde(_))); } + #[test] + fn find_one_model_validates_rows_after_first_match() { + let store = InMemoryReadModelStore::new(); + store + .upsert(&TestModel { + id: "1".into(), + value: 20, + }) + .unwrap(); + store + .save_raw("test_models:bad", b"not valid json".to_vec()) + .unwrap(); + + let err = store + .find_one_model::(&|m| m.value > 15) + .unwrap_err(); + + assert!(matches!(err, ReadModelError::Serde(_))); + } + #[test] fn clone_shares_storage() { let store = InMemoryReadModelStore::new(); From f050dffc32a1aaf171fcfa2b0babb6fa1fb39a0e Mon Sep 17 00:00:00 2001 From: Patrick Lee Scott Date: Wed, 20 May 2026 13:17:33 -0500 Subject: [PATCH 15/18] fix: record power up collection only on removal Implements [[tasks/review-power-up-digest-condition]] --- tests/bomberman/domain/game_map.rs | 29 ++++++++++++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/tests/bomberman/domain/game_map.rs b/tests/bomberman/domain/game_map.rs index 36fdc5ba7..17a01a76d 100644 --- a/tests/bomberman/domain/game_map.rs +++ b/tests/bomberman/domain/game_map.rs @@ -42,13 +42,13 @@ impl GameMap { } pub fn collect_power_up(&mut self, x: i32, y: i32) -> SourcedResult> { - self.entity.digest("PowerUpCollected", &(x, y))?; if let Some(idx) = self .power_ups .iter() .position(|((px, py), _)| *px == x && *py == y) { let (_, power_up) = self.power_ups.remove(idx); + self.entity.digest("PowerUpCollected", &(x, y))?; Ok(Some(power_up)) } else { Ok(None) @@ -130,3 +130,30 @@ sourced_rust::aggregate!(GameMap, entity { "BlockDestroyed"(x, y) => destroy_block, "PowerUpCollected"(x, y) => collect_power_up, }); + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn collect_power_up_without_match_does_not_record_event() { + let mut map = GameMap::default(); + + let collected = map.collect_power_up(1, 1).unwrap(); + + assert_eq!(collected, None); + assert!(map.entity.events().is_empty()); + } + + #[test] + fn collect_power_up_with_match_records_event() { + let mut map = GameMap::default(); + map.power_ups.push(((1, 1), PowerUp::BombUp)); + + let collected = map.collect_power_up(1, 1).unwrap(); + + assert_eq!(collected, Some(PowerUp::BombUp)); + assert_eq!(map.entity.events().len(), 1); + assert_eq!(map.entity.events()[0].event_name, "PowerUpCollected"); + } +} From 3c8d83eb74b0d95f54110c71f6bdfd935f8272d2 Mon Sep 17 00:00:00 2001 From: Patrick Lee Scott Date: Wed, 20 May 2026 13:32:36 -0500 Subject: [PATCH 16/18] fix: return bomberman outbox serialization errors Implements [[tasks/review-bomberman-outbox-serialization]] --- tests/bomberman/commands.rs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/tests/bomberman/commands.rs b/tests/bomberman/commands.rs index c6c1d69e5..18e51dca8 100644 --- a/tests/bomberman/commands.rs +++ b/tests/bomberman/commands.rs @@ -1,7 +1,7 @@ use sourced_rust::read_model::ReadModelStore; use sourced_rust::{ hydrate, Aggregate, Commit, CommitBuilderExt, Find, Get, GetAggregate, OutboxMessage, - TransactionalCommit, + RepositoryError, TransactionalCommit, }; use crate::domain::bomb::Bomb; @@ -207,7 +207,11 @@ pub fn tick Date: Wed, 20 May 2026 13:33:11 -0500 Subject: [PATCH 17/18] docs: remove duplicate postgres stream index Implements [[tasks/review-postgres-duplicate-index-doc]] --- docs/postgres-event-store.md | 1 - 1 file changed, 1 deletion(-) diff --git a/docs/postgres-event-store.md b/docs/postgres-event-store.md index 0ff07c130..7fdad1996 100644 --- a/docs/postgres-event-store.md +++ b/docs/postgres-event-store.md @@ -24,7 +24,6 @@ Recommended table shape: Required constraints and indexes: - `PRIMARY KEY (aggregate_type, aggregate_id, sequence)`. -- Index `(aggregate_type, aggregate_id, sequence)` for stream hydration. - Optional index `(event_name, event_version)` for migrations or diagnostics. ## Timestamp Representation From 1a8bbeefc2eff323eec8476c0e279a98b775a395 Mon Sep 17 00:00:00 2001 From: Patrick Lee Scott Date: Wed, 20 May 2026 19:18:54 -0500 Subject: [PATCH 18/18] refactor: model power up collection as digest command Refs [[tasks/review-power-up-digest-condition]] --- tests/bomberman/domain/game_map.rs | 31 +++++++++++++++++++++++------- 1 file changed, 24 insertions(+), 7 deletions(-) diff --git a/tests/bomberman/domain/game_map.rs b/tests/bomberman/domain/game_map.rs index 17a01a76d..5999e59df 100644 --- a/tests/bomberman/domain/game_map.rs +++ b/tests/bomberman/domain/game_map.rs @@ -42,16 +42,27 @@ impl GameMap { } pub fn collect_power_up(&mut self, x: i32, y: i32) -> SourcedResult> { + let Some(power_up) = self + .power_ups + .iter() + .find(|((px, py), _)| *px == x && *py == y) + .map(|(_, power_up)| power_up.clone()) + else { + return Ok(None); + }; + + self.record_power_up_collected(x, y, power_up.clone())?; + Ok(Some(power_up)) + } + + #[digest("PowerUpCollected")] + fn record_power_up_collected(&mut self, x: i32, y: i32, power_up: PowerUp) { if let Some(idx) = self .power_ups .iter() - .position(|((px, py), _)| *px == x && *py == y) + .position(|((px, py), existing)| *px == x && *py == y && existing == &power_up) { - let (_, power_up) = self.power_ups.remove(idx); - self.entity.digest("PowerUpCollected", &(x, y))?; - Ok(Some(power_up)) - } else { - Ok(None) + self.power_ups.remove(idx); } } @@ -128,7 +139,7 @@ impl GameMap { sourced_rust::aggregate!(GameMap, entity { "MapCreated"(id, width, height, tiles, spawn_points) => create, "BlockDestroyed"(x, y) => destroy_block, - "PowerUpCollected"(x, y) => collect_power_up, + "PowerUpCollected"(x, y, power_up) => record_power_up_collected, }); #[cfg(test)] @@ -155,5 +166,11 @@ mod tests { assert_eq!(collected, Some(PowerUp::BombUp)); assert_eq!(map.entity.events().len(), 1); assert_eq!(map.entity.events()[0].event_name, "PowerUpCollected"); + assert_eq!( + map.entity.events()[0] + .decode::<(i32, i32, PowerUp)>() + .unwrap(), + (1, 1, PowerUp::BombUp) + ); } }