From 398e78fb46e02d76f0b63328fc7993bc872c5e70 Mon Sep 17 00:00:00 2001 From: Patrick Lee Scott Date: Fri, 22 May 2026 22:30:27 -0500 Subject: [PATCH 01/26] feat: unify read-model ORM write plans Implements [[specs/read-model-orm-unification]]. Covers [[tasks/read-model-orm-01-inventory]], [[tasks/read-model-orm-02-metadata]], [[tasks/read-model-orm-03-session-write-plan]], [[tasks/read-model-orm-04-commit-builder-bridge]], [[tasks/read-model-orm-05-compat-conformance]], [[tasks/read-model-orm-06-distributed-idempotency]], [[tasks/read-model-orm-07-schema-bootstrap]], and [[tasks/read-model-orm-08-test-migration-docs]]. --- README.md | 28 +- docs/read-models.md | 379 +++---- sourced_rust_macros/src/read_model.rs | 724 +++++++++++-- src/commit_builder/mod.rs | 432 +++++++- src/hashmap_repo/repository.rs | 77 +- src/lib.rs | 20 +- src/read_model/in_memory.rs | 127 ++- src/read_model/metadata.rs | 515 +++++++++ src/read_model/mod.rs | 61 +- src/read_model/queued.rs | 111 +- src/read_model/repository.rs | 5 + src/read_model/schema.rs | 335 ++++++ src/read_model/session.rs | 991 ++++++++++++++++++ src/read_model/store.rs | 21 +- src/repository/batch.rs | 23 +- src/repository/mod.rs | 2 +- src/snapshot/repository.rs | 6 +- tests/bomberman/commands.rs | 2 +- tests/bomberman/main.rs | 2 +- tests/bomberman/sim.rs | 2 +- tests/bomberman/views.rs | 1 + tests/distributed_read_model/main.rs | 25 +- .../models/readmodels/account_summary.rs | 8 - tests/distributed_read_model/query_process.rs | 9 +- tests/distributed_read_model/read_model.rs | 40 +- tests/read_model_commit_bridge/main.rs | 51 + .../main.rs | 169 +++ tests/read_model_document_conformance/main.rs | 230 ++++ tests/read_model_metadata/main.rs | 167 +++ tests/read_model_schema_bootstrap/main.rs | 282 +++++ tests/read_model_session/main.rs | 270 +++++ tests/read_models/main.rs | 75 +- 32 files changed, 4662 insertions(+), 528 deletions(-) create mode 100644 src/read_model/metadata.rs create mode 100644 src/read_model/schema.rs create mode 100644 src/read_model/session.rs create mode 100644 tests/read_model_commit_bridge/main.rs create mode 100644 tests/read_model_distributed_idempotency/main.rs create mode 100644 tests/read_model_document_conformance/main.rs create mode 100644 tests/read_model_metadata/main.rs create mode 100644 tests/read_model_schema_bootstrap/main.rs create mode 100644 tests/read_model_session/main.rs diff --git a/README.md b/README.md index edbe04d..5b13407 100644 --- a/README.md +++ b/README.md @@ -1189,7 +1189,7 @@ microsvc::serve(service.clone(), "0.0.0.0:3000").await?; ## Read Models -Read models are denormalized views derived from event-sourced aggregates. They give you fast, purpose-built query models shaped for your UI or API consumers. +Read models are query-optimized projections derived from aggregates, event records, or published messages. Document read models store a whole view in a document payload column; normalized relational read models use table metadata plus `ReadModelSession` write plans. ### Defining a Read Model @@ -1226,9 +1226,31 @@ repo.readmodel(&view).commit(&mut game)?; // Return `view` to the client — it reflects the committed state ``` -This is a deliberate CAP theorem tradeoff: you're choosing **consistency** over **partition tolerance**. The read model is in sync with the aggregate when the repository implements `TransactionalCommit` and can write both in the same transaction boundary. For cross-service or cross-database views, use the eventually consistent outbox pattern instead. +For relational read models, stage structured row mutations in a session: -See [`docs/read-models.md`](docs/read-models.md) for the full guide, including eventually consistent projections, `QueuedReadModelStore`, and a decision flowchart. +```rust +use sourced_rust::{ReadModelSession, ReadModelSessionCommitExt}; + +let mut read_models = ReadModelSession::new(); +read_models.save(&player_view)?; +read_models.save_related(&player_view, "weapons", &weapon_view)?; + +repo.read_models(read_models).commit(&mut game)?; +``` + +Distributed projectors can commit the same session shape directly against a read-model adapter and mark messages processed in the same adapter transaction: + +```rust +let mut read_models = ReadModelSession::new(); +read_models.document(&view)?.mark_processed("game-view-projector", event_id); +let outcome = read_models.commit(&read_store)?; +``` + +This is a deliberate consistency tradeoff. The read model is in sync with the aggregate only when the repository implements `TransactionalCommit` and can write both in the same transaction boundary. For cross-service or cross-database views, use the eventually consistent outbox/projector pattern instead. + +Bomberman `BoardView` remains a document-row example backed by a whole-view payload, not a normalized relational ORM example. + +See [`docs/read-models.md`](docs/read-models.md) for the full guide, including relational metadata, document rows, session commits, schema bootstrap, distributed idempotency, and non-goals. ## Snapshots diff --git a/docs/read-models.md b/docs/read-models.md index 66758a7..b963a47 100644 --- a/docs/read-models.md +++ b/docs/read-models.md @@ -1,24 +1,20 @@ # Read Models -Read models are denormalized query views derived from aggregate state, -aggregate event records, or published domain/integration messages. -They give you fast, purpose-built query models shaped for your UI or API -consumers — without polluting your domain aggregates with read concerns. +Read models are query-optimized projection state. They can be stored as +document rows with a JSON/JSONB payload column, or as normalized relational +rows with table, column, key, index, relationship, and schema metadata. -`sourced_rust` supports three strategies for keeping read models up to date, -each suited to different consistency requirements: +The current implementation keeps these paths explicit: -| Strategy | Consistency | Use when… | +| Path | API | Use when | |---|---|---| -| Eventually consistent | Stale by ms–seconds | Dashboards, search, reports | -| Atomic commit | Immediate | Command response must include the updated view | -| QueuedReadModelStore | Serialized | Concurrent writers to the same view (rare) | +| Document rows | `ReadModelStore`, `.readmodel(&view)`, `ReadModelSession::document` | Whole-view JSON documents backed by a document payload column | +| Relational write mapping | `RelationalReadModel`, `ReadModelSession`, `ReadModelWritePlan` | Normalized tables, composite keys, foreign keys, JSONB columns | +| Schema lifecycle | `ReadModelSchemaRegistry`, `ReadModelSchemaAdapter` | Migration artifact generation, startup verification, explicit dev/test bootstrap | ---- +## Document Read Models -## Defining a Read Model - -Derive `ReadModel` on any serializable struct: +Derive `ReadModel` on any serializable document view: ```rust use serde::{Deserialize, Serialize}; @@ -31,301 +27,182 @@ pub struct GameView { pub id: String, pub player_name: String, pub score: i32, - pub board: Vec>, } ``` -- `#[readmodel(collection = "...")]` — maps to a table/collection/key-prefix. - Defaults to the snake_case struct name + `"s"` if omitted. -- `#[readmodel(id)]` — marks the unique identifier field. - Defaults to a field named `id` if omitted. - -Read models are stored and loaded through the `ReadModelStore` trait. -Every repository that implements `ReadModelStore` gets a typed accessor: +Use `ReadModelsExt` for typed key/value CRUD: ```rust use sourced_rust::ReadModelsExt; -// Typed read model access let view = repo.read_models::().get("game-42")?; repo.read_models::().upsert(&updated_view)?; +let read_only = repo + .read_models::() + .get_by_primary_key("game-42")?; ``` ---- - -## 1. Eventually Consistent (The Default Path) - -This is the bread-and-butter pattern for CQRS: aggregates record replayable -event records for their own state, domain or integration messages are published -through an outbox, and a separate denormalizer/projector process consumes those -messages to update read models. - -``` -Command → Aggregate → Outbox → [worker] → Denormalizer → Read Model -``` - -### How it works - -1. Commands produce aggregate event records via `#[digest]` or `#[sourced]`. -2. An `OutboxMessage` carrying the domain/integration message is committed alongside the aggregate. -3. An `OutboxWorker` polls for pending messages and publishes them - through an `OutboxPublisher`. -4. A subscriber (denormalizer) receives the published message and updates the - appropriate read model(s). - -```rust -use sourced_rust::{CommitBuilderExt, OutboxMessage}; +This path stores one serialized model at `collection:id`. A SQL adapter can +back it with a table such as `(collection, id, version, payload jsonb)`. +Predicate helpers such as `find` and `find_one` are in-memory/document-store +helpers; SQL adapters are not required to translate Rust closures into queries. -// After handling a command... -counter.increment(5); - -let outbox = OutboxMessage::encode( - "counter-1:incremented", - "CounterIncremented", - &counter.value(), -)?; - -repo.outbox(outbox).commit(&mut counter)?; -``` +## Relational Models -On the consumption side: +A model opts into relational metadata with `#[readmodel(table = "...")]` and +field attributes: ```rust -use sourced_rust::{OutboxWorker, LogPublisher}; +use serde::{Deserialize, Serialize}; +use sourced_rust::ReadModel; -let mut worker = OutboxWorker::new(LogPublisher::new()) - .with_batch_size(100) - .with_max_attempts(5); +#[derive(Clone, Debug, Serialize, Deserialize, ReadModel)] +#[readmodel(table = "players")] +pub struct PlayerView { + #[readmodel(id, column = "player_id")] + pub id: String, + pub display_name: String, + #[readmodel(jsonb)] + pub counters_by_game: std::collections::HashMap, +} -// In a loop or background task: -let mut messages = repo.claim_outbox_messages("worker-1", 100, lease)?; -let result = worker.process_batch(&mut messages); +#[derive(Clone, Debug, Serialize, Deserialize, ReadModel)] +#[readmodel(table = "player_weapons", primary_key = ["player_id", "weapon_id"])] +pub struct PlayerWeaponView { + #[readmodel(foreign_key = "players.player_id", delegated_from = "PlayerView.player_id")] + pub player_id: String, + pub weapon_id: String, + #[readmodel(index)] + pub acquired_at: String, +} ``` -### When to use it +The derive emits `RelationalReadModel` metadata, row conversion, primary-key +metadata, JSONB column metadata, indexes, and an adapter-owned version column. +Composite and delegated keys are represented in the schema and in session row +mutations. -- Dashboards, analytics, search indexes, reports -- Any view where "a few milliseconds stale" is perfectly fine -- Cross-service views (the outbox guarantees at-least-once delivery) -- Most read models in most systems +## Command-Side Atomic Writes -When projections run asynchronously, read models are eventually consistent: -there can be a short gap between a committed aggregate event record and the -query view reflecting the corresponding published message. - -This is the **default recommendation**. Start here unless you have a -specific reason not to. - ---- - -## 2. Atomic Commits (The Gaming Pattern) - -Sometimes the response to a command must include the fully consistent, -updated view. The canonical example: a single-player game where the -backend processes a move and returns the complete updated game state. - -`sourced_rust` supports this with commit builders backed by repositories that -implement `TransactionalCommit`. Those repositories write the aggregate and one -or more read models in a single transaction boundary: +Use `ReadModelSession` when a command or projector stages multiple document or +normalized row mutations. The current repository APIs are synchronous: ```rust -use sourced_rust::CommitBuilderExt; +use sourced_rust::{ReadModelSession, ReadModelSessionCommitExt}; -// Player submits a move -game.make_move(player_move); +let mut read_models = ReadModelSession::new(); +read_models.save(&player)?; +read_models.save_related(&player, "weapons", &weapon)?; -// Build the view from the updated aggregate -let mut view = GameView::from(&game); +repo.read_models(read_models).commit(&mut aggregate)?; +``` -// Commit aggregate + view in one transactional batch -repo.readmodel(&view).commit(&mut game)?; +Async adapters can expose the same shape at their boundary: -// Return `view` to the client — it reflects the committed state +```rust,ignore +repo.read_models(read_models).commit(&mut aggregate).await?; ``` -### Chaining multiple read models and outbox - -You can chain any combination of read models and outbox messages: +Builder ordering is semantic staging only. These forms are equivalent: ```rust -repo.readmodel(&game_view) - .readmodel(&leaderboard_entry) - .outbox(move_event) - .commit(&mut game)?; -``` +repo.read_models(read_models) + .outbox(message) + .commit(&mut aggregate)?; -Order doesn't matter — the commit writes everything in one transactional batch -when the repository implements `TransactionalCommit`. +repo.outbox(message) + .read_models(read_models) + .commit(&mut aggregate)?; -### Standalone read model writes +repo.aggregate(&mut aggregate) + .read_models(read_models) + .outbox(message) + .commit()?; +``` -If you need to write read models without an aggregate (e.g., materializing -a view in a denormalizer): +Document views use the same commit-builder spelling: ```rust -repo.readmodel(&view1) - .readmodel(&view2) - .commit_all()?; +repo.readmodel(&board_view).commit(&mut game)?; ``` -### When to use it - -- Single-player games or turn-based games where the response is the game state -- Wizard/multi-step workflows where each step returns the updated form state -- Any command where the caller **needs** the updated view in the response +## Standalone Distributed Projectors -### Why you don't need read model locking here +A read-model service can commit a session without owning an aggregate +repository: -If you're using a `QueuedRepository` (which serializes writes per entity), -the aggregate already has a lock. The read model commit just rides along -inside that lock scope. No additional read model locking is needed. - -``` -Request A (entity "game-42") ──→ [entity lock] ──→ commit(agg + view) ──→ unlock -Request B (entity "game-42") ──→ [waits] ──→ [entity lock] ──→ commit ──→ unlock +```rust +use sourced_rust::{ReadModelError, ReadModelSession, ReadModelSessionStore}; + +fn project_message( + store: &impl ReadModelSessionStore, + event_id: &str, + view: &GameView, +) -> Result<(), ReadModelError> { + let mut read_models = ReadModelSession::new(); + read_models + .document(view)? + .mark_processed("game-view-projector", event_id); + + let outcome = read_models.commit(store)?; + if outcome.was_applied() || outcome.was_skipped() { + // Ack the broker message after commit returns. + } + Ok(()) +} ``` -Each request gets the aggregate, processes its command, and commits the -aggregate + view through one transactional batch. The entity-level lock -serializes them. The read model is consistent with the aggregate when the -underlying repository can keep those writes in the same transaction boundary. - ---- +Processed-message marks are committed in the same adapter transaction as +read-model writes when the adapter advertises that capability. Duplicate +messages return a skipped outcome and do not apply the staged mutations again. -## 3. QueuedReadModelStore — The Escape Hatch +## Schema Registry And Bootstrap -`QueuedReadModelStore` wraps any `ReadModelStore` and adds per-instance -locking: `get` acquires a lock, writes (`upsert`, `commit`, etc.) release it. +Register relational models once and pass the registry to adapters: ```rust -use sourced_rust::{QueuedReadModelStore, HashMapRepository, ReadModelsExt}; - -let store = QueuedReadModelStore::new(HashMapRepository::new()); +use sourced_rust::ReadModelSchemaRegistry; -// get() acquires a lock on "counter-1" in the "counter_views" collection -let loaded = store.read_models::().get("counter-1")?.unwrap(); +let mut registry = ReadModelSchemaRegistry::new(); +registry + .register::()? + .register::()?; -// Modify the view... -let mut updated = loaded.data; -updated.set_value(42); - -// upsert() releases the lock -store.read_models::().upsert(&updated)?; +registry.validate()?; ``` -If another thread calls `get` on the same read model instance while it's -locked, it blocks until the lock is released. Different read model types -(different collections) with the same ID do **not** contend. +Adapters implement `ReadModelSchemaAdapter` to generate migration artifacts, +verify startup schema, or explicitly bootstrap dev/test schemas. Production +schema changes should be generated or user-authored migrations plus +verification; normal repository construction and command handling should not +silently sync production schemas. -### Peeking without locking +## Bomberman And Document Views -```rust -use sourced_rust::ReadOpts; +Bomberman `BoardView` is intentionally a document-row read model. It stores a +whole game board view with nested players, bombs, explosions, tiles, turn state, +and counters. Do not treat it as a normalized relational ORM example. -// Read without acquiring a lock -let peeked = store.get_model_with::("counter-1", ReadOpts::no_lock())?; -``` +A Postgres adapter can back this path with a JSONB payload column while +normalized relational models use real columns, primary keys, foreign keys, +indexes, and JSONB columns for selected semistructured fields. -### Aborting +## Queued Document Reads -If you decide not to write after reading, release the lock manually: +`QueuedReadModelStore` preserves key/value lock behavior for document stores. +Use explicit spellings for lock intent: ```rust -store.abort::("counter-1")?; +let locked = store.load_for_update::("game-42")?; +let peek = store.load_no_lock::("game-42")?; +store.abort::("game-42")?; ``` -### When you might think you need it +`get_by_primary_key` is a read helper and does not imply command-side ownership. -The typical scenario: two different aggregates updating the same read model -concurrently. For example, an `Order` aggregate and an `Inventory` aggregate -both updating a `ProductDashboardView`. - -### Why you probably don't - -Before reaching for `QueuedReadModelStore`, consider these alternatives: - -**Bad aggregate boundaries.** If two aggregates must update the same view -atomically, ask whether they should be one aggregate. The whole point of -aggregate boundaries is to define consistency boundaries. If two things -need transactional consistency, they belong together. - -**Use a saga.** If the aggregates are genuinely separate but their effects -need coordination, that's what sagas are for. An `OrderPlaced` event -triggers an inventory reservation through a saga — not through shared -mutable state. - -**Eventually consistent is fine.** Most cross-aggregate views don't need -real-time consistency. A dashboard that's 100ms stale is fine. Use the -outbox + denormalizer pattern and let each event update its slice of the -view independently. - -### When it's legitimately useful - -You've considered the above and still need it. The canonical example: -**seat holds** or **ticket reservations** — where the lock itself is the -feature ("held for you for X minutes"), not a concurrency workaround. - -In these cases, the read model represents a resource with contention by -design, and the lock semantics map directly to the domain concept. - ---- - -## Decision Flowchart - -``` -Do you need the updated view in the command response? -│ -├─ No -│ └─ Use eventually consistent (outbox + denormalizer) -│ -└─ Yes - └─ Use transactional commit: repo.readmodel(&view).commit(&mut agg) - │ - └─ Is there concurrent write contention on the view - from different aggregates? - │ - ├─ No - │ └─ You're done. Entity locking handles it. - │ - └─ Yes - ├─ Should the writers be one aggregate? → Merge them. - ├─ Should this be a saga? → Use a saga. - ├─ Is eventual consistency acceptable? → Use the outbox. - └─ Still need it? → Use QueuedReadModelStore. -``` +## Non-Goals ---- - -## API Quick Reference - -### ReadModelStore methods (via `read_models::()`) - -| Method | Description | -|---|---| -| `get(id)` | Load by ID. Returns `Option>` | -| `upsert(model)` | Insert or replace | -| `insert(model)` | Insert; fails if exists | -| `update(model, version)` | Optimistic concurrency update | -| `delete(id)` | Delete by ID | -| `find(predicate)` | Find all matching | -| `find_one(predicate)` | Find first matching | - -### CommitBuilder (via `CommitBuilderExt`) - -| Method | Description | -|---|---| -| `repo.readmodel(&view)` | Start a builder with a read model | -| `repo.outbox(msg)` | Start a builder with an outbox message | -| `.readmodel(&view)` | Add another read model to the batch | -| `.outbox(msg)` | Add another outbox message to the batch | -| `.commit(&mut agg)` | Write everything + the aggregate through `TransactionalCommit` | -| `.commit_all()` | Write staged read models/outbox messages without a primary aggregate | - -### QueuedReadModelStore extras - -| Method | Description | -|---|---| -| `get_model_with(id, ReadOpts::no_lock())` | Peek without locking | -| `find_models_with(pred, ReadOpts::no_lock())` | Find without locking | -| `lock::(id)` | Manually acquire lock | -| `unlock::(id)` / `abort::(id)` | Manually release lock | +The relational ORM slice is a persistence mapper, not a business layer. It does +not own business logic, authorization policy, aggregate invariants, domain event +selection, public query APIs, lifecycle hooks, hidden cascades, document-store +mutation APIs, or broad SQL query DSLs. diff --git a/sourced_rust_macros/src/read_model.rs b/sourced_rust_macros/src/read_model.rs index 38c1aad..c78d01e 100644 --- a/sourced_rust_macros/src/read_model.rs +++ b/sourced_rust_macros/src/read_model.rs @@ -1,6 +1,9 @@ use proc_macro::TokenStream; -use quote::quote; -use syn::{Data, DeriveInput, Fields, LitStr}; +use quote::{quote, ToTokens}; +use syn::{ + punctuated::Punctuated, Data, DeriveInput, Expr, ExprArray, ExprLit, Field, Fields, + GenericArgument, Lit, LitStr, PathArguments, Token, Type, +}; pub fn derive_read_model(input: TokenStream) -> TokenStream { let input = syn::parse_macro_input!(input as DeriveInput); @@ -12,50 +15,61 @@ pub fn derive_read_model(input: TokenStream) -> TokenStream { fn expand_read_model(input: DeriveInput) -> syn::Result { let name = &input.ident; + let struct_attrs = StructAttrs::from_input(&input)?; + let fields = named_fields(&input)?; + let field_attrs = fields + .named + .iter() + .map(FieldAttrs::from_field) + .collect::>>()?; + let relational = + struct_attrs.is_relational() || field_attrs.iter().any(FieldAttrs::is_relational); + + let id_field = find_id_field(&fields.named, &field_attrs)?; + let collection = struct_attrs + .collection + .clone() + .or_else(|| struct_attrs.table.clone()) + .unwrap_or_else(|| format!("{}s", to_snake_case(&name.to_string()))); + + let read_model_impl = if let Some(id_field) = &id_field { + Some(quote! { + impl sourced_rust::ReadModel for #name { + const COLLECTION: &'static str = #collection; + + fn id(&self) -> &str { + &self.#id_field + } + } + }) + } else if relational { + None + } else { + return Err(syn::Error::new_spanned( + input, + "ReadModel derive requires a field named `id` or a field marked with #[readmodel(id)]", + )); + }; - // Extract #[readmodel(collection = "...")] from struct-level attributes - let collection = extract_collection(&input); - - // Extract the field marked with #[readmodel(id)] or default to "id" - let id_field = extract_id_field(&input)?; + let relational_impl = if relational { + Some(expand_relational_read_model( + name, + &struct_attrs, + &fields.named, + &field_attrs, + id_field.as_ref(), + )?) + } else { + None + }; Ok(quote! { - impl sourced_rust::ReadModel for #name { - const COLLECTION: &'static str = #collection; - - fn id(&self) -> &str { - &self.#id_field - } - } + #read_model_impl + #relational_impl }) } -fn extract_collection(input: &DeriveInput) -> String { - for attr in &input.attrs { - if !attr.path().is_ident("readmodel") { - continue; - } - - let mut collection = None; - let _ = attr.parse_nested_meta(|meta| { - if meta.path.is_ident("collection") { - let value: LitStr = meta.value()?.parse()?; - collection = Some(value.value()); - } - Ok(()) - }); - - if let Some(c) = collection { - return c; - } - } - - // Default: snake_case struct name + "s" - let name = input.ident.to_string(); - format!("{}s", to_snake_case(&name)) -} - -fn extract_id_field(input: &DeriveInput) -> syn::Result { +fn named_fields(input: &DeriveInput) -> syn::Result<&FieldsNamed> { let Data::Struct(data_struct) = &input.data else { return Err(syn::Error::new_spanned( input, @@ -70,52 +84,575 @@ fn extract_id_field(input: &DeriveInput) -> syn::Result { )); }; - let mut explicit_id: Option = None; - for field in &fields.named { + Ok(fields) +} + +type FieldsNamed = syn::FieldsNamed; + +fn expand_relational_read_model( + name: &syn::Ident, + struct_attrs: &StructAttrs, + fields: &Punctuated, + field_attrs: &[FieldAttrs], + id_field: Option<&syn::Ident>, +) -> syn::Result { + let model_name = name.to_string(); + let table_name = struct_attrs + .table + .clone() + .or_else(|| struct_attrs.collection.clone()) + .unwrap_or_else(|| format!("{}s", to_snake_case(&model_name))); + + let primary_key_fields = + relational_primary_key_fields(struct_attrs, fields, field_attrs, id_field); + let primary_key_columns = primary_key_fields + .iter() + .map(|column| quote! { #column.to_string() }) + .collect::>(); + + let mut column_defs = Vec::new(); + let mut row_inserts = Vec::new(); + let mut row_fields = Vec::new(); + let mut key_inserts = Vec::new(); + let mut foreign_keys = Vec::new(); + let mut indexes = Vec::new(); + let mut relationships = Vec::new(); + + for (field, attrs) in fields.iter().zip(field_attrs) { + let ident = field + .ident + .as_ref() + .ok_or_else(|| syn::Error::new_spanned(field, "ReadModel fields must be named"))?; + let field_name = ident.to_string(); + + if let Some(relationship) = attrs.relationship_tokens(&field_name)? { + relationships.push(relationship); + row_fields.push(quote! { #ident: ::core::default::Default::default() }); + continue; + } + + if attrs.skip_query { + row_fields.push(quote! { #ident: ::core::default::Default::default() }); + continue; + } + + let column_name = attrs.column.clone().unwrap_or_else(|| field_name.clone()); + let primary_key = primary_key_fields + .iter() + .any(|pk| pk == &field_name || pk == &column_name); + let nullable = attrs.nullable || option_inner_type(&field.ty).is_some(); + let column_type = column_type_tokens(&field.ty, attrs.jsonb); + let default_tokens = option_string_tokens(attrs.default.as_deref()); + let foreign_key_value = attrs.foreign_key.as_ref().map(foreign_key_tokens); + let foreign_key = foreign_key_value + .as_ref() + .map(|foreign_key| quote! { Some(#foreign_key) }) + .unwrap_or_else(|| quote! { None }); + if let Some(foreign_key) = &foreign_key_value { + foreign_keys.push(foreign_key.clone()); + } + let delegated_from = option_string_tokens(attrs.delegated_from.as_deref()); + let has_default = attrs.has_default; + let jsonb = attrs.jsonb; + + column_defs.push(quote! { + sourced_rust::ColumnDef { + field_name: #field_name.to_string(), + column_name: #column_name.to_string(), + column_type: #column_type, + nullable: #nullable, + has_default: #has_default, + default: #default_tokens, + primary_key: #primary_key, + foreign_key: #foreign_key, + delegated_from: #delegated_from, + jsonb: #jsonb, + skipped: false, + } + }); + + row_inserts.push(quote! { + row.insert_serde(#column_name, &self.#ident)?; + }); + row_fields.push(quote! { + #ident: row.get_serde(#column_name)? + }); + + if primary_key { + key_inserts.push(quote! { + key.values.insert( + #column_name.to_string(), + sourced_rust::RowValue::from_serde(&self.#ident)?, + ); + }); + } + + if attrs.indexed || attrs.unique { + let index_name = attrs + .index_name + .clone() + .unwrap_or_else(|| format!("idx_{}_{}", table_name, column_name)); + let unique = attrs.unique; + indexes.push(quote! { + sourced_rust::IndexDef { + name: Some(#index_name.to_string()), + columns: vec![#column_name.to_string()], + unique: #unique, + } + }); + } + } + + Ok(quote! { + impl sourced_rust::RelationalReadModel for #name { + fn schema() -> sourced_rust::ReadModelSchema { + sourced_rust::ReadModelSchema { + model_name: #model_name.to_string(), + table_name: #table_name.to_string(), + columns: vec![#(#column_defs),*], + primary_key: sourced_rust::PrimaryKey { + columns: vec![#(#primary_key_columns),*], + }, + version_column: Some(sourced_rust::DEFAULT_READ_MODEL_VERSION_COLUMN.to_string()), + foreign_keys: vec![#(#foreign_keys),*], + indexes: vec![#(#indexes),*], + relationships: vec![#(#relationships),*], + } + } + + fn primary_key(&self) -> Result { + let mut key = sourced_rust::RowKey::default(); + #(#key_inserts)* + Ok(key) + } + + fn to_row(&self) -> Result { + let mut row = sourced_rust::RowValues::new(); + #(#row_inserts)* + Ok(row) + } + + fn from_row(row: sourced_rust::RowValues) -> Result { + Ok(Self { + #(#row_fields),* + }) + } + } + }) +} + +fn relational_primary_key_fields( + struct_attrs: &StructAttrs, + fields: &Punctuated, + field_attrs: &[FieldAttrs], + id_field: Option<&syn::Ident>, +) -> Vec { + if !struct_attrs.primary_key.is_empty() { + return struct_attrs + .primary_key + .iter() + .map(|key| { + fields + .iter() + .zip(field_attrs) + .find_map(|(field, attrs)| { + let field_name = field.ident.as_ref()?.to_string(); + if &field_name == key { + Some(attrs.column.clone().unwrap_or(field_name)) + } else { + None + } + }) + .unwrap_or_else(|| key.clone()) + }) + .collect(); + } + + id_field + .map(|id| { + let id_name = id.to_string(); + fields + .iter() + .zip(field_attrs) + .find_map(|(field, attrs)| { + let field_name = field.ident.as_ref()?.to_string(); + if field_name == id_name { + Some(attrs.column.clone().unwrap_or(field_name)) + } else { + None + } + }) + .unwrap_or(id_name) + }) + .into_iter() + .collect() +} + +#[derive(Default)] +struct StructAttrs { + collection: Option, + table: Option, + primary_key: Vec, +} + +impl StructAttrs { + fn from_input(input: &DeriveInput) -> syn::Result { + let mut attrs = Self::default(); + for attr in &input.attrs { + if !attr.path().is_ident("readmodel") { + continue; + } + + attr.parse_nested_meta(|meta| { + if meta.path.is_ident("collection") { + attrs.collection = Some(meta.value()?.parse::()?.value()); + } else if meta.path.is_ident("table") { + attrs.table = Some(meta.value()?.parse::()?.value()); + } else if meta.path.is_ident("primary_key") { + let expr = meta.value()?.parse::()?; + attrs.primary_key = parse_string_list(expr)?; + } + Ok(()) + })?; + } + Ok(attrs) + } + + fn is_relational(&self) -> bool { + self.table.is_some() || !self.primary_key.is_empty() + } +} + +#[derive(Default)] +struct FieldAttrs { + id: bool, + column: Option, + indexed: bool, + index_name: Option, + unique: bool, + jsonb: bool, + skip_query: bool, + nullable: bool, + has_default: bool, + default: Option, + foreign_key: Option, + delegated_from: Option, + relationship: Option, +} + +impl FieldAttrs { + fn from_field(field: &Field) -> syn::Result { + let mut attrs = Self::default(); for attr in &field.attrs { - if attr.path().is_ident("readmodel") { - let mut is_id = false; - attr.parse_nested_meta(|meta| { - if meta.path.is_ident("id") { - is_id = true; + if !attr.path().is_ident("readmodel") { + continue; + } + + attr.parse_nested_meta(|meta| { + if meta.path.is_ident("id") { + attrs.id = true; + } else if meta.path.is_ident("column") { + attrs.column = Some(meta.value()?.parse::()?.value()); + } else if meta.path.is_ident("index") { + attrs.indexed = true; + if meta.input.peek(Token![=]) { + attrs.index_name = Some(meta.value()?.parse::()?.value()); } - Ok(()) - })?; - if is_id { - let ident = field.ident.clone().ok_or_else(|| { - syn::Error::new_spanned(field, "ReadModel id field must be named") - })?; - if let Some(previous) = &explicit_id { - return Err(syn::Error::new_spanned( - field, - format!( - "Multiple #[readmodel(id)] fields found: `{}` and `{}`", - previous, ident - ), - )); + } else if meta.path.is_ident("unique") { + attrs.unique = true; + attrs.indexed = true; + } else if meta.path.is_ident("jsonb") { + attrs.jsonb = true; + } else if meta.path.is_ident("skip_query") + || meta.path.is_ident("skip") + || meta.path.is_ident("private") + { + attrs.skip_query = true; + } else if meta.path.is_ident("nullable") { + attrs.nullable = true; + } else if meta.path.is_ident("default") { + attrs.has_default = true; + if meta.input.peek(Token![=]) { + attrs.default = Some(meta.value()?.parse::()?.value()); } - explicit_id = Some(ident); + } else if meta.path.is_ident("foreign_key") { + let value = meta.value()?.parse::()?.value(); + if attrs.relationship.is_some() { + relationship_mut(&mut attrs, "foreign_key")?.foreign_key = Some(value); + } else { + attrs.foreign_key = Some(parse_foreign_key(&value)?); + } + } else if meta.path.is_ident("delegated_from") { + attrs.delegated_from = Some(meta.value()?.parse::()?.value()); + } else if meta.path.is_ident("has_many") { + let target = meta.value()?.parse::()?.value(); + attrs.relationship = Some(RelationshipAttr { + kind: RelationshipKindAttr::HasMany, + target_model: target, + foreign_key: None, + through: None, + }); + } else if meta.path.is_ident("belongs_to") { + let target = meta.value()?.parse::()?.value(); + attrs.relationship = Some(RelationshipAttr { + kind: RelationshipKindAttr::BelongsTo, + target_model: target, + foreign_key: None, + through: None, + }); + } else if meta.path.is_ident("many_to_many") { + let target = meta.value()?.parse::()?.value(); + attrs.relationship = Some(RelationshipAttr { + kind: RelationshipKindAttr::ManyToMany, + target_model: target, + foreign_key: None, + through: None, + }); + } else if meta.path.is_ident("through") { + let through = meta.value()?.parse::()?.value(); + relationship_mut(&mut attrs, "through")?.through = Some(through); } - } + Ok(()) + })?; } + Ok(attrs) } - if let Some(ident) = explicit_id { - return Ok(ident); + + fn is_relational(&self) -> bool { + self.column.is_some() + || self.indexed + || self.unique + || self.jsonb + || self.skip_query + || self.nullable + || self.has_default + || self.foreign_key.is_some() + || self.delegated_from.is_some() + || self.relationship.is_some() } - // Default: look for a field named "id" - for field in &fields.named { - if let Some(ident) = &field.ident { - if ident == "id" { - return Ok(ident.clone()); + fn relationship_tokens( + &self, + field_name: &str, + ) -> syn::Result> { + let Some(relationship) = &self.relationship else { + return Ok(None); + }; + let Some(foreign_key) = relationship.foreign_key.as_deref() else { + return Err(syn::Error::new( + proc_macro2::Span::call_site(), + format!("relationship `{field_name}` must declare `foreign_key = \"...\"`"), + )); + }; + let target_model = &relationship.target_model; + let through = option_string_tokens(relationship.through.as_deref()); + let kind = match relationship.kind { + RelationshipKindAttr::HasMany => quote! { sourced_rust::RelationshipKind::HasMany }, + RelationshipKindAttr::BelongsTo => quote! { sourced_rust::RelationshipKind::BelongsTo }, + RelationshipKindAttr::ManyToMany => { + quote! { sourced_rust::RelationshipKind::ManyToMany } } + }; + Ok(Some(quote! { + sourced_rust::RelationshipDef { + field_name: #field_name.to_string(), + kind: #kind, + target_model: #target_model.to_string(), + foreign_key: Some(#foreign_key.to_string()), + through: #through, + } + })) + } +} + +fn relationship_mut<'a>( + attrs: &'a mut FieldAttrs, + name: &str, +) -> syn::Result<&'a mut RelationshipAttr> { + attrs.relationship.as_mut().ok_or_else(|| { + syn::Error::new( + proc_macro2::Span::call_site(), + format!("`{name}` must be declared after a relationship attribute"), + ) + }) +} + +#[derive(Clone)] +struct ForeignKeyParts { + table: String, + column: String, +} + +#[derive(Clone)] +struct RelationshipAttr { + kind: RelationshipKindAttr, + target_model: String, + foreign_key: Option, + through: Option, +} + +#[derive(Clone, Copy)] +enum RelationshipKindAttr { + HasMany, + BelongsTo, + ManyToMany, +} + +fn find_id_field( + fields: &Punctuated, + field_attrs: &[FieldAttrs], +) -> syn::Result> { + let mut explicit_id: Option = None; + for (field, attrs) in fields.iter().zip(field_attrs) { + if attrs.id { + let ident = field.ident.clone().ok_or_else(|| { + syn::Error::new_spanned(field, "ReadModel id field must be named") + })?; + if let Some(previous) = &explicit_id { + return Err(syn::Error::new_spanned( + field, + format!( + "Multiple #[readmodel(id)] fields found: `{}` and `{}`", + previous, ident + ), + )); + } + explicit_id = Some(ident); } } - Err(syn::Error::new_spanned( - input, - "ReadModel derive requires a field named `id` or a field marked with #[readmodel(id)]", - )) + if explicit_id.is_some() { + return Ok(explicit_id); + } + + Ok(fields + .iter() + .filter_map(|field| field.ident.clone()) + .find(|ident| ident == "id")) +} + +fn parse_string_list(expr: Expr) -> syn::Result> { + match expr { + Expr::Array(ExprArray { elems, .. }) => elems.into_iter().map(parse_string_expr).collect(), + expr => parse_string_expr(expr).map(|value| vec![value]), + } +} + +fn parse_string_expr(expr: Expr) -> syn::Result { + match expr { + Expr::Lit(ExprLit { + lit: Lit::Str(value), + .. + }) => Ok(value.value()), + other => Err(syn::Error::new_spanned( + other, + "expected string literal in readmodel attribute", + )), + } +} + +fn parse_foreign_key(value: &str) -> syn::Result { + let Some((table, column)) = value.split_once('.') else { + return Err(syn::Error::new( + proc_macro2::Span::call_site(), + "foreign_key must use `table.column` syntax", + )); + }; + Ok(ForeignKeyParts { + table: table.to_string(), + column: column.to_string(), + }) +} + +fn foreign_key_tokens(foreign_key: &ForeignKeyParts) -> proc_macro2::TokenStream { + let table = &foreign_key.table; + let column = &foreign_key.column; + quote! { + sourced_rust::ForeignKey { + table: #table.to_string(), + column: #column.to_string(), + } + } +} + +fn option_string_tokens(value: Option<&str>) -> proc_macro2::TokenStream { + match value { + Some(value) => quote! { Some(#value.to_string()) }, + None => quote! { None }, + } +} + +fn column_type_tokens(ty: &Type, jsonb: bool) -> proc_macro2::TokenStream { + if jsonb { + return quote! { sourced_rust::ColumnType::Json }; + } + + let ty = option_inner_type(ty).unwrap_or(ty); + if let Some(last) = last_type_segment(ty) { + let ident = last.ident.to_string(); + return match ident.as_str() { + "String" | "str" => quote! { sourced_rust::ColumnType::Text }, + "bool" => quote! { sourced_rust::ColumnType::Boolean }, + "i8" | "i16" | "i32" | "i64" | "isize" => { + quote! { sourced_rust::ColumnType::Integer } + } + "u8" | "u16" | "u32" | "u64" | "usize" => { + quote! { sourced_rust::ColumnType::UnsignedInteger } + } + "f32" | "f64" => quote! { sourced_rust::ColumnType::Float }, + "Vec" => { + if vec_inner_is_u8(last) { + quote! { sourced_rust::ColumnType::Bytes } + } else { + quote! { sourced_rust::ColumnType::Json } + } + } + "HashMap" | "BTreeMap" | "Value" => quote! { sourced_rust::ColumnType::Json }, + _ => { + let type_name = ty.to_token_stream().to_string(); + quote! { sourced_rust::ColumnType::Unsupported(#type_name.to_string()) } + } + }; + } + + let type_name = ty.to_token_stream().to_string(); + quote! { sourced_rust::ColumnType::Unsupported(#type_name.to_string()) } +} + +fn option_inner_type(ty: &Type) -> Option<&Type> { + let segment = last_type_segment(ty)?; + if segment.ident != "Option" { + return None; + } + let PathArguments::AngleBracketed(args) = &segment.arguments else { + return None; + }; + args.args.iter().find_map(|arg| match arg { + GenericArgument::Type(ty) => Some(ty), + _ => None, + }) +} + +fn last_type_segment(ty: &Type) -> Option<&syn::PathSegment> { + match ty { + Type::Path(path) => path.path.segments.last(), + Type::Reference(reference) => last_type_segment(&reference.elem), + _ => None, + } +} + +fn vec_inner_is_u8(segment: &syn::PathSegment) -> bool { + let PathArguments::AngleBracketed(args) = &segment.arguments else { + return false; + }; + args.args.iter().any(|arg| match arg { + GenericArgument::Type(Type::Path(path)) => path + .path + .segments + .last() + .is_some_and(|segment| segment.ident == "u8"), + _ => false, + }) } fn to_snake_case(s: &str) -> String { @@ -170,7 +707,7 @@ mod tests { } #[test] - fn expand_read_model_rejects_missing_id_field() { + fn expand_read_model_rejects_missing_id_field_for_document_models() { let input: DeriveInput = syn::parse_quote! { struct CounterView { value: i32, @@ -185,6 +722,23 @@ mod tests { ); } + #[test] + fn expand_read_model_allows_composite_relational_models_without_string_id() { + let input: DeriveInput = syn::parse_quote! { + #[readmodel(table = "player_weapons", primary_key = ["player_id", "weapon_id"])] + struct PlayerWeapon { + #[readmodel(foreign_key = "players.player_id", delegated_from = "Player.player_id")] + player_id: String, + weapon_id: String, + } + }; + + let expanded = expand_read_model(input).unwrap().to_string(); + + assert!(expanded.contains("impl sourced_rust :: RelationalReadModel for PlayerWeapon")); + assert!(!expanded.contains("impl sourced_rust :: ReadModel for PlayerWeapon")); + } + #[test] fn expand_read_model_rejects_multiple_explicit_id_attributes() { let input: DeriveInput = syn::parse_quote! { @@ -220,6 +774,26 @@ mod tests { ); } + #[test] + fn expand_read_model_rejects_relationships_without_foreign_key() { + let input: DeriveInput = syn::parse_quote! { + #[readmodel(table = "players")] + struct Player { + #[readmodel(id)] + player_id: String, + #[readmodel(has_many = "PlayerWeapon")] + weapons: Vec, + } + }; + + let err = expand_read_model(input).expect_err("missing relationship key should fail"); + + assert!( + err.to_string().contains("foreign_key"), + "unexpected error: {err}" + ); + } + #[test] fn snake_case_preserves_multi_char_lowercase_mapping() { assert_eq!(to_snake_case("İdView"), "i\u{307}d_view"); diff --git a/src/commit_builder/mod.rs b/src/commit_builder/mod.rs index 389b84c..5d36506 100644 --- a/src/commit_builder/mod.rs +++ b/src/commit_builder/mod.rs @@ -1,31 +1,47 @@ -//! CommitBuilder - Chain read models, outbox, and aggregates into one transactional commit batch. +//! CommitBuilder - chain read models, sessions, outbox, and aggregates into one transactional batch. //! //! ## Example //! //! ```ignore -//! // All of these are equivalent - chain methods in any order: +//! // Document read model. //! repo //! .readmodel(&game_view) //! .outbox(message) //! .commit(&mut game)?; //! +//! // Relational/session read models. +//! let mut read_models = sourced_rust::ReadModelSession::new(); +//! read_models.save(&player)?; +//! read_models.save_related(&player, "weapons", &weapon)?; +//! +//! repo +//! .read_models(read_models) +//! .commit(&mut game)?; +//! +//! // Ordering is semantic staging only. //! repo //! .outbox(message) -//! .readmodel(&game_view) +//! .read_models(read_models) //! .commit(&mut game)?; +//! +//! repo +//! .aggregate(&mut game) +//! .read_models(read_models) +//! .outbox(message) +//! .commit()?; //! ``` use crate::aggregate::Aggregate; use crate::entity::Entity; use crate::outbox::OutboxMessage; -use crate::read_model::ReadModel; -use crate::repository::{CommitBatch, ReadModelWrite, RepositoryError, TransactionalCommit}; +use crate::read_model::{ReadModel, ReadModelError, ReadModelSession, ReadModelWritePlan}; +use crate::repository::{CommitBatch, RepositoryError, TransactionalCommit}; /// Builder for chaining multiple items into a single transactional commit batch. pub struct CommitBuilder<'a, R> { repo: &'a R, entities: Vec, - models: Vec, + read_model_plans: Vec, error: Option, } @@ -34,7 +50,7 @@ impl<'a, R> CommitBuilder<'a, R> { Self { repo, entities: vec![], - models: vec![], + read_model_plans: vec![], error: None, } } @@ -48,15 +64,22 @@ impl<'a, R> CommitBuilder<'a, R> { return self; } - let key = format!("{}:{}", M::COLLECTION, model.id()); - 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 - ))); - } + match document_plan(model) { + Ok(plan) => self.read_model_plans.push(plan), + Err(err) => self.error = Some(err), + } + self + } + + /// Add a read-model session to the commit. + pub fn read_models(mut self, session: ReadModelSession) -> Self { + if self.error.is_some() { + return self; + } + + match session.into_write_plan() { + Ok(plan) => self.read_model_plans.push(plan), + Err(err) => self.error = Some(err.into()), } self } @@ -67,6 +90,13 @@ impl<'a, R> CommitBuilder<'a, R> { self } + /// Stage an aggregate and switch to a no-argument staged commit builder. + pub fn aggregate(self, aggregate: &'a mut A) -> StagedCommitBuilder<'a, R> { + let mut builder = StagedCommitBuilder::from_builder(self); + builder.staged_entities.push(aggregate.entity_mut()); + builder + } + /// Commit all items plus the primary aggregate. pub fn commit(mut self, aggregate: &mut A) -> Result<(), RepositoryError> where @@ -78,7 +108,7 @@ impl<'a, R> CommitBuilder<'a, R> { entity_refs.push(aggregate.entity_mut()); self.repo.commit_batch(CommitBatch { entities: entity_refs, - read_models: self.models, + read_model_plans: self.read_model_plans, snapshots: Vec::new(), }) } @@ -102,7 +132,7 @@ impl<'a, R> CommitBuilder<'a, R> { } self.repo.commit_batch(CommitBatch { entities: entity_refs, - read_models: self.models, + read_model_plans: self.read_model_plans, snapshots: Vec::new(), }) } @@ -117,7 +147,89 @@ impl<'a, R> CommitBuilder<'a, R> { let entity_refs: Vec<&mut Entity> = self.entities.iter_mut().collect(); self.repo.commit_batch(CommitBatch { entities: entity_refs, - read_models: self.models, + read_model_plans: self.read_model_plans, + snapshots: Vec::new(), + }) + } + + fn check_staged(&mut self) -> Result<(), RepositoryError> { + if let Some(err) = self.error.take() { + return Err(err); + } + Ok(()) + } +} + +/// Builder returned after one or more aggregates are staged explicitly. +pub struct StagedCommitBuilder<'a, R> { + repo: &'a R, + entities: Vec, + staged_entities: Vec<&'a mut Entity>, + read_model_plans: Vec, + error: Option, +} + +impl<'a, R> StagedCommitBuilder<'a, R> { + fn from_builder(builder: CommitBuilder<'a, R>) -> Self { + Self { + repo: builder.repo, + entities: builder.entities, + staged_entities: Vec::new(), + read_model_plans: builder.read_model_plans, + error: builder.error, + } + } + + pub fn readmodel(mut self, model: &M) -> Self { + if self.error.is_some() { + return self; + } + + match document_plan(model) { + Ok(plan) => self.read_model_plans.push(plan), + Err(err) => self.error = Some(err), + } + self + } + + pub fn read_models(mut self, session: ReadModelSession) -> Self { + if self.error.is_some() { + return self; + } + + match session.into_write_plan() { + Ok(plan) => self.read_model_plans.push(plan), + Err(err) => self.error = Some(err.into()), + } + self + } + + pub fn outbox(mut self, msg: OutboxMessage) -> Self { + self.entities.push(msg.into_entity()); + self + } + + pub fn aggregate(mut self, aggregate: &'a mut A) -> Self { + self.staged_entities.push(aggregate.entity_mut()); + self + } + + pub fn entity(mut self, entity: &'a mut Entity) -> Self { + self.staged_entities.push(entity); + self + } + + pub fn commit(mut self) -> Result<(), RepositoryError> + where + R: TransactionalCommit, + { + self.check_staged()?; + + let mut entity_refs: Vec<&mut Entity> = self.entities.iter_mut().collect(); + entity_refs.extend(self.staged_entities); + self.repo.commit_batch(CommitBatch { + entities: entity_refs, + read_model_plans: self.read_model_plans, snapshots: Vec::new(), }) } @@ -145,11 +257,50 @@ pub trait CommitBuilderExt: TransactionalCommit + Sized { impl CommitBuilderExt for R {} +fn document_plan(model: &M) -> Result { + let mut session = ReadModelSession::new(); + session.document(model).map_err(|err| match err { + ReadModelError::Serde(message) => RepositoryError::Model(format!( + "failed to serialize read model {}:{}: {}", + M::COLLECTION, + model.id(), + message + )), + other => other.into(), + })?; + Ok(session.into_write_plan()?) +} + +/// Extension trait for the new relational read-model session commit entrypoints. +/// +/// Kept separate from `CommitBuilderExt` so the existing +/// `ReadModelsExt::read_models::()` query accessor remains unambiguous unless +/// callers explicitly opt into the session starter. +pub trait ReadModelSessionCommitExt: TransactionalCommit + Sized { + /// Start a commit builder chain with a relational read-model session. + fn read_models(&self, session: ReadModelSession) -> CommitBuilder<'_, Self> { + CommitBuilder::new(self).read_models(session) + } + + /// Start a staged commit builder with an aggregate. + fn aggregate<'a, A: Aggregate>( + &'a self, + aggregate: &'a mut A, + ) -> StagedCommitBuilder<'a, Self> { + CommitBuilder::new(self).aggregate(aggregate) + } +} + +impl ReadModelSessionCommitExt for R {} + #[cfg(test)] mod tests { use super::*; + use crate::lock::{Lock, LockManager}; use crate::read_model::ReadModelsExt; - use crate::{impl_aggregate, Entity, EventRecord, Get, HashMapRepository}; + use crate::{ + impl_aggregate, Entity, EventRecord, Get, HashMapRepository, QueuedReadModelStore, + }; use serde::{Deserialize, Serialize}; use std::cell::RefCell; @@ -186,6 +337,14 @@ mod tests { } } + #[derive(Serialize, Deserialize, Debug, PartialEq, Clone, crate::ReadModel)] + #[readmodel(table = "relational_views")] + struct RelationalView { + #[readmodel(id)] + id: String, + counter: i32, + } + #[derive(Deserialize, Clone)] struct FailingView { id: String, @@ -222,9 +381,14 @@ mod tests { .map(|entity| entity.id().to_string()) .collect(); *self.read_model_keys.borrow_mut() = batch - .read_models + .read_model_plans .iter() - .map(|write| write.key.clone()) + .flat_map(|plan| { + plan.mutations + .iter() + .map(|mutation| mutation.lock_key()) + .collect::>() + }) .collect(); if self.fail { @@ -238,6 +402,12 @@ mod tests { } } + fn raw_session(view: &TestView) -> crate::read_model::ReadModelSession { + let mut session = crate::read_model::ReadModelSession::new(); + session.document(view).unwrap(); + session + } + #[test] fn commit_readmodel_and_aggregate() { let repo = HashMapRepository::new(); @@ -253,11 +423,54 @@ mod tests { repo.readmodel(&view).commit(&mut agg).unwrap(); // Verify read model stored - let loaded = repo.read_models::().get("1").unwrap(); + let loaded = ReadModelsExt::read_models::(&repo) + .get("1") + .unwrap(); assert!(loaded.is_some()); assert_eq!(loaded.unwrap().data.counter, 42); } + #[test] + fn readmodel_commits_document_plan() { + let repo = HashMapRepository::new(); + let view = TestView { + id: "alias".into(), + counter: 12, + }; + let mut agg = TestAggregate::default(); + agg.touch(); + + repo.readmodel(&view).commit(&mut agg).unwrap(); + + let loaded = ReadModelsExt::read_models::(&repo) + .get("alias") + .unwrap() + .unwrap(); + assert_eq!(loaded.data.counter, 12); + } + + #[test] + fn read_models_session_primary_command_side_form_commits_document_plan() { + let repo = HashMapRepository::new(); + let view = TestView { + id: "session".into(), + counter: 13, + }; + let mut agg = TestAggregate::default(); + agg.touch(); + + ReadModelSessionCommitExt::read_models(&repo, raw_session(&view)) + .commit(&mut agg) + .unwrap(); + + let loaded = ReadModelsExt::read_models::(&repo) + .get("session") + .unwrap() + .unwrap(); + assert_eq!(loaded.data.counter, 13); + assert_eq!(agg.entity().committed_version(), 1); + } + #[test] fn commit_multiple_readmodels() { let repo = HashMapRepository::new(); @@ -279,8 +492,14 @@ mod tests { .commit(&mut agg) .unwrap(); - let loaded1 = repo.read_models::().get("1").unwrap().unwrap(); - let loaded2 = repo.read_models::().get("2").unwrap().unwrap(); + let loaded1 = ReadModelsExt::read_models::(&repo) + .get("1") + .unwrap() + .unwrap(); + let loaded2 = ReadModelsExt::read_models::(&repo) + .get("2") + .unwrap() + .unwrap(); assert_eq!(loaded1.data.counter, 10); assert_eq!(loaded2.data.counter, 20); } @@ -305,7 +524,9 @@ mod tests { .commit(&mut agg) .unwrap(); - let loaded = repo.read_models::().get("1").unwrap(); + let loaded = ReadModelsExt::read_models::(&repo) + .get("1") + .unwrap(); assert!(loaded.is_some()); assert_eq!(loaded.unwrap().data.counter, 42); } @@ -330,7 +551,9 @@ mod tests { .commit(&mut agg) .unwrap(); - let loaded = repo.read_models::().get("1").unwrap(); + let loaded = ReadModelsExt::read_models::(&repo) + .get("1") + .unwrap(); assert!(loaded.is_some()); assert_eq!(loaded.unwrap().data.counter, 99); } @@ -353,13 +576,11 @@ mod tests { .commit_all() .unwrap(); - let loaded1 = repo - .read_models::() + let loaded1 = ReadModelsExt::read_models::(&repo) .get("standalone-1") .unwrap() .unwrap(); - let loaded2 = repo - .read_models::() + let loaded2 = ReadModelsExt::read_models::(&repo) .get("standalone-2") .unwrap() .unwrap(); @@ -389,8 +610,7 @@ mod tests { .unwrap(); // Verify read model stored - let loaded = repo - .read_models::() + let loaded = ReadModelsExt::read_models::(&repo) .get("multi") .unwrap() .unwrap(); @@ -403,6 +623,152 @@ mod tests { assert!(e2.is_some()); } + #[test] + fn staged_builder_ordering_is_semantic_for_outbox_session_and_aggregate() { + fn record(order: u8) -> (Vec, Vec) { + let repo = RecordingBatchRepo::default(); + let view = TestView { + id: "ordered".into(), + counter: 7, + }; + let outbox = OutboxMessage::create("ordered-msg", "TestEvent", b"{}".to_vec()).unwrap(); + let mut agg = TestAggregate::default(); + agg.touch(); + + match order { + 0 => ReadModelSessionCommitExt::read_models(&repo, raw_session(&view)) + .outbox(outbox) + .aggregate(&mut agg) + .commit() + .unwrap(), + 1 => repo + .outbox(outbox) + .read_models(raw_session(&view)) + .aggregate(&mut agg) + .commit() + .unwrap(), + _ => ReadModelSessionCommitExt::aggregate(&repo, &mut agg) + .read_models(raw_session(&view)) + .outbox(outbox) + .commit() + .unwrap(), + } + + let recorded = ( + repo.entity_ids.borrow().clone(), + repo.read_model_keys.borrow().clone(), + ); + recorded + } + + let baseline = record(0); + assert_eq!(record(1), baseline); + assert_eq!(record(2), baseline); + } + + #[test] + fn staged_builder_supports_multiple_aggregates() { + let repo = RecordingBatchRepo::default(); + let view = TestView { + id: "staged-multi".into(), + counter: 77, + }; + let mut agg1 = TestAggregate::default(); + agg1.touch(); + agg1.entity.set_id("agg-1"); + let mut agg2 = TestAggregate::default(); + agg2.touch(); + agg2.entity.set_id("agg-2"); + + ReadModelSessionCommitExt::read_models(&repo, raw_session(&view)) + .aggregate(&mut agg1) + .aggregate(&mut agg2) + .commit() + .unwrap(); + + assert_eq!( + repo.read_model_keys.borrow().as_slice(), + &["test_view:staged-multi".to_string()] + ); + assert_eq!( + repo.entity_ids.borrow().as_slice(), + &["agg-1".to_string(), "agg-2".to_string()] + ); + } + + #[test] + fn queued_read_model_lock_is_released_after_session_commit() { + let repo = QueuedReadModelStore::new(HashMapRepository::new()); + let view = TestView { + id: "locked".into(), + counter: 1, + }; + ReadModelsExt::read_models::(&repo) + .upsert(&view) + .unwrap(); + let _loaded = ReadModelsExt::read_models::(&repo) + .get("locked") + .unwrap() + .unwrap(); + + let updated = TestView { + id: "locked".into(), + counter: 2, + }; + let mut agg = TestAggregate::default(); + agg.touch(); + + ReadModelSessionCommitExt::read_models(&repo, raw_session(&updated)) + .commit(&mut agg) + .unwrap(); + + let lock = repo.lock_manager().get_lock("test_view:locked").unwrap(); + assert!(lock.try_lock().unwrap()); + lock.unlock().unwrap(); + } + + #[test] + fn invalid_session_plan_does_not_commit_aggregate() { + let repo = RecordingBatchRepo::default(); + let mut session = crate::read_model::ReadModelSession::new(); + session.mark_processed("", "message-1"); + let mut agg = TestAggregate::default(); + agg.touch(); + + let err = ReadModelSessionCommitExt::read_models(&repo, session) + .commit(&mut agg) + .unwrap_err(); + + assert!( + matches!(err, RepositoryError::Model(message) if message.contains("processed-message")) + ); + assert_eq!(agg.entity().committed_version(), 0); + assert!(repo.entity_ids.borrow().is_empty()); + } + + #[test] + fn unsupported_relational_write_plan_does_not_commit_aggregate() { + let repo = HashMapRepository::new(); + let view = RelationalView { + id: "relational".into(), + counter: 3, + }; + let mut session = crate::read_model::ReadModelSession::new(); + session.save(&view).unwrap(); + let mut agg = TestAggregate::default(); + agg.touch(); + + let err = ReadModelSessionCommitExt::read_models(&repo, session) + .commit(&mut agg) + .unwrap_err(); + + assert!( + matches!(err, RepositoryError::Model(message) if message.contains("relational row writes")) + ); + assert_eq!(agg.entity().committed_version(), 0); + assert!(repo.get("agg-1").unwrap().is_none()); + } + #[test] fn commit_builder_failure_does_not_mark_aggregate_committed() { let repo = RecordingBatchRepo { diff --git a/src/hashmap_repo/repository.rs b/src/hashmap_repo/repository.rs index 987eef5..9448893 100644 --- a/src/hashmap_repo/repository.rs +++ b/src/hashmap_repo/repository.rs @@ -2,9 +2,10 @@ use std::collections::{HashMap, HashSet}; use std::sync::{Arc, RwLock}; use crate::entity::{Committable, Entity, EventRecord}; -use crate::read_model::in_memory::{next_model_version, StoredModel}; +use crate::read_model::in_memory::apply_document_write_plan; use crate::read_model::{ - InMemoryReadModelStore, ReadModel, ReadModelError, ReadModelStore, Versioned, + InMemoryReadModelStore, ReadModel, ReadModelAdapterCapabilities, ReadModelCommitOutcome, + ReadModelError, ReadModelSessionStore, ReadModelStore, ReadModelWritePlan, Versioned, }; use crate::repository::{ Commit, CommitBatch, GetMany, GetOne, RepositoryError, SnapshotWrite, TransactionalCommit, @@ -104,6 +105,11 @@ impl TransactionalCommit for HashMapRepository { .storage .write() .map_err(|_| RepositoryError::LockPoisoned("read model write"))?; + let mut processed_messages = self + .model_store + .processed_messages + .write() + .map_err(|_| RepositoryError::LockPoisoned("processed-message write"))?; let mut snapshot_storage = self .snapshot_store .storage @@ -112,6 +118,7 @@ impl TransactionalCommit for HashMapRepository { let mut staged_events = storage.clone(); let mut staged_models = model_storage.clone(); + let mut staged_processed_messages = processed_messages.clone(); let mut staged_snapshots = snapshot_storage.clone(); // Phase 1: Validate all stream versions before staging any writes. @@ -135,16 +142,18 @@ impl TransactionalCommit for HashMapRepository { stored.extend(new_events); } - for write in batch.read_models { - let new_version = - next_model_version(&write.key, staged_models.get(&write.key).map(|s| s.version))?; - staged_models.insert( - write.key, - StoredModel { - bytes: write.bytes, - version: new_version, - }, - ); + for plan in batch.read_model_plans { + let outcome = apply_document_write_plan( + plan, + &mut staged_models, + &mut staged_processed_messages, + )?; + if let Some(mark) = outcome.duplicate_message() { + return Err(RepositoryError::Model(format!( + "processed message already handled by consumer `{}`: `{}`", + mark.consumer_name, mark.message_id + ))); + } } for write in batch.snapshots { @@ -158,6 +167,7 @@ impl TransactionalCommit for HashMapRepository { // Phase 3: Publish staged state only after all validation and staging succeeds. *storage = staged_events; *model_storage = staged_models; + *processed_messages = staged_processed_messages; *snapshot_storage = staged_snapshots; for entity in batch.entities { @@ -190,6 +200,13 @@ impl ReadModelStore for HashMapRepository { self.model_store.get_model(id) } + fn get_by_primary_key( + &self, + id: &str, + ) -> Result>, ReadModelError> { + self.model_store.get_by_primary_key(id) + } + fn upsert(&self, model: &M) -> Result, ReadModelError> { self.model_store.upsert(model) } @@ -223,9 +240,22 @@ impl ReadModelStore for HashMapRepository { ) -> Result>, ReadModelError> { self.model_store.find_one_model(predicate) } +} + +impl ReadModelSessionStore for HashMapRepository { + fn read_model_capabilities(&self) -> ReadModelAdapterCapabilities { + self.model_store.read_model_capabilities() + } - fn upsert_raw(&self, key: &str, bytes: Vec) -> Result<(), ReadModelError> { - self.model_store.upsert_raw(key, bytes) + fn commit_write_plan( + &self, + plan: ReadModelWritePlan, + ) -> Result { + self.model_store.commit_write_plan(plan) + } + + fn is_processed(&self, consumer_name: &str, message_id: &str) -> Result { + self.model_store.is_processed(consumer_name, message_id) } } @@ -246,6 +276,8 @@ impl SnapshotStore for HashMapRepository { #[cfg(test)] mod tests { use super::*; + use crate::read_model::in_memory::StoredModel; + use crate::read_model::{DocumentMutation, ReadModelMutation}; use crate::repository::Get; #[test] @@ -312,9 +344,9 @@ mod tests { } #[test] - fn read_model_batch_rejects_version_overflow_without_writing() { + fn document_plan_rejects_version_overflow_without_writing() { let repo = HashMapRepository::new(); - let key = "test_models:1".to_string(); + let key = "test_models:plan-overflow".to_string(); let original_bytes = b"old".to_vec(); repo.model_store.storage.write().unwrap().insert( key.clone(), @@ -323,14 +355,19 @@ mod tests { version: u64::MAX, }, ); + let plan = ReadModelWritePlan::new( + vec![ReadModelMutation::Document(DocumentMutation { + collection: "test_models".into(), + id: "plan-overflow".into(), + bytes: b"new".to_vec(), + })], + Vec::new(), + ); let err = repo .commit_batch(CommitBatch { entities: Vec::new(), - read_models: vec![crate::repository::ReadModelWrite::new( - key.clone(), - b"new".to_vec(), - )], + read_model_plans: vec![plan], snapshots: Vec::new(), }) .unwrap_err(); diff --git a/src/lib.rs b/src/lib.rs index 328a231..70b68d6 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -32,8 +32,8 @@ pub type SourcedResult = std::result::Result; // Re-export repository traits at crate root for convenience pub use repository::{ - Commit, CommitBatch, Get, GetMany, GetOne, Gettable, ReadModelWrite, Repository, - RepositoryError, SnapshotWrite, TransactionalCommit, + Commit, CommitBatch, Get, GetMany, GetOne, Gettable, Repository, RepositoryError, + SnapshotWrite, TransactionalCommit, }; // Re-export aggregate types at crate root for convenience @@ -93,12 +93,22 @@ pub use queued_repo::{ // Read models: projections and read-optimized views pub use read_model::{ - InMemoryReadModelStore, QueuedReadModelStore, ReadModel, ReadModelError, ReadModelStore, - ReadModelsExt, Versioned, + ColumnDef, ColumnType, DeleteRowMutation, DocumentMutation, ExpectedVersion, ForeignKey, + InMemoryReadModelStore, IndexDef, PatchMode, PatchRowMutation, PrimaryKey, + ProcessedMessageMark, QueuedReadModelStore, ReadModel, ReadModelAdapterCapabilities, + ReadModelCommitOutcome, ReadModelError, ReadModelLoadRequest, ReadModelMigrationArtifact, + ReadModelMutation, ReadModelSchema, ReadModelSchemaAdapter, ReadModelSchemaAdapterCapabilities, + ReadModelSchemaBootstrap, ReadModelSchemaIssue, ReadModelSchemaIssueKind, + ReadModelSchemaRegistry, ReadModelSchemaVerification, ReadModelSession, ReadModelSessionStore, + ReadModelStore, ReadModelWritePlan, ReadModelsExt, RelationalReadModel, RelationshipDef, + RelationshipKind, RowKey, RowMutation, RowPatch, RowValue, RowValues, RowWriteMode, Versioned, + DEFAULT_READ_MODEL_VERSION_COLUMN, }; // CommitBuilder: transactional batches of read models, outbox, and aggregates -pub use commit_builder::{CommitBuilder, CommitBuilderExt}; +pub use commit_builder::{ + CommitBuilder, CommitBuilderExt, ReadModelSessionCommitExt, StagedCommitBuilder, +}; // Snapshot: periodic aggregate snapshots for fast hydration pub use snapshot::{ diff --git a/src/read_model/in_memory.rs b/src/read_model/in_memory.rs index 2f428c6..9bd1f3b 100644 --- a/src/read_model/in_memory.rs +++ b/src/read_model/in_memory.rs @@ -1,9 +1,13 @@ //! InMemoryReadModelStore - HashMap-backed read model store for testing and development. -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::sync::{Arc, RwLock}; -use super::{ReadModel, ReadModelError, ReadModelStore, Versioned}; +use super::{ + ProcessedMessageMark, ReadModel, ReadModelAdapterCapabilities, ReadModelCommitOutcome, + ReadModelError, ReadModelMutation, ReadModelSessionStore, ReadModelStore, ReadModelWritePlan, + Versioned, +}; /// Internal stored representation of a read model. #[derive(Clone)] @@ -12,6 +16,8 @@ pub(crate) struct StoredModel { pub(crate) version: u64, } +pub(crate) type ProcessedMessageSet = HashSet<(String, String)>; + pub(crate) const INITIAL_MODEL_VERSION: u64 = 1; /// Return the next optimistic version for a read model row. @@ -30,12 +36,64 @@ pub(crate) fn next_model_version( } } +pub(crate) fn apply_document_write_plan( + plan: ReadModelWritePlan, + staged_models: &mut HashMap, + staged_processed_messages: &mut ProcessedMessageSet, +) -> Result { + let capabilities = document_capabilities(); + plan.validate_for(&capabilities)?; + + let mut marks_in_plan = HashSet::with_capacity(plan.processed_messages.len()); + for mark in &plan.processed_messages { + let key = processed_message_key(mark); + if staged_processed_messages.contains(&key) || !marks_in_plan.insert(key) { + return Ok(ReadModelCommitOutcome::skipped_duplicate(mark.clone())); + } + } + + for mutation in plan.mutations { + if let ReadModelMutation::Document(mutation) = mutation { + let key = mutation.key(); + let new_version = next_model_version(&key, staged_models.get(&key).map(|s| s.version))?; + staged_models.insert( + key, + StoredModel { + bytes: mutation.bytes, + version: new_version, + }, + ); + } + } + + for mark in plan.processed_messages { + staged_processed_messages.insert(processed_message_key(&mark)); + } + + Ok(ReadModelCommitOutcome::applied()) +} + +pub(crate) fn document_capabilities() -> ReadModelAdapterCapabilities { + ReadModelAdapterCapabilities { + relational_rows: false, + document_rows: true, + sparse_patches: false, + deletes: false, + processed_messages: true, + } +} + +fn processed_message_key(mark: &ProcessedMessageMark) -> (String, String) { + (mark.consumer_name.clone(), mark.message_id.clone()) +} + /// In-memory read model store backed by a HashMap. /// /// Storage key is `"TABLE:id"`. Clone-friendly via Arc. #[derive(Clone)] pub struct InMemoryReadModelStore { pub(crate) storage: Arc>>, + pub(crate) processed_messages: Arc>, } impl Default for InMemoryReadModelStore { @@ -49,6 +107,7 @@ impl InMemoryReadModelStore { pub fn new() -> Self { Self { storage: Arc::new(RwLock::new(HashMap::new())), + processed_messages: Arc::new(RwLock::new(HashSet::new())), } } @@ -56,8 +115,13 @@ impl InMemoryReadModelStore { format!("{}:{}", table, id) } - /// Save a raw read model entry (used by CommitBuilder for type-erased writes). - pub(crate) fn save_raw(&self, key: &str, bytes: Vec) -> Result { + /// Save pre-serialized document bytes by storage key for in-memory test setup. + #[cfg(test)] + pub(crate) fn save_document_bytes( + &self, + key: &str, + bytes: Vec, + ) -> Result { let mut storage = self .storage .write() @@ -77,6 +141,46 @@ impl InMemoryReadModelStore { } } +impl ReadModelSessionStore for InMemoryReadModelStore { + fn read_model_capabilities(&self) -> ReadModelAdapterCapabilities { + document_capabilities() + } + + fn commit_write_plan( + &self, + plan: ReadModelWritePlan, + ) -> Result { + let mut storage = self + .storage + .write() + .map_err(|_| ReadModelError::Storage("lock poisoned".into()))?; + let mut processed_messages = self + .processed_messages + .write() + .map_err(|_| ReadModelError::Storage("lock poisoned".into()))?; + + let mut staged_models = storage.clone(); + let mut staged_processed_messages = processed_messages.clone(); + let outcome = + apply_document_write_plan(plan, &mut staged_models, &mut staged_processed_messages)?; + + if outcome.was_applied() { + *storage = staged_models; + *processed_messages = staged_processed_messages; + } + + Ok(outcome) + } + + fn is_processed(&self, consumer_name: &str, message_id: &str) -> Result { + let processed_messages = self + .processed_messages + .read() + .map_err(|_| ReadModelError::Storage("lock poisoned".into()))?; + Ok(processed_messages.contains(&(consumer_name.to_string(), message_id.to_string()))) + } +} + impl ReadModelStore for InMemoryReadModelStore { fn get_model(&self, id: &str) -> Result>, ReadModelError> { let key = Self::make_key(M::COLLECTION, id); @@ -266,11 +370,6 @@ impl ReadModelStore for InMemoryReadModelStore { Ok(matched) } - - fn upsert_raw(&self, key: &str, bytes: Vec) -> Result<(), ReadModelError> { - self.save_raw(key, bytes)?; - Ok(()) - } } #[cfg(test)] @@ -326,7 +425,7 @@ mod tests { } #[test] - fn save_raw_returns_error_on_version_overflow() { + fn save_document_bytes_returns_error_on_version_overflow() { let store = InMemoryReadModelStore::new(); let key = InMemoryReadModelStore::make_key(TestModel::COLLECTION, "1"); let bytes = serde_json::to_vec(&TestModel { @@ -342,7 +441,7 @@ mod tests { }, ); - let err = store.save_raw(&key, b"{}".to_vec()).unwrap_err(); + let err = store.save_document_bytes(&key, b"{}".to_vec()).unwrap_err(); assert!( matches!(err, ReadModelError::Storage(message) if message.contains("version overflow")) @@ -533,7 +632,7 @@ mod tests { fn find_models_returns_error_for_corrupted_rows() { let store = InMemoryReadModelStore::new(); store - .save_raw("test_models:bad", b"not valid json".to_vec()) + .save_document_bytes("test_models:bad", b"not valid json".to_vec()) .unwrap(); let err = store.find_models::(&|_| true).unwrap_err(); @@ -545,7 +644,7 @@ mod tests { 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()) + .save_document_bytes("test_models:bad", b"not valid json".to_vec()) .unwrap(); let err = store.find_one_model::(&|_| true).unwrap_err(); @@ -563,7 +662,7 @@ mod tests { }) .unwrap(); store - .save_raw("test_models:bad", b"not valid json".to_vec()) + .save_document_bytes("test_models:bad", b"not valid json".to_vec()) .unwrap(); let err = store diff --git a/src/read_model/metadata.rs b/src/read_model/metadata.rs new file mode 100644 index 0000000..68157ff --- /dev/null +++ b/src/read_model/metadata.rs @@ -0,0 +1,515 @@ +use std::collections::{BTreeMap, BTreeSet}; + +use serde::{de::DeserializeOwned, Serialize}; + +use super::ReadModelError; + +pub const DEFAULT_READ_MODEL_VERSION_COLUMN: &str = "_sourced_version"; + +/// Logical storage type for a relational read-model column. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum ColumnType { + Text, + Boolean, + Integer, + UnsignedInteger, + Float, + Bytes, + Json, + Unsupported(String), +} + +/// A foreign-key declaration from one read-model column to another table column. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ForeignKey { + pub table: String, + pub column: String, +} + +impl ForeignKey { + pub fn new(table: impl Into, column: impl Into) -> Self { + Self { + table: table.into(), + column: column.into(), + } + } +} + +/// Primary-key metadata for a relational read-model table. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct PrimaryKey { + pub columns: Vec, +} + +impl PrimaryKey { + pub fn new(columns: impl IntoIterator>) -> Self { + Self { + columns: columns.into_iter().map(Into::into).collect(), + } + } +} + +/// Runtime primary-key values for one read-model row. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct RowKey { + pub values: BTreeMap, +} + +impl RowKey { + pub fn new(values: impl IntoIterator, RowValue)>) -> Self { + Self { + values: values + .into_iter() + .map(|(column, value)| (column.into(), value)) + .collect(), + } + } + + pub fn insert(&mut self, column: impl Into, value: RowValue) -> Option { + self.values.insert(column.into(), value) + } + + pub fn get(&self, column: &str) -> Option<&RowValue> { + self.values.get(column) + } + + pub fn iter(&self) -> impl Iterator { + self.values + .iter() + .map(|(column, value)| (column.as_str(), value)) + } + + pub fn is_empty(&self) -> bool { + self.values.is_empty() + } +} + +/// Column metadata for a relational read-model table. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ColumnDef { + pub field_name: String, + pub column_name: String, + pub column_type: ColumnType, + pub nullable: bool, + pub has_default: bool, + pub default: Option, + pub primary_key: bool, + pub foreign_key: Option, + pub delegated_from: Option, + pub jsonb: bool, + pub skipped: bool, +} + +impl ColumnDef { + pub fn new( + field_name: impl Into, + column_name: impl Into, + column_type: ColumnType, + ) -> Self { + Self { + field_name: field_name.into(), + column_name: column_name.into(), + column_type, + nullable: false, + has_default: false, + default: None, + primary_key: false, + foreign_key: None, + delegated_from: None, + jsonb: false, + skipped: false, + } + } +} + +/// Index or unique-constraint metadata for a relational read-model table. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct IndexDef { + pub name: Option, + pub columns: Vec, + pub unique: bool, +} + +impl IndexDef { + pub fn new(columns: impl IntoIterator>) -> Self { + Self { + name: None, + columns: columns.into_iter().map(Into::into).collect(), + unique: false, + } + } +} + +/// Relationship category for later session/write-plan lowering. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum RelationshipKind { + HasMany, + BelongsTo, + ManyToMany, +} + +/// Relationship metadata for a relational read model. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct RelationshipDef { + pub field_name: String, + pub kind: RelationshipKind, + pub target_model: String, + pub foreign_key: Option, + pub through: Option, +} + +/// Schema metadata for one relational read-model table. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ReadModelSchema { + pub model_name: String, + pub table_name: String, + pub columns: Vec, + pub primary_key: PrimaryKey, + pub version_column: Option, + pub foreign_keys: Vec, + pub indexes: Vec, + pub relationships: Vec, +} + +impl ReadModelSchema { + pub fn validate(&self) -> Result<(), ReadModelError> { + if self.table_name.is_empty() { + return Err(ReadModelError::Metadata( + "read model schema must declare a table name".into(), + )); + } + + let mut columns = BTreeSet::new(); + for column in &self.columns { + if column.column_name.is_empty() { + return Err(ReadModelError::Metadata(format!( + "read model `{}` has a column with an empty name", + self.model_name + ))); + } + if !columns.insert(column.column_name.as_str()) { + return Err(ReadModelError::Metadata(format!( + "read model `{}` declares duplicate column `{}`", + self.model_name, column.column_name + ))); + } + if let ColumnType::Unsupported(type_name) = &column.column_type { + return Err(ReadModelError::Metadata(format!( + "read model `{}` field `{}` has unsupported field shape `{}`", + self.model_name, column.field_name, type_name + ))); + } + if let Some(foreign_key) = &column.foreign_key { + validate_foreign_key(&self.model_name, foreign_key)?; + } + } + + if let Some(version_column) = &self.version_column { + if version_column.is_empty() { + return Err(ReadModelError::Metadata(format!( + "read model `{}` declares an empty version column", + self.model_name + ))); + } + if columns.contains(version_column.as_str()) { + return Err(ReadModelError::Metadata(format!( + "read model `{}` version column `{}` conflicts with a mapped column", + self.model_name, version_column + ))); + } + } + + if self.primary_key.columns.is_empty() { + return Err(ReadModelError::Metadata(format!( + "read model `{}` must declare at least one primary-key column", + self.model_name + ))); + } + + for column in &self.primary_key.columns { + if !columns.contains(column.as_str()) { + return Err(ReadModelError::Metadata(format!( + "read model `{}` primary key references missing column `{}`", + self.model_name, column + ))); + } + } + + for foreign_key in &self.foreign_keys { + validate_foreign_key(&self.model_name, foreign_key)?; + } + + for index in &self.indexes { + if index.columns.is_empty() { + return Err(ReadModelError::Metadata(format!( + "read model `{}` declares an index with no columns", + self.model_name + ))); + } + for column in &index.columns { + if !columns.contains(column.as_str()) { + return Err(ReadModelError::Metadata(format!( + "read model `{}` index references missing column `{}`", + self.model_name, column + ))); + } + } + } + + for relationship in &self.relationships { + if relationship.target_model.is_empty() { + return Err(ReadModelError::Metadata(format!( + "read model `{}` relationship `{}` must declare a target model", + self.model_name, relationship.field_name + ))); + } + if relationship + .foreign_key + .as_deref() + .is_none_or(str::is_empty) + { + return Err(ReadModelError::Metadata(format!( + "read model `{}` relationship `{}` must declare a foreign key", + self.model_name, relationship.field_name + ))); + } + } + + Ok(()) + } +} + +fn validate_foreign_key(model_name: &str, foreign_key: &ForeignKey) -> Result<(), ReadModelError> { + if foreign_key.table.is_empty() || foreign_key.column.is_empty() { + return Err(ReadModelError::Metadata(format!( + "read model `{model_name}` has an invalid foreign-key declaration" + ))); + } + Ok(()) +} + +/// A typed value in a relational read-model row. +#[derive(Clone, Debug, PartialEq)] +pub enum RowValue { + Null, + Bool(bool), + I64(i64), + U64(u64), + F64(f64), + String(String), + Bytes(Vec), + Json(serde_json::Value), +} + +impl RowValue { + pub fn from_serde(value: &T) -> Result { + let value = + serde_json::to_value(value).map_err(|err| ReadModelError::Serde(err.to_string()))?; + Ok(Self::from_json_value(value)) + } + + pub fn into_json(self) -> serde_json::Value { + match self { + RowValue::Null => serde_json::Value::Null, + RowValue::Bool(value) => serde_json::Value::Bool(value), + RowValue::I64(value) => serde_json::Value::Number(value.into()), + RowValue::U64(value) => serde_json::Value::Number(value.into()), + RowValue::F64(value) => serde_json::json!(value), + RowValue::String(value) => serde_json::Value::String(value), + RowValue::Bytes(value) => serde_json::json!(value), + RowValue::Json(value) => value, + } + } + + fn from_json_value(value: serde_json::Value) -> Self { + match value { + serde_json::Value::Null => RowValue::Null, + serde_json::Value::Bool(value) => RowValue::Bool(value), + serde_json::Value::Number(value) => { + if let Some(value) = value.as_i64() { + RowValue::I64(value) + } else if let Some(value) = value.as_u64() { + RowValue::U64(value) + } else if let Some(value) = value.as_f64() { + RowValue::F64(value) + } else { + RowValue::Json(serde_json::Value::Number(value)) + } + } + serde_json::Value::String(value) => RowValue::String(value), + value @ (serde_json::Value::Array(_) | serde_json::Value::Object(_)) => { + RowValue::Json(value) + } + } + } +} + +/// Column-value map for one relational read-model row. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct RowValues { + values: BTreeMap, +} + +impl RowValues { + pub fn new() -> Self { + Self::default() + } + + pub fn insert(&mut self, column: impl Into, value: RowValue) -> Option { + self.values.insert(column.into(), value) + } + + pub fn insert_serde( + &mut self, + column: impl Into, + value: &T, + ) -> Result, ReadModelError> { + let value = RowValue::from_serde(value)?; + Ok(self.insert(column, value)) + } + + pub fn get(&self, column: &str) -> Option<&RowValue> { + self.values.get(column) + } + + pub fn contains_key(&self, column: &str) -> bool { + self.values.contains_key(column) + } + + pub fn len(&self) -> usize { + self.values.len() + } + + pub fn is_empty(&self) -> bool { + self.values.is_empty() + } + + pub fn get_serde(&self, column: &str) -> Result { + let value = self.values.get(column).ok_or_else(|| { + ReadModelError::Metadata(format!("row is missing required column `{column}`")) + })?; + serde_json::from_value(value.clone().into_json()) + .map_err(|err| ReadModelError::Serde(err.to_string())) + } + + pub fn iter(&self) -> impl Iterator { + self.values + .iter() + .map(|(column, value)| (column.as_str(), value)) + } +} + +impl IntoIterator for RowValues { + type Item = (String, RowValue); + type IntoIter = std::collections::btree_map::IntoIter; + + fn into_iter(self) -> Self::IntoIter { + self.values.into_iter() + } +} + +/// Opt-in trait for table-mapped relational read models. +pub trait RelationalReadModel: Clone + Send + Sync + Sized { + fn schema() -> ReadModelSchema; + fn primary_key(&self) -> Result; + fn to_row(&self) -> Result; + fn from_row(row: RowValues) -> Result; +} + +#[cfg(test)] +mod tests { + use super::*; + + fn valid_schema() -> ReadModelSchema { + ReadModelSchema { + model_name: "PlayerWeapon".into(), + table_name: "player_weapons".into(), + columns: vec![ + ColumnDef { + primary_key: true, + foreign_key: Some(ForeignKey::new("players", "player_id")), + delegated_from: Some("Player.player_id".into()), + ..ColumnDef::new("player_id", "player_id", ColumnType::Text) + }, + ColumnDef { + primary_key: true, + ..ColumnDef::new("weapon_id", "weapon_id", ColumnType::Text) + }, + ], + primary_key: PrimaryKey::new(["player_id", "weapon_id"]), + version_column: Some(DEFAULT_READ_MODEL_VERSION_COLUMN.into()), + foreign_keys: vec![ForeignKey::new("players", "player_id")], + indexes: vec![IndexDef::new(["player_id"])], + relationships: Vec::new(), + } + } + + #[test] + fn validate_accepts_composite_delegated_key_metadata() { + let schema = valid_schema(); + + schema.validate().unwrap(); + } + + #[test] + fn validate_rejects_missing_primary_key() { + let mut schema = valid_schema(); + schema.primary_key = PrimaryKey::default(); + + let err = schema.validate().unwrap_err(); + + assert!( + matches!(err, ReadModelError::Metadata(message) if message.contains("primary-key")) + ); + } + + #[test] + fn validate_rejects_unsupported_field_shapes() { + let mut schema = valid_schema(); + schema.columns.push(ColumnDef::new( + "callback", + "callback", + ColumnType::Unsupported("fn()".into()), + )); + + let err = schema.validate().unwrap_err(); + + assert!( + matches!(err, ReadModelError::Metadata(message) if message.contains("unsupported field shape")) + ); + } + + #[test] + fn validate_rejects_relationships_without_foreign_keys() { + let mut schema = valid_schema(); + schema.relationships.push(RelationshipDef { + field_name: "weapons".into(), + kind: RelationshipKind::HasMany, + target_model: "PlayerWeapon".into(), + foreign_key: None, + through: None, + }); + + let err = schema.validate().unwrap_err(); + + assert!( + matches!(err, ReadModelError::Metadata(message) if message.contains("foreign key")) + ); + } + + #[test] + fn row_values_round_trip_scalar_and_json_values() { + let mut row = RowValues::new(); + row.insert_serde("name", "Ada").unwrap(); + row.insert_serde("count", &3_i64).unwrap(); + row.insert_serde("payload", &serde_json::json!({"wins": [1, 2]})) + .unwrap(); + + assert_eq!(row.get_serde::("name").unwrap(), "Ada"); + assert_eq!(row.get_serde::("count").unwrap(), 3); + assert_eq!( + row.get_serde::("payload").unwrap(), + serde_json::json!({"wins": [1, 2]}) + ); + } +} diff --git a/src/read_model/mod.rs b/src/read_model/mod.rs index 9a46363..fe7de76 100644 --- a/src/read_model/mod.rs +++ b/src/read_model/mod.rs @@ -1,29 +1,55 @@ -//! Read Models - Storage-backed data for projections and read-optimized views. +//! Read Models - storage-backed projections and read-optimized views. //! -//! Read models provide a simple CRUD abstraction for storing typed data, -//! updated alongside event-sourced aggregates through transactional commit batches. +//! The crate supports one read-model write-plan surface with two row shapes: //! -//! ## Example +//! - document rows through [`ReadModelStore`] and `collection:id` JSON payloads; +//! - normalized relational rows through [`RelationalReadModel`], +//! [`ReadModelSession`], [`ReadModelWritePlan`], and schema metadata. +//! +//! Document views can use typed key/value CRUD: //! //! ```ignore -//! use sourced_rust::{ReadModel, InMemoryReadModelStore, ReadModelsExt, Versioned}; +//! use sourced_rust::{InMemoryReadModelStore, ReadModel, ReadModelsExt}; //! //! #[derive(Serialize, Deserialize, Clone, ReadModel)] //! #[readmodel(collection = "game_views")] //! struct GameView { //! #[readmodel(id)] -//! pub id: String, -//! pub score: u32, +//! id: String, +//! score: u32, //! } //! //! let store = InMemoryReadModelStore::new(); //! store.read_models::().upsert(&view)?; -//! let loaded = store.read_models::().get("game-1")?; +//! let loaded = store.read_models::().get_by_primary_key("game-1")?; +//! ``` +//! +//! Relational models stage explicit row mutations: +//! +//! ```ignore +//! use sourced_rust::{ReadModelSession, ReadModelSessionCommitExt}; +//! +//! let mut read_models = ReadModelSession::new(); +//! read_models.save(&player)?; +//! read_models.save_related(&player, "weapons", &weapon)?; +//! repo.read_models(read_models).commit(&mut aggregate)?; +//! ``` +//! +//! Distributed projectors can commit a session directly against a read-model +//! adapter and mark messages processed in the same adapter transaction: +//! +//! ```ignore +//! let mut read_models = ReadModelSession::new(); +//! read_models.document(&view)?.mark_processed("projection", event_id); +//! let outcome = read_models.commit(&read_store)?; //! ``` pub(crate) mod in_memory; +mod metadata; mod queued; mod repository; +mod schema; +mod session; mod store; use serde::{de::DeserializeOwned, Serialize}; @@ -64,6 +90,8 @@ pub enum ReadModelError { NotFound { collection: String, id: String }, /// Lock error. Lock(crate::lock::LockError), + /// Relational read-model metadata error. + Metadata(String), } impl fmt::Display for ReadModelError { @@ -85,6 +113,7 @@ impl fmt::Display for ReadModelError { write!(f, "read model not found: {}:{}", collection, id) } ReadModelError::Lock(err) => write!(f, "read model lock error: {}", err), + ReadModelError::Metadata(msg) => write!(f, "read model metadata error: {}", msg), } } } @@ -98,6 +127,22 @@ impl From for ReadModelError { } pub use in_memory::InMemoryReadModelStore; +pub use metadata::{ + ColumnDef, ColumnType, ForeignKey, IndexDef, PrimaryKey, ReadModelSchema, RelationalReadModel, + RelationshipDef, RelationshipKind, RowKey, RowValue, RowValues, + DEFAULT_READ_MODEL_VERSION_COLUMN, +}; pub use queued::QueuedReadModelStore; pub use repository::{ReadModelRepository, ReadModelsExt}; +pub use schema::{ + ReadModelMigrationArtifact, ReadModelSchemaAdapter, ReadModelSchemaAdapterCapabilities, + ReadModelSchemaBootstrap, ReadModelSchemaIssue, ReadModelSchemaIssueKind, + ReadModelSchemaRegistry, ReadModelSchemaVerification, +}; +pub use session::{ + DeleteRowMutation, DocumentMutation, ExpectedVersion, PatchMode, PatchRowMutation, + ProcessedMessageMark, ReadModelAdapterCapabilities, ReadModelCommitOutcome, + ReadModelLoadRequest, ReadModelMutation, ReadModelSession, ReadModelSessionStore, + ReadModelWritePlan, RowMutation, RowPatch, RowWriteMode, +}; pub use store::ReadModelStore; diff --git a/src/read_model/queued.rs b/src/read_model/queued.rs index 93f4d1c..ac0c647 100644 --- a/src/read_model/queued.rs +++ b/src/read_model/queued.rs @@ -2,7 +2,7 @@ //! //! Mirrors the `QueuedRepository` pattern for entities: //! `get_model` acquires a lock, write operations (`upsert`, `insert`, `update`, -//! `delete`, `upsert_raw`) release it. Callers can also release manually via `unlock`. +//! `delete`) release it. Callers can also release manually via `unlock`. use std::sync::Arc; @@ -11,13 +11,16 @@ use crate::lock::{InMemoryLockManager, Lock, LockManager}; use crate::queued_repo::ReadOpts; use crate::repository::{Commit, CommitBatch, RepositoryError, TransactionalCommit}; -use super::{ReadModel, ReadModelError, ReadModelStore, Versioned}; +use super::{ + ReadModel, ReadModelAdapterCapabilities, ReadModelCommitOutcome, ReadModelError, + ReadModelSessionStore, ReadModelStore, ReadModelWritePlan, Versioned, +}; /// A `ReadModelStore` wrapper that provides per-instance locking. /// /// Lock lifecycle matches the entity `QueuedRepository` pattern: /// - `get_model` acquires a lock (or waits if already locked) -/// - `upsert` / `insert` / `update` / `delete` / `upsert_raw` release the lock on success +/// - `upsert` / `insert` / `update` / `delete` release the lock on success /// - `unlock` / `abort` release the lock manually /// /// Lock keys are `"collection:id"`, so different read model types with the same ID @@ -118,6 +121,13 @@ impl ReadModelStore for QueuedReadModelStore< self.inner.get_model(id) } + fn get_by_primary_key( + &self, + id: &str, + ) -> Result>, ReadModelError> { + self.inner.get_by_primary_key(id) + } + fn upsert(&self, model: &M) -> Result, ReadModelError> { let key = Self::make_key(M::COLLECTION, model.id()); let result = self.inner.upsert(model); @@ -217,14 +227,6 @@ impl ReadModelStore for QueuedReadModelStore< Ok(None) } - - fn upsert_raw(&self, key: &str, bytes: Vec) -> Result<(), ReadModelError> { - let result = self.inner.upsert_raw(key, bytes); - if result.is_ok() { - self.release(key); - } - result - } } // ============================================================================ @@ -240,9 +242,9 @@ impl Commit for QueuedReadModelStore { impl TransactionalCommit for QueuedReadModelStore { fn commit_batch(&self, batch: CommitBatch<'_>) -> Result<(), RepositoryError> { let read_model_keys: Vec = batch - .read_models + .read_model_plans .iter() - .map(|write| write.key.clone()) + .flat_map(|plan| plan.mutations.iter().map(|mutation| mutation.lock_key())) .collect(); let result = self.inner.commit_batch(batch); @@ -256,11 +258,63 @@ impl TransactionalCommit for QueuedReadM } } +impl ReadModelSessionStore + for QueuedReadModelStore +{ + fn read_model_capabilities(&self) -> ReadModelAdapterCapabilities { + self.inner.read_model_capabilities() + } + + fn commit_write_plan( + &self, + plan: ReadModelWritePlan, + ) -> Result { + let read_model_keys: Vec = plan + .mutations + .iter() + .map(|mutation| mutation.lock_key()) + .collect(); + let _locks = self.lock_ids_in_order(&read_model_keys)?; + + let result = self.inner.commit_write_plan(plan); + if result.is_ok() { + for key in read_model_keys { + self.release(&key); + } + } + + result + } + + fn is_processed(&self, consumer_name: &str, message_id: &str) -> Result { + self.inner.is_processed(consumer_name, message_id) + } +} + // ============================================================================ // WithOpts methods for opting out of locking // ============================================================================ impl QueuedReadModelStore { + /// Load and lock a read model for update. + /// + /// This is the explicit spelling for the default locking behavior of + /// `get_model`. + pub fn load_for_update( + &self, + id: &str, + ) -> Result>, ReadModelError> { + self.get_model(id) + } + + /// Load a read model without acquiring the document-store lock. + pub fn load_no_lock( + &self, + id: &str, + ) -> Result>, ReadModelError> { + self.get_model_with(id, ReadOpts::no_lock()) + } + /// Get a read model with options (opt out of locking with `ReadOpts::no_lock()`). pub fn get_model_with( &self, @@ -558,35 +612,4 @@ mod tests { // cleanup store.unlock::("2").unwrap(); } - - #[test] - fn upsert_raw_releases_lock() { - let store = QueuedReadModelStore::new(InMemoryReadModelStore::new()); - let key = "test_models:1"; - - // Seed via inner - store - .inner() - .upsert(&TestModel { - id: "1".into(), - value: 10, - }) - .unwrap(); - - // get_model locks - let _loaded = store.get_model::("1").unwrap(); - - // upsert_raw releases (this is what CommitBuilder calls) - let bytes = serde_json::to_vec(&TestModel { - id: "1".into(), - value: 99, - }) - .unwrap(); - store.upsert_raw(key, bytes).unwrap(); - - // Can get again - let reloaded = store.get_model::("1").unwrap().unwrap(); - assert_eq!(reloaded.data.value, 99); - store.unlock::("1").unwrap(); - } } diff --git a/src/read_model/repository.rs b/src/read_model/repository.rs index f406ec6..7d09260 100644 --- a/src/read_model/repository.rs +++ b/src/read_model/repository.rs @@ -25,6 +25,11 @@ impl<'a, S: ReadModelStore, M: ReadModel> ReadModelRepository<'a, S, M> { self.store.get_model(id) } + /// Read a model by primary key without implying command-side ownership. + pub fn get_by_primary_key(&self, id: &str) -> Result>, ReadModelError> { + self.store.get_by_primary_key(id) + } + /// Upsert a read model (insert or update, no version check). pub fn upsert(&self, model: &M) -> Result, ReadModelError> { self.store.upsert(model) diff --git a/src/read_model/schema.rs b/src/read_model/schema.rs new file mode 100644 index 0000000..8606225 --- /dev/null +++ b/src/read_model/schema.rs @@ -0,0 +1,335 @@ +use std::collections::{BTreeMap, BTreeSet}; + +use super::{ReadModelError, ReadModelSchema, RelationalReadModel}; + +/// Registry of table-mapped read-model schemas an adapter should manage. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct ReadModelSchemaRegistry { + schemas_by_table: BTreeMap, + tables_by_model: BTreeMap, +} + +impl ReadModelSchemaRegistry { + pub fn new() -> Self { + Self::default() + } + + pub fn register(&mut self) -> Result<&mut Self, ReadModelError> + where + M: RelationalReadModel, + { + self.register_schema(M::schema()) + } + + pub fn register_schema( + &mut self, + schema: ReadModelSchema, + ) -> Result<&mut Self, ReadModelError> { + schema.validate()?; + + if self.schemas_by_table.contains_key(&schema.table_name) { + return Err(ReadModelError::Metadata(format!( + "read-model schema registry already contains table `{}`", + schema.table_name + ))); + } + if self.tables_by_model.contains_key(&schema.model_name) { + return Err(ReadModelError::Metadata(format!( + "read-model schema registry already contains model `{}`", + schema.model_name + ))); + } + + self.tables_by_model + .insert(schema.model_name.clone(), schema.table_name.clone()); + self.schemas_by_table + .insert(schema.table_name.clone(), schema); + Ok(self) + } + + pub fn len(&self) -> usize { + self.schemas_by_table.len() + } + + pub fn is_empty(&self) -> bool { + self.schemas_by_table.is_empty() + } + + pub fn schemas(&self) -> impl Iterator { + self.schemas_by_table.values() + } + + pub fn table_names(&self) -> impl Iterator { + self.schemas_by_table.keys().map(String::as_str) + } + + pub fn schema_for_table(&self, table_name: &str) -> Option<&ReadModelSchema> { + self.schemas_by_table.get(table_name) + } + + pub fn schema_for_model(&self, model_name: &str) -> Option<&ReadModelSchema> { + self.tables_by_model + .get(model_name) + .and_then(|table_name| self.schema_for_table(table_name)) + } + + pub fn validate(&self) -> Result<(), ReadModelError> { + let table_names = self + .schemas_by_table + .keys() + .cloned() + .collect::>(); + let model_names = self + .tables_by_model + .keys() + .cloned() + .collect::>(); + + for schema in self.schemas() { + schema.validate()?; + self.validate_column_foreign_keys(schema, &table_names)?; + self.validate_schema_foreign_keys(schema, &table_names)?; + self.validate_relationships(schema, &model_names, &table_names)?; + } + + Ok(()) + } + + fn validate_column_foreign_keys( + &self, + schema: &ReadModelSchema, + table_names: &BTreeSet, + ) -> Result<(), ReadModelError> { + for column in &schema.columns { + let Some(foreign_key) = &column.foreign_key else { + continue; + }; + self.validate_foreign_key_target( + &schema.model_name, + &schema.table_name, + &column.column_name, + &foreign_key.table, + &foreign_key.column, + table_names, + )?; + } + Ok(()) + } + + fn validate_schema_foreign_keys( + &self, + schema: &ReadModelSchema, + table_names: &BTreeSet, + ) -> Result<(), ReadModelError> { + for foreign_key in &schema.foreign_keys { + self.validate_foreign_key_target( + &schema.model_name, + &schema.table_name, + "", + &foreign_key.table, + &foreign_key.column, + table_names, + )?; + } + Ok(()) + } + + fn validate_relationships( + &self, + schema: &ReadModelSchema, + model_names: &BTreeSet, + table_names: &BTreeSet, + ) -> Result<(), ReadModelError> { + for relationship in &schema.relationships { + if !model_names.contains(&relationship.target_model) { + return Err(ReadModelError::Metadata(format!( + "read model `{}` relationship `{}` targets unregistered model `{}`", + schema.model_name, relationship.field_name, relationship.target_model + ))); + } + + if let Some(through) = relationship.through.as_deref() { + if !table_names.contains(through) { + return Err(ReadModelError::Metadata(format!( + "read model `{}` relationship `{}` references unregistered join table `{}`", + schema.model_name, relationship.field_name, through + ))); + } + } + } + Ok(()) + } + + fn validate_foreign_key_target( + &self, + model_name: &str, + table_name: &str, + column_name: &str, + target_table: &str, + target_column: &str, + table_names: &BTreeSet, + ) -> Result<(), ReadModelError> { + if !table_names.contains(target_table) { + return Err(ReadModelError::Metadata(format!( + "read model `{model_name}` table `{table_name}` references unregistered foreign-key table `{target_table}`" + ))); + } + + let target_schema = self.schemas_by_table.get(target_table).ok_or_else(|| { + ReadModelError::Metadata(format!( + "read model `{model_name}` references unavailable foreign-key table `{target_table}`" + )) + })?; + if !target_schema + .columns + .iter() + .any(|column| column.column_name == target_column) + { + let local_column = if column_name.is_empty() { + "schema".to_string() + } else { + format!("column `{column_name}`") + }; + return Err(ReadModelError::Metadata(format!( + "read model `{model_name}` {local_column} references missing foreign-key column `{target_table}.{target_column}`" + ))); + } + + Ok(()) + } +} + +/// Schema lifecycle operations an adapter can support. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct ReadModelSchemaAdapterCapabilities { + pub migration_artifacts: bool, + pub schema_verification: bool, + pub dev_bootstrap: bool, +} + +impl ReadModelSchemaAdapterCapabilities { + pub fn all() -> Self { + Self { + migration_artifacts: true, + schema_verification: true, + dev_bootstrap: true, + } + } +} + +/// Generated or user-consumable migration artifact for registered schemas. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ReadModelMigrationArtifact { + pub name: String, + pub statements: Vec, +} + +impl ReadModelMigrationArtifact { + pub fn new(name: impl Into, statements: impl IntoIterator) -> Self { + Self { + name: name.into(), + statements: statements.into_iter().collect(), + } + } +} + +/// Result of verifying registered metadata against an adapter-owned schema. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct ReadModelSchemaVerification { + pub issues: Vec, +} + +impl ReadModelSchemaVerification { + pub fn verified() -> Self { + Self::default() + } + + pub fn is_verified(&self) -> bool { + self.issues.is_empty() + } +} + +/// Adapter-facing schema verification issue. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ReadModelSchemaIssue { + pub table_name: String, + pub column_name: Option, + pub kind: ReadModelSchemaIssueKind, + pub message: String, +} + +impl ReadModelSchemaIssue { + pub fn new( + table_name: impl Into, + column_name: Option>, + kind: ReadModelSchemaIssueKind, + message: impl Into, + ) -> Self { + Self { + table_name: table_name.into(), + column_name: column_name.map(Into::into), + kind, + message: message.into(), + } + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum ReadModelSchemaIssueKind { + MissingTable, + MissingColumn, + TypeMismatch, + PrimaryKeyMismatch, + ForeignKeyMismatch, + IndexMismatch, + NullabilityMismatch, + DefaultMismatch, + VersionColumnMismatch, + Unsupported(String), +} + +/// Result of an explicit dev/test schema bootstrap operation. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct ReadModelSchemaBootstrap { + pub bootstrapped_tables: Vec, +} + +impl ReadModelSchemaBootstrap { + pub fn new(bootstrapped_tables: impl IntoIterator) -> Self { + Self { + bootstrapped_tables: bootstrapped_tables.into_iter().collect(), + } + } +} + +/// Adapter contract for schema generation, verification, and dev/test bootstrap. +pub trait ReadModelSchemaAdapter { + fn schema_capabilities(&self) -> ReadModelSchemaAdapterCapabilities; + + fn generate_migration_artifacts( + &self, + _registry: &ReadModelSchemaRegistry, + ) -> Result, ReadModelError> { + Err(ReadModelError::Metadata( + "read-model schema adapter does not support migration artifact generation".into(), + )) + } + + fn verify_schema( + &self, + _registry: &ReadModelSchemaRegistry, + ) -> Result { + Err(ReadModelError::Metadata( + "read-model schema adapter does not support startup schema verification".into(), + )) + } + + fn bootstrap_schema_for_dev( + &self, + _registry: &ReadModelSchemaRegistry, + ) -> Result { + Err(ReadModelError::Metadata( + "read-model schema adapter does not support explicit dev/test bootstrap".into(), + )) + } +} diff --git a/src/read_model/session.rs b/src/read_model/session.rs new file mode 100644 index 0000000..3101501 --- /dev/null +++ b/src/read_model/session.rs @@ -0,0 +1,991 @@ +use std::collections::BTreeMap; + +use serde::Serialize; + +use super::{ + ReadModel, ReadModelError, ReadModelSchema, RelationalReadModel, RelationshipDef, RowKey, + RowValue, RowValues, Versioned, +}; + +/// Expected optimistic version carried by a staged read-model write. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub enum ExpectedVersion { + /// No optimistic version check is requested. + #[default] + Any, + /// The target row must currently have this version. + Exact(u64), + /// The target row must not exist yet. + NotExists, +} + +/// Full-row write behavior for a relational row mutation. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum RowWriteMode { + Insert, + Upsert, +} + +/// Sparse patch behavior for a relational row mutation. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum PatchMode { + UpdateExisting, + InsertMissing, +} + +/// Adapter capabilities used to validate a write plan before any storage write. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ReadModelAdapterCapabilities { + pub relational_rows: bool, + pub document_rows: bool, + pub sparse_patches: bool, + pub deletes: bool, + pub processed_messages: bool, +} + +impl Default for ReadModelAdapterCapabilities { + fn default() -> Self { + Self { + relational_rows: true, + document_rows: true, + sparse_patches: true, + deletes: true, + processed_messages: true, + } + } +} + +/// Result of applying a standalone read-model write plan. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ReadModelCommitOutcome { + applied: bool, + duplicate_message: Option, +} + +impl ReadModelCommitOutcome { + pub fn applied() -> Self { + Self { + applied: true, + duplicate_message: None, + } + } + + pub fn skipped_duplicate(mark: ProcessedMessageMark) -> Self { + Self { + applied: false, + duplicate_message: Some(mark), + } + } + + pub fn was_applied(&self) -> bool { + self.applied + } + + pub fn was_skipped(&self) -> bool { + !self.applied + } + + pub fn duplicate_message(&self) -> Option<&ProcessedMessageMark> { + self.duplicate_message.as_ref() + } +} + +/// Adapter contract for committing read-model sessions without an aggregate repository. +pub trait ReadModelSessionStore: Send + Sync { + fn read_model_capabilities(&self) -> ReadModelAdapterCapabilities; + + fn commit_write_plan( + &self, + plan: ReadModelWritePlan, + ) -> Result; + + fn is_processed(&self, consumer_name: &str, message_id: &str) -> Result; +} + +/// A request an adapter can satisfy with a primary-key read plus explicit includes. +#[derive(Clone, Debug, PartialEq)] +pub struct ReadModelLoadRequest { + pub schema: ReadModelSchema, + pub key: RowKey, + pub includes: Vec, +} + +/// Sparse column updates for a relational row. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct RowPatch { + values: RowValues, +} + +impl RowPatch { + pub fn new() -> Self { + Self::default() + } + + pub fn set(mut self, column: impl Into, value: RowValue) -> Self { + self.values.insert(column, value); + self + } + + pub fn set_serde( + mut self, + column: impl Into, + value: &T, + ) -> Result { + self.values.insert_serde(column, value)?; + Ok(self) + } + + pub fn get(&self, column: &str) -> Option<&RowValue> { + self.values.get(column) + } + + pub fn iter(&self) -> impl Iterator { + self.values.iter() + } + + pub fn is_empty(&self) -> bool { + self.values.is_empty() + } + + pub fn into_values(self) -> RowValues { + self.values + } +} + +/// Whole-document read-model write backed by a document column such as JSONB. +#[derive(Clone, Debug, PartialEq)] +pub struct DocumentMutation { + pub collection: String, + pub id: String, + pub bytes: Vec, +} + +impl DocumentMutation { + pub fn key(&self) -> String { + format!("{}:{}", self.collection, self.id) + } +} + +/// Full relational row insert/upsert mutation. +#[derive(Clone, Debug, PartialEq)] +pub struct RowMutation { + pub schema: ReadModelSchema, + pub key: RowKey, + pub values: RowValues, + pub expected_version: ExpectedVersion, + pub mode: RowWriteMode, +} + +/// Sparse relational row patch mutation. +#[derive(Clone, Debug, PartialEq)] +pub struct PatchRowMutation { + pub schema: ReadModelSchema, + pub key: RowKey, + pub patch: RowPatch, + pub expected_version: ExpectedVersion, + pub mode: PatchMode, +} + +/// Relational row delete mutation. +#[derive(Clone, Debug, PartialEq)] +pub struct DeleteRowMutation { + pub schema: ReadModelSchema, + pub key: RowKey, + pub expected_version: ExpectedVersion, +} + +/// First-pass read-model write-plan mutation surface. +#[derive(Clone, Debug, PartialEq)] +pub enum ReadModelMutation { + Document(DocumentMutation), + UpsertRow(RowMutation), + PatchRow(PatchRowMutation), + DeleteRow(DeleteRowMutation), +} + +impl ReadModelMutation { + pub fn table_name(&self) -> Option<&str> { + match self { + ReadModelMutation::Document(mutation) => Some(mutation.collection.as_str()), + ReadModelMutation::UpsertRow(mutation) => Some(mutation.schema.table_name.as_str()), + ReadModelMutation::PatchRow(mutation) => Some(mutation.schema.table_name.as_str()), + ReadModelMutation::DeleteRow(mutation) => Some(mutation.schema.table_name.as_str()), + } + } + + pub fn lock_key(&self) -> String { + match self { + ReadModelMutation::Document(mutation) => mutation.key(), + ReadModelMutation::UpsertRow(mutation) => format!( + "{}:{}", + mutation.schema.table_name, + key_fingerprint(&mutation.key) + ), + ReadModelMutation::PatchRow(mutation) => format!( + "{}:{}", + mutation.schema.table_name, + key_fingerprint(&mutation.key) + ), + ReadModelMutation::DeleteRow(mutation) => format!( + "{}:{}", + mutation.schema.table_name, + key_fingerprint(&mutation.key) + ), + } + } + + fn sort_key(&self) -> String { + match self { + ReadModelMutation::Document(mutation) => format!("0|{}", mutation.key()), + ReadModelMutation::UpsertRow(mutation) => format!( + "1|{}|{}", + mutation.schema.table_name, + key_fingerprint(&mutation.key) + ), + ReadModelMutation::PatchRow(mutation) => format!( + "2|{}|{}", + mutation.schema.table_name, + key_fingerprint(&mutation.key) + ), + ReadModelMutation::DeleteRow(mutation) => format!( + "3|{}|{}", + mutation.schema.table_name, + key_fingerprint(&mutation.key) + ), + } + } +} + +/// A processed-message marker staged with read-model writes. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ProcessedMessageMark { + pub consumer_name: String, + pub message_id: String, +} + +/// Deterministic unit-of-work output for relational read-model adapters. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct ReadModelWritePlan { + pub mutations: Vec, + pub processed_messages: Vec, +} + +impl ReadModelWritePlan { + pub fn new( + mutations: Vec, + processed_messages: Vec, + ) -> Self { + Self { + mutations, + processed_messages, + } + } + + pub fn is_empty(&self) -> bool { + self.mutations.is_empty() && self.processed_messages.is_empty() + } + + pub fn validate(&self) -> Result<(), ReadModelError> { + self.validate_for(&ReadModelAdapterCapabilities::default()) + } + + pub fn validate_for( + &self, + capabilities: &ReadModelAdapterCapabilities, + ) -> Result<(), ReadModelError> { + for mutation in &self.mutations { + match mutation { + ReadModelMutation::Document(mutation) => { + if !capabilities.document_rows { + return Err(ReadModelError::Metadata( + "read-model adapter does not support document row writes".into(), + )); + } + validate_document_mutation(mutation)?; + } + ReadModelMutation::UpsertRow(mutation) => { + if !capabilities.relational_rows { + return Err(ReadModelError::Metadata( + "read-model adapter does not support relational row writes".into(), + )); + } + validate_row_mutation(mutation)?; + } + ReadModelMutation::PatchRow(mutation) => { + if !capabilities.relational_rows || !capabilities.sparse_patches { + return Err(ReadModelError::Metadata( + "read-model adapter does not support sparse row patches".into(), + )); + } + validate_patch_mutation(mutation)?; + } + ReadModelMutation::DeleteRow(mutation) => { + if !capabilities.relational_rows || !capabilities.deletes { + return Err(ReadModelError::Metadata( + "read-model adapter does not support row deletes".into(), + )); + } + validate_delete_mutation(mutation)?; + } + } + } + + if !capabilities.processed_messages && !self.processed_messages.is_empty() { + return Err(ReadModelError::Metadata( + "read-model adapter does not support processed-message marks".into(), + )); + } + + for mark in &self.processed_messages { + if mark.consumer_name.is_empty() || mark.message_id.is_empty() { + return Err(ReadModelError::Metadata( + "processed-message marks require consumer name and message id".into(), + )); + } + } + + Ok(()) + } +} + +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)] +struct RowIdentity { + table_name: String, + key: String, +} + +#[derive(Clone, Debug)] +struct StagedMutation { + sequence: u64, + mutation: ReadModelMutation, +} + +/// Short-lived unit of work that stages read-model mutations before commit. +#[derive(Clone, Debug, Default)] +pub struct ReadModelSession { + mutations: Vec, + processed_messages: Vec, + expected_versions: BTreeMap, + next_sequence: u64, +} + +impl ReadModelSession { + pub fn new() -> Self { + Self::default() + } + + pub fn is_empty(&self) -> bool { + self.mutations.is_empty() && self.processed_messages.is_empty() + } + + pub fn load(&self, key: RowKey) -> Result + where + M: RelationalReadModel, + { + self.load_with::, String>(key, Vec::new()) + } + + pub fn load_with( + &self, + key: RowKey, + includes: I, + ) -> Result + where + M: RelationalReadModel, + I: IntoIterator, + S: Into, + { + let schema = validated_schema::()?; + validate_key(&schema, &key)?; + let includes: Vec = includes.into_iter().map(Into::into).collect(); + for include in &includes { + if !schema + .relationships + .iter() + .any(|relationship| relationship.field_name == *include) + { + return Err(ReadModelError::Metadata(format!( + "read model `{}` has no relationship `{}`", + schema.model_name, include + ))); + } + } + + Ok(ReadModelLoadRequest { + schema, + key, + includes, + }) + } + + pub fn track_loaded(&mut self, versioned: &Versioned) -> Result<&mut Self, ReadModelError> + where + M: RelationalReadModel, + { + self.expect_version::(versioned.data.primary_key()?, versioned.version) + } + + pub fn expect_version( + &mut self, + key: RowKey, + expected_version: u64, + ) -> Result<&mut Self, ReadModelError> + where + M: RelationalReadModel, + { + let schema = validated_schema::()?; + validate_key(&schema, &key)?; + validate_expected_version(&ExpectedVersion::Exact(expected_version), &schema)?; + self.expected_versions.insert( + RowIdentity { + table_name: schema.table_name, + key: key_fingerprint(&key), + }, + expected_version, + ); + Ok(self) + } + + pub fn insert(&mut self, model: &M) -> Result<&mut Self, ReadModelError> + where + M: RelationalReadModel, + { + self.stage_full_row( + model, + RowWriteMode::Insert, + Some(ExpectedVersion::NotExists), + ) + } + + pub fn save(&mut self, model: &M) -> Result<&mut Self, ReadModelError> + where + M: RelationalReadModel, + { + self.stage_full_row(model, RowWriteMode::Upsert, None) + } + + pub fn upsert(&mut self, model: &M) -> Result<&mut Self, ReadModelError> + where + M: RelationalReadModel, + { + self.save(model) + } + + pub fn insert_related( + &mut self, + parent: &P, + relationship_field: &str, + child: &C, + ) -> Result<&mut Self, ReadModelError> + where + P: RelationalReadModel, + C: RelationalReadModel, + { + self.stage_related_row(parent, relationship_field, child, RowWriteMode::Insert) + } + + pub fn save_related( + &mut self, + parent: &P, + relationship_field: &str, + child: &C, + ) -> Result<&mut Self, ReadModelError> + where + P: RelationalReadModel, + C: RelationalReadModel, + { + self.stage_related_row(parent, relationship_field, child, RowWriteMode::Upsert) + } + + pub fn patch(&mut self, key: RowKey, patch: RowPatch) -> Result<&mut Self, ReadModelError> + where + M: RelationalReadModel, + { + self.stage_patch::(key, patch, PatchMode::UpdateExisting) + } + + pub fn upsert_patch( + &mut self, + key: RowKey, + patch: RowPatch, + ) -> Result<&mut Self, ReadModelError> + where + M: RelationalReadModel, + { + self.stage_patch::(key, patch, PatchMode::InsertMissing) + } + + pub fn delete(&mut self, key: RowKey) -> Result<&mut Self, ReadModelError> + where + M: RelationalReadModel, + { + let schema = validated_schema::()?; + validate_key(&schema, &key)?; + let expected_version = self.expected_for(&schema, &key); + let mutation = DeleteRowMutation { + schema, + key, + expected_version, + }; + self.push(ReadModelMutation::DeleteRow(mutation)); + Ok(self) + } + + pub fn delete_model(&mut self, model: &M) -> Result<&mut Self, ReadModelError> + where + M: RelationalReadModel, + { + self.delete::(model.primary_key()?) + } + + pub fn document(&mut self, model: &M) -> Result<&mut Self, ReadModelError> + where + M: ReadModel, + { + let bytes = + serde_json::to_vec(model).map_err(|err| ReadModelError::Serde(err.to_string()))?; + self.push(ReadModelMutation::Document(DocumentMutation { + collection: M::COLLECTION.to_string(), + id: model.id().to_string(), + bytes, + })); + Ok(self) + } + + pub fn mark_processed( + &mut self, + consumer_name: impl Into, + message_id: impl Into, + ) -> &mut Self { + self.processed_messages.push(ProcessedMessageMark { + consumer_name: consumer_name.into(), + message_id: message_id.into(), + }); + self + } + + pub fn into_write_plan(self) -> Result { + let mut mutations = self.mutations; + mutations.sort_by(|left, right| { + left.mutation + .sort_key() + .cmp(&right.mutation.sort_key()) + .then(left.sequence.cmp(&right.sequence)) + }); + let mutations = mutations + .into_iter() + .map(|staged| staged.mutation) + .collect::>(); + let plan = ReadModelWritePlan::new(mutations, self.processed_messages); + plan.validate()?; + Ok(plan) + } + + pub fn commit(self, store: &S) -> Result + where + S: ReadModelSessionStore + ?Sized, + { + store.commit_write_plan(self.into_write_plan()?) + } + + fn stage_full_row( + &mut self, + model: &M, + mode: RowWriteMode, + expected_version: Option, + ) -> Result<&mut Self, ReadModelError> + where + M: RelationalReadModel, + { + let schema = validated_schema::()?; + let key = model.primary_key()?; + let values = model.to_row()?; + validate_key(&schema, &key)?; + let expected_version = expected_version.unwrap_or_else(|| self.expected_for(&schema, &key)); + let mutation = RowMutation { + schema, + key, + values, + expected_version, + mode, + }; + self.push(ReadModelMutation::UpsertRow(mutation)); + Ok(self) + } + + fn stage_related_row( + &mut self, + parent: &P, + relationship_field: &str, + child: &C, + mode: RowWriteMode, + ) -> Result<&mut Self, ReadModelError> + where + P: RelationalReadModel, + C: RelationalReadModel, + { + let parent_schema = validated_schema::

()?; + let child_schema = validated_schema::()?; + let relationship = parent_schema + .relationships + .iter() + .find(|relationship| relationship.field_name == relationship_field) + .ok_or_else(|| { + ReadModelError::Metadata(format!( + "read model `{}` has no relationship `{}`", + parent_schema.model_name, relationship_field + )) + })?; + + if relationship.target_model != child_schema.model_name { + return Err(ReadModelError::Metadata(format!( + "relationship `{}` targets `{}`, not `{}`", + relationship.field_name, relationship.target_model, child_schema.model_name + ))); + } + + let parent_row = parent.to_row()?; + let mut child_row = child.to_row()?; + populate_delegated_relationship_values( + &parent_schema, + &parent_row, + relationship, + &child_schema, + &mut child_row, + )?; + let key = key_from_row(&child_schema, &child_row)?; + let expected_version = match mode { + RowWriteMode::Insert => ExpectedVersion::NotExists, + RowWriteMode::Upsert => self.expected_for(&child_schema, &key), + }; + let mutation = RowMutation { + schema: child_schema, + key, + values: child_row, + expected_version, + mode, + }; + self.push(ReadModelMutation::UpsertRow(mutation)); + Ok(self) + } + + fn stage_patch( + &mut self, + key: RowKey, + patch: RowPatch, + mode: PatchMode, + ) -> Result<&mut Self, ReadModelError> + where + M: RelationalReadModel, + { + let schema = validated_schema::()?; + validate_key(&schema, &key)?; + let expected_version = self.expected_for(&schema, &key); + let mutation = PatchRowMutation { + schema, + key, + patch, + expected_version, + mode, + }; + self.push(ReadModelMutation::PatchRow(mutation)); + Ok(self) + } + + fn push(&mut self, mutation: ReadModelMutation) { + let sequence = self.next_sequence; + self.next_sequence = self.next_sequence.saturating_add(1); + self.mutations.push(StagedMutation { sequence, mutation }); + } + + fn expected_for(&self, schema: &ReadModelSchema, key: &RowKey) -> ExpectedVersion { + self.expected_versions + .get(&RowIdentity { + table_name: schema.table_name.clone(), + key: key_fingerprint(key), + }) + .copied() + .map(ExpectedVersion::Exact) + .unwrap_or(ExpectedVersion::Any) + } +} + +fn validated_schema() -> Result +where + M: RelationalReadModel, +{ + let schema = M::schema(); + schema.validate()?; + Ok(schema) +} + +fn validate_document_mutation(mutation: &DocumentMutation) -> Result<(), ReadModelError> { + if mutation.collection.is_empty() || mutation.id.is_empty() { + return Err(ReadModelError::Metadata( + "document read-model writes require collection and id".into(), + )); + } + Ok(()) +} + +fn validate_row_mutation(mutation: &RowMutation) -> Result<(), ReadModelError> { + mutation.schema.validate()?; + validate_key(&mutation.schema, &mutation.key)?; + validate_expected_version(&mutation.expected_version, &mutation.schema)?; + validate_row_values(&mutation.schema, &mutation.values, true) +} + +fn validate_patch_mutation(mutation: &PatchRowMutation) -> Result<(), ReadModelError> { + mutation.schema.validate()?; + validate_key(&mutation.schema, &mutation.key)?; + validate_expected_version(&mutation.expected_version, &mutation.schema)?; + if mutation.patch.is_empty() { + return Err(ReadModelError::Metadata(format!( + "read model `{}` patch must set at least one column", + mutation.schema.model_name + ))); + } + validate_row_values(&mutation.schema, &mutation.patch.values, false) +} + +fn validate_delete_mutation(mutation: &DeleteRowMutation) -> Result<(), ReadModelError> { + mutation.schema.validate()?; + validate_key(&mutation.schema, &mutation.key)?; + validate_expected_version(&mutation.expected_version, &mutation.schema) +} + +fn validate_expected_version( + expected_version: &ExpectedVersion, + schema: &ReadModelSchema, +) -> Result<(), ReadModelError> { + if matches!(expected_version, ExpectedVersion::Exact(0)) { + return Err(ReadModelError::Metadata(format!( + "read model `{}` expected version must be greater than zero", + schema.model_name + ))); + } + Ok(()) +} + +fn validate_key(schema: &ReadModelSchema, key: &RowKey) -> Result<(), ReadModelError> { + if key.is_empty() { + return Err(ReadModelError::Metadata(format!( + "read model `{}` row key cannot be empty", + schema.model_name + ))); + } + + for column in &schema.primary_key.columns { + match key.get(column) { + Some(RowValue::Null) => { + return Err(ReadModelError::Metadata(format!( + "read model `{}` primary-key column `{}` cannot be null", + schema.model_name, column + ))); + } + Some(_) => {} + None => { + return Err(ReadModelError::Metadata(format!( + "read model `{}` row key is missing primary-key column `{}`", + schema.model_name, column + ))); + } + } + } + + for (column, _) in key.iter() { + if !schema.primary_key.columns.iter().any(|key| key == column) { + return Err(ReadModelError::Metadata(format!( + "read model `{}` row key includes non-primary-key column `{}`", + schema.model_name, column + ))); + } + } + + Ok(()) +} + +fn validate_row_values( + schema: &ReadModelSchema, + values: &RowValues, + full_row: bool, +) -> Result<(), ReadModelError> { + for (column_name, value) in values.iter() { + let column = schema + .columns + .iter() + .find(|column| column.column_name == column_name) + .ok_or_else(|| { + ReadModelError::Metadata(format!( + "read model `{}` write references missing column `{}`", + schema.model_name, column_name + )) + })?; + + if matches!(value, RowValue::Null) { + if column.primary_key { + return Err(ReadModelError::Metadata(format!( + "read model `{}` primary-key column `{}` cannot be null", + schema.model_name, column.column_name + ))); + } + if !column.nullable && !column.has_default { + return Err(ReadModelError::Metadata(format!( + "read model `{}` column `{}` is not nullable", + schema.model_name, column.column_name + ))); + } + } + } + + if full_row { + for column in &schema.columns { + if column.skipped || column.nullable || column.has_default { + continue; + } + if !values.contains_key(&column.column_name) { + return Err(ReadModelError::Metadata(format!( + "read model `{}` row is missing required column `{}`", + schema.model_name, column.column_name + ))); + } + } + + for column in schema + .columns + .iter() + .filter(|column| column.delegated_from.is_some()) + { + match values.get(&column.column_name) { + Some(RowValue::Null) | None => { + return Err(ReadModelError::Metadata(format!( + "read model `{}` delegated column `{}` must be populated before write", + schema.model_name, column.column_name + ))); + } + Some(_) => {} + } + } + } + + Ok(()) +} + +fn key_from_row(schema: &ReadModelSchema, row: &RowValues) -> Result { + let mut key = RowKey::default(); + for column in &schema.primary_key.columns { + let value = row.get(column).cloned().ok_or_else(|| { + ReadModelError::Metadata(format!( + "read model `{}` row is missing primary-key column `{}`", + schema.model_name, column + )) + })?; + key.insert(column.clone(), value); + } + validate_key(schema, &key)?; + Ok(key) +} + +fn populate_delegated_relationship_values( + parent_schema: &ReadModelSchema, + parent_row: &RowValues, + relationship: &RelationshipDef, + child_schema: &ReadModelSchema, + child_row: &mut RowValues, +) -> Result<(), ReadModelError> { + let mut populated = 0; + for column in child_schema + .columns + .iter() + .filter(|column| column.delegated_from.is_some()) + { + let delegated_from = column.delegated_from.as_deref().unwrap_or_default(); + let Some((model_name, source_name)) = delegated_from.split_once('.') else { + return Err(ReadModelError::Metadata(format!( + "read model `{}` delegated column `{}` has invalid source `{}`", + child_schema.model_name, column.column_name, delegated_from + ))); + }; + + if model_name != parent_schema.model_name { + continue; + } + + let source_column = column_name_for(parent_schema, source_name).ok_or_else(|| { + ReadModelError::Metadata(format!( + "read model `{}` delegated source `{}` is not a parent column", + child_schema.model_name, delegated_from + )) + })?; + let value = parent_row.get(&source_column).cloned().ok_or_else(|| { + ReadModelError::Metadata(format!( + "read model `{}` parent row is missing delegated source column `{}`", + parent_schema.model_name, source_column + )) + })?; + child_row.insert(column.column_name.clone(), value); + populated += 1; + } + + if populated == 0 { + let foreign_key = relationship.foreign_key.as_deref().ok_or_else(|| { + ReadModelError::Metadata(format!( + "read model `{}` relationship `{}` must declare a foreign key", + parent_schema.model_name, relationship.field_name + )) + })?; + let child_column = column_name_for(child_schema, foreign_key).ok_or_else(|| { + ReadModelError::Metadata(format!( + "relationship `{}` foreign key `{}` is not a child column", + relationship.field_name, foreign_key + )) + })?; + let parent_column = column_name_for(parent_schema, foreign_key) + .or_else(|| parent_schema.primary_key.columns.first().cloned()) + .ok_or_else(|| { + ReadModelError::Metadata(format!( + "relationship `{}` has no parent key to delegate", + relationship.field_name + )) + })?; + let value = parent_row.get(&parent_column).cloned().ok_or_else(|| { + ReadModelError::Metadata(format!( + "read model `{}` parent row is missing relationship key `{}`", + parent_schema.model_name, parent_column + )) + })?; + child_row.insert(child_column, value); + } + + Ok(()) +} + +fn column_name_for(schema: &ReadModelSchema, field_or_column: &str) -> Option { + schema + .columns + .iter() + .find(|column| { + column.field_name == field_or_column || column.column_name == field_or_column + }) + .map(|column| column.column_name.clone()) +} + +fn key_fingerprint(key: &RowKey) -> String { + key.iter() + .map(|(column, value)| format!("{column}={}", value_fingerprint(value))) + .collect::>() + .join(",") +} + +fn value_fingerprint(value: &RowValue) -> String { + match value { + RowValue::Null => "null".into(), + RowValue::Bool(value) => value.to_string(), + RowValue::I64(value) => value.to_string(), + RowValue::U64(value) => value.to_string(), + RowValue::F64(value) => value.to_string(), + RowValue::String(value) => value.clone(), + RowValue::Bytes(value) => format!("{value:?}"), + RowValue::Json(value) => serde_json::to_string(value).unwrap_or_else(|_| value.to_string()), + } +} diff --git a/src/read_model/store.rs b/src/read_model/store.rs index 0a9cd35..4f68683 100644 --- a/src/read_model/store.rs +++ b/src/read_model/store.rs @@ -11,6 +11,17 @@ pub trait ReadModelStore: Send + Sync { /// Get a read model by ID. Returns None if not found. fn get_model(&self, id: &str) -> Result>, ReadModelError>; + /// Read a model by its primary key without implying command-side ownership. + /// + /// This is a query/read helper. Lock-aware wrappers may override it to use + /// their non-locking read path. + fn get_by_primary_key( + &self, + id: &str, + ) -> Result>, ReadModelError> { + self.get_model(id) + } + /// Upsert a read model (insert or update, no version check). fn upsert(&self, model: &M) -> Result, ReadModelError>; @@ -28,18 +39,20 @@ pub trait ReadModelStore: Send + Sync { fn delete(&self, id: &str) -> Result; /// Find read models matching a predicate. + /// + /// This is an in-memory/document-store helper. SQL adapters are not + /// required to translate arbitrary Rust closures into queries. fn find_models( &self, predicate: &dyn Fn(&M) -> bool, ) -> Result>, ReadModelError>; /// Find the first read model matching a predicate. + /// + /// This is an in-memory/document-store helper. SQL adapters are not + /// required to translate arbitrary Rust closures into queries. fn find_one_model( &self, predicate: &dyn Fn(&M) -> bool, ) -> Result>, ReadModelError>; - - /// Save pre-serialized read model bytes by key. Used internally by CommitBuilder - /// for type-erased transactional batch writes. - fn upsert_raw(&self, key: &str, bytes: Vec) -> Result<(), ReadModelError>; } diff --git a/src/repository/batch.rs b/src/repository/batch.rs index 4a12e8d..37c271c 100644 --- a/src/repository/batch.rs +++ b/src/repository/batch.rs @@ -1,26 +1,9 @@ use crate::entity::Entity; +use crate::read_model::ReadModelWritePlan; use crate::snapshot::SnapshotRecord; use super::RepositoryError; -/// A typed read-model write staged as part of a transactional commit. -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct ReadModelWrite { - /// Storage key in the form `collection:id`. - pub key: String, - /// Serialized read model bytes. - pub bytes: Vec, -} - -impl ReadModelWrite { - pub fn new(key: impl Into, bytes: Vec) -> Self { - Self { - key: key.into(), - bytes, - } - } -} - /// A snapshot write staged as part of a transactional commit. #[derive(Clone, Debug)] pub enum SnapshotWrite { @@ -30,7 +13,7 @@ pub enum SnapshotWrite { /// A structured set of writes that must commit under one transaction boundary. pub struct CommitBatch<'a> { pub entities: Vec<&'a mut Entity>, - pub read_models: Vec, + pub read_model_plans: Vec, pub snapshots: Vec, } @@ -38,7 +21,7 @@ impl<'a> CommitBatch<'a> { pub fn new(entities: Vec<&'a mut Entity>) -> Self { Self { entities, - read_models: Vec::new(), + read_model_plans: Vec::new(), snapshots: Vec::new(), } } diff --git a/src/repository/mod.rs b/src/repository/mod.rs index 8efc298..4f77286 100644 --- a/src/repository/mod.rs +++ b/src/repository/mod.rs @@ -3,7 +3,7 @@ mod error; mod gettable; mod repository; -pub use batch::{CommitBatch, ReadModelWrite, SnapshotWrite, TransactionalCommit}; +pub use batch::{CommitBatch, SnapshotWrite, TransactionalCommit}; pub use error::RepositoryError; pub use gettable::{GetMany, GetOne, Gettable}; pub use repository::{Commit, Get, Repository}; diff --git a/src/snapshot/repository.rs b/src/snapshot/repository.rs index 0f8abae..db2377a 100644 --- a/src/snapshot/repository.rs +++ b/src/snapshot/repository.rs @@ -129,7 +129,7 @@ where self.inner.repo().commit_batch(CommitBatch { entities: vec![aggregate.entity_mut()], - read_models: Vec::new(), + read_model_plans: Vec::new(), snapshots, })?; @@ -157,7 +157,7 @@ where .collect(); self.inner.repo().commit_batch(CommitBatch { entities, - read_models: Vec::new(), + read_model_plans: Vec::new(), snapshots, })?; @@ -285,7 +285,7 @@ where self.snap_repo.inner.repo().commit_batch(CommitBatch { entities: vec![aggregate.entity_mut(), self.outbox.entity_mut()], - read_models: Vec::new(), + read_model_plans: Vec::new(), snapshots, })?; diff --git a/tests/bomberman/commands.rs b/tests/bomberman/commands.rs index 199c39c..b58a01f 100644 --- a/tests/bomberman/commands.rs +++ b/tests/bomberman/commands.rs @@ -15,7 +15,7 @@ use crate::views::{build_board, BoardView}; fn load_board(repo: &R, game_id: &str) -> Result { repo.read_models::() - .get(game_id) + .get_by_primary_key(game_id) .map_err(RepositoryError::from)? .map(|board| board.data) .ok_or(GameError::GameNotFound) diff --git a/tests/bomberman/main.rs b/tests/bomberman/main.rs index 670c68d..c159a7b 100644 --- a/tests/bomberman/main.rs +++ b/tests/bomberman/main.rs @@ -362,7 +362,7 @@ fn concurrent_bomb_placement() { // Verify both bombs placed let _board = repo .read_models::() - .get("game-5") + .get_by_primary_key("game-5") .unwrap() .unwrap(); // Board may show 1 or 2 bombs depending on which thread's board view won the race, diff --git a/tests/bomberman/sim.rs b/tests/bomberman/sim.rs index a73f51d..b8907cb 100644 --- a/tests/bomberman/sim.rs +++ b/tests/bomberman/sim.rs @@ -40,7 +40,7 @@ where pub fn board(&self) -> Result, GameError> { self.repo .read_models::() - .get(&self.game_id) + .get_by_primary_key(&self.game_id) .map_err(|e| { GameError::Repository(sourced_rust::RepositoryError::Model(e.to_string())) })? diff --git a/tests/bomberman/views.rs b/tests/bomberman/views.rs index 18bfca5..b7777a8 100644 --- a/tests/bomberman/views.rs +++ b/tests/bomberman/views.rs @@ -28,6 +28,7 @@ pub struct ExplosionState { pub cells: Vec<(i32, i32)>, } +/// Whole-board document view stored as a single read-model document row. #[derive(Clone, Debug, Serialize, Deserialize, ReadModel)] #[readmodel(collection = "boards")] pub struct BoardView { diff --git a/tests/distributed_read_model/main.rs b/tests/distributed_read_model/main.rs index b2f6284..8c58196 100644 --- a/tests/distributed_read_model/main.rs +++ b/tests/distributed_read_model/main.rs @@ -27,11 +27,12 @@ use std::time::{Duration, Instant}; use models::aggregates::account::{Account, DepositMoney, OpenAccount}; use models::readmodels::account_summary::AccountSummary; use query_process::AccountSummaryQueryProcess; -use read_model::{start_account_summary_service, wait_for_summary}; +use read_model::{start_account_summary_service, wait_for_summary, ACCOUNT_SUMMARY_CONSUMER}; use sourced_rust::microsvc::{Service, Session}; use sourced_rust::{ - AggregateBuilder, AggregateRepository, GetAggregate, HashMapRepository, InMemoryQueue, - OutboxWorkerThread, Queueable, QueuedRepository, ReadModelsExt, + AggregateBuilder, AggregateRepository, HashMapRepository, InMemoryQueue, + InMemoryReadModelStore, OutboxWorkerThread, Queueable, QueuedRepository, ReadModelSessionStore, + ReadModelsExt, }; pub(crate) type AccountRepo = AggregateRepository, Account>; @@ -115,7 +116,7 @@ fn write_model_service_feeds_separate_read_model_service() { let outbox_worker = OutboxWorkerThread::spawn(write_store.clone(), queue.clone(), Duration::from_millis(5)); - let read_store = HashMapRepository::new(); + let read_store = InMemoryReadModelStore::new(); let read_model_service = start_account_summary_service(queue.clone(), read_store.clone()); let query_process = AccountSummaryQueryProcess::new(read_store.clone()); @@ -153,6 +154,14 @@ fn write_model_service_feeds_separate_read_model_service() { assert_eq!(summary.owner.as_deref(), Some("Ada Lovelace")); assert_eq!(summary.balance_cents, 2500); assert_eq!(summary.deposit_count, 1); + for event in queue.events() { + assert!( + read_store + .is_processed(ACCOUNT_SUMMARY_CONSUMER, &event.id) + .expect("processed-message lookup should succeed"), + "read-model service should mark projected events processed before they are acknowledged" + ); + } let queried_summary = query_process .get("acct-1") @@ -190,14 +199,6 @@ fn write_model_service_feeds_separate_read_model_service() { "write-side service should not own the account summary projection" ); - let read_side_account = read_store - .get_aggregate::("acct-1") - .expect("read-side aggregate lookup should succeed"); - assert!( - read_side_account.is_none(), - "read-model service should not own the account aggregate" - ); - read_model_service.stop(); let worker_stats = outbox_worker .stop() diff --git a/tests/distributed_read_model/models/readmodels/account_summary.rs b/tests/distributed_read_model/models/readmodels/account_summary.rs index 2e2fa97..3bf3159 100644 --- a/tests/distributed_read_model/models/readmodels/account_summary.rs +++ b/tests/distributed_read_model/models/readmodels/account_summary.rs @@ -22,12 +22,4 @@ impl AccountSummary { projected_event_ids: Vec::new(), } } - - pub fn has_projected(&self, event_id: &str) -> bool { - self.projected_event_ids.iter().any(|id| id == event_id) - } - - pub fn mark_projected(&mut self, event_id: &str) { - self.projected_event_ids.push(event_id.to_string()); - } } diff --git a/tests/distributed_read_model/query_process.rs b/tests/distributed_read_model/query_process.rs index 07b4d74..7875837 100644 --- a/tests/distributed_read_model/query_process.rs +++ b/tests/distributed_read_model/query_process.rs @@ -1,21 +1,20 @@ -use sourced_rust::{HashMapRepository, ReadModelError, ReadModelsExt}; +use sourced_rust::{InMemoryReadModelStore, ReadModelError, ReadModelStore}; use crate::models::readmodels::account_summary::AccountSummary; #[derive(Clone)] pub struct AccountSummaryQueryProcess { - store: HashMapRepository, + store: InMemoryReadModelStore, } impl AccountSummaryQueryProcess { - pub fn new(store: HashMapRepository) -> Self { + pub fn new(store: InMemoryReadModelStore) -> Self { Self { store } } pub fn get(&self, account_id: &str) -> Result, ReadModelError> { self.store - .read_models::() - .get(account_id) + .get_by_primary_key::(account_id) .map(|summary| summary.map(|view| view.data)) } } diff --git a/tests/distributed_read_model/read_model.rs b/tests/distributed_read_model/read_model.rs index dd5b07a..81d0088 100644 --- a/tests/distributed_read_model/read_model.rs +++ b/tests/distributed_read_model/read_model.rs @@ -3,11 +3,15 @@ use std::thread; use std::time::{Duration, Instant}; use sourced_rust::bus::{Bus, Event}; -use sourced_rust::{HashMapRepository, InMemoryQueue, ReadModelsExt}; +use sourced_rust::{ + InMemoryQueue, InMemoryReadModelStore, ReadModelCommitOutcome, ReadModelSession, ReadModelStore, +}; use crate::models::aggregates::account::AccountSnapshot; use crate::models::readmodels::account_summary::AccountSummary; +pub const ACCOUNT_SUMMARY_CONSUMER: &str = "account-summary-projection"; + pub struct ReadModelServiceHandle { stop_tx: mpsc::Sender<()>, handle: thread::JoinHandle<()>, @@ -24,7 +28,7 @@ impl ReadModelServiceHandle { pub fn start_account_summary_service( queue: InMemoryQueue, - store: HashMapRepository, + store: InMemoryReadModelStore, ) -> ReadModelServiceHandle { let (stop_tx, stop_rx) = mpsc::channel(); let (ready_tx, ready_rx) = mpsc::channel(); @@ -62,23 +66,21 @@ pub fn start_account_summary_service( ReadModelServiceHandle { stop_tx, handle } } -fn load_summary(store: &HashMapRepository, account_id: &str) -> AccountSummary { +fn load_summary(store: &InMemoryReadModelStore, account_id: &str) -> AccountSummary { store - .read_models::() - .get(account_id) + .get_by_primary_key::(account_id) .expect("read model load should succeed") .map(|view| view.data) .unwrap_or_else(|| AccountSummary::empty(account_id)) } -fn project_account_summary(store: &HashMapRepository, event: &Event) { +fn project_account_summary( + store: &InMemoryReadModelStore, + event: &Event, +) -> ReadModelCommitOutcome { let snapshot: AccountSnapshot = event.decode().expect("account snapshot should decode"); let mut summary = load_summary(store, &snapshot.id); - if summary.has_projected(&event.id) { - return; - } - match event.event_type.as_str() { "AccountOpened" => { summary.owner = Some(snapshot.owner); @@ -91,15 +93,18 @@ fn project_account_summary(store: &HashMapRepository, event: &Event) { other => panic!("unexpected account event: {other}"), } - summary.mark_projected(&event.id); - store - .read_models::() - .upsert(&summary) - .expect("account summary projection should persist"); + let mut session = ReadModelSession::new(); + session + .document(&summary) + .expect("account summary projection should serialize") + .mark_processed(ACCOUNT_SUMMARY_CONSUMER, &event.id); + session + .commit(store) + .expect("account summary projection should persist") } pub fn wait_for_summary( - store: &HashMapRepository, + store: &InMemoryReadModelStore, account_id: &str, ready: impl Fn(&AccountSummary) -> bool, ) -> AccountSummary { @@ -107,8 +112,7 @@ pub fn wait_for_summary( loop { if let Some(summary) = store - .read_models::() - .get(account_id) + .get_by_primary_key::(account_id) .expect("read model load should succeed") .map(|view| view.data) { diff --git a/tests/read_model_commit_bridge/main.rs b/tests/read_model_commit_bridge/main.rs new file mode 100644 index 0000000..e43d9f3 --- /dev/null +++ b/tests/read_model_commit_bridge/main.rs @@ -0,0 +1,51 @@ +use serde::{Deserialize, Serialize}; +use sourced_rust::{ + impl_aggregate, Entity, EventRecord, HashMapRepository, ReadModel, ReadModelSession, + ReadModelSessionCommitExt, ReadModelStore, +}; + +#[derive(Default)] +struct TestAggregate { + entity: Entity, +} + +impl TestAggregate { + fn touch(&mut self) { + if self.entity.id().is_empty() { + self.entity.set_id("agg-1"); + } + self.entity.digest_empty("Touched").unwrap(); + } + + fn replay(&mut self, _event: &EventRecord) -> Result<(), String> { + Ok(()) + } +} + +impl_aggregate!(TestAggregate, entity, replay); + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, ReadModel)] +#[readmodel(collection = "bridge_views")] +struct BridgeView { + #[readmodel(id)] + id: String, + value: i32, +} + +#[test] +fn repo_first_read_models_session_commit_form_is_available() { + let repo = HashMapRepository::new(); + let view = BridgeView { + id: "view-1".into(), + value: 42, + }; + let mut session = ReadModelSession::new(); + session.document(&view).unwrap(); + let mut aggregate = TestAggregate::default(); + aggregate.touch(); + + repo.read_models(session).commit(&mut aggregate).unwrap(); + + let loaded = repo.get_model::("view-1").unwrap().unwrap(); + assert_eq!(loaded.data, view); +} diff --git a/tests/read_model_distributed_idempotency/main.rs b/tests/read_model_distributed_idempotency/main.rs new file mode 100644 index 0000000..133dd95 --- /dev/null +++ b/tests/read_model_distributed_idempotency/main.rs @@ -0,0 +1,169 @@ +use serde::{Deserialize, Serialize}; +use sourced_rust::bus::{Event, Publisher, Subscriber}; +use sourced_rust::{ + InMemoryQueue, InMemoryReadModelStore, ReadModel, ReadModelSession, ReadModelSessionStore, + ReadModelStore, +}; + +const CONSUMER: &str = "counter-projection"; + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, ReadModel)] +#[readmodel(collection = "counter_views")] +struct CounterView { + #[readmodel(id)] + id: String, + value: i32, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, ReadModel)] +#[readmodel(table = "relational_counters")] +struct RelationalCounter { + #[readmodel(id)] + id: String, + value: i32, +} + +fn counter_session(view: &CounterView, message_id: &str) -> ReadModelSession { + let mut session = ReadModelSession::new(); + session + .document(view) + .unwrap() + .mark_processed(CONSUMER, message_id); + session +} + +#[test] +fn standalone_session_commit_applies_document_and_marks_processed() { + let store = InMemoryReadModelStore::new(); + assert!(store.read_model_capabilities().processed_messages); + let view = CounterView { + id: "counter-1".into(), + value: 1, + }; + + let outcome = counter_session(&view, "message-1").commit(&store).unwrap(); + + assert!(outcome.was_applied()); + assert!(store.is_processed(CONSUMER, "message-1").unwrap()); + let loaded = store + .get_by_primary_key::("counter-1") + .unwrap() + .unwrap(); + assert_eq!(loaded.data, view); + assert_eq!(loaded.version, 1); +} + +#[test] +fn duplicate_processed_message_skips_mutations_idempotently() { + let store = InMemoryReadModelStore::new(); + let original = CounterView { + id: "counter-1".into(), + value: 1, + }; + counter_session(&original, "message-1") + .commit(&store) + .unwrap(); + + let duplicate_update = CounterView { + id: "counter-1".into(), + value: 99, + }; + let outcome = counter_session(&duplicate_update, "message-1") + .commit(&store) + .unwrap(); + + assert!(outcome.was_skipped()); + assert_eq!( + outcome + .duplicate_message() + .map(|mark| mark.message_id.as_str()), + Some("message-1") + ); + let loaded = store + .get_by_primary_key::("counter-1") + .unwrap() + .unwrap(); + assert_eq!(loaded.data, original); + assert_eq!(loaded.version, 1); +} + +#[test] +fn read_model_write_and_processed_mark_are_atomic() { + let store = InMemoryReadModelStore::new(); + let view = CounterView { + id: "counter-1".into(), + value: 1, + }; + let row = RelationalCounter { + id: "counter-1".into(), + value: 1, + }; + let mut session = ReadModelSession::new(); + session + .document(&view) + .unwrap() + .mark_processed(CONSUMER, "message-1") + .save(&row) + .unwrap(); + + let err = session.commit(&store).unwrap_err(); + + assert!(err.to_string().contains("relational row writes")); + assert!(!store.is_processed(CONSUMER, "message-1").unwrap()); + assert!(store + .get_by_primary_key::("counter-1") + .unwrap() + .is_none()); +} + +#[test] +fn ack_happens_only_after_successful_standalone_commit() { + let queue = InMemoryQueue::new(); + let store = InMemoryReadModelStore::new(); + + queue + .publish(Event::with_string_payload( + "message-fail", + "CounterChanged", + "{}", + )) + .unwrap(); + let failed = queue.poll(0).unwrap().unwrap(); + let row = RelationalCounter { + id: "counter-1".into(), + value: 1, + }; + let mut failed_session = ReadModelSession::new(); + failed_session + .save(&row) + .unwrap() + .mark_processed(CONSUMER, &failed.id); + + let err = failed_session.commit(&store).unwrap_err(); + + assert!(err.to_string().contains("relational row writes")); + assert!(queue.acknowledged().is_empty()); + assert!(!store.is_processed(CONSUMER, &failed.id).unwrap()); + + queue + .publish(Event::with_string_payload( + "message-ok", + "CounterChanged", + "{}", + )) + .unwrap(); + let succeeded = queue.poll(0).unwrap().unwrap(); + let view = CounterView { + id: "counter-1".into(), + value: 2, + }; + let outcome = counter_session(&view, &succeeded.id) + .commit(&store) + .unwrap(); + assert!(outcome.was_applied()); + + queue.ack(&succeeded.id).unwrap(); + + assert_eq!(queue.acknowledged(), vec!["message-ok"]); + assert!(store.is_processed(CONSUMER, &succeeded.id).unwrap()); +} diff --git a/tests/read_model_document_conformance/main.rs b/tests/read_model_document_conformance/main.rs new file mode 100644 index 0000000..0831227 --- /dev/null +++ b/tests/read_model_document_conformance/main.rs @@ -0,0 +1,230 @@ +use serde::{Deserialize, Serialize}; +use sourced_rust::{ + HashMapRepository, InMemoryReadModelStore, Lock, LockManager, QueuedReadModelStore, ReadModel, + ReadModelError, ReadModelSession, ReadModelSessionCommitExt, ReadModelStore, ReadOpts, +}; + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, ReadModel)] +#[readmodel(collection = "document_views")] +struct DocumentView { + #[readmodel(id)] + id: String, + value: i32, + category: String, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, ReadModel)] +#[readmodel(collection = "other_document_views")] +struct OtherDocumentView { + #[readmodel(id)] + id: String, + value: i32, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, ReadModel)] +#[readmodel(table = "relational_document_views")] +struct RelationalDocumentView { + #[readmodel(id)] + id: String, + value: i32, +} + +fn document_view(id: &str, value: i32, category: &str) -> DocumentView { + DocumentView { + id: id.into(), + value, + category: category.into(), + } +} + +fn document_session(view: &DocumentView) -> ReadModelSession { + let mut session = ReadModelSession::new(); + session.document(view).unwrap(); + session +} + +#[test] +fn document_session_plan_uses_key_value_store_and_shared_clone_storage() { + let repo = HashMapRepository::new(); + let clone = repo.clone(); + let view = document_view("document-1", 10, "a"); + + repo.read_models(document_session(&view)) + .commit_all() + .unwrap(); + + let loaded = clone + .get_model::("document-1") + .unwrap() + .unwrap(); + assert_eq!(loaded.data, view); +} + +#[test] +fn unsupported_row_plan_rejection_does_not_apply_prior_document_write() { + let repo = HashMapRepository::new(); + let document = document_view("mixed", 10, "a"); + let relational = RelationalDocumentView { + id: "relational".into(), + value: 20, + }; + let mut session = document_session(&document); + session.save(&relational).unwrap(); + + let err = repo.read_models(session).commit_all().unwrap_err(); + + assert!( + matches!(err, sourced_rust::RepositoryError::Model(message) if message.contains("relational row writes")) + ); + assert!(repo.get_model::("mixed").unwrap().is_none()); +} + +#[test] +fn optimistic_conflicts_and_delete_behavior_remain_document_row_storage() { + let store = InMemoryReadModelStore::new(); + let view = document_view("conflict", 1, "a"); + store.insert(&view).unwrap(); + + let err = store + .update(&document_view("conflict", 2, "a"), 99) + .unwrap_err(); + + assert!(matches!( + err, + ReadModelError::ConcurrencyConflict { + collection, + id, + expected: 99, + actual: 1, + } if collection == "document_views" && id == "conflict" + )); + assert!(store.delete::("conflict").unwrap()); + assert!(!store.delete::("conflict").unwrap()); +} + +#[test] +fn predicate_helpers_are_in_memory_only_and_still_work_in_memory() { + let store = InMemoryReadModelStore::new(); + store.upsert(&document_view("a-1", 1, "a")).unwrap(); + store.upsert(&document_view("a-2", 2, "a")).unwrap(); + store.upsert(&document_view("b-1", 3, "b")).unwrap(); + + let a_views = store + .find_models::(&|view| view.category == "a") + .unwrap(); + let first_high = store + .find_one_model::(&|view| view.value > 2) + .unwrap() + .unwrap(); + + assert_eq!(a_views.len(), 2); + assert_eq!(first_high.data.id, "b-1"); +} + +#[test] +fn queued_load_for_update_no_lock_read_and_session_commit_release_lock() { + let store = QueuedReadModelStore::new(HashMapRepository::new()); + store.upsert(&document_view("locked", 1, "a")).unwrap(); + let loaded = store + .load_for_update::("locked") + .unwrap() + .unwrap(); + + let peeked = store + .load_no_lock::("locked") + .unwrap() + .unwrap(); + assert_eq!(peeked.data, loaded.data); + + store + .read_models(document_session(&document_view("locked", 2, "a"))) + .commit_all() + .unwrap(); + + let lock = store + .lock_manager() + .get_lock("document_views:locked") + .unwrap(); + assert!(lock.try_lock().unwrap()); + lock.unlock().unwrap(); +} + +#[test] +fn queued_abort_unlocks_and_same_id_different_models_are_independent() { + let store = QueuedReadModelStore::new(HashMapRepository::new()); + store.upsert(&document_view("same", 1, "a")).unwrap(); + store + .upsert(&OtherDocumentView { + id: "same".into(), + value: 2, + }) + .unwrap(); + + store + .load_for_update::("same") + .unwrap() + .unwrap(); + store + .load_for_update::("same") + .unwrap() + .unwrap(); + store.abort::("same").unwrap(); + store.abort::("same").unwrap(); + + let document_lock = store + .lock_manager() + .get_lock("document_views:same") + .unwrap(); + let other_lock = store + .lock_manager() + .get_lock("other_document_views:same") + .unwrap(); + assert!(document_lock.try_lock().unwrap()); + assert!(other_lock.try_lock().unwrap()); + document_lock.unlock().unwrap(); + other_lock.unlock().unwrap(); +} + +#[test] +fn queued_session_commit_failure_keeps_lock_until_explicit_abort() { + let store = QueuedReadModelStore::new(HashMapRepository::new()); + store.upsert(&document_view("failed", 1, "a")).unwrap(); + store + .load_for_update::("failed") + .unwrap() + .unwrap(); + let relational = RelationalDocumentView { + id: "relational".into(), + value: 20, + }; + let mut session = ReadModelSession::new(); + session.save(&relational).unwrap(); + + let err = store.read_models(session).commit_all().unwrap_err(); + + assert!( + matches!(err, sourced_rust::RepositoryError::Model(message) if message.contains("relational row writes")) + ); + let lock = store + .lock_manager() + .get_lock("document_views:failed") + .unwrap(); + assert!(!lock.try_lock().unwrap()); + store.abort::("failed").unwrap(); + assert!(lock.try_lock().unwrap()); + lock.unlock().unwrap(); +} + +#[test] +fn read_opts_no_lock_matches_explicit_no_lock_helper() { + let store = QueuedReadModelStore::new(HashMapRepository::new()); + store.upsert(&document_view("opts", 1, "a")).unwrap(); + + let via_opts = store + .get_model_with::("opts", ReadOpts::no_lock()) + .unwrap() + .unwrap(); + let via_helper = store.load_no_lock::("opts").unwrap().unwrap(); + + assert_eq!(via_opts.data, via_helper.data); +} diff --git a/tests/read_model_metadata/main.rs b/tests/read_model_metadata/main.rs new file mode 100644 index 0000000..2d1e83f --- /dev/null +++ b/tests/read_model_metadata/main.rs @@ -0,0 +1,167 @@ +use std::collections::HashMap; + +use serde::{Deserialize, Serialize}; +use sourced_rust::{ + ColumnType, ReadModel, ReadModelError, RelationalReadModel, RelationshipKind, RowValue, + DEFAULT_READ_MODEL_VERSION_COLUMN, +}; + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, ReadModel)] +#[readmodel(table = "account_summaries")] +struct AccountSummary { + #[readmodel(id, column = "account_id")] + account_id: String, + #[readmodel(index)] + owner: Option, + balance_cents: i64, + #[readmodel(default = "0")] + deposit_count: u32, + #[readmodel(jsonb)] + counters_by_game: HashMap, + #[readmodel(skip_query)] + projected_event_ids: Vec, +} + +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, ReadModel)] +#[readmodel(table = "players")] +struct Player { + #[readmodel(id, column = "player_id")] + player_id: String, + display_name: String, + #[readmodel(has_many = "PlayerWeapon", foreign_key = "player_id")] + weapons: Vec, +} + +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, ReadModel)] +#[readmodel(table = "player_weapons", primary_key = ["player_id", "weapon_id"])] +struct PlayerWeapon { + #[readmodel(foreign_key = "players.player_id", delegated_from = "Player.player_id")] + player_id: String, + weapon_id: String, + acquired_at: String, +} + +#[test] +fn derive_allows_table_models_with_string_ids_to_use_document_rows() { + let summary = AccountSummary { + account_id: "acct-1".into(), + owner: Some("Ada".into()), + balance_cents: 100, + deposit_count: 1, + counters_by_game: HashMap::new(), + projected_event_ids: Vec::new(), + }; + + assert_eq!(AccountSummary::COLLECTION, "account_summaries"); + assert_eq!(summary.id(), "acct-1"); +} + +#[test] +fn derive_describes_columns_indexes_nullability_and_jsonb() { + let schema = AccountSummary::schema(); + + schema.validate().unwrap(); + assert_eq!(schema.table_name, "account_summaries"); + assert_eq!(schema.primary_key.columns, vec!["account_id"]); + assert_eq!( + schema.version_column.as_deref(), + Some(DEFAULT_READ_MODEL_VERSION_COLUMN) + ); + + let owner = schema + .columns + .iter() + .find(|column| column.column_name == "owner") + .unwrap(); + assert!(owner.nullable); + + let counters = schema + .columns + .iter() + .find(|column| column.column_name == "counters_by_game") + .unwrap(); + assert_eq!(counters.column_type, ColumnType::Json); + assert!(counters.jsonb); + + assert_eq!(schema.indexes.len(), 1); + assert_eq!(schema.indexes[0].columns, vec!["owner"]); + + let deposit_count = schema + .columns + .iter() + .find(|column| column.column_name == "deposit_count") + .unwrap(); + assert!(deposit_count.has_default); + assert_eq!(deposit_count.default.as_deref(), Some("0")); +} + +#[test] +fn row_conversion_round_trips_scalar_option_and_jsonb_fields() { + let mut counters = HashMap::new(); + counters.insert("arena".to_string(), 3); + let summary = AccountSummary { + account_id: "acct-1".into(), + owner: Some("Ada".into()), + balance_cents: 2500, + deposit_count: 2, + counters_by_game: counters, + projected_event_ids: Vec::new(), + }; + + let row = summary.to_row().unwrap(); + assert_eq!( + row.get("account_id"), + Some(&RowValue::String("acct-1".into())) + ); + + let round_trip = AccountSummary::from_row(row).unwrap(); + assert_eq!(round_trip, summary); +} + +#[test] +fn derive_represents_relationships_composite_keys_and_delegated_foreign_keys() { + let player_schema = Player::schema(); + let weapon_schema = PlayerWeapon::schema(); + + player_schema.validate().unwrap(); + weapon_schema.validate().unwrap(); + + assert_eq!(player_schema.relationships.len(), 1); + assert_eq!( + player_schema.relationships[0].kind, + RelationshipKind::HasMany + ); + assert_eq!(player_schema.relationships[0].target_model, "PlayerWeapon"); + assert_eq!( + player_schema.relationships[0].foreign_key.as_deref(), + Some("player_id") + ); + + assert_eq!( + weapon_schema.primary_key.columns, + vec!["player_id", "weapon_id"] + ); + let player_id = weapon_schema + .columns + .iter() + .find(|column| column.column_name == "player_id") + .unwrap(); + assert_eq!( + player_id.delegated_from.as_deref(), + Some("Player.player_id") + ); + assert_eq!(player_id.foreign_key.as_ref().unwrap().table, "players"); +} + +#[test] +fn metadata_validation_reports_missing_keys_before_storage_writes() { + #[derive(Clone, Debug, Serialize, Deserialize, ReadModel)] + #[readmodel(table = "missing_key_models")] + struct MissingKeyModel { + value: String, + } + + let err = MissingKeyModel::schema().validate().unwrap_err(); + + assert!(matches!(err, ReadModelError::Metadata(message) if message.contains("primary-key"))); +} diff --git a/tests/read_model_schema_bootstrap/main.rs b/tests/read_model_schema_bootstrap/main.rs new file mode 100644 index 0000000..3a5e799 --- /dev/null +++ b/tests/read_model_schema_bootstrap/main.rs @@ -0,0 +1,282 @@ +use std::collections::{BTreeSet, HashMap}; + +use serde::{Deserialize, Serialize}; +use sourced_rust::{ + ColumnType, ReadModel, ReadModelError, ReadModelMigrationArtifact, ReadModelSchema, + ReadModelSchemaAdapter, ReadModelSchemaAdapterCapabilities, ReadModelSchemaBootstrap, + ReadModelSchemaIssue, ReadModelSchemaIssueKind, ReadModelSchemaRegistry, + ReadModelSchemaVerification, RelationalReadModel, DEFAULT_READ_MODEL_VERSION_COLUMN, +}; + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, ReadModel)] +#[readmodel(table = "account_summaries")] +struct AccountSummary { + #[readmodel(id, column = "account_id")] + account_id: String, + #[readmodel(unique)] + owner_slug: String, + balance_cents: i64, + #[readmodel(default = "0")] + deposit_count: u32, + #[readmodel(jsonb)] + counters_by_game: HashMap, +} + +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, ReadModel)] +#[readmodel(table = "players")] +struct Player { + #[readmodel(id, column = "player_id")] + player_id: String, + display_name: String, + #[readmodel(has_many = "PlayerWeapon", foreign_key = "player_id")] + weapons: Vec, +} + +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, ReadModel)] +#[readmodel(table = "player_weapons", primary_key = ["player_id", "weapon_id"])] +struct PlayerWeapon { + #[readmodel(foreign_key = "players.player_id", delegated_from = "Player.player_id")] + player_id: String, + weapon_id: String, + #[readmodel(index)] + acquired_at: String, +} + +struct UnsupportedSchemaAdapter; + +impl ReadModelSchemaAdapter for UnsupportedSchemaAdapter { + fn schema_capabilities(&self) -> ReadModelSchemaAdapterCapabilities { + ReadModelSchemaAdapterCapabilities::default() + } +} + +struct FakeSqlSchemaAdapter { + existing_tables: BTreeSet, +} + +impl FakeSqlSchemaAdapter { + fn new(existing_tables: impl IntoIterator>) -> Self { + Self { + existing_tables: existing_tables.into_iter().map(Into::into).collect(), + } + } +} + +impl ReadModelSchemaAdapter for FakeSqlSchemaAdapter { + fn schema_capabilities(&self) -> ReadModelSchemaAdapterCapabilities { + ReadModelSchemaAdapterCapabilities::all() + } + + fn generate_migration_artifacts( + &self, + registry: &ReadModelSchemaRegistry, + ) -> Result, ReadModelError> { + registry.validate()?; + Ok(vec![ReadModelMigrationArtifact::new( + "read-models", + registry.schemas().map(create_table_statement), + )]) + } + + fn verify_schema( + &self, + registry: &ReadModelSchemaRegistry, + ) -> Result { + registry.validate()?; + let mut issues = Vec::new(); + for schema in registry.schemas() { + if !self.existing_tables.contains(&schema.table_name) { + issues.push(ReadModelSchemaIssue::new( + schema.table_name.clone(), + None::, + ReadModelSchemaIssueKind::MissingTable, + format!("missing table `{}`", schema.table_name), + )); + } + } + Ok(ReadModelSchemaVerification { issues }) + } + + fn bootstrap_schema_for_dev( + &self, + registry: &ReadModelSchemaRegistry, + ) -> Result { + registry.validate()?; + Ok(ReadModelSchemaBootstrap::new( + registry.table_names().map(str::to_string), + )) + } +} + +fn registry() -> ReadModelSchemaRegistry { + let mut registry = ReadModelSchemaRegistry::new(); + registry + .register::() + .unwrap() + .register::() + .unwrap() + .register::() + .unwrap(); + registry +} + +fn create_table_statement(schema: &ReadModelSchema) -> String { + let columns = schema + .columns + .iter() + .map(|column| { + let nullability = if column.nullable { "null" } else { "not null" }; + format!( + "{} {} {}", + column.column_name, + logical_type_name(&column.column_type, column.jsonb), + nullability + ) + }) + .chain( + schema + .version_column + .iter() + .map(|column| format!("{column} unsigned_integer not null default 1")), + ) + .collect::>() + .join(", "); + let primary_key = schema.primary_key.columns.join(", "); + format!( + "create table {} ({columns}, primary key ({primary_key}));", + schema.table_name + ) +} + +fn logical_type_name(column_type: &ColumnType, jsonb: bool) -> &'static str { + if jsonb { + return "jsonb"; + } + + match column_type { + ColumnType::Text => "text", + ColumnType::Boolean => "boolean", + ColumnType::Integer => "integer", + ColumnType::UnsignedInteger => "unsigned_integer", + ColumnType::Float => "float", + ColumnType::Bytes => "bytes", + ColumnType::Json => "json", + ColumnType::Unsupported(_) => "unsupported", + } +} + +#[test] +fn registry_registers_relational_models_and_exposes_schema_metadata() { + let registry = registry(); + + registry.validate().unwrap(); + assert_eq!(registry.len(), 3); + assert_eq!( + registry.table_names().collect::>(), + vec!["account_summaries", "player_weapons", "players"] + ); + + let summary = registry.schema_for_model("AccountSummary").unwrap(); + assert_eq!(summary.table_name, "account_summaries"); + assert_eq!( + summary.version_column.as_deref(), + Some(DEFAULT_READ_MODEL_VERSION_COLUMN) + ); + assert!(summary + .columns + .iter() + .any(|column| column.column_name == "counters_by_game" && column.jsonb)); + assert!(summary + .indexes + .iter() + .any(|index| index.unique && index.columns == vec!["owner_slug"])); + + let weapon = registry.schema_for_table("player_weapons").unwrap(); + assert_eq!(weapon.primary_key.columns, vec!["player_id", "weapon_id"]); + assert!(weapon.columns.iter().any(|column| { + column.column_name == "player_id" + && column.delegated_from.as_deref() == Some("Player.player_id") + && column + .foreign_key + .as_ref() + .is_some_and(|foreign_key| foreign_key.table == "players") + })); +} + +#[test] +fn registry_rejects_duplicate_tables_and_invalid_foreign_key_targets() { + let mut duplicate_registry = ReadModelSchemaRegistry::new(); + duplicate_registry.register::().unwrap(); + + let err = duplicate_registry.register::().unwrap_err(); + + assert!( + matches!(err, ReadModelError::Metadata(message) if message.contains("already contains table")) + ); + + let mut invalid_registry = ReadModelSchemaRegistry::new(); + invalid_registry + .register_schema(PlayerWeapon::schema()) + .unwrap(); + + let err = invalid_registry.validate().unwrap_err(); + + assert!( + matches!(err, ReadModelError::Metadata(message) if message.contains("unregistered foreign-key table")) + ); +} + +#[test] +fn adapters_can_generate_migration_artifacts_or_report_unsupported() { + let registry = registry(); + let unsupported = UnsupportedSchemaAdapter; + + let err = unsupported + .generate_migration_artifacts(®istry) + .unwrap_err(); + + assert!( + matches!(err, ReadModelError::Metadata(message) if message.contains("migration artifact generation")) + ); + + let adapter = FakeSqlSchemaAdapter::new(Vec::::new()); + let artifacts = adapter.generate_migration_artifacts(®istry).unwrap(); + + assert_eq!(artifacts.len(), 1); + assert_eq!(artifacts[0].name, "read-models"); + assert!(artifacts[0] + .statements + .iter() + .any(|statement| statement.contains("account_summaries") + && statement.contains("jsonb") + && statement.contains(DEFAULT_READ_MODEL_VERSION_COLUMN))); + assert!(artifacts[0] + .statements + .iter() + .any(|statement| statement.contains("primary key (player_id, weapon_id)"))); +} + +#[test] +fn adapters_can_verify_schema_and_explicitly_bootstrap_dev_schema() { + let registry = registry(); + let adapter = FakeSqlSchemaAdapter::new(["account_summaries"]); + + let verification = adapter.verify_schema(®istry).unwrap(); + + assert!(!verification.is_verified()); + assert_eq!( + verification + .issues + .iter() + .filter(|issue| issue.kind == ReadModelSchemaIssueKind::MissingTable) + .count(), + 2 + ); + + let bootstrap = adapter.bootstrap_schema_for_dev(®istry).unwrap(); + + assert_eq!( + bootstrap.bootstrapped_tables, + vec!["account_summaries", "player_weapons", "players"] + ); +} diff --git a/tests/read_model_session/main.rs b/tests/read_model_session/main.rs new file mode 100644 index 0000000..0a95a41 --- /dev/null +++ b/tests/read_model_session/main.rs @@ -0,0 +1,270 @@ +use std::collections::HashMap; + +use serde::{Deserialize, Serialize}; +use sourced_rust::{ + ExpectedVersion, PatchMode, ReadModel, ReadModelAdapterCapabilities, ReadModelError, + ReadModelMutation, ReadModelSession, RowKey, RowPatch, RowValue, RowWriteMode, Versioned, +}; + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, ReadModel)] +#[readmodel(table = "account_summaries")] +struct AccountSummary { + #[readmodel(id, column = "account_id")] + account_id: String, + #[readmodel(index)] + owner: Option, + balance_cents: i64, + #[readmodel(default = "0")] + deposit_count: u32, + #[readmodel(jsonb)] + counters_by_game: HashMap, + #[readmodel(skip_query)] + projected_event_ids: Vec, +} + +impl AccountSummary { + fn new(account_id: &str) -> Self { + Self { + account_id: account_id.into(), + owner: Some("Ada".into()), + balance_cents: 100, + deposit_count: 1, + counters_by_game: HashMap::new(), + projected_event_ids: Vec::new(), + } + } +} + +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, ReadModel)] +#[readmodel(table = "players")] +struct Player { + #[readmodel(id, column = "player_id")] + player_id: String, + display_name: String, + #[readmodel(has_many = "PlayerWeapon", foreign_key = "player_id")] + weapons: Vec, +} + +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, ReadModel)] +#[readmodel(table = "player_weapons", primary_key = ["player_id", "weapon_id"])] +struct PlayerWeapon { + #[readmodel(foreign_key = "players.player_id", delegated_from = "Player.player_id")] + player_id: String, + weapon_id: String, + acquired_at: String, +} + +fn account_key(account_id: &str) -> RowKey { + RowKey::new([("account_id", RowValue::String(account_id.into()))]) +} + +#[test] +fn session_stages_multiple_read_model_types_in_deterministic_plan() { + let mut session = ReadModelSession::new(); + let weapon = PlayerWeapon { + player_id: "player-1".into(), + weapon_id: "sword".into(), + acquired_at: "2026-05-23T00:00:00Z".into(), + }; + let account = AccountSummary::new("acct-1"); + + session.save(&weapon).unwrap().save(&account).unwrap(); + + let plan = session.into_write_plan().unwrap(); + + assert_eq!(plan.mutations.len(), 2); + assert_eq!(plan.mutations[0].table_name(), Some("account_summaries")); + assert_eq!(plan.mutations[1].table_name(), Some("player_weapons")); +} + +#[test] +fn write_plan_contains_document_and_relational_rows_only() { + let mut session = ReadModelSession::new(); + let account = AccountSummary::new("acct-1"); + + session + .document(&account) + .unwrap() + .save(&account) + .unwrap() + .delete::(account_key("acct-2")) + .unwrap(); + + let plan = session.into_write_plan().unwrap(); + + assert!(matches!(plan.mutations[0], ReadModelMutation::Document(_))); + assert!(matches!(plan.mutations[1], ReadModelMutation::UpsertRow(_))); + assert!(matches!(plan.mutations[2], ReadModelMutation::DeleteRow(_))); +} + +#[test] +fn sparse_patches_and_full_replacements_are_distinct() { + let mut session = ReadModelSession::new(); + let account = AccountSummary::new("acct-1"); + let patch = RowPatch::new().set("owner", RowValue::Null); + + session.save(&account).unwrap(); + session + .patch::(account_key("acct-1"), patch) + .unwrap(); + + let plan = session.into_write_plan().unwrap(); + + let ReadModelMutation::UpsertRow(full_row) = &plan.mutations[0] else { + panic!("expected full-row mutation"); + }; + assert_eq!(full_row.mode, RowWriteMode::Upsert); + assert!(full_row.values.contains_key("balance_cents")); + assert!(full_row.values.contains_key("counters_by_game")); + + let ReadModelMutation::PatchRow(patch_row) = &plan.mutations[1] else { + panic!("expected patch-row mutation"); + }; + assert_eq!(patch_row.mode, PatchMode::UpdateExisting); + assert_eq!(patch_row.patch.get("owner"), Some(&RowValue::Null)); + assert_eq!(patch_row.patch.iter().count(), 1); +} + +#[test] +fn insert_and_upsert_patch_carry_explicit_missing_row_behavior() { + let mut session = ReadModelSession::new(); + let account = AccountSummary::new("acct-1"); + let patch = RowPatch::new().set("owner", RowValue::String("Grace".into())); + + session.insert(&account).unwrap(); + session + .upsert_patch::(account_key("acct-2"), patch) + .unwrap(); + + let plan = session.into_write_plan().unwrap(); + + let ReadModelMutation::UpsertRow(insert_row) = &plan.mutations[0] else { + panic!("expected insert row mutation"); + }; + assert_eq!(insert_row.mode, RowWriteMode::Insert); + assert_eq!(insert_row.expected_version, ExpectedVersion::NotExists); + + let ReadModelMutation::PatchRow(upsert_patch) = &plan.mutations[1] else { + panic!("expected upsert patch mutation"); + }; + assert_eq!(upsert_patch.mode, PatchMode::InsertMissing); +} + +#[test] +fn relationship_operation_populates_child_foreign_key_in_explicit_row_mutation() { + let player = Player { + player_id: "player-1".into(), + display_name: "Ada".into(), + weapons: Vec::new(), + }; + let weapon = PlayerWeapon { + player_id: String::new(), + weapon_id: "sword".into(), + acquired_at: "2026-05-23T00:00:00Z".into(), + }; + let mut session = ReadModelSession::new(); + + session.save_related(&player, "weapons", &weapon).unwrap(); + + let plan = session.into_write_plan().unwrap(); + + let ReadModelMutation::UpsertRow(child_row) = &plan.mutations[0] else { + panic!("expected child row mutation"); + }; + assert_eq!(child_row.schema.table_name, "player_weapons"); + assert_eq!( + child_row.values.get("player_id"), + Some(&RowValue::String("player-1".into())) + ); + assert_eq!( + child_row.key.get("player_id"), + Some(&RowValue::String("player-1".into())) + ); +} + +#[test] +fn expected_versions_and_processed_messages_are_carried_into_plan() { + let mut account = AccountSummary::new("acct-1"); + let loaded = Versioned { + data: account.clone(), + version: 7, + }; + account.balance_cents = 250; + let mut session = ReadModelSession::new(); + + session + .track_loaded(&loaded) + .unwrap() + .save(&account) + .unwrap() + .mark_processed("account-projection", "message-1"); + + let plan = session.into_write_plan().unwrap(); + + let ReadModelMutation::UpsertRow(row) = &plan.mutations[0] else { + panic!("expected upsert row"); + }; + assert_eq!(row.expected_version, ExpectedVersion::Exact(7)); + assert_eq!( + plan.processed_messages[0].consumer_name, + "account-projection" + ); + assert_eq!(plan.processed_messages[0].message_id, "message-1"); +} + +#[test] +fn load_requests_validate_primary_keys_and_explicit_relationship_includes() { + let session = ReadModelSession::new(); + + let request = session + .load_with::( + RowKey::new([("player_id", RowValue::String("player-1".into()))]), + ["weapons"], + ) + .unwrap(); + + assert_eq!(request.schema.table_name, "players"); + assert_eq!(request.includes, vec!["weapons"]); + + let err = session + .load_with::( + RowKey::new([("player_id", RowValue::String("player-1".into()))]), + ["missing"], + ) + .unwrap_err(); + assert!(matches!(err, ReadModelError::Metadata(message) if message.contains("relationship"))); +} + +#[test] +fn validation_failures_happen_before_storage_writes() { + let mut session = ReadModelSession::new(); + let patch = RowPatch::new().set("balance_cents", RowValue::Null); + + session + .patch::(account_key("acct-1"), patch) + .unwrap(); + + let err = session.into_write_plan().unwrap_err(); + + assert!(matches!(err, ReadModelError::Metadata(message) if message.contains("not nullable"))); +} + +#[test] +fn write_plan_validation_reports_unsupported_adapter_capabilities() { + let mut session = ReadModelSession::new(); + let patch = RowPatch::new().set("owner", RowValue::String("Grace".into())); + session + .patch::(account_key("acct-1"), patch) + .unwrap(); + let plan = session.into_write_plan().unwrap(); + let capabilities = ReadModelAdapterCapabilities { + sparse_patches: false, + ..ReadModelAdapterCapabilities::default() + }; + + let err = plan.validate_for(&capabilities).unwrap_err(); + + assert!( + matches!(err, ReadModelError::Metadata(message) if message.contains("sparse row patches")) + ); +} diff --git a/tests/read_models/main.rs b/tests/read_models/main.rs index 71fe955..2ae125f 100644 --- a/tests/read_models/main.rs +++ b/tests/read_models/main.rs @@ -11,7 +11,7 @@ use std::time::Duration; use aggregate::Counter; use sourced_rust::{ AggregateBuilder, CommitBuilderExt, HashMapRepository, OutboxMessage, QueuedReadModelStore, - ReadModelsExt, ReadOpts, + ReadModelSession, ReadModelStore, ReadModelsExt, ReadOpts, }; use views::{CounterView, UserCountersIndexView}; @@ -239,6 +239,79 @@ fn commit_all_without_aggregate() { assert_eq!(loaded2.data.id, "standalone-2"); } +#[test] +fn standalone_session_commit_and_primary_key_read() { + let repo = HashMapRepository::new(); + let view = CounterView::new("session-standalone", "Session", "user-session"); + let mut session = ReadModelSession::new(); + session.document(&view).unwrap(); + + let outcome = session.commit(&repo).unwrap(); + + assert!(outcome.was_applied()); + let loaded = repo + .get_by_primary_key::("session-standalone") + .unwrap() + .unwrap(); + assert_eq!(loaded.data.name, "Session"); +} + +#[test] +fn read_models_session_commits_with_aggregate() { + let repo = HashMapRepository::new(); + let mut counter = Counter::new(); + counter + .create( + "counter-session".into(), + "Session Commit".into(), + "user-session".into(), + ) + .unwrap(); + counter.increment(11).unwrap(); + + let mut view = CounterView::new("counter-session", "Session Commit", "user-session"); + view.set_value(counter.value()); + let mut session = ReadModelSession::new(); + session.document(&view).unwrap(); + + sourced_rust::ReadModelSessionCommitExt::read_models(&repo, session) + .commit(&mut counter) + .unwrap(); + + let stored_counter = repo + .clone() + .aggregate::() + .get("counter-session") + .unwrap() + .unwrap(); + assert_eq!(stored_counter.value(), 11); + let stored_view = repo + .read_models::() + .get("counter-session") + .unwrap() + .unwrap(); + assert_eq!(stored_view.data.value, 11); +} + +#[test] +fn readmodel_commits_document_path() { + let repo = HashMapRepository::new(); + let mut counter = Counter::new(); + counter + .create("alias-1".into(), "Alias".into(), "user-alias".into()) + .unwrap(); + let view = CounterView::new("alias-1", "Alias", "user-alias"); + + repo.readmodel(&view).commit(&mut counter).unwrap(); + + let stored = repo + .read_models::() + .get("alias-1") + .unwrap() + .unwrap(); + assert_eq!(stored.data.name, "Alias"); +} + #[test] fn outbox_then_readmodel_order() { let repo = HashMapRepository::new(); From 55dc48fd08df1c85e04b892e3b86b3087e9b3d5f Mon Sep 17 00:00:00 2001 From: Patrick Lee Scott Date: Fri, 22 May 2026 22:45:00 -0500 Subject: [PATCH 02/26] fix: harden read-model derive metadata parsing --- sourced_rust_macros/src/read_model.rs | 121 +++++++++++++++++++++++++- 1 file changed, 119 insertions(+), 2 deletions(-) diff --git a/sourced_rust_macros/src/read_model.rs b/sourced_rust_macros/src/read_model.rs index c78d01e..4f25aad 100644 --- a/sourced_rust_macros/src/read_model.rs +++ b/sourced_rust_macros/src/read_model.rs @@ -311,6 +311,8 @@ impl StructAttrs { } else if meta.path.is_ident("primary_key") { let expr = meta.value()?.parse::()?; attrs.primary_key = parse_string_list(expr)?; + } else { + return Err(meta.error("unknown readmodel struct attribute")); } Ok(()) })?; @@ -343,6 +345,8 @@ struct FieldAttrs { impl FieldAttrs { fn from_field(field: &Field) -> syn::Result { let mut attrs = Self::default(); + let mut pending_foreign_key: Option = None; + let mut pending_through: Option = None; for attr in &field.attrs { if !attr.path().is_ident("readmodel") { continue; @@ -380,7 +384,7 @@ impl FieldAttrs { if attrs.relationship.is_some() { relationship_mut(&mut attrs, "foreign_key")?.foreign_key = Some(value); } else { - attrs.foreign_key = Some(parse_foreign_key(&value)?); + pending_foreign_key = Some(value); } } else if meta.path.is_ident("delegated_from") { attrs.delegated_from = Some(meta.value()?.parse::()?.value()); @@ -410,11 +414,49 @@ impl FieldAttrs { }); } else if meta.path.is_ident("through") { let through = meta.value()?.parse::()?.value(); - relationship_mut(&mut attrs, "through")?.through = Some(through); + if attrs.relationship.is_some() { + relationship_mut(&mut attrs, "through")?.through = Some(through); + } else { + pending_through = Some(through); + } + } else { + return Err(meta.error("unknown readmodel field attribute")); } Ok(()) })?; } + + if let Some(value) = pending_foreign_key { + if let Some(relationship) = attrs.relationship.as_mut() { + if relationship.foreign_key.is_some() { + return Err(syn::Error::new_spanned( + field, + "relationship foreign_key declared more than once", + )); + } + relationship.foreign_key = Some(value); + } else { + attrs.foreign_key = Some(parse_foreign_key(&value)?); + } + } + + if let Some(through) = pending_through { + if let Some(relationship) = attrs.relationship.as_mut() { + if relationship.through.is_some() { + return Err(syn::Error::new_spanned( + field, + "relationship through declared more than once", + )); + } + relationship.through = Some(through); + } else { + return Err(syn::Error::new_spanned( + field, + "`through` must be declared with a relationship attribute", + )); + } + } + Ok(attrs) } @@ -760,6 +802,81 @@ mod tests { ); } + #[test] + fn expand_read_model_rejects_unknown_struct_attributes() { + let input: DeriveInput = syn::parse_quote! { + #[readmodel(tabel = "counter_views")] + struct CounterView { + id: String, + value: i32, + } + }; + + let err = expand_read_model(input).expect_err("unknown struct attribute should fail"); + + assert!( + err.to_string() + .contains("unknown readmodel struct attribute"), + "unexpected error: {err}" + ); + } + + #[test] + fn expand_read_model_rejects_unknown_field_attributes() { + let input: DeriveInput = syn::parse_quote! { + struct CounterView { + #[readmodel(ide)] + id: String, + value: i32, + } + }; + + let err = expand_read_model(input).expect_err("unknown field attribute should fail"); + + assert!( + err.to_string() + .contains("unknown readmodel field attribute"), + "unexpected error: {err}" + ); + } + + #[test] + fn expand_read_model_accepts_relationship_metadata_before_relationship_kind() { + let input: DeriveInput = syn::parse_quote! { + #[readmodel(table = "players")] + struct Player { + #[readmodel(id, column = "player_id")] + id: String, + #[readmodel(foreign_key = "player_id", has_many = "PlayerWeapon")] + weapons: Vec, + } + }; + + let expanded = expand_read_model(input).unwrap().to_string(); + + assert!(expanded.contains("RelationshipKind :: HasMany")); + assert!(expanded.contains("foreign_key : Some (\"player_id\"")); + } + + #[test] + fn expand_read_model_accepts_through_before_many_to_many() { + let input: DeriveInput = syn::parse_quote! { + #[readmodel(table = "players")] + struct Player { + #[readmodel(id, column = "player_id")] + id: String, + #[readmodel(through = "player_weapon_links", foreign_key = "player_id", many_to_many = "Weapon")] + weapons: Vec, + } + }; + + let expanded = expand_read_model(input).unwrap().to_string(); + + assert!(expanded.contains("RelationshipKind :: ManyToMany")); + assert!(expanded.contains("through : Some (\"player_weapon_links\"")); + assert!(expanded.contains("foreign_key : Some (\"player_id\"")); + } + #[test] fn expand_read_model_rejects_tuple_structs() { let input: DeriveInput = syn::parse_quote! { From 21cefef3b28480ba3772a5aa066d78aef83e6ae0 Mon Sep 17 00:00:00 2001 From: Patrick Lee Scott Date: Fri, 22 May 2026 22:46:12 -0500 Subject: [PATCH 03/26] fix: avoid relational row key fingerprint collisions --- src/read_model/session.rs | 61 ++++++++++++++++++++++++++++++++------- 1 file changed, 50 insertions(+), 11 deletions(-) diff --git a/src/read_model/session.rs b/src/read_model/session.rs index 3101501..2b6ee49 100644 --- a/src/read_model/session.rs +++ b/src/read_model/session.rs @@ -971,21 +971,60 @@ fn column_name_for(schema: &ReadModelSchema, field_or_column: &str) -> Option String { - key.iter() - .map(|(column, value)| format!("{column}={}", value_fingerprint(value))) - .collect::>() - .join(",") + let mut fingerprint = String::new(); + for (column, value) in key.iter() { + push_fingerprint_part(&mut fingerprint, column); + push_fingerprint_part(&mut fingerprint, &value_fingerprint(value)); + } + fingerprint +} + +fn push_fingerprint_part(fingerprint: &mut String, part: &str) { + fingerprint.push_str(&part.len().to_string()); + fingerprint.push(':'); + fingerprint.push_str(part); + fingerprint.push(';'); } fn value_fingerprint(value: &RowValue) -> String { match value { RowValue::Null => "null".into(), - RowValue::Bool(value) => value.to_string(), - RowValue::I64(value) => value.to_string(), - RowValue::U64(value) => value.to_string(), - RowValue::F64(value) => value.to_string(), - RowValue::String(value) => value.clone(), - RowValue::Bytes(value) => format!("{value:?}"), - RowValue::Json(value) => serde_json::to_string(value).unwrap_or_else(|_| value.to_string()), + RowValue::Bool(value) => format!("bool:{value}"), + RowValue::I64(value) => format!("i64:{value}"), + RowValue::U64(value) => format!("u64:{value}"), + RowValue::F64(value) => format!("f64:{value:?}"), + RowValue::String(value) => format!("string:{value}"), + RowValue::Bytes(value) => format!("bytes:{value:?}"), + RowValue::Json(value) => format!( + "json:{}", + serde_json::to_string(value).unwrap_or_else(|_| value.to_string()) + ), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn key_fingerprint_distinguishes_delimiter_collisions() { + let left = RowKey::new([ + ("a", RowValue::String("x,b=y".into())), + ("b", RowValue::String("z".into())), + ]); + let right = RowKey::new([ + ("a", RowValue::String("x".into())), + ("b", RowValue::String("y,b=z".into())), + ]); + + assert_ne!(key_fingerprint(&left), key_fingerprint(&right)); + } + + #[test] + fn key_fingerprint_distinguishes_row_value_types() { + let integer = RowKey::new([("id", RowValue::I64(1))]); + let string = RowKey::new([("id", RowValue::String("1".into()))]); + + assert_ne!(key_fingerprint(&integer), key_fingerprint(&string)); } } From 10932b3b2a56159c54251ee2f8ae289292aafd8e Mon Sep 17 00:00:00 2001 From: Patrick Lee Scott Date: Fri, 22 May 2026 22:47:28 -0500 Subject: [PATCH 04/26] fix: validate read-model relationship foreign keys --- src/read_model/schema.rs | 77 ++++++++++++++++++----- tests/read_model_schema_bootstrap/main.rs | 20 ++++++ 2 files changed, 82 insertions(+), 15 deletions(-) diff --git a/src/read_model/schema.rs b/src/read_model/schema.rs index 8606225..746ce20 100644 --- a/src/read_model/schema.rs +++ b/src/read_model/schema.rs @@ -1,6 +1,6 @@ use std::collections::{BTreeMap, BTreeSet}; -use super::{ReadModelError, ReadModelSchema, RelationalReadModel}; +use super::{ReadModelError, ReadModelSchema, RelationalReadModel, RelationshipKind}; /// Registry of table-mapped read-model schemas an adapter should manage. #[derive(Clone, Debug, Default, PartialEq, Eq)] @@ -79,17 +79,11 @@ impl ReadModelSchemaRegistry { .keys() .cloned() .collect::>(); - let model_names = self - .tables_by_model - .keys() - .cloned() - .collect::>(); - for schema in self.schemas() { schema.validate()?; self.validate_column_foreign_keys(schema, &table_names)?; self.validate_schema_foreign_keys(schema, &table_names)?; - self.validate_relationships(schema, &model_names, &table_names)?; + self.validate_relationships(schema, &table_names)?; } Ok(()) @@ -137,16 +131,17 @@ impl ReadModelSchemaRegistry { fn validate_relationships( &self, schema: &ReadModelSchema, - model_names: &BTreeSet, table_names: &BTreeSet, ) -> Result<(), ReadModelError> { for relationship in &schema.relationships { - if !model_names.contains(&relationship.target_model) { - return Err(ReadModelError::Metadata(format!( - "read model `{}` relationship `{}` targets unregistered model `{}`", - schema.model_name, relationship.field_name, relationship.target_model - ))); - } + let target_schema = self + .schema_for_model(&relationship.target_model) + .ok_or_else(|| { + ReadModelError::Metadata(format!( + "read model `{}` relationship `{}` targets unregistered model `{}`", + schema.model_name, relationship.field_name, relationship.target_model + )) + })?; if let Some(through) = relationship.through.as_deref() { if !table_names.contains(through) { @@ -156,6 +151,51 @@ impl ReadModelSchemaRegistry { ))); } } + + let foreign_key = relationship.foreign_key.as_deref().unwrap_or_default(); + match relationship.kind { + RelationshipKind::HasMany => { + if !schema_has_column_or_field(target_schema, foreign_key) { + return Err(ReadModelError::Metadata(format!( + "read model `{}` relationship `{}` foreign key `{}` is not a column on target model `{}`", + schema.model_name, + relationship.field_name, + foreign_key, + relationship.target_model + ))); + } + } + RelationshipKind::BelongsTo => { + if !schema_has_column_or_field(schema, foreign_key) { + return Err(ReadModelError::Metadata(format!( + "read model `{}` relationship `{}` foreign key `{}` is not a column on source model `{}`", + schema.model_name, + relationship.field_name, + foreign_key, + schema.model_name + ))); + } + } + RelationshipKind::ManyToMany => { + if let Some(through) = relationship.through.as_deref() { + let through_schema = self.schema_for_table(through).ok_or_else(|| { + ReadModelError::Metadata(format!( + "read model `{}` relationship `{}` references unavailable join table `{}`", + schema.model_name, relationship.field_name, through + )) + })?; + if !schema_has_column_or_field(through_schema, foreign_key) { + return Err(ReadModelError::Metadata(format!( + "read model `{}` relationship `{}` foreign key `{}` is not a column on join table `{}`", + schema.model_name, + relationship.field_name, + foreign_key, + through + ))); + } + } + } + } } Ok(()) } @@ -199,6 +239,13 @@ impl ReadModelSchemaRegistry { } } +fn schema_has_column_or_field(schema: &ReadModelSchema, name: &str) -> bool { + schema + .columns + .iter() + .any(|column| column.column_name == name || column.field_name == name) +} + /// Schema lifecycle operations an adapter can support. #[derive(Clone, Debug, Default, PartialEq, Eq)] pub struct ReadModelSchemaAdapterCapabilities { diff --git a/tests/read_model_schema_bootstrap/main.rs b/tests/read_model_schema_bootstrap/main.rs index 3a5e799..b40c84e 100644 --- a/tests/read_model_schema_bootstrap/main.rs +++ b/tests/read_model_schema_bootstrap/main.rs @@ -226,6 +226,26 @@ fn registry_rejects_duplicate_tables_and_invalid_foreign_key_targets() { ); } +#[test] +fn registry_rejects_relationship_foreign_keys_missing_from_target_model() { + let mut player_schema = Player::schema(); + player_schema.relationships[0].foreign_key = Some("missing_player_id".into()); + let mut registry = ReadModelSchemaRegistry::new(); + registry + .register::() + .unwrap() + .register_schema(player_schema) + .unwrap() + .register::() + .unwrap(); + + let err = registry.validate().unwrap_err(); + + assert!(matches!(err, ReadModelError::Metadata(message) + if message.contains("foreign key `missing_player_id`") + && message.contains("target model `PlayerWeapon`"))); +} + #[test] fn adapters_can_generate_migration_artifacts_or_report_unsupported() { let registry = registry(); From 6b1f073507d97da74dcbf4a90a92c319e9193fa8 Mon Sep 17 00:00:00 2001 From: Patrick Lee Scott Date: Fri, 22 May 2026 23:35:36 -0500 Subject: [PATCH 05/26] feat: add read-model helper attributes Adds direct ReadModel helper attributes for collection, table, column, id, field indexes, unique indexes, and struct-level compound indexes. Updates distributed, metadata, session, schema, and docs coverage. Implements [[tasks/read-model-orm-09-compound-indexes]]. --- README.md | 4 +- docs/read-models.md | 34 +- sourced_rust_macros/src/lib.rs | 29 +- sourced_rust_macros/src/read_model.rs | 391 ++++++++++++++++-- .../models/readmodels/account_summary.rs | 4 +- .../main.rs | 8 +- tests/read_model_metadata/main.rs | 65 ++- tests/read_model_schema_bootstrap/main.rs | 4 +- tests/read_model_session/main.rs | 2 +- 9 files changed, 482 insertions(+), 59 deletions(-) diff --git a/README.md b/README.md index 5b13407..31fc5bc 100644 --- a/README.md +++ b/README.md @@ -1198,9 +1198,9 @@ use serde::{Deserialize, Serialize}; use sourced_rust::ReadModel; #[derive(Clone, Debug, Serialize, Deserialize, ReadModel)] -#[readmodel(collection = "game_views")] +#[collection("game_views")] pub struct GameView { - #[readmodel(id)] + #[id] pub id: String, pub player_name: String, pub score: i32, diff --git a/docs/read-models.md b/docs/read-models.md index b963a47..110faa0 100644 --- a/docs/read-models.md +++ b/docs/read-models.md @@ -23,7 +23,20 @@ use sourced_rust::ReadModel; #[derive(Clone, Debug, Serialize, Deserialize, ReadModel)] #[readmodel(collection = "game_views")] pub struct GameView { - #[readmodel(id)] + #[id] + pub id: String, + pub player_name: String, + pub score: i32, +} +``` + +The same collection mapping can be written with the higher-level helper: + +```rust +#[derive(Clone, Debug, Serialize, Deserialize, ReadModel)] +#[collection("game_views")] +pub struct GameView { + #[id] pub id: String, pub player_name: String, pub score: i32, @@ -50,16 +63,17 @@ helpers; SQL adapters are not required to translate Rust closures into queries. ## Relational Models A model opts into relational metadata with `#[readmodel(table = "...")]` and -field attributes: +field attributes. Common collection, table, column, id, index, and unique +metadata also have direct helper attributes: ```rust use serde::{Deserialize, Serialize}; use sourced_rust::ReadModel; #[derive(Clone, Debug, Serialize, Deserialize, ReadModel)] -#[readmodel(table = "players")] +#[table("players")] pub struct PlayerView { - #[readmodel(id, column = "player_id")] + #[id("player_id")] pub id: String, pub display_name: String, #[readmodel(jsonb)] @@ -68,11 +82,15 @@ pub struct PlayerView { #[derive(Clone, Debug, Serialize, Deserialize, ReadModel)] #[readmodel(table = "player_weapons", primary_key = ["player_id", "weapon_id"])] +#[index( + name = "idx_player_weapons_player_acquired", + columns = ["player_id", "acquired_at"] +)] pub struct PlayerWeaponView { #[readmodel(foreign_key = "players.player_id", delegated_from = "PlayerView.player_id")] pub player_id: String, pub weapon_id: String, - #[readmodel(index)] + #[index] pub acquired_at: String, } ``` @@ -82,6 +100,12 @@ metadata, JSONB column metadata, indexes, and an adapter-owned version column. Composite and delegated keys are represented in the schema and in session row mutations. +Use `#[index]` or `#[index("index_name")]` for a secondary field index. Use +`#[unique]` or `#[unique("index_name")]` for a unique field index. +For compound indexes, put `#[index(columns = ["field_a", "field_b"])]` or +`#[unique(columns = ["field_a", "field_b"])]` on the struct. Add +`name = "..."` when the storage index name must be fixed. + ## Command-Side Atomic Writes Use `ReadModelSession` when a command or projector stages multiple document or diff --git a/sourced_rust_macros/src/lib.rs b/sourced_rust_macros/src/lib.rs index 9844b34..241fde4 100644 --- a/sourced_rust_macros/src/lib.rs +++ b/sourced_rust_macros/src/lib.rs @@ -1198,20 +1198,39 @@ pub fn sourced(attr: TokenStream, item: TokenStream) -> TokenStream { /// /// ```ignore /// #[derive(Clone, Serialize, Deserialize, ReadModel)] -/// #[readmodel(collection = "counter_views")] +/// #[collection("counter_views")] /// struct CounterView { -/// #[readmodel(id)] +/// #[id] /// pub id: String, /// pub name: String, /// pub value: i32, /// } /// ``` /// -/// - `#[readmodel(collection = "...")]` sets the collection name. +/// - `#[readmodel(collection = "...")]` or `#[collection("...")]` sets the +/// collection name. /// If omitted, defaults to snake_case struct name + "s". -/// - `#[readmodel(id)]` marks the field used as the unique identifier. +/// - `#[readmodel(table = "...")]` or `#[table("...")]` opts into relational +/// table metadata. +/// - `#[readmodel(column = "...")]` or `#[column("...")]` sets a relational +/// column name. +/// - `#[readmodel(id)]`, `#[id]`, or `#[id("column_name")]` marks the field +/// used as the unique identifier. /// If omitted, defaults to a field named `id`. -#[proc_macro_derive(ReadModel, attributes(readmodel))] +/// - `#[readmodel(index)]`, `#[index]`, or `#[index("index_name")]` declares a +/// secondary index on a field. +/// - `#[readmodel(unique)]`, `#[unique]`, or `#[unique("index_name")]` +/// declares a unique secondary index on a field. +/// - `#[index(columns = ["field_a", "field_b"])]` or +/// `#[index(name = "...", columns = ["field_a", "field_b"])]` declares a +/// compound secondary index on a struct. +/// - `#[unique(columns = ["field_a", "field_b"])]` or +/// `#[unique(name = "...", columns = ["field_a", "field_b"])]` declares a +/// compound unique index on a struct. +#[proc_macro_derive( + ReadModel, + attributes(readmodel, collection, table, column, id, index, unique) +)] pub fn derive_read_model(input: TokenStream) -> TokenStream { read_model::derive_read_model(input) } diff --git a/sourced_rust_macros/src/read_model.rs b/sourced_rust_macros/src/read_model.rs index 4f25aad..fea9cbf 100644 --- a/sourced_rust_macros/src/read_model.rs +++ b/sourced_rust_macros/src/read_model.rs @@ -1,8 +1,8 @@ use proc_macro::TokenStream; use quote::{quote, ToTokens}; use syn::{ - punctuated::Punctuated, Data, DeriveInput, Expr, ExprArray, ExprLit, Field, Fields, - GenericArgument, Lit, LitStr, PathArguments, Token, Type, + punctuated::Punctuated, Attribute, Data, DeriveInput, Expr, ExprArray, ExprLit, Field, Fields, + GenericArgument, Lit, LitStr, Meta, PathArguments, Token, Type, }; pub fn derive_read_model(input: TokenStream) -> TokenStream { @@ -188,21 +188,29 @@ fn expand_relational_read_model( } if attrs.indexed || attrs.unique { + let index_columns = vec![column_name.clone()]; let index_name = attrs .index_name .clone() - .unwrap_or_else(|| format!("idx_{}_{}", table_name, column_name)); + .unwrap_or_else(|| default_index_name(&table_name, &index_columns, attrs.unique)); let unique = attrs.unique; - indexes.push(quote! { - sourced_rust::IndexDef { - name: Some(#index_name.to_string()), - columns: vec![#column_name.to_string()], - unique: #unique, - } - }); + indexes.push(index_def_tokens(index_name, index_columns, unique)); } } + for index in &struct_attrs.indexes { + let index_columns = index + .columns + .iter() + .map(|column| resolve_column_reference(column, fields, field_attrs)) + .collect::>(); + let index_name = index + .name + .clone() + .unwrap_or_else(|| default_index_name(&table_name, &index_columns, index.unique)); + indexes.push(index_def_tokens(index_name, index_columns, index.unique)); + } + Ok(quote! { impl sourced_rust::RelationalReadModel for #name { fn schema() -> sourced_rust::ReadModelSchema { @@ -251,54 +259,104 @@ fn relational_primary_key_fields( return struct_attrs .primary_key .iter() - .map(|key| { - fields - .iter() - .zip(field_attrs) - .find_map(|(field, attrs)| { - let field_name = field.ident.as_ref()?.to_string(); - if &field_name == key { - Some(attrs.column.clone().unwrap_or(field_name)) - } else { - None - } - }) - .unwrap_or_else(|| key.clone()) - }) + .map(|key| resolve_column_reference(key, fields, field_attrs)) .collect(); } id_field .map(|id| { let id_name = id.to_string(); - fields - .iter() - .zip(field_attrs) - .find_map(|(field, attrs)| { - let field_name = field.ident.as_ref()?.to_string(); - if field_name == id_name { - Some(attrs.column.clone().unwrap_or(field_name)) - } else { - None - } - }) - .unwrap_or(id_name) + resolve_column_reference(&id_name, fields, field_attrs) }) .into_iter() .collect() } +fn resolve_column_reference( + reference: &str, + fields: &Punctuated, + field_attrs: &[FieldAttrs], +) -> String { + fields + .iter() + .zip(field_attrs) + .find_map(|(field, attrs)| { + let field_name = field.ident.as_ref()?.to_string(); + if field_name == reference { + Some(attrs.column.clone().unwrap_or(field_name)) + } else { + None + } + }) + .unwrap_or_else(|| reference.to_string()) +} + +fn default_index_name(table_name: &str, columns: &[String], unique: bool) -> String { + let prefix = if unique { "uq" } else { "idx" }; + format!("{prefix}_{table_name}_{}", columns.join("_")) +} + +fn index_def_tokens( + index_name: String, + index_columns: Vec, + unique: bool, +) -> proc_macro2::TokenStream { + let columns = index_columns + .iter() + .map(|column| quote! { #column.to_string() }) + .collect::>(); + + quote! { + sourced_rust::IndexDef { + name: Some(#index_name.to_string()), + columns: vec![#(#columns),*], + unique: #unique, + } + } +} + #[derive(Default)] struct StructAttrs { collection: Option, table: Option, primary_key: Vec, + indexes: Vec, +} + +struct IndexAttr { + name: Option, + columns: Vec, + unique: bool, } impl StructAttrs { fn from_input(input: &DeriveInput) -> syn::Result { let mut attrs = Self::default(); for attr in &input.attrs { + if attr.path().is_ident("collection") { + attrs.collection = Some(parse_direct_string_attr(attr, "collection")?); + continue; + } + + if attr.path().is_ident("table") { + attrs.table = Some(parse_direct_string_attr(attr, "table")?); + continue; + } + + if attr.path().is_ident("index") { + attrs + .indexes + .push(parse_direct_index_attr(attr, "index", false)?); + continue; + } + + if attr.path().is_ident("unique") { + attrs + .indexes + .push(parse_direct_index_attr(attr, "unique", true)?); + continue; + } + if !attr.path().is_ident("readmodel") { continue; } @@ -321,7 +379,7 @@ impl StructAttrs { } fn is_relational(&self) -> bool { - self.table.is_some() || !self.primary_key.is_empty() + self.table.is_some() || !self.primary_key.is_empty() || !self.indexes.is_empty() } } @@ -348,6 +406,36 @@ impl FieldAttrs { let mut pending_foreign_key: Option = None; let mut pending_through: Option = None; for attr in &field.attrs { + if attr.path().is_ident("id") { + attrs.id = true; + if let Some(column) = parse_optional_direct_string_attr(attr)? { + attrs.column = Some(column); + } + continue; + } + + if attr.path().is_ident("column") { + attrs.column = Some(parse_direct_string_attr(attr, "column")?); + continue; + } + + if attr.path().is_ident("index") { + attrs.indexed = true; + if let Some(index_name) = parse_optional_direct_string_attr(attr)? { + attrs.index_name = Some(index_name); + } + continue; + } + + if attr.path().is_ident("unique") { + attrs.unique = true; + attrs.indexed = true; + if let Some(index_name) = parse_optional_direct_string_attr(attr)? { + attrs.index_name = Some(index_name); + } + continue; + } + if !attr.path().is_ident("readmodel") { continue; } @@ -593,6 +681,65 @@ fn parse_string_expr(expr: Expr) -> syn::Result { } } +fn parse_direct_string_attr(attr: &Attribute, attr_name: &str) -> syn::Result { + parse_optional_direct_string_attr(attr)?.ok_or_else(|| { + syn::Error::new_spanned(attr, format!("#[{attr_name}] requires a string literal")) + }) +} + +fn parse_optional_direct_string_attr(attr: &Attribute) -> syn::Result> { + match &attr.meta { + Meta::List(list) => Ok(Some(list.parse_args::()?.value())), + Meta::NameValue(name_value) => parse_string_expr(name_value.value.clone()).map(Some), + Meta::Path(_) => Ok(None), + } +} + +fn parse_direct_index_attr( + attr: &Attribute, + attr_name: &str, + unique: bool, +) -> syn::Result { + let mut name = None; + let mut columns = None; + + match &attr.meta { + Meta::List(_) => { + attr.parse_nested_meta(|meta| { + if meta.path.is_ident("name") { + name = Some(meta.value()?.parse::()?.value()); + } else if meta.path.is_ident("columns") { + let expr = meta.value()?.parse::()?; + columns = Some(parse_string_list(expr)?); + } else { + return Err(meta.error(format!("unknown {attr_name} attribute"))); + } + Ok(()) + })?; + } + Meta::NameValue(name_value) => { + columns = Some(parse_string_list(name_value.value.clone())?); + } + Meta::Path(_) => {} + } + + let columns = columns.ok_or_else(|| { + syn::Error::new_spanned(attr, format!("#[{attr_name}] requires columns = [\"...\"]")) + })?; + if columns.is_empty() { + return Err(syn::Error::new_spanned( + attr, + format!("#[{attr_name}] requires at least one column"), + )); + } + + Ok(IndexAttr { + name, + columns, + unique, + }) +} + fn parse_foreign_key(value: &str) -> syn::Result { let Some((table, column)) = value.split_once('.') else { return Err(syn::Error::new( @@ -748,6 +895,176 @@ mod tests { assert!(!expanded.contains("& self . id")); } + #[test] + fn expand_read_model_accepts_direct_collection_attribute() { + let input: DeriveInput = syn::parse_quote! { + #[collection("counter_views")] + struct CounterView { + #[id] + counter_id: String, + value: i32, + } + }; + + let expanded = expand_read_model(input).unwrap().to_string(); + + assert!(expanded.contains("const COLLECTION : & 'static str = \"counter_views\"")); + assert!(expanded.contains("& self . counter_id")); + } + + #[test] + fn expand_read_model_accepts_direct_table_and_id_column_attributes() { + let input: DeriveInput = syn::parse_quote! { + #[table = "counter_views"] + struct CounterView { + #[id("counter_id")] + id: String, + value: i32, + } + }; + + let expanded = expand_read_model(input).unwrap().to_string(); + + assert!(expanded.contains("table_name : \"counter_views\"")); + assert!(expanded.contains("column_name : \"counter_id\"")); + } + + #[test] + fn expand_read_model_accepts_direct_column_attribute() { + let input: DeriveInput = syn::parse_quote! { + #[table("counter_views")] + struct CounterView { + #[id] + id: String, + #[column("counter_value")] + value: i32, + } + }; + + let expanded = expand_read_model(input).unwrap().to_string(); + + assert!(expanded.contains("column_name : \"counter_value\"")); + } + + #[test] + fn expand_read_model_accepts_direct_index_attribute() { + let input: DeriveInput = syn::parse_quote! { + #[table("counter_views")] + struct CounterView { + #[id] + id: String, + #[index("idx_counter_views_value")] + value: i32, + } + }; + + let expanded = expand_read_model(input).unwrap().to_string(); + + assert!(expanded.contains("name : Some (\"idx_counter_views_value\"")); + assert!(expanded.contains("columns : vec ! [\"value\"")); + assert!(expanded.contains("unique : false")); + } + + #[test] + fn expand_read_model_accepts_direct_unique_attribute() { + let input: DeriveInput = syn::parse_quote! { + #[table("counter_views")] + struct CounterView { + #[id] + id: String, + #[unique("uq_counter_views_slug")] + slug: String, + } + }; + + let expanded = expand_read_model(input).unwrap().to_string(); + + assert!(expanded.contains("name : Some (\"uq_counter_views_slug\"")); + assert!(expanded.contains("columns : vec ! [\"slug\"")); + assert!(expanded.contains("unique : true")); + } + + #[test] + fn expand_read_model_accepts_struct_compound_index_attribute() { + let input: DeriveInput = syn::parse_quote! { + #[table("account_summaries")] + #[index(name = "idx_account_summaries_owner_created", columns = ["owner", "created_at"])] + struct AccountSummary { + #[id("account_id")] + id: String, + owner: String, + #[column("created_at_utc")] + created_at: String, + } + }; + + let expanded = expand_read_model(input).unwrap().to_string(); + + assert!(expanded.contains("name : Some (\"idx_account_summaries_owner_created\"")); + assert!(expanded.contains("columns : vec ! [\"owner\" . to_string () , \"created_at_utc\"")); + assert!(expanded.contains("unique : false")); + } + + #[test] + fn expand_read_model_accepts_struct_compound_unique_attribute() { + let input: DeriveInput = syn::parse_quote! { + #[table("accounts")] + #[unique(columns = ["tenant_id", "slug"])] + struct AccountSummary { + #[id("account_id")] + id: String, + tenant_id: String, + slug: String, + } + }; + + let expanded = expand_read_model(input).unwrap().to_string(); + + assert!(expanded.contains("name : Some (\"uq_accounts_tenant_id_slug\"")); + assert!(expanded.contains("columns : vec ! [\"tenant_id\"")); + assert!(expanded.contains("\"slug\"")); + assert!(expanded.contains("unique : true")); + } + + #[test] + fn expand_read_model_rejects_struct_index_without_columns() { + let input: DeriveInput = syn::parse_quote! { + #[table("counter_views")] + #[index] + struct CounterView { + #[id] + id: String, + value: i32, + } + }; + + let err = expand_read_model(input).expect_err("struct index needs columns"); + + assert!( + err.to_string().contains("#[index] requires columns"), + "unexpected error: {err}" + ); + } + + #[test] + fn expand_read_model_rejects_direct_attributes_without_values() { + let input: DeriveInput = syn::parse_quote! { + #[collection] + struct CounterView { + id: String, + value: i32, + } + }; + + let err = expand_read_model(input).expect_err("direct collection needs a value"); + + assert!( + err.to_string() + .contains("#[collection] requires a string literal"), + "unexpected error: {err}" + ); + } + #[test] fn expand_read_model_rejects_missing_id_field_for_document_models() { let input: DeriveInput = syn::parse_quote! { diff --git a/tests/distributed_read_model/models/readmodels/account_summary.rs b/tests/distributed_read_model/models/readmodels/account_summary.rs index 3bf3159..aa0551c 100644 --- a/tests/distributed_read_model/models/readmodels/account_summary.rs +++ b/tests/distributed_read_model/models/readmodels/account_summary.rs @@ -2,9 +2,9 @@ use serde::{Deserialize, Serialize}; use sourced_rust::ReadModel; #[derive(Clone, Debug, Deserialize, Serialize, ReadModel)] -#[readmodel(collection = "account_summaries")] +#[collection("account_summaries")] pub struct AccountSummary { - #[readmodel(id)] + #[id] pub account_id: String, pub owner: Option, pub balance_cents: i64, diff --git a/tests/read_model_distributed_idempotency/main.rs b/tests/read_model_distributed_idempotency/main.rs index 133dd95..22fcddf 100644 --- a/tests/read_model_distributed_idempotency/main.rs +++ b/tests/read_model_distributed_idempotency/main.rs @@ -8,17 +8,17 @@ use sourced_rust::{ const CONSUMER: &str = "counter-projection"; #[derive(Clone, Debug, PartialEq, Serialize, Deserialize, ReadModel)] -#[readmodel(collection = "counter_views")] +#[collection("counter_views")] struct CounterView { - #[readmodel(id)] + #[id] id: String, value: i32, } #[derive(Clone, Debug, PartialEq, Serialize, Deserialize, ReadModel)] -#[readmodel(table = "relational_counters")] +#[table("relational_counters")] struct RelationalCounter { - #[readmodel(id)] + #[id] id: String, value: i32, } diff --git a/tests/read_model_metadata/main.rs b/tests/read_model_metadata/main.rs index 2d1e83f..5e12bb6 100644 --- a/tests/read_model_metadata/main.rs +++ b/tests/read_model_metadata/main.rs @@ -11,7 +11,7 @@ use sourced_rust::{ struct AccountSummary { #[readmodel(id, column = "account_id")] account_id: String, - #[readmodel(index)] + #[index] owner: Option, balance_cents: i64, #[readmodel(default = "0")] @@ -41,6 +41,31 @@ struct PlayerWeapon { acquired_at: String, } +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, ReadModel)] +#[collection("direct_document_views")] +struct DirectDocumentView { + #[id] + id: String, + value: i32, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, ReadModel)] +#[table("direct_table_views")] +#[index( + name = "idx_direct_table_views_tenant_value", + columns = ["tenant_id", "value"] +)] +#[unique(columns = ["tenant_id", "slug"])] +struct DirectTableView { + #[id("direct_id")] + id: String, + tenant_id: String, + slug: String, + #[column("direct_value")] + #[index("idx_direct_table_views_direct_value")] + value: i32, +} + #[test] fn derive_allows_table_models_with_string_ids_to_use_document_rows() { let summary = AccountSummary { @@ -165,3 +190,41 @@ fn metadata_validation_reports_missing_keys_before_storage_writes() { assert!(matches!(err, ReadModelError::Metadata(message) if message.contains("primary-key"))); } + +#[test] +fn direct_collection_table_column_and_index_attributes_wrap_readmodel_metadata() { + let direct = DirectDocumentView { + id: "doc-1".into(), + value: 7, + }; + let schema = DirectTableView::schema(); + + assert_eq!(DirectDocumentView::COLLECTION, "direct_document_views"); + assert_eq!(direct.id(), "doc-1"); + assert_eq!(DirectTableView::COLLECTION, "direct_table_views"); + assert_eq!(schema.table_name, "direct_table_views"); + assert_eq!(schema.primary_key.columns, vec!["direct_id"]); + assert!(schema + .columns + .iter() + .any(|column| column.field_name == "id" && column.column_name == "direct_id")); + assert!(schema + .columns + .iter() + .any(|column| column.field_name == "value" && column.column_name == "direct_value")); + assert!(schema.indexes.iter().any(|index| { + index.name.as_deref() == Some("idx_direct_table_views_direct_value") + && index.columns == vec!["direct_value"] + && !index.unique + })); + assert!(schema.indexes.iter().any(|index| { + index.name.as_deref() == Some("idx_direct_table_views_tenant_value") + && index.columns == vec!["tenant_id", "direct_value"] + && !index.unique + })); + assert!(schema.indexes.iter().any(|index| { + index.name.as_deref() == Some("uq_direct_table_views_tenant_id_slug") + && index.columns == vec!["tenant_id", "slug"] + && index.unique + })); +} diff --git a/tests/read_model_schema_bootstrap/main.rs b/tests/read_model_schema_bootstrap/main.rs index b40c84e..4ae6136 100644 --- a/tests/read_model_schema_bootstrap/main.rs +++ b/tests/read_model_schema_bootstrap/main.rs @@ -13,7 +13,7 @@ use sourced_rust::{ struct AccountSummary { #[readmodel(id, column = "account_id")] account_id: String, - #[readmodel(unique)] + #[unique] owner_slug: String, balance_cents: i64, #[readmodel(default = "0")] @@ -38,7 +38,7 @@ struct PlayerWeapon { #[readmodel(foreign_key = "players.player_id", delegated_from = "Player.player_id")] player_id: String, weapon_id: String, - #[readmodel(index)] + #[index] acquired_at: String, } diff --git a/tests/read_model_session/main.rs b/tests/read_model_session/main.rs index 0a95a41..49714c1 100644 --- a/tests/read_model_session/main.rs +++ b/tests/read_model_session/main.rs @@ -11,7 +11,7 @@ use sourced_rust::{ struct AccountSummary { #[readmodel(id, column = "account_id")] account_id: String, - #[readmodel(index)] + #[index] owner: Option, balance_cents: i64, #[readmodel(default = "0")] From 5f23048b1904bacae28831984457d6df4ded1a2e Mon Sep 17 00:00:00 2001 From: Patrick Lee Scott Date: Fri, 22 May 2026 23:43:21 -0500 Subject: [PATCH 06/26] test: organize distributed read model services Moves the distributed read-model integration test into account_service, projections_service, and query_service modules while preserving existing behavior. --- .../aggregates => account_service}/account.rs | 0 .../handlers/account_deposit.rs | 4 +- .../handlers/account_open.rs | 4 +- .../account_service/handlers/mod.rs | 2 + .../account_service/mod.rs | 63 ++++++++++ tests/distributed_read_model/handlers/mod.rs | 2 - tests/distributed_read_model/main.rs | 109 +++++------------- .../models/aggregates/mod.rs | 1 - tests/distributed_read_model/models/mod.rs | 2 - .../models/readmodels/mod.rs | 1 - .../mod.rs} | 24 ++-- .../account_summary.rs | 0 .../mod.rs} | 8 +- 13 files changed, 113 insertions(+), 107 deletions(-) rename tests/distributed_read_model/{models/aggregates => account_service}/account.rs (100%) rename tests/distributed_read_model/{ => account_service}/handlers/account_deposit.rs (90%) rename tests/distributed_read_model/{ => account_service}/handlers/account_open.rs (89%) create mode 100644 tests/distributed_read_model/account_service/handlers/mod.rs create mode 100644 tests/distributed_read_model/account_service/mod.rs delete mode 100644 tests/distributed_read_model/handlers/mod.rs delete mode 100644 tests/distributed_read_model/models/aggregates/mod.rs delete mode 100644 tests/distributed_read_model/models/mod.rs delete mode 100644 tests/distributed_read_model/models/readmodels/mod.rs rename tests/distributed_read_model/{read_model.rs => projections_service/mod.rs} (84%) rename tests/distributed_read_model/{models/readmodels => query_service}/account_summary.rs (100%) rename tests/distributed_read_model/{query_process.rs => query_service/mod.rs} (76%) diff --git a/tests/distributed_read_model/models/aggregates/account.rs b/tests/distributed_read_model/account_service/account.rs similarity index 100% rename from tests/distributed_read_model/models/aggregates/account.rs rename to tests/distributed_read_model/account_service/account.rs diff --git a/tests/distributed_read_model/handlers/account_deposit.rs b/tests/distributed_read_model/account_service/handlers/account_deposit.rs similarity index 90% rename from tests/distributed_read_model/handlers/account_deposit.rs rename to tests/distributed_read_model/account_service/handlers/account_deposit.rs index e904829..ae02ac6 100644 --- a/tests/distributed_read_model/handlers/account_deposit.rs +++ b/tests/distributed_read_model/account_service/handlers/account_deposit.rs @@ -2,8 +2,8 @@ use serde_json::{json, Value}; use sourced_rust::microsvc::{Context, HandlerError}; use sourced_rust::{OutboxCommitExt, OutboxMessage}; -use crate::models::aggregates::account::{Account, DepositMoney}; -use crate::AccountRepo; +use crate::account_service::account::{Account, DepositMoney}; +use crate::account_service::AccountRepo; pub const COMMAND: &str = "account.deposit"; diff --git a/tests/distributed_read_model/handlers/account_open.rs b/tests/distributed_read_model/account_service/handlers/account_open.rs similarity index 89% rename from tests/distributed_read_model/handlers/account_open.rs rename to tests/distributed_read_model/account_service/handlers/account_open.rs index b0e4462..5c1bde7 100644 --- a/tests/distributed_read_model/handlers/account_open.rs +++ b/tests/distributed_read_model/account_service/handlers/account_open.rs @@ -2,8 +2,8 @@ use serde_json::{json, Value}; use sourced_rust::microsvc::{Context, HandlerError}; use sourced_rust::{OutboxCommitExt, OutboxMessage}; -use crate::models::aggregates::account::{Account, OpenAccount}; -use crate::AccountRepo; +use crate::account_service::account::{Account, OpenAccount}; +use crate::account_service::AccountRepo; pub const COMMAND: &str = "account.open"; diff --git a/tests/distributed_read_model/account_service/handlers/mod.rs b/tests/distributed_read_model/account_service/handlers/mod.rs new file mode 100644 index 0000000..2ee94b7 --- /dev/null +++ b/tests/distributed_read_model/account_service/handlers/mod.rs @@ -0,0 +1,2 @@ +pub(super) mod account_deposit; +pub(super) mod account_open; diff --git a/tests/distributed_read_model/account_service/mod.rs b/tests/distributed_read_model/account_service/mod.rs new file mode 100644 index 0000000..19a4325 --- /dev/null +++ b/tests/distributed_read_model/account_service/mod.rs @@ -0,0 +1,63 @@ +pub mod account; +mod handlers; + +use std::sync::Arc; + +use sourced_rust::microsvc::Service; +use sourced_rust::{AggregateRepository, HashMapRepository, QueuedRepository}; + +pub use account::{Account, DepositMoney, OpenAccount}; + +pub type AccountRepo = AggregateRepository, Account>; + +pub fn model_service(repo: AccountRepo) -> Arc> { + Arc::new(sourced_rust::register_handlers!( + Service::new(repo), + handlers::account_open, + handlers::account_deposit, + )) +} + +#[cfg(feature = "http")] +pub async fn start_http_service(service: Arc>) -> String { + let app = sourced_rust::microsvc::router(service); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("HTTP test server should bind"); + let addr = listener + .local_addr() + .expect("HTTP test server should expose local address"); + + tokio::spawn(async move { + axum::serve(listener, app) + .await + .expect("HTTP test server should serve"); + }); + + format!("http://{addr}") +} + +#[cfg(feature = "grpc")] +pub async fn start_grpc_service( + service: Arc>, +) -> sourced_rust::microsvc::grpc::CommandServiceClient { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("gRPC test server should bind"); + let addr = listener + .local_addr() + .expect("gRPC test server should expose local address"); + let grpc_svc = sourced_rust::microsvc::grpc_server(service); + + tokio::spawn(async move { + tonic::transport::Server::builder() + .add_service(grpc_svc) + .serve_with_incoming(tokio_stream::wrappers::TcpListenerStream::new(listener)) + .await + .expect("gRPC test server should serve"); + }); + + sourced_rust::microsvc::grpc::CommandServiceClient::connect(format!("http://{addr}")) + .await + .expect("gRPC test client should connect") +} diff --git a/tests/distributed_read_model/handlers/mod.rs b/tests/distributed_read_model/handlers/mod.rs deleted file mode 100644 index aaf868e..0000000 --- a/tests/distributed_read_model/handlers/mod.rs +++ /dev/null @@ -1,2 +0,0 @@ -pub mod account_deposit; -pub mod account_open; diff --git a/tests/distributed_read_model/main.rs b/tests/distributed_read_model/main.rs index 8c58196..a97d098 100644 --- a/tests/distributed_read_model/main.rs +++ b/tests/distributed_read_model/main.rs @@ -3,10 +3,10 @@ //! This demonstrates a distributed CQRS deployment shape: //! - the account model service owns the event-sourced aggregate and outbox //! - the account summary projector owns read-model updates -//! - a separate query process reads from the projected read-model store +//! - a separate query service reads from the projected read-model store //! - the write side and projector are connected only through the bus //! -//! The test uses threads and `InMemoryQueue` as process stand-ins. In a real +//! The test uses threads and `InMemoryQueue` as service stand-ins. In a real //! deployment, each service would use its own process, a shared broker, and a //! shared read-model database. A query API such as Hasura can sit in front of //! that database while the read-model worker keeps the tables updated. @@ -15,36 +15,24 @@ //! be exposed through direct dispatch, HTTP, or gRPC. The query side is not a //! command handler; it just reads the projected store. -mod handlers; -mod models; -mod query_process; -mod read_model; +mod account_service; +mod projections_service; +mod query_service; -use std::sync::Arc; use std::thread; use std::time::{Duration, Instant}; -use models::aggregates::account::{Account, DepositMoney, OpenAccount}; -use models::readmodels::account_summary::AccountSummary; -use query_process::AccountSummaryQueryProcess; -use read_model::{start_account_summary_service, wait_for_summary, ACCOUNT_SUMMARY_CONSUMER}; -use sourced_rust::microsvc::{Service, Session}; +use account_service::{Account, DepositMoney, OpenAccount}; +use projections_service::{ + start_account_summary_projection_service, wait_for_summary, ACCOUNT_SUMMARY_CONSUMER, +}; +use query_service::{AccountSummary, AccountSummaryQueryService}; +use sourced_rust::microsvc::Session; use sourced_rust::{ - AggregateBuilder, AggregateRepository, HashMapRepository, InMemoryQueue, - InMemoryReadModelStore, OutboxWorkerThread, Queueable, QueuedRepository, ReadModelSessionStore, - ReadModelsExt, + AggregateBuilder, HashMapRepository, InMemoryQueue, InMemoryReadModelStore, OutboxWorkerThread, + Queueable, ReadModelSessionStore, ReadModelsExt, }; -pub(crate) type AccountRepo = AggregateRepository, Account>; - -fn account_model_service(repo: AccountRepo) -> Arc> { - Arc::new(sourced_rust::register_handlers!( - Service::new(repo), - handlers::account_open, - handlers::account_deposit, - )) -} - fn wait_for_published_events(queue: &InMemoryQueue, expected_count: usize) { let deadline = Instant::now() + Duration::from_secs(10); @@ -61,64 +49,21 @@ fn wait_for_published_events(queue: &InMemoryQueue, expected_count: usize) { } } -#[cfg(feature = "http")] -async fn start_http_service(service: Arc>) -> String { - let app = sourced_rust::microsvc::router(service); - let listener = tokio::net::TcpListener::bind("127.0.0.1:0") - .await - .expect("HTTP test server should bind"); - let addr = listener - .local_addr() - .expect("HTTP test server should expose local address"); - - tokio::spawn(async move { - axum::serve(listener, app) - .await - .expect("HTTP test server should serve"); - }); - - format!("http://{addr}") -} - -#[cfg(feature = "grpc")] -async fn start_grpc_service( - service: Arc>, -) -> sourced_rust::microsvc::grpc::CommandServiceClient { - let listener = tokio::net::TcpListener::bind("127.0.0.1:0") - .await - .expect("gRPC test server should bind"); - let addr = listener - .local_addr() - .expect("gRPC test server should expose local address"); - let grpc_svc = sourced_rust::microsvc::grpc_server(service); - - tokio::spawn(async move { - tonic::transport::Server::builder() - .add_service(grpc_svc) - .serve_with_incoming(tokio_stream::wrappers::TcpListenerStream::new(listener)) - .await - .expect("gRPC test server should serve"); - }); - - sourced_rust::microsvc::grpc::CommandServiceClient::connect(format!("http://{addr}")) - .await - .expect("gRPC test client should connect") -} - #[test] fn write_model_service_feeds_separate_read_model_service() { let queue = InMemoryQueue::new(); let write_store = HashMapRepository::new(); let account_repo = write_store.clone().queued().aggregate::(); - let model_service = account_model_service(account_repo); + let model_service = account_service::model_service(account_repo); let outbox_worker = OutboxWorkerThread::spawn(write_store.clone(), queue.clone(), Duration::from_millis(5)); let read_store = InMemoryReadModelStore::new(); - let read_model_service = start_account_summary_service(queue.clone(), read_store.clone()); - let query_process = AccountSummaryQueryProcess::new(read_store.clone()); + let projections_service = + start_account_summary_projection_service(queue.clone(), read_store.clone()); + let query_service = AccountSummaryQueryService::new(read_store.clone()); model_service .dispatch( @@ -163,16 +108,16 @@ fn write_model_service_feeds_separate_read_model_service() { ); } - let queried_summary = query_process + let queried_summary = query_service .get("acct-1") - .expect("query process should read projected account summary") - .expect("query process should find projected account summary"); + .expect("query service should read projected account summary") + .expect("query service should find projected account summary"); assert_eq!(queried_summary.owner.as_deref(), Some("Ada Lovelace")); assert_eq!(queried_summary.balance_cents, 2500); assert_eq!(queried_summary.deposit_count, 1); - assert!(query_process + assert!(query_service .get("missing-account") - .expect("query process should read projected account summary") + .expect("query service should read projected account summary") .is_none()); let mut model_commands = model_service.commands(); @@ -199,7 +144,7 @@ fn write_model_service_feeds_separate_read_model_service() { "write-side service should not own the account summary projection" ); - read_model_service.stop(); + projections_service.stop(); let worker_stats = outbox_worker .stop() .expect("outbox worker should stop cleanly"); @@ -212,8 +157,8 @@ fn write_model_service_feeds_separate_read_model_service() { async fn model_commands_can_be_http_service() { let write_store = HashMapRepository::new(); let account_repo = write_store.clone().queued().aggregate::(); - let model_service = account_model_service(account_repo); - let model_base = start_http_service(model_service.clone()).await; + let model_service = account_service::model_service(account_repo); + let model_base = account_service::start_http_service(model_service.clone()).await; let client = reqwest::Client::new(); let open = client @@ -251,8 +196,8 @@ async fn model_commands_can_be_http_service() { async fn model_commands_can_be_grpc_service() { let write_store = HashMapRepository::new(); let account_repo = write_store.clone().queued().aggregate::(); - let model_service = account_model_service(account_repo); - let mut model_client = start_grpc_service(model_service.clone()).await; + let model_service = account_service::model_service(account_repo); + let mut model_client = account_service::start_grpc_service(model_service.clone()).await; let open = model_client .dispatch(sourced_rust::microsvc::grpc::GrpcRequest { diff --git a/tests/distributed_read_model/models/aggregates/mod.rs b/tests/distributed_read_model/models/aggregates/mod.rs deleted file mode 100644 index b0edc6c..0000000 --- a/tests/distributed_read_model/models/aggregates/mod.rs +++ /dev/null @@ -1 +0,0 @@ -pub mod account; diff --git a/tests/distributed_read_model/models/mod.rs b/tests/distributed_read_model/models/mod.rs deleted file mode 100644 index 1c2f136..0000000 --- a/tests/distributed_read_model/models/mod.rs +++ /dev/null @@ -1,2 +0,0 @@ -pub mod aggregates; -pub mod readmodels; diff --git a/tests/distributed_read_model/models/readmodels/mod.rs b/tests/distributed_read_model/models/readmodels/mod.rs deleted file mode 100644 index cea3873..0000000 --- a/tests/distributed_read_model/models/readmodels/mod.rs +++ /dev/null @@ -1 +0,0 @@ -pub mod account_summary; diff --git a/tests/distributed_read_model/read_model.rs b/tests/distributed_read_model/projections_service/mod.rs similarity index 84% rename from tests/distributed_read_model/read_model.rs rename to tests/distributed_read_model/projections_service/mod.rs index 81d0088..a902ab7 100644 --- a/tests/distributed_read_model/read_model.rs +++ b/tests/distributed_read_model/projections_service/mod.rs @@ -7,29 +7,29 @@ use sourced_rust::{ InMemoryQueue, InMemoryReadModelStore, ReadModelCommitOutcome, ReadModelSession, ReadModelStore, }; -use crate::models::aggregates::account::AccountSnapshot; -use crate::models::readmodels::account_summary::AccountSummary; +use crate::account_service::account::AccountSnapshot; +use crate::query_service::AccountSummary; pub const ACCOUNT_SUMMARY_CONSUMER: &str = "account-summary-projection"; -pub struct ReadModelServiceHandle { +pub struct ProjectionServiceHandle { stop_tx: mpsc::Sender<()>, handle: thread::JoinHandle<()>, } -impl ReadModelServiceHandle { +impl ProjectionServiceHandle { pub fn stop(self) { let _ = self.stop_tx.send(()); self.handle .join() - .expect("read model service should stop cleanly"); + .expect("projection service should stop cleanly"); } } -pub fn start_account_summary_service( +pub fn start_account_summary_projection_service( queue: InMemoryQueue, store: InMemoryReadModelStore, -) -> ReadModelServiceHandle { +) -> ProjectionServiceHandle { let (stop_tx, stop_rx) = mpsc::channel(); let (ready_tx, ready_rx) = mpsc::channel(); @@ -38,7 +38,7 @@ pub fn start_account_summary_service( let events = bus.subscribe(&["AccountOpened", "MoneyDeposited"]); ready_tx .send(()) - .expect("read model service should signal readiness"); + .expect("projection service should signal readiness"); loop { match stop_rx.try_recv() { @@ -51,19 +51,19 @@ pub fn start_account_summary_service( project_account_summary(&store, &event); events .ack(&event.id) - .expect("read model service should ack projected events"); + .expect("projection service should ack projected events"); } Ok(None) => {} - Err(err) => panic!("read model service failed to receive event: {err}"), + Err(err) => panic!("projection service failed to receive event: {err}"), } } }); ready_rx .recv_timeout(Duration::from_secs(3)) - .expect("read model service should subscribe before accepting writes"); + .expect("projection service should subscribe before accepting writes"); - ReadModelServiceHandle { stop_tx, handle } + ProjectionServiceHandle { stop_tx, handle } } fn load_summary(store: &InMemoryReadModelStore, account_id: &str) -> AccountSummary { diff --git a/tests/distributed_read_model/models/readmodels/account_summary.rs b/tests/distributed_read_model/query_service/account_summary.rs similarity index 100% rename from tests/distributed_read_model/models/readmodels/account_summary.rs rename to tests/distributed_read_model/query_service/account_summary.rs diff --git a/tests/distributed_read_model/query_process.rs b/tests/distributed_read_model/query_service/mod.rs similarity index 76% rename from tests/distributed_read_model/query_process.rs rename to tests/distributed_read_model/query_service/mod.rs index 7875837..d957be8 100644 --- a/tests/distributed_read_model/query_process.rs +++ b/tests/distributed_read_model/query_service/mod.rs @@ -1,13 +1,15 @@ use sourced_rust::{InMemoryReadModelStore, ReadModelError, ReadModelStore}; -use crate::models::readmodels::account_summary::AccountSummary; +pub mod account_summary; + +pub use account_summary::AccountSummary; #[derive(Clone)] -pub struct AccountSummaryQueryProcess { +pub struct AccountSummaryQueryService { store: InMemoryReadModelStore, } -impl AccountSummaryQueryProcess { +impl AccountSummaryQueryService { pub fn new(store: InMemoryReadModelStore) -> Self { Self { store } } From 532c6c1b1d6f19a690c14d4b95ada2586e50192b Mon Sep 17 00:00:00 2001 From: Patrick Lee Scott Date: Sat, 23 May 2026 14:01:52 -0500 Subject: [PATCH 07/26] feat: add tracked read model relationship includes --- docs/read-models.md | 44 ++ sourced_rust_macros/src/read_model.rs | 165 ++++++ src/lib.rs | 15 +- src/read_model/in_memory.rs | 477 +++++++++++++++++- src/read_model/metadata.rs | 11 + src/read_model/mod.rs | 10 +- src/read_model/session.rs | 441 +++++++++++++++- .../main.rs | 14 +- .../read_model_relationship_includes/main.rs | 361 +++++++++++++ 9 files changed, 1512 insertions(+), 26 deletions(-) create mode 100644 tests/read_model_relationship_includes/main.rs diff --git a/docs/read-models.md b/docs/read-models.md index 110faa0..65b8a7b 100644 --- a/docs/read-models.md +++ b/docs/read-models.md @@ -10,6 +10,7 @@ The current implementation keeps these paths explicit: |---|---|---| | Document rows | `ReadModelStore`, `.readmodel(&view)`, `ReadModelSession::document` | Whole-view JSON documents backed by a document payload column | | Relational write mapping | `RelationalReadModel`, `ReadModelSession`, `ReadModelWritePlan` | Normalized tables, composite keys, foreign keys, JSONB columns | +| Explicit relationship includes | `store.session().load(...).include(...).one()`, `save_changes` | Internal primary-key reads with declared one-level relationships | | Schema lifecycle | `ReadModelSchemaRegistry`, `ReadModelSchemaAdapter` | Migration artifact generation, startup verification, explicit dev/test bootstrap | ## Document Read Models @@ -106,6 +107,49 @@ For compound indexes, put `#[index(columns = ["field_a", "field_b"])]` or `#[unique(columns = ["field_a", "field_b"])]` on the struct. Add `name = "..."` when the storage index name must be fixed. +## Explicit Relationship Includes + +Relationship includes are primary-key anchored and opt-in. Register the +relational schemas with an adapter, load one root row, ask for each relationship +explicitly, mutate the hydrated struct, then save the tracked changes: + +```rust +use sourced_rust::{InMemoryReadModelStore, ReadModelUnitOfWorkExt, RowKey, RowValue}; + +let store = InMemoryReadModelStore::new(); +store.register_schema::()?; +store.register_schema::()?; + +let mut read_models = store.session(); +let mut player = read_models + .load::(RowKey::new([("player_id", RowValue::String("player-1".into()))])) + .include("weapons") + .one()? + .expect("player should exist") + .data; + +player.display_name = "Ada Lovelace".into(); +player.weapons.push(PlayerWeaponView { + player_id: String::new(), + weapon_id: "sword".into(), + acquired_at: "2026-05-23".into(), +}); + +read_models.save_changes(player)?; +read_models.commit()?; +``` + +`has_many` relationships hydrate `Vec` fields. `belongs_to` relationships +hydrate `Option` fields. Added `has_many` children have delegated foreign keys +filled before the write plan is staged. Removing a child from a loaded +collection does not delete storage by default; use an explicit delete operation +when deletion is intended. + +This API is for command handlers, projectors, tests, admin tools, and adapter +conformance that need typed internal includes. It is not a public query DSL. +Hasura or another query gateway remains the intended public GraphQL/query API +for normalized Postgres read models. + ## Command-Side Atomic Writes Use `ReadModelSession` when a command or projector stages multiple document or diff --git a/sourced_rust_macros/src/read_model.rs b/sourced_rust_macros/src/read_model.rs index fea9cbf..303f4e3 100644 --- a/sourced_rust_macros/src/read_model.rs +++ b/sourced_rust_macros/src/read_model.rs @@ -117,6 +117,8 @@ fn expand_relational_read_model( let mut foreign_keys = Vec::new(); let mut indexes = Vec::new(); let mut relationships = Vec::new(); + let mut hydrate_include_arms = Vec::new(); + let mut include_rows_arms = Vec::new(); for (field, attrs) in fields.iter().zip(field_attrs) { let ident = field @@ -127,6 +129,10 @@ fn expand_relational_read_model( if let Some(relationship) = attrs.relationship_tokens(&field_name)? { relationships.push(relationship); + let (hydrate_arm, include_rows_arm) = + attrs.relationship_include_tokens(field, &field_name)?; + hydrate_include_arms.push(hydrate_arm); + include_rows_arms.push(include_rows_arm); row_fields.push(quote! { #ident: ::core::default::Default::default() }); continue; } @@ -246,6 +252,37 @@ fn expand_relational_read_model( }) } } + + impl sourced_rust::RelationalReadModelIncludes for #name { + fn hydrate_include( + &mut self, + include: &str, + rows: Vec, + ) -> Result<(), sourced_rust::ReadModelError> { + match include { + #(#hydrate_include_arms,)* + _ => Err(sourced_rust::ReadModelError::Metadata(format!( + "read model `{}` has no hydratable relationship `{}`", + #model_name, + include + ))), + } + } + + fn include_rows( + &self, + include: &str, + ) -> Result, sourced_rust::ReadModelError> { + match include { + #(#include_rows_arms,)* + _ => Err(sourced_rust::ReadModelError::Metadata(format!( + "read model `{}` has no tracked relationship `{}`", + #model_name, + include + ))), + } + } + } }) } @@ -593,6 +630,96 @@ impl FieldAttrs { } })) } + + fn relationship_include_tokens( + &self, + field: &Field, + field_name: &str, + ) -> syn::Result<(proc_macro2::TokenStream, proc_macro2::TokenStream)> { + let relationship = self.relationship.as_ref().ok_or_else(|| { + syn::Error::new_spanned(field, "field is not a read-model relationship") + })?; + let ident = field + .ident + .as_ref() + .ok_or_else(|| syn::Error::new_spanned(field, "ReadModel fields must be named"))?; + + match relationship.kind { + RelationshipKindAttr::HasMany | RelationshipKindAttr::ManyToMany => { + let inner = vec_inner_type(&field.ty).ok_or_else(|| { + syn::Error::new_spanned( + field, + format!("relationship `{field_name}` must be shaped as `Vec`"), + ) + })?; + validate_relationship_target_type( + field, + inner, + &relationship.target_model, + field_name, + )?; + let hydrate = quote! { + #field_name => { + self.#ident = rows + .into_iter() + .map(<#inner as sourced_rust::RelationalReadModel>::from_row) + .collect::, sourced_rust::ReadModelError>>()?; + Ok(()) + } + }; + let include_rows = quote! { + #field_name => self + .#ident + .iter() + .map(sourced_rust::RelationalReadModel::to_row) + .collect::, sourced_rust::ReadModelError>>() + }; + Ok((hydrate, include_rows)) + } + RelationshipKindAttr::BelongsTo => { + let inner = option_inner_type(&field.ty).ok_or_else(|| { + syn::Error::new_spanned( + field, + format!( + "belongs_to relationship `{field_name}` must be shaped as `Option`" + ), + ) + })?; + validate_relationship_target_type( + field, + inner, + &relationship.target_model, + field_name, + )?; + let hydrate = quote! { + #field_name => { + let mut rows = rows.into_iter(); + self.#ident = match rows.next() { + Some(row) => Some(<#inner as sourced_rust::RelationalReadModel>::from_row(row)?), + None => None, + }; + if rows.next().is_some() { + return Err(sourced_rust::ReadModelError::Metadata(format!( + "belongs_to relationship `{}` returned more than one row", + #field_name + ))); + } + Ok(()) + } + }; + let include_rows = quote! { + #field_name => { + let mut rows = Vec::new(); + if let Some(value) = &self.#ident { + rows.push(sourced_rust::RelationalReadModel::to_row(value)?); + } + Ok(rows) + } + }; + Ok((hydrate, include_rows)) + } + } + } } fn relationship_mut<'a>( @@ -822,6 +949,44 @@ fn option_inner_type(ty: &Type) -> Option<&Type> { }) } +fn vec_inner_type(ty: &Type) -> Option<&Type> { + let segment = last_type_segment(ty)?; + if segment.ident != "Vec" { + return None; + } + let PathArguments::AngleBracketed(args) = &segment.arguments else { + return None; + }; + args.args.iter().find_map(|arg| match arg { + GenericArgument::Type(ty) => Some(ty), + _ => None, + }) +} + +fn validate_relationship_target_type( + field: &Field, + ty: &Type, + target_model: &str, + field_name: &str, +) -> syn::Result<()> { + let Some(segment) = last_type_segment(ty) else { + return Err(syn::Error::new_spanned( + field, + format!("relationship `{field_name}` target type must be a named read model"), + )); + }; + if segment.ident != target_model { + return Err(syn::Error::new_spanned( + field, + format!( + "relationship `{field_name}` targets `{target_model}` but the field stores `{}`", + segment.ident + ), + )); + } + Ok(()) +} + fn last_type_segment(ty: &Type) -> Option<&syn::PathSegment> { match ty { Type::Path(path) => path.path.segments.last(), diff --git a/src/lib.rs b/src/lib.rs index 70b68d6..0f82aa7 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -96,12 +96,15 @@ pub use read_model::{ ColumnDef, ColumnType, DeleteRowMutation, DocumentMutation, ExpectedVersion, ForeignKey, InMemoryReadModelStore, IndexDef, PatchMode, PatchRowMutation, PrimaryKey, ProcessedMessageMark, QueuedReadModelStore, ReadModel, ReadModelAdapterCapabilities, - ReadModelCommitOutcome, ReadModelError, ReadModelLoadRequest, ReadModelMigrationArtifact, - ReadModelMutation, ReadModelSchema, ReadModelSchemaAdapter, ReadModelSchemaAdapterCapabilities, - ReadModelSchemaBootstrap, ReadModelSchemaIssue, ReadModelSchemaIssueKind, - ReadModelSchemaRegistry, ReadModelSchemaVerification, ReadModelSession, ReadModelSessionStore, - ReadModelStore, ReadModelWritePlan, ReadModelsExt, RelationalReadModel, RelationshipDef, - RelationshipKind, RowKey, RowMutation, RowPatch, RowValue, RowValues, RowWriteMode, Versioned, + ReadModelCommitOutcome, ReadModelError, ReadModelIncludeRows, ReadModelLoadGraph, + ReadModelLoadRequest, ReadModelMigrationArtifact, ReadModelMutation, + ReadModelQueryCapabilities, ReadModelSchema, ReadModelSchemaAdapter, + ReadModelSchemaAdapterCapabilities, ReadModelSchemaBootstrap, ReadModelSchemaIssue, + ReadModelSchemaIssueKind, ReadModelSchemaRegistry, ReadModelSchemaVerification, + ReadModelSession, ReadModelSessionStore, ReadModelSessionUnitOfWork, ReadModelStore, + ReadModelUnitOfWorkExt, ReadModelWritePlan, ReadModelsExt, RelationalReadModel, + RelationalReadModelIncludes, RelationalReadModelQueryStore, RelationshipDef, RelationshipKind, + RowKey, RowMutation, RowPatch, RowValue, RowValues, RowWriteMode, Versioned, DEFAULT_READ_MODEL_VERSION_COLUMN, }; diff --git a/src/read_model/in_memory.rs b/src/read_model/in_memory.rs index 9bd1f3b..77383cd 100644 --- a/src/read_model/in_memory.rs +++ b/src/read_model/in_memory.rs @@ -1,12 +1,16 @@ //! InMemoryReadModelStore - HashMap-backed read model store for testing and development. -use std::collections::{HashMap, HashSet}; +use std::collections::{BTreeMap, HashMap, HashSet}; use std::sync::{Arc, RwLock}; +use super::session::{column_name_for, key_fingerprint, validate_key}; use super::{ - ProcessedMessageMark, ReadModel, ReadModelAdapterCapabilities, ReadModelCommitOutcome, - ReadModelError, ReadModelMutation, ReadModelSessionStore, ReadModelStore, ReadModelWritePlan, - Versioned, + ExpectedVersion, PatchMode, ProcessedMessageMark, ReadModel, ReadModelAdapterCapabilities, + ReadModelCommitOutcome, ReadModelError, ReadModelIncludeRows, ReadModelLoadGraph, + ReadModelLoadRequest, ReadModelMutation, ReadModelQueryCapabilities, ReadModelSchema, + ReadModelSchemaRegistry, ReadModelSessionStore, ReadModelStore, ReadModelWritePlan, + RelationalReadModel, RelationalReadModelQueryStore, RelationshipDef, RelationshipKind, RowKey, + RowValue, RowValues, RowWriteMode, Versioned, }; /// Internal stored representation of a read model. @@ -16,6 +20,12 @@ pub(crate) struct StoredModel { pub(crate) version: u64, } +#[derive(Clone)] +pub(crate) struct StoredRow { + pub(crate) values: RowValues, + pub(crate) version: u64, +} + pub(crate) type ProcessedMessageSet = HashSet<(String, String)>; pub(crate) const INITIAL_MODEL_VERSION: u64 = 1; @@ -83,17 +93,176 @@ pub(crate) fn document_capabilities() -> ReadModelAdapterCapabilities { } } +fn relational_capabilities() -> ReadModelAdapterCapabilities { + ReadModelAdapterCapabilities::default() +} + +fn apply_read_model_write_plan( + plan: ReadModelWritePlan, + staged_models: &mut HashMap, + staged_rows: &mut HashMap, + staged_processed_messages: &mut ProcessedMessageSet, +) -> Result { + plan.validate_for(&relational_capabilities())?; + + let mut marks_in_plan = HashSet::with_capacity(plan.processed_messages.len()); + for mark in &plan.processed_messages { + let key = processed_message_key(mark); + if staged_processed_messages.contains(&key) || !marks_in_plan.insert(key) { + return Ok(ReadModelCommitOutcome::skipped_duplicate(mark.clone())); + } + } + + for mutation in plan.mutations { + match mutation { + ReadModelMutation::Document(mutation) => { + let key = mutation.key(); + let new_version = + next_model_version(&key, staged_models.get(&key).map(|s| s.version))?; + staged_models.insert( + key, + StoredModel { + bytes: mutation.bytes, + version: new_version, + }, + ); + } + ReadModelMutation::UpsertRow(mutation) => { + let key = relational_storage_key(&mutation.schema.table_name, &mutation.key); + let current_version = staged_rows.get(&key).map(|row| row.version); + validate_row_expected_version( + &mutation.schema, + &mutation.key, + &mutation.expected_version, + current_version, + )?; + if matches!(mutation.mode, RowWriteMode::Insert) && current_version.is_some() { + return Err(concurrency_conflict( + &mutation.schema, + &mutation.key, + 0, + current_version.unwrap_or_default(), + )); + } + + let new_version = next_model_version(&key, current_version)?; + staged_rows.insert( + key, + StoredRow { + values: mutation.values, + version: new_version, + }, + ); + } + ReadModelMutation::PatchRow(mutation) => { + let key = relational_storage_key(&mutation.schema.table_name, &mutation.key); + let current_version = staged_rows.get(&key).map(|row| row.version); + validate_row_expected_version( + &mutation.schema, + &mutation.key, + &mutation.expected_version, + current_version, + )?; + + match staged_rows.get_mut(&key) { + Some(row) => { + for (column, value) in mutation.patch.into_values() { + row.values.insert(column, value); + } + row.version = next_model_version(&key, current_version)?; + } + None if matches!(mutation.mode, PatchMode::InsertMissing) => { + staged_rows.insert( + key.clone(), + StoredRow { + values: mutation.patch.into_values(), + version: INITIAL_MODEL_VERSION, + }, + ); + } + None => { + return Err(ReadModelError::NotFound { + collection: mutation.schema.table_name, + id: key_fingerprint(&mutation.key), + }); + } + } + } + ReadModelMutation::DeleteRow(mutation) => { + let key = relational_storage_key(&mutation.schema.table_name, &mutation.key); + let current_version = staged_rows.get(&key).map(|row| row.version); + validate_row_expected_version( + &mutation.schema, + &mutation.key, + &mutation.expected_version, + current_version, + )?; + staged_rows.remove(&key); + } + } + } + + for mark in plan.processed_messages { + staged_processed_messages.insert(processed_message_key(&mark)); + } + + Ok(ReadModelCommitOutcome::applied()) +} + fn processed_message_key(mark: &ProcessedMessageMark) -> (String, String) { (mark.consumer_name.clone(), mark.message_id.clone()) } +fn relational_storage_key(table_name: &str, key: &RowKey) -> String { + format!("{}:{}", table_name, key_fingerprint(key)) +} + +fn validate_row_expected_version( + schema: &ReadModelSchema, + key: &RowKey, + expected_version: &ExpectedVersion, + current_version: Option, +) -> Result<(), ReadModelError> { + match (expected_version, current_version) { + (ExpectedVersion::Any, _) => Ok(()), + (ExpectedVersion::Exact(expected), Some(actual)) if expected == &actual => Ok(()), + (ExpectedVersion::Exact(expected), Some(actual)) => { + Err(concurrency_conflict(schema, key, *expected, actual)) + } + (ExpectedVersion::Exact(_), None) => Err(ReadModelError::NotFound { + collection: schema.table_name.clone(), + id: key_fingerprint(key), + }), + (ExpectedVersion::NotExists, None) => Ok(()), + (ExpectedVersion::NotExists, Some(actual)) => { + Err(concurrency_conflict(schema, key, 0, actual)) + } + } +} + +fn concurrency_conflict( + schema: &ReadModelSchema, + key: &RowKey, + expected: u64, + actual: u64, +) -> ReadModelError { + ReadModelError::ConcurrencyConflict { + collection: schema.table_name.clone(), + id: key_fingerprint(key), + expected, + actual, + } +} + /// In-memory read model store backed by a HashMap. /// /// Storage key is `"TABLE:id"`. Clone-friendly via Arc. #[derive(Clone)] pub struct InMemoryReadModelStore { pub(crate) storage: Arc>>, + pub(crate) relational_rows: Arc>>, pub(crate) processed_messages: Arc>, + schema_registry: Arc>, } impl Default for InMemoryReadModelStore { @@ -107,7 +276,9 @@ impl InMemoryReadModelStore { pub fn new() -> Self { Self { storage: Arc::new(RwLock::new(HashMap::new())), + relational_rows: Arc::new(RwLock::new(HashMap::new())), processed_messages: Arc::new(RwLock::new(HashSet::new())), + schema_registry: Arc::new(RwLock::new(ReadModelSchemaRegistry::new())), } } @@ -115,6 +286,32 @@ impl InMemoryReadModelStore { format!("{}:{}", table, id) } + /// Register a relational read-model schema for explicit include execution. + pub fn register_schema(&self) -> Result<(), ReadModelError> + where + M: RelationalReadModel, + { + let mut registry = self + .schema_registry + .write() + .map_err(|_| ReadModelError::Storage("schema registry lock poisoned".into()))?; + registry.register::()?; + Ok(()) + } + + /// Register an already-built relational read-model schema. + pub fn register_read_model_schema( + &self, + schema: ReadModelSchema, + ) -> Result<(), ReadModelError> { + let mut registry = self + .schema_registry + .write() + .map_err(|_| ReadModelError::Storage("schema registry lock poisoned".into()))?; + registry.register_schema(schema)?; + Ok(()) + } + /// Save pre-serialized document bytes by storage key for in-memory test setup. #[cfg(test)] pub(crate) fn save_document_bytes( @@ -143,7 +340,7 @@ impl InMemoryReadModelStore { impl ReadModelSessionStore for InMemoryReadModelStore { fn read_model_capabilities(&self) -> ReadModelAdapterCapabilities { - document_capabilities() + relational_capabilities() } fn commit_write_plan( @@ -154,18 +351,28 @@ impl ReadModelSessionStore for InMemoryReadModelStore { .storage .write() .map_err(|_| ReadModelError::Storage("lock poisoned".into()))?; + let mut relational_rows = self + .relational_rows + .write() + .map_err(|_| ReadModelError::Storage("lock poisoned".into()))?; let mut processed_messages = self .processed_messages .write() .map_err(|_| ReadModelError::Storage("lock poisoned".into()))?; let mut staged_models = storage.clone(); + let mut staged_rows = relational_rows.clone(); let mut staged_processed_messages = processed_messages.clone(); - let outcome = - apply_document_write_plan(plan, &mut staged_models, &mut staged_processed_messages)?; + let outcome = apply_read_model_write_plan( + plan, + &mut staged_models, + &mut staged_rows, + &mut staged_processed_messages, + )?; if outcome.was_applied() { *storage = staged_models; + *relational_rows = staged_rows; *processed_messages = staged_processed_messages; } @@ -181,6 +388,262 @@ impl ReadModelSessionStore for InMemoryReadModelStore { } } +#[derive(Clone)] +struct IncludeSpec { + name: String, + relationship: RelationshipDef, + target_schema: ReadModelSchema, +} + +impl RelationalReadModelQueryStore for InMemoryReadModelStore { + fn read_model_query_capabilities(&self) -> ReadModelQueryCapabilities { + ReadModelQueryCapabilities::relationship_includes() + } + + fn load_graph( + &self, + request: ReadModelLoadRequest, + ) -> Result { + request.validate_for_query_capabilities(&self.read_model_query_capabilities())?; + + let (root_schema, include_specs) = { + let registry = self + .schema_registry + .read() + .map_err(|_| ReadModelError::Storage("schema registry lock poisoned".into()))?; + resolve_request_schemas(®istry, &request)? + }; + validate_key(&root_schema, &request.key)?; + + let rows = self + .relational_rows + .read() + .map_err(|_| ReadModelError::Storage("lock poisoned".into()))?; + let root_storage_key = relational_storage_key(&root_schema.table_name, &request.key); + let Some(root_row) = rows.get(&root_storage_key) else { + return Ok(ReadModelLoadGraph::default()); + }; + let root = Versioned { + data: root_row.values.clone(), + version: root_row.version, + }; + + let mut includes = BTreeMap::new(); + for spec in include_specs { + let loaded_rows = load_relationship_rows(&rows, &root_schema, &root.data, &spec)?; + includes.insert( + spec.name, + ReadModelIncludeRows { + relationship: spec.relationship, + target_schema: spec.target_schema, + rows: loaded_rows, + }, + ); + } + + Ok(ReadModelLoadGraph { + root: Some(root), + includes, + }) + } +} + +fn resolve_request_schemas( + registry: &ReadModelSchemaRegistry, + request: &ReadModelLoadRequest, +) -> Result<(ReadModelSchema, Vec), ReadModelError> { + let root_schema = registry + .schema_for_model(&request.schema.model_name) + .ok_or_else(|| { + ReadModelError::Metadata(format!( + "read model `{}` is not registered for relationship includes", + request.schema.model_name + )) + })?; + if root_schema != &request.schema { + return Err(ReadModelError::Metadata(format!( + "read model `{}` load request does not match registered schema", + request.schema.model_name + ))); + } + + let mut include_specs = Vec::with_capacity(request.includes.len()); + for include_name in &request.includes { + let relationship = root_schema + .relationships + .iter() + .find(|relationship| relationship.field_name == *include_name) + .ok_or_else(|| { + ReadModelError::Metadata(format!( + "read model `{}` has no relationship `{}`", + root_schema.model_name, include_name + )) + })?; + if matches!(relationship.kind, RelationshipKind::ManyToMany) { + return Err(ReadModelError::Metadata(format!( + "many-to-many relationship `{}` includes are not supported until join metadata declares source and target keys", + relationship.field_name + ))); + } + let target_schema = registry + .schema_for_model(&relationship.target_model) + .ok_or_else(|| { + ReadModelError::Metadata(format!( + "read model `{}` relationship `{}` targets unregistered model `{}`", + root_schema.model_name, relationship.field_name, relationship.target_model + )) + })?; + + include_specs.push(IncludeSpec { + name: include_name.clone(), + relationship: relationship.clone(), + target_schema: target_schema.clone(), + }); + } + + Ok((root_schema.clone(), include_specs)) +} + +fn load_relationship_rows( + rows: &HashMap, + root_schema: &ReadModelSchema, + root_row: &RowValues, + spec: &IncludeSpec, +) -> Result>, ReadModelError> { + match spec.relationship.kind { + RelationshipKind::HasMany => load_has_many_rows(rows, root_schema, root_row, spec), + RelationshipKind::BelongsTo => load_belongs_to_rows(rows, root_schema, root_row, spec), + RelationshipKind::ManyToMany => Err(ReadModelError::Metadata(format!( + "many-to-many relationship `{}` includes are not supported yet", + spec.relationship.field_name + ))), + } +} + +fn load_has_many_rows( + rows: &HashMap, + root_schema: &ReadModelSchema, + root_row: &RowValues, + spec: &IncludeSpec, +) -> Result>, ReadModelError> { + let foreign_key = spec.relationship.foreign_key.as_deref().ok_or_else(|| { + ReadModelError::Metadata(format!( + "relationship `{}` must declare a foreign key", + spec.relationship.field_name + )) + })?; + let target_column = column_name_for(&spec.target_schema, foreign_key).ok_or_else(|| { + ReadModelError::Metadata(format!( + "relationship `{}` foreign key `{}` is not a target column", + spec.relationship.field_name, foreign_key + )) + })?; + let root_column = column_name_for(root_schema, foreign_key) + .or_else(|| root_schema.primary_key.columns.first().cloned()) + .ok_or_else(|| { + ReadModelError::Metadata(format!( + "relationship `{}` has no root key column", + spec.relationship.field_name + )) + })?; + let root_value = root_row.get(&root_column).ok_or_else(|| { + ReadModelError::Metadata(format!( + "read model `{}` root row is missing relationship key `{}`", + root_schema.model_name, root_column + )) + })?; + Ok(rows_matching_column( + rows, + &spec.target_schema.table_name, + &target_column, + root_value, + )) +} + +fn load_belongs_to_rows( + rows: &HashMap, + root_schema: &ReadModelSchema, + root_row: &RowValues, + spec: &IncludeSpec, +) -> Result>, ReadModelError> { + let foreign_key = spec.relationship.foreign_key.as_deref().ok_or_else(|| { + ReadModelError::Metadata(format!( + "relationship `{}` must declare a foreign key", + spec.relationship.field_name + )) + })?; + let source_column = column_name_for(root_schema, foreign_key).ok_or_else(|| { + ReadModelError::Metadata(format!( + "relationship `{}` foreign key `{}` is not a source column", + spec.relationship.field_name, foreign_key + )) + })?; + let target_column = belongs_to_target_column(&spec.target_schema, &source_column)?; + let source_value = root_row.get(&source_column).ok_or_else(|| { + ReadModelError::Metadata(format!( + "read model `{}` root row is missing relationship key `{}`", + root_schema.model_name, source_column + )) + })?; + let key = RowKey::new([(target_column, source_value.clone())]); + let storage_key = relational_storage_key(&spec.target_schema.table_name, &key); + Ok(rows + .get(&storage_key) + .map(|row| { + vec![Versioned { + data: row.values.clone(), + version: row.version, + }] + }) + .unwrap_or_default()) +} + +fn belongs_to_target_column( + target_schema: &ReadModelSchema, + source_column: &str, +) -> Result { + if target_schema.primary_key.columns.len() == 1 { + return Ok(target_schema.primary_key.columns[0].clone()); + } + + target_schema + .primary_key + .columns + .iter() + .find(|column| column.as_str() == source_column) + .cloned() + .ok_or_else(|| { + ReadModelError::Metadata(format!( + "belongs_to target `{}` has a composite primary key that cannot be loaded from `{}`", + target_schema.model_name, source_column + )) + }) +} + +fn rows_matching_column( + rows: &HashMap, + table_name: &str, + column: &str, + value: &RowValue, +) -> Vec> { + let prefix = format!("{table_name}:"); + let mut matches = rows + .iter() + .filter(|(key, row)| key.starts_with(&prefix) && row.values.get(column) == Some(value)) + .map(|(key, row)| { + ( + key.clone(), + Versioned { + data: row.values.clone(), + version: row.version, + }, + ) + }) + .collect::>(); + matches.sort_by(|left, right| left.0.cmp(&right.0)); + matches.into_iter().map(|(_, row)| row).collect() +} + impl ReadModelStore for InMemoryReadModelStore { fn get_model(&self, id: &str) -> Result>, ReadModelError> { let key = Self::make_key(M::COLLECTION, id); diff --git a/src/read_model/metadata.rs b/src/read_model/metadata.rs index 68157ff..b5ebbb3 100644 --- a/src/read_model/metadata.rs +++ b/src/read_model/metadata.rs @@ -416,6 +416,17 @@ pub trait RelationalReadModel: Clone + Send + Sync + Sized { fn from_row(row: RowValues) -> Result; } +/// Relationship hydration hooks generated for table-mapped read models. +pub trait RelationalReadModelIncludes: RelationalReadModel { + fn hydrate_include( + &mut self, + include: &str, + rows: Vec, + ) -> Result<(), ReadModelError>; + + fn include_rows(&self, include: &str) -> Result, ReadModelError>; +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/read_model/mod.rs b/src/read_model/mod.rs index fe7de76..6ec0dae 100644 --- a/src/read_model/mod.rs +++ b/src/read_model/mod.rs @@ -66,7 +66,7 @@ pub trait ReadModel: Serialize + DeserializeOwned + Clone + Send + Sync { } /// A versioned wrapper around read model data for optimistic concurrency control. -#[derive(Debug, Clone)] +#[derive(Debug, Clone, PartialEq)] pub struct Versioned { pub data: T, pub version: u64, @@ -129,7 +129,7 @@ impl From for ReadModelError { pub use in_memory::InMemoryReadModelStore; pub use metadata::{ ColumnDef, ColumnType, ForeignKey, IndexDef, PrimaryKey, ReadModelSchema, RelationalReadModel, - RelationshipDef, RelationshipKind, RowKey, RowValue, RowValues, + RelationalReadModelIncludes, RelationshipDef, RelationshipKind, RowKey, RowValue, RowValues, DEFAULT_READ_MODEL_VERSION_COLUMN, }; pub use queued::QueuedReadModelStore; @@ -142,7 +142,9 @@ pub use schema::{ pub use session::{ DeleteRowMutation, DocumentMutation, ExpectedVersion, PatchMode, PatchRowMutation, ProcessedMessageMark, ReadModelAdapterCapabilities, ReadModelCommitOutcome, - ReadModelLoadRequest, ReadModelMutation, ReadModelSession, ReadModelSessionStore, - ReadModelWritePlan, RowMutation, RowPatch, RowWriteMode, + ReadModelIncludeRows, ReadModelLoadGraph, ReadModelLoadRequest, ReadModelMutation, + ReadModelQueryCapabilities, ReadModelSession, ReadModelSessionStore, + ReadModelSessionUnitOfWork, ReadModelUnitOfWorkExt, ReadModelWritePlan, + RelationalReadModelQueryStore, RowMutation, RowPatch, RowWriteMode, }; pub use store::ReadModelStore; diff --git a/src/read_model/session.rs b/src/read_model/session.rs index 2b6ee49..d9bc43b 100644 --- a/src/read_model/session.rs +++ b/src/read_model/session.rs @@ -1,10 +1,11 @@ use std::collections::BTreeMap; +use std::marker::PhantomData; use serde::Serialize; use super::{ - ReadModel, ReadModelError, ReadModelSchema, RelationalReadModel, RelationshipDef, RowKey, - RowValue, RowValues, Versioned, + ReadModel, ReadModelError, ReadModelSchema, RelationalReadModel, RelationalReadModelIncludes, + RelationshipDef, RelationshipKind, RowKey, RowValue, RowValues, Versioned, }; /// Expected optimistic version carried by a staged read-model write. @@ -110,6 +111,60 @@ pub struct ReadModelLoadRequest { pub includes: Vec, } +impl ReadModelLoadRequest { + pub fn validate_for_query_capabilities( + &self, + capabilities: &ReadModelQueryCapabilities, + ) -> Result<(), ReadModelError> { + if !self.includes.is_empty() && !capabilities.relationship_includes { + return Err(ReadModelError::Metadata( + "read-model adapter does not support relationship includes".into(), + )); + } + + Ok(()) + } +} + +/// Adapter capabilities for primary-key relational read-model loads. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct ReadModelQueryCapabilities { + pub relationship_includes: bool, +} + +impl ReadModelQueryCapabilities { + pub fn relationship_includes() -> Self { + Self { + relationship_includes: true, + } + } +} + +/// Rows loaded for one requested relationship include. +#[derive(Clone, Debug, PartialEq)] +pub struct ReadModelIncludeRows { + pub relationship: RelationshipDef, + pub target_schema: ReadModelSchema, + pub rows: Vec>, +} + +/// Untyped graph loaded by a relational read-model adapter. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct ReadModelLoadGraph { + pub root: Option>, + pub includes: BTreeMap, +} + +/// Adapter contract for explicit primary-key read-model loads and includes. +pub trait RelationalReadModelQueryStore: Send + Sync { + fn read_model_query_capabilities(&self) -> ReadModelQueryCapabilities; + + fn load_graph( + &self, + request: ReadModelLoadRequest, + ) -> Result; +} + /// Sparse column updates for a relational row. #[derive(Clone, Debug, Default, PartialEq)] pub struct RowPatch { @@ -710,6 +765,377 @@ impl ReadModelSession { } } +#[derive(Clone, Debug)] +struct TrackedRowBaseline { + key: RowKey, + row: RowValues, + version: u64, +} + +#[derive(Clone, Debug)] +struct TrackedIncludeBaseline { + relationship: RelationshipDef, + target_schema: ReadModelSchema, + rows: BTreeMap, +} + +#[derive(Clone, Debug)] +struct TrackedModelBaseline { + root_schema: ReadModelSchema, + root_key: RowKey, + root_row: RowValues, + root_version: u64, + includes: BTreeMap, +} + +/// Store-bound read-model unit of work for load, mutate, save-changes, commit workflows. +pub struct ReadModelSessionUnitOfWork<'a, S> { + store: &'a S, + writes: ReadModelSession, + baselines: Vec, +} + +impl<'a, S> ReadModelSessionUnitOfWork<'a, S> +where + S: ReadModelSessionStore + RelationalReadModelQueryStore, +{ + pub fn new(store: &'a S) -> Self { + Self { + store, + writes: ReadModelSession::new(), + baselines: Vec::new(), + } + } + + pub fn is_empty(&self) -> bool { + self.writes.is_empty() + } + + pub fn load(&mut self, key: RowKey) -> ReadModelLoadBuilder<'_, 'a, S, M> + where + M: RelationalReadModel + RelationalReadModelIncludes, + { + ReadModelLoadBuilder { + unit: self, + key, + includes: Vec::new(), + _marker: PhantomData, + } + } + + pub fn save_changes(&mut self, model: M) -> Result<&mut Self, ReadModelError> + where + M: RelationalReadModel + RelationalReadModelIncludes, + { + let schema = validated_schema::()?; + let key = model.primary_key()?; + validate_key(&schema, &key)?; + let identity = RowIdentity { + table_name: schema.table_name.clone(), + key: key_fingerprint(&key), + }; + let baseline = self + .baselines + .iter() + .find(|baseline| { + baseline.root_schema.table_name == identity.table_name + && key_fingerprint(&baseline.root_key) == identity.key + }) + .cloned() + .ok_or_else(|| { + ReadModelError::Metadata(format!( + "read model `{}` has no tracked baseline for save_changes", + schema.model_name + )) + })?; + let current_row = model.to_row()?; + + self.stage_row_diff( + schema, + key, + &baseline.root_row, + ¤t_row, + baseline.root_version, + )?; + + for (include_name, include) in &baseline.includes { + let current_rows = model.include_rows(include_name)?; + self.stage_include_changes(&baseline.root_schema, ¤t_row, include, current_rows)?; + } + + Ok(self) + } + + pub fn save(&mut self, model: &M) -> Result<&mut Self, ReadModelError> + where + M: RelationalReadModel, + { + self.writes.save(model)?; + Ok(self) + } + + pub fn insert(&mut self, model: &M) -> Result<&mut Self, ReadModelError> + where + M: RelationalReadModel, + { + self.writes.insert(model)?; + Ok(self) + } + + pub fn patch(&mut self, key: RowKey, patch: RowPatch) -> Result<&mut Self, ReadModelError> + where + M: RelationalReadModel, + { + self.writes.patch::(key, patch)?; + Ok(self) + } + + pub fn delete(&mut self, key: RowKey) -> Result<&mut Self, ReadModelError> + where + M: RelationalReadModel, + { + self.writes.delete::(key)?; + Ok(self) + } + + pub fn mark_processed( + &mut self, + consumer_name: impl Into, + message_id: impl Into, + ) -> &mut Self { + self.writes.mark_processed(consumer_name, message_id); + self + } + + pub fn into_write_plan(self) -> Result { + self.writes.into_write_plan() + } + + pub fn commit(self) -> Result { + self.writes.commit(self.store) + } + + fn track_graph( + &mut self, + schema: ReadModelSchema, + root: Versioned, + includes: BTreeMap, + ) -> Result<(), ReadModelError> { + let root_key = key_from_row(&schema, &root.data)?; + let root_identity = RowIdentity { + table_name: schema.table_name.clone(), + key: key_fingerprint(&root_key), + }; + self.writes + .expected_versions + .insert(root_identity, root.version); + + let mut tracked_includes = BTreeMap::new(); + for (include_name, include_rows) in includes { + let mut rows = BTreeMap::new(); + for row in include_rows.rows { + let key = key_from_row(&include_rows.target_schema, &row.data)?; + rows.insert( + key_fingerprint(&key), + TrackedRowBaseline { + key, + row: row.data, + version: row.version, + }, + ); + } + tracked_includes.insert( + include_name, + TrackedIncludeBaseline { + relationship: include_rows.relationship, + target_schema: include_rows.target_schema, + rows, + }, + ); + } + + let fingerprint = key_fingerprint(&root_key); + self.baselines.retain(|baseline| { + baseline.root_schema.table_name != schema.table_name + || key_fingerprint(&baseline.root_key) != fingerprint + }); + self.baselines.push(TrackedModelBaseline { + root_schema: schema, + root_key, + root_row: root.data, + root_version: root.version, + includes: tracked_includes, + }); + Ok(()) + } + + fn stage_include_changes( + &mut self, + root_schema: &ReadModelSchema, + root_row: &RowValues, + baseline: &TrackedIncludeBaseline, + current_rows: Vec, + ) -> Result<(), ReadModelError> { + if matches!(baseline.relationship.kind, RelationshipKind::BelongsTo) + && current_rows.len() > 1 + { + return Err(ReadModelError::Metadata(format!( + "belongs_to relationship `{}` can save at most one related row", + baseline.relationship.field_name + ))); + } + + for mut current_row in current_rows { + match baseline.relationship.kind { + RelationshipKind::HasMany => populate_delegated_relationship_values( + root_schema, + root_row, + &baseline.relationship, + &baseline.target_schema, + &mut current_row, + )?, + RelationshipKind::BelongsTo => {} + RelationshipKind::ManyToMany => { + return Err(ReadModelError::Metadata(format!( + "many-to-many relationship `{}` includes are not supported yet", + baseline.relationship.field_name + ))); + } + } + + let key = key_from_row(&baseline.target_schema, ¤t_row)?; + let fingerprint = key_fingerprint(&key); + if let Some(loaded) = baseline.rows.get(&fingerprint) { + self.stage_row_diff( + baseline.target_schema.clone(), + loaded.key.clone(), + &loaded.row, + ¤t_row, + loaded.version, + )?; + } else { + self.stage_upsert_row(baseline.target_schema.clone(), key, current_row)?; + } + } + + Ok(()) + } + + fn stage_row_diff( + &mut self, + schema: ReadModelSchema, + key: RowKey, + before: &RowValues, + after: &RowValues, + expected_version: u64, + ) -> Result<(), ReadModelError> { + let patch = diff_rows(before, after); + if patch.is_empty() { + return Ok(()); + } + + let mutation = PatchRowMutation { + schema, + key, + patch, + expected_version: ExpectedVersion::Exact(expected_version), + mode: PatchMode::UpdateExisting, + }; + validate_patch_mutation(&mutation)?; + self.writes.push(ReadModelMutation::PatchRow(mutation)); + Ok(()) + } + + fn stage_upsert_row( + &mut self, + schema: ReadModelSchema, + key: RowKey, + values: RowValues, + ) -> Result<(), ReadModelError> { + let mutation = RowMutation { + schema, + key, + values, + expected_version: ExpectedVersion::Any, + mode: RowWriteMode::Upsert, + }; + validate_row_mutation(&mutation)?; + self.writes.push(ReadModelMutation::UpsertRow(mutation)); + Ok(()) + } +} + +/// Builder for one explicit primary-key read-model load. +pub struct ReadModelLoadBuilder<'session, 'store, S, M> +where + S: ReadModelSessionStore + RelationalReadModelQueryStore, +{ + unit: &'session mut ReadModelSessionUnitOfWork<'store, S>, + key: RowKey, + includes: Vec, + _marker: PhantomData, +} + +impl<'session, 'store, S, M> ReadModelLoadBuilder<'session, 'store, S, M> +where + S: ReadModelSessionStore + RelationalReadModelQueryStore, + M: RelationalReadModel + RelationalReadModelIncludes, +{ + pub fn include(mut self, relationship: impl Into) -> Self { + self.includes.push(relationship.into()); + self + } + + pub fn one(self) -> Result>, ReadModelError> { + let request = self + .unit + .writes + .load_with::(self.key, self.includes)?; + let graph = self.unit.store.load_graph(request.clone())?; + let Some(root) = graph.root else { + return Ok(None); + }; + + let mut model = M::from_row(root.data.clone())?; + for (include_name, include_rows) in &graph.includes { + let rows = include_rows + .rows + .iter() + .map(|row| row.data.clone()) + .collect::>(); + model.hydrate_include(include_name, rows)?; + } + + self.unit + .track_graph(request.schema, root.clone(), graph.includes)?; + Ok(Some(Versioned { + data: model, + version: root.version, + })) + } +} + +/// Extension trait that starts a friendly tracked read-model session from a store. +pub trait ReadModelUnitOfWorkExt: + ReadModelSessionStore + RelationalReadModelQueryStore + Sized +{ + fn session(&self) -> ReadModelSessionUnitOfWork<'_, Self> { + ReadModelSessionUnitOfWork::new(self) + } +} + +impl ReadModelUnitOfWorkExt for S where S: ReadModelSessionStore + RelationalReadModelQueryStore {} + +fn diff_rows(before: &RowValues, after: &RowValues) -> RowPatch { + let mut patch = RowPatch::new(); + for (column, value) in after.iter() { + if before.get(column) != Some(value) { + patch = patch.set(column.to_string(), value.clone()); + } + } + patch +} + fn validated_schema() -> Result where M: RelationalReadModel, @@ -767,7 +1193,7 @@ fn validate_expected_version( Ok(()) } -fn validate_key(schema: &ReadModelSchema, key: &RowKey) -> Result<(), ReadModelError> { +pub(crate) fn validate_key(schema: &ReadModelSchema, key: &RowKey) -> Result<(), ReadModelError> { if key.is_empty() { return Err(ReadModelError::Metadata(format!( "read model `{}` row key cannot be empty", @@ -871,7 +1297,10 @@ fn validate_row_values( Ok(()) } -fn key_from_row(schema: &ReadModelSchema, row: &RowValues) -> Result { +pub(crate) fn key_from_row( + schema: &ReadModelSchema, + row: &RowValues, +) -> Result { let mut key = RowKey::default(); for column in &schema.primary_key.columns { let value = row.get(column).cloned().ok_or_else(|| { @@ -960,7 +1389,7 @@ fn populate_delegated_relationship_values( Ok(()) } -fn column_name_for(schema: &ReadModelSchema, field_or_column: &str) -> Option { +pub(crate) fn column_name_for(schema: &ReadModelSchema, field_or_column: &str) -> Option { schema .columns .iter() @@ -970,7 +1399,7 @@ fn column_name_for(schema: &ReadModelSchema, field_or_column: &str) -> Option String { +pub(crate) fn key_fingerprint(key: &RowKey) -> String { let mut fingerprint = String::new(); for (column, value) in key.iter() { push_fingerprint_part(&mut fingerprint, column); diff --git a/tests/read_model_distributed_idempotency/main.rs b/tests/read_model_distributed_idempotency/main.rs index 22fcddf..4156d7a 100644 --- a/tests/read_model_distributed_idempotency/main.rs +++ b/tests/read_model_distributed_idempotency/main.rs @@ -2,7 +2,7 @@ use serde::{Deserialize, Serialize}; use sourced_rust::bus::{Event, Publisher, Subscriber}; use sourced_rust::{ InMemoryQueue, InMemoryReadModelStore, ReadModel, ReadModelSession, ReadModelSessionStore, - ReadModelStore, + ReadModelStore, RowKey, RowValue, }; const CONSUMER: &str = "counter-projection"; @@ -32,6 +32,10 @@ fn counter_session(view: &CounterView, message_id: &str) -> ReadModelSession { session } +fn relational_counter_key(id: &str) -> RowKey { + RowKey::new([("id", RowValue::String(id.into()))]) +} + #[test] fn standalone_session_commit_applies_document_and_marks_processed() { let store = InMemoryReadModelStore::new(); @@ -103,12 +107,14 @@ fn read_model_write_and_processed_mark_are_atomic() { .document(&view) .unwrap() .mark_processed(CONSUMER, "message-1") + .expect_version::(relational_counter_key("counter-1"), 99) + .unwrap() .save(&row) .unwrap(); let err = session.commit(&store).unwrap_err(); - assert!(err.to_string().contains("relational row writes")); + assert!(err.to_string().contains("not found")); assert!(!store.is_processed(CONSUMER, "message-1").unwrap()); assert!(store .get_by_primary_key::("counter-1") @@ -135,13 +141,15 @@ fn ack_happens_only_after_successful_standalone_commit() { }; let mut failed_session = ReadModelSession::new(); failed_session + .expect_version::(relational_counter_key("counter-1"), 99) + .unwrap() .save(&row) .unwrap() .mark_processed(CONSUMER, &failed.id); let err = failed_session.commit(&store).unwrap_err(); - assert!(err.to_string().contains("relational row writes")); + assert!(err.to_string().contains("not found")); assert!(queue.acknowledged().is_empty()); assert!(!store.is_processed(CONSUMER, &failed.id).unwrap()); diff --git a/tests/read_model_relationship_includes/main.rs b/tests/read_model_relationship_includes/main.rs new file mode 100644 index 0000000..fab4cd8 --- /dev/null +++ b/tests/read_model_relationship_includes/main.rs @@ -0,0 +1,361 @@ +use serde::{Deserialize, Serialize}; +use sourced_rust::{ + InMemoryReadModelStore, ReadModel, ReadModelAdapterCapabilities, ReadModelCommitOutcome, + ReadModelError, ReadModelLoadGraph, ReadModelLoadRequest, ReadModelQueryCapabilities, + ReadModelSessionStore, ReadModelUnitOfWorkExt, ReadModelWritePlan, + RelationalReadModelQueryStore, RowKey, RowValue, +}; + +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, ReadModel)] +#[readmodel(table = "players")] +struct Player { + #[readmodel(id, column = "player_id")] + player_id: String, + display_name: String, + #[readmodel(has_many = "PlayerWeapon", foreign_key = "player_id")] + weapons: Vec, +} + +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, ReadModel)] +#[readmodel(table = "player_weapons", primary_key = ["player_id", "weapon_id"])] +struct PlayerWeapon { + #[readmodel(foreign_key = "players.player_id", delegated_from = "Player.player_id")] + player_id: String, + weapon_id: String, + acquired_at: String, + #[readmodel(belongs_to = "Player", foreign_key = "player_id")] + player: Option, +} + +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, ReadModel)] +#[readmodel(table = "players_with_many")] +struct PlayerWithMany { + #[readmodel(id, column = "player_id")] + player_id: String, + #[readmodel( + many_to_many = "Weapon", + through = "player_weapon_links", + foreign_key = "player_id" + )] + weapons: Vec, +} + +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, ReadModel)] +#[readmodel(table = "weapons")] +struct Weapon { + #[readmodel(id, column = "weapon_id")] + weapon_id: String, +} + +struct NoIncludeStore { + inner: InMemoryReadModelStore, +} + +impl NoIncludeStore { + fn new(inner: InMemoryReadModelStore) -> Self { + Self { inner } + } +} + +impl ReadModelSessionStore for NoIncludeStore { + fn read_model_capabilities(&self) -> ReadModelAdapterCapabilities { + self.inner.read_model_capabilities() + } + + fn commit_write_plan( + &self, + plan: ReadModelWritePlan, + ) -> Result { + self.inner.commit_write_plan(plan) + } + + fn is_processed(&self, consumer_name: &str, message_id: &str) -> Result { + self.inner.is_processed(consumer_name, message_id) + } +} + +impl RelationalReadModelQueryStore for NoIncludeStore { + fn read_model_query_capabilities(&self) -> ReadModelQueryCapabilities { + ReadModelQueryCapabilities::default() + } + + fn load_graph( + &self, + request: ReadModelLoadRequest, + ) -> Result { + request.validate_for_query_capabilities(&self.read_model_query_capabilities())?; + self.inner.load_graph(request) + } +} + +fn player_key(player_id: &str) -> RowKey { + RowKey::new([("player_id", RowValue::String(player_id.into()))]) +} + +fn weapon_key(player_id: &str, weapon_id: &str) -> RowKey { + RowKey::new([ + ("player_id", RowValue::String(player_id.into())), + ("weapon_id", RowValue::String(weapon_id.into())), + ]) +} + +fn player(player_id: &str, display_name: &str) -> Player { + Player { + player_id: player_id.into(), + display_name: display_name.into(), + weapons: Vec::new(), + } +} + +fn weapon(player_id: &str, weapon_id: &str, acquired_at: &str) -> PlayerWeapon { + PlayerWeapon { + player_id: player_id.into(), + weapon_id: weapon_id.into(), + acquired_at: acquired_at.into(), + player: None, + } +} + +fn store_with_player_and_weapons( + weapons: impl IntoIterator, +) -> InMemoryReadModelStore { + let store = InMemoryReadModelStore::new(); + store.register_schema::().unwrap(); + store.register_schema::().unwrap(); + + let mut session = sourced_rust::ReadModelSession::new(); + session.save(&player("player-1", "Ada")).unwrap(); + for weapon in weapons { + session.save(&weapon).unwrap(); + } + session.commit(&store).unwrap(); + store +} + +#[test] +fn friendly_session_loads_one_root_by_primary_key_without_includes() { + let store = store_with_player_and_weapons([]); + let mut read_models = store.session(); + + let loaded = read_models + .load::(player_key("player-1")) + .one() + .unwrap(); + + assert_eq!(loaded.unwrap().data.display_name, "Ada"); +} + +#[test] +fn friendly_session_hydrates_has_many_include() { + let store = store_with_player_and_weapons([weapon("player-1", "sword", "2026-05-23")]); + let mut read_models = store.session(); + + let loaded = read_models + .load::(player_key("player-1")) + .include("weapons") + .one() + .unwrap() + .unwrap(); + + assert_eq!(loaded.data.weapons[0].weapon_id, "sword"); +} + +#[test] +fn friendly_session_hydrates_belongs_to_include() { + let store = store_with_player_and_weapons([weapon("player-1", "sword", "2026-05-23")]); + let mut read_models = store.session(); + + let loaded = read_models + .load::(weapon_key("player-1", "sword")) + .include("player") + .one() + .unwrap() + .unwrap(); + + assert_eq!(loaded.data.player.unwrap().display_name, "Ada"); +} + +#[test] +fn save_changes_persists_loaded_scalar_field_without_manual_patch() { + let store = store_with_player_and_weapons([]); + let mut read_models = store.session(); + let mut loaded = read_models + .load::(player_key("player-1")) + .one() + .unwrap() + .unwrap() + .data; + loaded.display_name = "Ada Lovelace".into(); + + read_models.save_changes(loaded).unwrap(); + read_models.commit().unwrap(); + + let mut check = store.session(); + let reloaded = check + .load::(player_key("player-1")) + .one() + .unwrap() + .unwrap(); + assert_eq!(reloaded.data.display_name, "Ada Lovelace"); +} + +#[test] +fn save_changes_persists_added_and_modified_related_rows() { + let store = store_with_player_and_weapons([weapon("player-1", "sword", "2026-05-23")]); + let mut read_models = store.session(); + let mut loaded = read_models + .load::(player_key("player-1")) + .include("weapons") + .one() + .unwrap() + .unwrap() + .data; + loaded.weapons[0].acquired_at = "2026-05-24".into(); + loaded.weapons.push(weapon("", "shield", "2026-05-25")); + + read_models.save_changes(loaded).unwrap(); + read_models.commit().unwrap(); + + let mut check = store.session(); + let mut reloaded = check + .load::(player_key("player-1")) + .include("weapons") + .one() + .unwrap() + .unwrap() + .data; + reloaded + .weapons + .sort_by(|left, right| left.weapon_id.cmp(&right.weapon_id)); + assert_eq!(reloaded.weapons[0].player_id, "player-1"); + assert_eq!(reloaded.weapons[0].weapon_id, "shield"); + assert_eq!(reloaded.weapons[1].acquired_at, "2026-05-24"); +} + +#[test] +fn save_changes_does_not_delete_removed_related_rows_by_default() { + let store = store_with_player_and_weapons([ + weapon("player-1", "shield", "2026-05-24"), + weapon("player-1", "sword", "2026-05-23"), + ]); + let mut read_models = store.session(); + let mut loaded = read_models + .load::(player_key("player-1")) + .include("weapons") + .one() + .unwrap() + .unwrap() + .data; + loaded.weapons.retain(|weapon| weapon.weapon_id == "sword"); + + read_models.save_changes(loaded).unwrap(); + read_models.commit().unwrap(); + + let mut check = store.session(); + let reloaded = check + .load::(player_key("player-1")) + .include("weapons") + .one() + .unwrap() + .unwrap(); + assert_eq!(reloaded.data.weapons.len(), 2); +} + +#[test] +fn missing_root_returns_none_without_include_loading() { + let store = store_with_player_and_weapons([weapon("player-1", "sword", "2026-05-23")]); + let mut read_models = store.session(); + + let loaded = read_models + .load::(player_key("missing")) + .include("weapons") + .one() + .unwrap(); + + assert!(loaded.is_none()); +} + +#[test] +fn unregistered_relationship_target_fails_before_loading() { + let store = InMemoryReadModelStore::new(); + store.register_schema::().unwrap(); + let mut session = sourced_rust::ReadModelSession::new(); + session.save(&player("player-1", "Ada")).unwrap(); + session.commit(&store).unwrap(); + let mut read_models = store.session(); + + let err = read_models + .load::(player_key("player-1")) + .include("weapons") + .one() + .unwrap_err(); + + assert!( + matches!(err, ReadModelError::Metadata(message) if message.contains("unregistered model `PlayerWeapon`")) + ); +} + +#[test] +fn unregistered_root_schema_fails_before_loading() { + let store = InMemoryReadModelStore::new(); + let mut read_models = store.session(); + + let err = read_models + .load::(player_key("player-1")) + .one() + .unwrap_err(); + + assert!( + matches!(err, ReadModelError::Metadata(message) if message.contains("is not registered")) + ); +} + +#[test] +fn adapter_without_include_capability_rejects_includes() { + let inner = store_with_player_and_weapons([weapon("player-1", "sword", "2026-05-23")]); + let store = NoIncludeStore::new(inner); + let mut read_models = store.session(); + + let err = read_models + .load::(player_key("player-1")) + .include("weapons") + .one() + .unwrap_err(); + + assert!( + matches!(err, ReadModelError::Metadata(message) if message.contains("relationship includes")) + ); +} + +#[test] +fn nested_query_style_include_paths_are_not_a_public_query_dsl() { + let store = store_with_player_and_weapons([weapon("player-1", "sword", "2026-05-23")]); + let mut read_models = store.session(); + + let err = read_models + .load::(player_key("player-1")) + .include("weapons.owner") + .one() + .unwrap_err(); + + assert!( + matches!(err, ReadModelError::Metadata(message) if message.contains("has no relationship")) + ); +} + +#[test] +fn many_to_many_include_fails_until_join_metadata_is_rich_enough() { + let store = InMemoryReadModelStore::new(); + store.register_schema::().unwrap(); + let mut read_models = store.session(); + + let err = read_models + .load::(player_key("player-1")) + .include("weapons") + .one() + .unwrap_err(); + + assert!( + matches!(err, ReadModelError::Metadata(message) if message.contains("many-to-many relationship")) + ); +} From e46e851bb0598a59610866be6f0385a3b48ec531 Mon Sep 17 00:00:00 2001 From: Patrick Lee Scott Date: Sat, 23 May 2026 14:48:24 -0500 Subject: [PATCH 08/26] fix: address read model review feedback --- docs/read-models.md | 2 + src/read_model/in_memory.rs | 45 ++++++++++------ src/read_model/session.rs | 2 +- .../read_model_relationship_includes/main.rs | 53 +++++++++++++++++++ tests/read_model_session/main.rs | 52 +++++++++++++++++- 5 files changed, 135 insertions(+), 19 deletions(-) diff --git a/docs/read-models.md b/docs/read-models.md index 65b8a7b..831d2a0 100644 --- a/docs/read-models.md +++ b/docs/read-models.md @@ -79,6 +79,8 @@ pub struct PlayerView { pub display_name: String, #[readmodel(jsonb)] pub counters_by_game: std::collections::HashMap, + #[readmodel(has_many = "PlayerWeaponView", foreign_key = "player_id")] + pub weapons: Vec, } #[derive(Clone, Debug, Serialize, Deserialize, ReadModel)] diff --git a/src/read_model/in_memory.rs b/src/read_model/in_memory.rs index 77383cd..7d8e27e 100644 --- a/src/read_model/in_memory.rs +++ b/src/read_model/in_memory.rs @@ -3,7 +3,7 @@ use std::collections::{BTreeMap, HashMap, HashSet}; use std::sync::{Arc, RwLock}; -use super::session::{column_name_for, key_fingerprint, validate_key}; +use super::session::{column_name_for, key_fingerprint, validate_key, validate_row_values}; use super::{ ExpectedVersion, PatchMode, ProcessedMessageMark, ReadModel, ReadModelAdapterCapabilities, ReadModelCommitOutcome, ReadModelError, ReadModelIncludeRows, ReadModelLoadGraph, @@ -172,10 +172,15 @@ fn apply_read_model_write_plan( row.version = next_model_version(&key, current_version)?; } None if matches!(mutation.mode, PatchMode::InsertMissing) => { + let values = row_values_from_key_and_patch( + &mutation.schema, + &mutation.key, + mutation.patch.into_values(), + )?; staged_rows.insert( key.clone(), StoredRow { - values: mutation.patch.into_values(), + values, version: INITIAL_MODEL_VERSION, }, ); @@ -217,6 +222,22 @@ fn relational_storage_key(table_name: &str, key: &RowKey) -> String { format!("{}:{}", table_name, key_fingerprint(key)) } +fn row_values_from_key_and_patch( + schema: &ReadModelSchema, + key: &RowKey, + patch_values: RowValues, +) -> Result { + let mut values = RowValues::new(); + for (column, value) in key.iter() { + values.insert(column.to_string(), value.clone()); + } + for (column, value) in patch_values { + values.insert(column, value); + } + validate_row_values(schema, &values, true)?; + Ok(values) +} + fn validate_row_expected_version( schema: &ReadModelSchema, key: &RowKey, @@ -602,22 +623,14 @@ fn belongs_to_target_column( target_schema: &ReadModelSchema, source_column: &str, ) -> Result { - if target_schema.primary_key.columns.len() == 1 { - return Ok(target_schema.primary_key.columns[0].clone()); + if target_schema.primary_key.columns.len() != 1 { + return Err(ReadModelError::Metadata(format!( + "belongs_to target `{}` must have a single-column primary key to load from `{}`", + target_schema.model_name, source_column + ))); } - target_schema - .primary_key - .columns - .iter() - .find(|column| column.as_str() == source_column) - .cloned() - .ok_or_else(|| { - ReadModelError::Metadata(format!( - "belongs_to target `{}` has a composite primary key that cannot be loaded from `{}`", - target_schema.model_name, source_column - )) - }) + Ok(target_schema.primary_key.columns[0].clone()) } fn rows_matching_column( diff --git a/src/read_model/session.rs b/src/read_model/session.rs index d9bc43b..5754c8b 100644 --- a/src/read_model/session.rs +++ b/src/read_model/session.rs @@ -1231,7 +1231,7 @@ pub(crate) fn validate_key(schema: &ReadModelSchema, key: &RowKey) -> Result<(), Ok(()) } -fn validate_row_values( +pub(crate) fn validate_row_values( schema: &ReadModelSchema, values: &RowValues, full_row: bool, diff --git a/tests/read_model_relationship_includes/main.rs b/tests/read_model_relationship_includes/main.rs index fab4cd8..e991cfa 100644 --- a/tests/read_model_relationship_includes/main.rs +++ b/tests/read_model_relationship_includes/main.rs @@ -47,6 +47,24 @@ struct Weapon { weapon_id: String, } +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, ReadModel)] +#[readmodel(table = "weapon_label_refs")] +struct WeaponLabelRef { + #[readmodel(id, column = "ref_id")] + ref_id: String, + player_id: String, + #[readmodel(belongs_to = "CompositeWeaponLabel", foreign_key = "player_id")] + label: Option, +} + +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, ReadModel)] +#[readmodel(table = "weapon_labels", primary_key = ["player_id", "weapon_id"])] +struct CompositeWeaponLabel { + player_id: String, + weapon_id: String, + label: String, +} + struct NoIncludeStore { inner: InMemoryReadModelStore, } @@ -359,3 +377,38 @@ fn many_to_many_include_fails_until_join_metadata_is_rich_enough() { matches!(err, ReadModelError::Metadata(message) if message.contains("many-to-many relationship")) ); } + +#[test] +fn belongs_to_include_rejects_composite_target_primary_key() { + let store = InMemoryReadModelStore::new(); + store.register_schema::().unwrap(); + store.register_schema::().unwrap(); + let mut session = sourced_rust::ReadModelSession::new(); + session + .save(&WeaponLabelRef { + ref_id: "ref-1".into(), + player_id: "player-1".into(), + label: None, + }) + .unwrap() + .save(&CompositeWeaponLabel { + player_id: "player-1".into(), + weapon_id: "sword".into(), + label: "Sword".into(), + }) + .unwrap(); + session.commit(&store).unwrap(); + let mut read_models = store.session(); + + let err = read_models + .load::(RowKey::new([("ref_id", RowValue::String("ref-1".into()))])) + .include("label") + .one() + .unwrap_err(); + + assert!( + matches!(err, ReadModelError::Metadata(message) if message.contains("CompositeWeaponLabel") + && message.contains("player_id") + && message.contains("single-column primary key")) + ); +} diff --git a/tests/read_model_session/main.rs b/tests/read_model_session/main.rs index 49714c1..4cc94f5 100644 --- a/tests/read_model_session/main.rs +++ b/tests/read_model_session/main.rs @@ -2,8 +2,9 @@ use std::collections::HashMap; use serde::{Deserialize, Serialize}; use sourced_rust::{ - ExpectedVersion, PatchMode, ReadModel, ReadModelAdapterCapabilities, ReadModelError, - ReadModelMutation, ReadModelSession, RowKey, RowPatch, RowValue, RowWriteMode, Versioned, + ExpectedVersion, InMemoryReadModelStore, PatchMode, ReadModel, ReadModelAdapterCapabilities, + ReadModelError, ReadModelMutation, ReadModelSession, ReadModelUnitOfWorkExt, RowKey, RowPatch, + RowValue, RowWriteMode, Versioned, }; #[derive(Clone, Debug, PartialEq, Serialize, Deserialize, ReadModel)] @@ -150,6 +151,53 @@ fn insert_and_upsert_patch_carry_explicit_missing_row_behavior() { assert_eq!(upsert_patch.mode, PatchMode::InsertMissing); } +#[test] +fn insert_missing_patch_builds_full_row_from_key_before_insert() { + let store = InMemoryReadModelStore::new(); + store.register_schema::().unwrap(); + let patch = RowPatch::new() + .set("owner", RowValue::String("Grace".into())) + .set("balance_cents", RowValue::I64(250)) + .set("deposit_count", RowValue::U64(2)) + .set( + "counters_by_game", + RowValue::Json(serde_json::json!({"deposits": 1})), + ); + let mut session = ReadModelSession::new(); + + session + .upsert_patch::(account_key("acct-1"), patch) + .unwrap(); + session.commit(&store).unwrap(); + + let mut read_models = store.session(); + let loaded = read_models + .load::(account_key("acct-1")) + .one() + .unwrap() + .unwrap(); + assert_eq!(loaded.data.account_id, "acct-1"); + assert_eq!(loaded.data.owner, Some("Grace".into())); + assert_eq!(loaded.data.balance_cents, 250); + assert_eq!(loaded.data.deposit_count, 2); +} + +#[test] +fn insert_missing_patch_rejects_partial_new_row() { + let store = InMemoryReadModelStore::new(); + let patch = RowPatch::new().set("owner", RowValue::String("Grace".into())); + let mut session = ReadModelSession::new(); + + session + .upsert_patch::(account_key("acct-1"), patch) + .unwrap(); + let err = session.commit(&store).unwrap_err(); + + assert!( + matches!(err, ReadModelError::Metadata(message) if message.contains("missing required column `balance_cents`")) + ); +} + #[test] fn relationship_operation_populates_child_foreign_key_in_explicit_row_mutation() { let player = Player { From 6313605270242247220cdde2bdacade6dd2b7655 Mon Sep 17 00:00:00 2001 From: Patrick Lee Scott Date: Sat, 23 May 2026 16:36:31 -0500 Subject: [PATCH 09/26] fix: guard primary keys in row patches --- src/read_model/in_memory.rs | 42 +++++++++++++++++++++++++++---- tests/read_model_session/main.rs | 43 ++++++++++++++++++++++++++++++++ 2 files changed, 80 insertions(+), 5 deletions(-) diff --git a/src/read_model/in_memory.rs b/src/read_model/in_memory.rs index 7d8e27e..d7d3a0e 100644 --- a/src/read_model/in_memory.rs +++ b/src/read_model/in_memory.rs @@ -166,9 +166,12 @@ fn apply_read_model_write_plan( match staged_rows.get_mut(&key) { Some(row) => { - for (column, value) in mutation.patch.into_values() { - row.values.insert(column, value); - } + apply_patch_values_preserving_key( + &mutation.schema, + &mutation.key, + &mut row.values, + mutation.patch.into_values(), + )?; row.version = next_model_version(&key, current_version)?; } None if matches!(mutation.mode, PatchMode::InsertMissing) => { @@ -231,11 +234,40 @@ fn row_values_from_key_and_patch( for (column, value) in key.iter() { values.insert(column.to_string(), value.clone()); } + apply_patch_values_preserving_key(schema, key, &mut values, patch_values)?; + validate_row_values(schema, &values, true)?; + Ok(values) +} + +fn apply_patch_values_preserving_key( + schema: &ReadModelSchema, + key: &RowKey, + values: &mut RowValues, + patch_values: RowValues, +) -> Result<(), ReadModelError> { for (column, value) in patch_values { + if schema + .primary_key + .columns + .iter() + .any(|primary_key| primary_key == &column) + { + let key_value = key.get(&column).ok_or_else(|| { + ReadModelError::Metadata(format!( + "read model `{}` row key is missing primary-key column `{}`", + schema.model_name, column + )) + })?; + if key_value != &value { + return Err(ReadModelError::Metadata(format!( + "read model `{}` patch cannot change primary-key column `{}`", + schema.model_name, column + ))); + } + } values.insert(column, value); } - validate_row_values(schema, &values, true)?; - Ok(values) + Ok(()) } fn validate_row_expected_version( diff --git a/tests/read_model_session/main.rs b/tests/read_model_session/main.rs index 4cc94f5..3a7ba7d 100644 --- a/tests/read_model_session/main.rs +++ b/tests/read_model_session/main.rs @@ -156,6 +156,7 @@ fn insert_missing_patch_builds_full_row_from_key_before_insert() { let store = InMemoryReadModelStore::new(); store.register_schema::().unwrap(); let patch = RowPatch::new() + .set("account_id", RowValue::String("acct-1".into())) .set("owner", RowValue::String("Grace".into())) .set("balance_cents", RowValue::I64(250)) .set("deposit_count", RowValue::U64(2)) @@ -182,6 +183,27 @@ fn insert_missing_patch_builds_full_row_from_key_before_insert() { assert_eq!(loaded.data.deposit_count, 2); } +#[test] +fn insert_missing_patch_rejects_primary_key_mismatch() { + let store = InMemoryReadModelStore::new(); + let patch = RowPatch::new() + .set("account_id", RowValue::String("acct-2".into())) + .set("owner", RowValue::String("Grace".into())) + .set("balance_cents", RowValue::I64(250)) + .set("deposit_count", RowValue::U64(2)) + .set("counters_by_game", RowValue::Json(serde_json::json!({}))); + let mut session = ReadModelSession::new(); + + session + .upsert_patch::(account_key("acct-1"), patch) + .unwrap(); + let err = session.commit(&store).unwrap_err(); + + assert!( + matches!(err, ReadModelError::Metadata(message) if message.contains("primary-key column `account_id`")) + ); +} + #[test] fn insert_missing_patch_rejects_partial_new_row() { let store = InMemoryReadModelStore::new(); @@ -198,6 +220,27 @@ fn insert_missing_patch_rejects_partial_new_row() { ); } +#[test] +fn existing_patch_rejects_primary_key_mismatch() { + let store = InMemoryReadModelStore::new(); + let mut setup = ReadModelSession::new(); + setup.save(&AccountSummary::new("acct-1")).unwrap(); + setup.commit(&store).unwrap(); + let patch = RowPatch::new() + .set("account_id", RowValue::String("acct-2".into())) + .set("owner", RowValue::String("Grace".into())); + let mut session = ReadModelSession::new(); + + session + .patch::(account_key("acct-1"), patch) + .unwrap(); + let err = session.commit(&store).unwrap_err(); + + assert!( + matches!(err, ReadModelError::Metadata(message) if message.contains("primary-key column `account_id`")) + ); +} + #[test] fn relationship_operation_populates_child_foreign_key_in_explicit_row_mutation() { let player = Player { From b81a895c4bbc0d05648edd9aebb45df9749de587 Mon Sep 17 00:00:00 2001 From: Patrick Lee Scott Date: Sat, 23 May 2026 16:39:06 -0500 Subject: [PATCH 10/26] test: assert failed sparse insert leaves no row --- tests/read_model_session/main.rs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/tests/read_model_session/main.rs b/tests/read_model_session/main.rs index 3a7ba7d..1a19685 100644 --- a/tests/read_model_session/main.rs +++ b/tests/read_model_session/main.rs @@ -207,6 +207,7 @@ fn insert_missing_patch_rejects_primary_key_mismatch() { #[test] fn insert_missing_patch_rejects_partial_new_row() { let store = InMemoryReadModelStore::new(); + store.register_schema::().unwrap(); let patch = RowPatch::new().set("owner", RowValue::String("Grace".into())); let mut session = ReadModelSession::new(); @@ -218,6 +219,13 @@ fn insert_missing_patch_rejects_partial_new_row() { assert!( matches!(err, ReadModelError::Metadata(message) if message.contains("missing required column `balance_cents`")) ); + + let mut read_models = store.session(); + let loaded = read_models + .load::(account_key("acct-1")) + .one() + .unwrap(); + assert!(loaded.is_none()); } #[test] From 32715756adbb702f81fd9450b7f847e30aaa36f4 Mon Sep 17 00:00:00 2001 From: Patrick Lee Scott Date: Sat, 23 May 2026 17:08:23 -0500 Subject: [PATCH 11/26] feat: delete removed has_many children on save_changes Make `save_changes` reconcile included collections to the struct: an owned has_many child dropped from the loaded Vec is deleted, lowering to an explicit DeleteRow with the loaded expected version. belongs_to clear-to-None stays a no-op on the target. Safe because has_many includes load the complete owned set. Replaces the prior "removal does not delete by default" behavior, which was asymmetric (auto-persisted adds/edits but silently dropped removals). Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/read-models.md | 17 ++++++--- src/read_model/session.rs | 35 ++++++++++++++++++- .../read_model_relationship_includes/main.rs | 27 ++++++++++++-- 3 files changed, 72 insertions(+), 7 deletions(-) diff --git a/docs/read-models.md b/docs/read-models.md index 831d2a0..02bfbab 100644 --- a/docs/read-models.md +++ b/docs/read-models.md @@ -142,10 +142,19 @@ read_models.commit()?; ``` `has_many` relationships hydrate `Vec` fields. `belongs_to` relationships -hydrate `Option` fields. Added `has_many` children have delegated foreign keys -filled before the write plan is staged. Removing a child from a loaded -collection does not delete storage by default; use an explicit delete operation -when deletion is intended. +hydrate `Option` fields. + +`save_changes` makes storage match the struct: added items are inserted, changed +items updated, and **removed items deleted**. For an included `has_many` +collection, dropping a child from the `Vec` deletes that child row (the loaded +collection is the complete owned set, so the struct is the source of truth). +Added children have their delegated foreign keys filled before the write plan is +staged. Every change — including the delete — lowers to an explicit mutation in +the `ReadModelWritePlan`; nothing cascades to rows you did not load. + +Clearing a `belongs_to` field to `None` is a no-op on the target: it never +deletes the owner, since other rows may reference it. To delete a whole root, +use `delete::(key)`. This API is for command handlers, projectors, tests, admin tools, and adapter conformance that need typed internal includes. It is not a public query DSL. diff --git a/src/read_model/session.rs b/src/read_model/session.rs index 5754c8b..b0cad02 100644 --- a/src/read_model/session.rs +++ b/src/read_model/session.rs @@ -1,4 +1,4 @@ -use std::collections::BTreeMap; +use std::collections::{BTreeMap, BTreeSet}; use std::marker::PhantomData; use serde::Serialize; @@ -985,6 +985,7 @@ where ))); } + let mut current_fingerprints = BTreeSet::new(); for mut current_row in current_rows { match baseline.relationship.kind { RelationshipKind::HasMany => populate_delegated_relationship_values( @@ -1005,6 +1006,7 @@ where let key = key_from_row(&baseline.target_schema, ¤t_row)?; let fingerprint = key_fingerprint(&key); + current_fingerprints.insert(fingerprint.clone()); if let Some(loaded) = baseline.rows.get(&fingerprint) { self.stage_row_diff( baseline.target_schema.clone(), @@ -1018,6 +1020,21 @@ where } } + // `save_changes` makes storage match the struct: an owned `has_many` child + // dropped from the loaded collection is deleted. `belongs_to` clears never + // delete the target, which is the owner that other rows may reference. + if matches!(baseline.relationship.kind, RelationshipKind::HasMany) { + for (fingerprint, loaded) in &baseline.rows { + if !current_fingerprints.contains(fingerprint) { + self.stage_delete_row( + baseline.target_schema.clone(), + loaded.key.clone(), + loaded.version, + )?; + } + } + } + Ok(()) } @@ -1063,6 +1080,22 @@ where self.writes.push(ReadModelMutation::UpsertRow(mutation)); Ok(()) } + + fn stage_delete_row( + &mut self, + schema: ReadModelSchema, + key: RowKey, + expected_version: u64, + ) -> Result<(), ReadModelError> { + let mutation = DeleteRowMutation { + schema, + key, + expected_version: ExpectedVersion::Exact(expected_version), + }; + validate_delete_mutation(&mutation)?; + self.writes.push(ReadModelMutation::DeleteRow(mutation)); + Ok(()) + } } /// Builder for one explicit primary-key read-model load. diff --git a/tests/read_model_relationship_includes/main.rs b/tests/read_model_relationship_includes/main.rs index e991cfa..9c35542 100644 --- a/tests/read_model_relationship_includes/main.rs +++ b/tests/read_model_relationship_includes/main.rs @@ -251,7 +251,7 @@ fn save_changes_persists_added_and_modified_related_rows() { } #[test] -fn save_changes_does_not_delete_removed_related_rows_by_default() { +fn save_changes_deletes_removed_related_rows() { let store = store_with_player_and_weapons([ weapon("player-1", "shield", "2026-05-24"), weapon("player-1", "sword", "2026-05-23"), @@ -276,7 +276,30 @@ fn save_changes_does_not_delete_removed_related_rows_by_default() { .one() .unwrap() .unwrap(); - assert_eq!(reloaded.data.weapons.len(), 2); + assert_eq!(reloaded.data.weapons.len(), 1); + assert_eq!(reloaded.data.weapons[0].weapon_id, "sword"); +} + +#[test] +fn save_changes_clearing_belongs_to_does_not_delete_target() { + let store = store_with_player_and_weapons([weapon("player-1", "sword", "2026-05-23")]); + let mut read_models = store.session(); + let mut loaded = read_models + .load::(weapon_key("player-1", "sword")) + .include("player") + .one() + .unwrap() + .unwrap() + .data; + assert!(loaded.player.is_some()); + loaded.player = None; + + read_models.save_changes(loaded).unwrap(); + read_models.commit().unwrap(); + + let mut check = store.session(); + let player = check.load::(player_key("player-1")).one().unwrap(); + assert_eq!(player.unwrap().data.display_name, "Ada"); } #[test] From 2da5196b007ef1061c2c8261c1cdb4aadda6adee Mon Sep 17 00:00:00 2001 From: Patrick Lee Scott Date: Sat, 23 May 2026 19:31:44 -0500 Subject: [PATCH 12/26] test: distributed relational read-model examples with fulfillment saga MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rework tests/distributed_read_model into a Catalog + Order CQRS slice over normalized relational read models (ProductView, OrderView has_many OrderLineView belongs_to ProductView, JSONB columns), and add a kanban Board + Cards example. Add an order-fulfillment saga (inventory, payment, saga orchestrator) driving confirm/cancel with a compensation path, projected into an OrderFulfillmentStepView has_many child for a multi-include query. Conventions: each write service is a microsvc::Service with service.rs + handlers/ (one file per message) + models/ (aggregate); the projection service is one dispatcher organized into handler modules; published domain events are lowercase dot-namespaced. Services publish via the outbox and subscribe via microsvc::subscribe — no bespoke transport. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../account_service/account.rs | 38 -- .../handlers/account_deposit.rs | 35 -- .../account_service/handlers/account_open.rs | 31 - .../account_service/handlers/mod.rs | 2 - .../catalog_service/handlers/mod.rs | 2 + .../catalog_service/handlers/product_add.rs | 29 + .../handlers/product_reprice.rs | 29 + .../catalog_service/mod.rs | 15 + .../catalog_service/models/mod.rs | 3 + .../catalog_service/models/product.rs | 37 ++ .../catalog_service/service.rs | 13 + tests/distributed_read_model/fulfillment.rs | 58 ++ .../inventory_service/handlers/mod.rs | 2 + .../inventory_service/handlers/release.rs | 33 + .../inventory_service/handlers/reserve.rs | 33 + .../inventory_service/mod.rs | 16 + .../inventory_service/models/inventory.rs | 31 + .../inventory_service/models/mod.rs | 3 + .../inventory_service/service.rs | 25 + tests/distributed_read_model/main.rs | 571 ++++++++++++------ .../handlers/mod.rs | 5 + .../handlers/on_inventory_released.rs | 34 ++ .../handlers/on_inventory_reserved.rs | 35 ++ .../handlers/on_payment_declined.rs | 36 ++ .../handlers/on_payment_succeeded.rs | 33 + .../handlers/on_requested.rs | 37 ++ .../order_fulfillment_saga_service/mod.rs | 16 + .../models/mod.rs | 3 + .../models/saga.rs | 54 ++ .../order_fulfillment_saga_service/service.rs | 16 + .../order_service/handlers/mod.rs | 7 + .../order_service/handlers/order_add_line.rs | 34 ++ .../order_service/handlers/order_cancel.rs | 31 + .../handlers/order_change_quantity.rs | 31 + .../order_service/handlers/order_confirm.rs | 31 + .../order_service/handlers/order_place.rs | 29 + .../handlers/order_remove_line.rs | 26 + .../order_service/handlers/order_submit.rs | 26 + .../order_service/mod.rs | 21 + .../order_service/models/mod.rs | 5 + .../order_service/models/order.rs | 103 ++++ .../mod.rs => order_service/service.rs} | 23 +- .../payment_service/handlers/charge.rs | 44 ++ .../payment_service/handlers/mod.rs | 1 + .../payment_service/mod.rs | 15 + .../payment_service/models/mod.rs | 3 + .../payment_service/models/payment.rs | 26 + .../payment_service/service.rs | 12 + .../handlers/fulfillment.rs | 42 ++ .../projections_service/handlers/mod.rs | 33 + .../projections_service/handlers/order.rs | 101 ++++ .../projections_service/handlers/product.rs | 30 + .../projections_service/mod.rs | 94 +-- .../query_service/account_summary.rs | 25 - .../query_service/mod.rs | 45 +- .../distributed_read_model/read_models/mod.rs | 36 ++ .../order_fulfillment_step_view.rs | 14 + .../read_models/order_line_view.rs | 20 + .../read_models/order_view.rs | 30 + .../read_models/product_view.rs | 17 + .../board_service/handlers/board_add_card.rs | 32 + .../board_service/handlers/board_move_card.rs | 26 + .../board_service/handlers/board_open.rs | 29 + .../handlers/board_remove_card.rs | 26 + .../board_service/handlers/mod.rs | 4 + .../board_service/mod.rs | 14 + .../board_service/models/board.rs | 90 +++ .../board_service/models/mod.rs | 3 + .../board_service/service.rs | 15 + tests/distributed_read_model_board/main.rs | 185 ++++++ .../projections_service/handlers/board.rs | 87 +++ .../projections_service/handlers/mod.rs | 21 + .../projections_service/mod.rs | 99 +++ .../query_service/mod.rs | 41 ++ .../read_models/board_view.rs | 17 + .../read_models/card_view.rs | 28 + .../read_models/mod.rs | 28 + 77 files changed, 2578 insertions(+), 397 deletions(-) delete mode 100644 tests/distributed_read_model/account_service/account.rs delete mode 100644 tests/distributed_read_model/account_service/handlers/account_deposit.rs delete mode 100644 tests/distributed_read_model/account_service/handlers/account_open.rs delete mode 100644 tests/distributed_read_model/account_service/handlers/mod.rs create mode 100644 tests/distributed_read_model/catalog_service/handlers/mod.rs create mode 100644 tests/distributed_read_model/catalog_service/handlers/product_add.rs create mode 100644 tests/distributed_read_model/catalog_service/handlers/product_reprice.rs create mode 100644 tests/distributed_read_model/catalog_service/mod.rs create mode 100644 tests/distributed_read_model/catalog_service/models/mod.rs create mode 100644 tests/distributed_read_model/catalog_service/models/product.rs create mode 100644 tests/distributed_read_model/catalog_service/service.rs create mode 100644 tests/distributed_read_model/fulfillment.rs create mode 100644 tests/distributed_read_model/inventory_service/handlers/mod.rs create mode 100644 tests/distributed_read_model/inventory_service/handlers/release.rs create mode 100644 tests/distributed_read_model/inventory_service/handlers/reserve.rs create mode 100644 tests/distributed_read_model/inventory_service/mod.rs create mode 100644 tests/distributed_read_model/inventory_service/models/inventory.rs create mode 100644 tests/distributed_read_model/inventory_service/models/mod.rs create mode 100644 tests/distributed_read_model/inventory_service/service.rs create mode 100644 tests/distributed_read_model/order_fulfillment_saga_service/handlers/mod.rs create mode 100644 tests/distributed_read_model/order_fulfillment_saga_service/handlers/on_inventory_released.rs create mode 100644 tests/distributed_read_model/order_fulfillment_saga_service/handlers/on_inventory_reserved.rs create mode 100644 tests/distributed_read_model/order_fulfillment_saga_service/handlers/on_payment_declined.rs create mode 100644 tests/distributed_read_model/order_fulfillment_saga_service/handlers/on_payment_succeeded.rs create mode 100644 tests/distributed_read_model/order_fulfillment_saga_service/handlers/on_requested.rs create mode 100644 tests/distributed_read_model/order_fulfillment_saga_service/mod.rs create mode 100644 tests/distributed_read_model/order_fulfillment_saga_service/models/mod.rs create mode 100644 tests/distributed_read_model/order_fulfillment_saga_service/models/saga.rs create mode 100644 tests/distributed_read_model/order_fulfillment_saga_service/service.rs create mode 100644 tests/distributed_read_model/order_service/handlers/mod.rs create mode 100644 tests/distributed_read_model/order_service/handlers/order_add_line.rs create mode 100644 tests/distributed_read_model/order_service/handlers/order_cancel.rs create mode 100644 tests/distributed_read_model/order_service/handlers/order_change_quantity.rs create mode 100644 tests/distributed_read_model/order_service/handlers/order_confirm.rs create mode 100644 tests/distributed_read_model/order_service/handlers/order_place.rs create mode 100644 tests/distributed_read_model/order_service/handlers/order_remove_line.rs create mode 100644 tests/distributed_read_model/order_service/handlers/order_submit.rs create mode 100644 tests/distributed_read_model/order_service/mod.rs create mode 100644 tests/distributed_read_model/order_service/models/mod.rs create mode 100644 tests/distributed_read_model/order_service/models/order.rs rename tests/distributed_read_model/{account_service/mod.rs => order_service/service.rs} (74%) create mode 100644 tests/distributed_read_model/payment_service/handlers/charge.rs create mode 100644 tests/distributed_read_model/payment_service/handlers/mod.rs create mode 100644 tests/distributed_read_model/payment_service/mod.rs create mode 100644 tests/distributed_read_model/payment_service/models/mod.rs create mode 100644 tests/distributed_read_model/payment_service/models/payment.rs create mode 100644 tests/distributed_read_model/payment_service/service.rs create mode 100644 tests/distributed_read_model/projections_service/handlers/fulfillment.rs create mode 100644 tests/distributed_read_model/projections_service/handlers/mod.rs create mode 100644 tests/distributed_read_model/projections_service/handlers/order.rs create mode 100644 tests/distributed_read_model/projections_service/handlers/product.rs delete mode 100644 tests/distributed_read_model/query_service/account_summary.rs create mode 100644 tests/distributed_read_model/read_models/mod.rs create mode 100644 tests/distributed_read_model/read_models/order_fulfillment_step_view.rs create mode 100644 tests/distributed_read_model/read_models/order_line_view.rs create mode 100644 tests/distributed_read_model/read_models/order_view.rs create mode 100644 tests/distributed_read_model/read_models/product_view.rs create mode 100644 tests/distributed_read_model_board/board_service/handlers/board_add_card.rs create mode 100644 tests/distributed_read_model_board/board_service/handlers/board_move_card.rs create mode 100644 tests/distributed_read_model_board/board_service/handlers/board_open.rs create mode 100644 tests/distributed_read_model_board/board_service/handlers/board_remove_card.rs create mode 100644 tests/distributed_read_model_board/board_service/handlers/mod.rs create mode 100644 tests/distributed_read_model_board/board_service/mod.rs create mode 100644 tests/distributed_read_model_board/board_service/models/board.rs create mode 100644 tests/distributed_read_model_board/board_service/models/mod.rs create mode 100644 tests/distributed_read_model_board/board_service/service.rs create mode 100644 tests/distributed_read_model_board/main.rs create mode 100644 tests/distributed_read_model_board/projections_service/handlers/board.rs create mode 100644 tests/distributed_read_model_board/projections_service/handlers/mod.rs create mode 100644 tests/distributed_read_model_board/projections_service/mod.rs create mode 100644 tests/distributed_read_model_board/query_service/mod.rs create mode 100644 tests/distributed_read_model_board/read_models/board_view.rs create mode 100644 tests/distributed_read_model_board/read_models/card_view.rs create mode 100644 tests/distributed_read_model_board/read_models/mod.rs diff --git a/tests/distributed_read_model/account_service/account.rs b/tests/distributed_read_model/account_service/account.rs deleted file mode 100644 index 4f3eb09..0000000 --- a/tests/distributed_read_model/account_service/account.rs +++ /dev/null @@ -1,38 +0,0 @@ -use serde::{Deserialize, Serialize}; -use sourced_rust::{sourced, Entity, Snapshot}; - -#[derive(Default, Snapshot)] -pub struct Account { - pub entity: Entity, - pub owner: String, - pub balance_cents: i64, - pub is_open: bool, -} - -#[sourced(entity)] -impl Account { - #[event("AccountOpened")] - pub fn open(&mut self, id: String, owner: String) { - self.entity.set_id(&id); - self.owner = owner; - self.balance_cents = 0; - self.is_open = true; - } - - #[event("MoneyDeposited", when = self.is_open && amount_cents > 0)] - pub fn deposit(&mut self, amount_cents: i64) { - self.balance_cents += amount_cents; - } -} - -#[derive(Clone, Debug, Deserialize, Serialize)] -pub struct OpenAccount { - pub id: String, - pub owner: String, -} - -#[derive(Clone, Debug, Deserialize, Serialize)] -pub struct DepositMoney { - pub id: String, - pub amount_cents: i64, -} diff --git a/tests/distributed_read_model/account_service/handlers/account_deposit.rs b/tests/distributed_read_model/account_service/handlers/account_deposit.rs deleted file mode 100644 index ae02ac6..0000000 --- a/tests/distributed_read_model/account_service/handlers/account_deposit.rs +++ /dev/null @@ -1,35 +0,0 @@ -use serde_json::{json, Value}; -use sourced_rust::microsvc::{Context, HandlerError}; -use sourced_rust::{OutboxCommitExt, OutboxMessage}; - -use crate::account_service::account::{Account, DepositMoney}; -use crate::account_service::AccountRepo; - -pub const COMMAND: &str = "account.deposit"; - -pub fn guard(ctx: &Context) -> bool { - ctx.has_fields(&["id", "amount_cents"]) -} - -pub fn handle(ctx: &Context) -> Result { - let input = ctx.input::()?; - if input.amount_cents <= 0 { - return Err(HandlerError::Rejected( - "deposit amount must be positive".to_string(), - )); - } - - let mut account: Account = ctx - .repo() - .get(&input.id)? - .ok_or_else(|| HandlerError::NotFound(input.id.clone()))?; - account.deposit(input.amount_cents)?; - - let mut outbox = OutboxMessage::domain_event("MoneyDeposited", &account)?; - ctx.repo().outbox(&mut outbox).commit(&mut account)?; - - Ok(json!({ - "id": input.id, - "balance_cents": account.balance_cents, - })) -} diff --git a/tests/distributed_read_model/account_service/handlers/account_open.rs b/tests/distributed_read_model/account_service/handlers/account_open.rs deleted file mode 100644 index 5c1bde7..0000000 --- a/tests/distributed_read_model/account_service/handlers/account_open.rs +++ /dev/null @@ -1,31 +0,0 @@ -use serde_json::{json, Value}; -use sourced_rust::microsvc::{Context, HandlerError}; -use sourced_rust::{OutboxCommitExt, OutboxMessage}; - -use crate::account_service::account::{Account, OpenAccount}; -use crate::account_service::AccountRepo; - -pub const COMMAND: &str = "account.open"; - -pub fn guard(ctx: &Context) -> bool { - ctx.has_fields(&["id", "owner"]) -} - -pub fn handle(ctx: &Context) -> Result { - let input = ctx.input::()?; - - if ctx.repo().peek(&input.id)?.is_some() { - return Err(HandlerError::Rejected(format!( - "account {} already exists", - input.id - ))); - } - - let mut account = Account::default(); - account.open(input.id.clone(), input.owner.clone())?; - - let mut outbox = OutboxMessage::domain_event("AccountOpened", &account)?; - ctx.repo().outbox(&mut outbox).commit(&mut account)?; - - Ok(json!({ "id": input.id, "owner": input.owner })) -} diff --git a/tests/distributed_read_model/account_service/handlers/mod.rs b/tests/distributed_read_model/account_service/handlers/mod.rs deleted file mode 100644 index 2ee94b7..0000000 --- a/tests/distributed_read_model/account_service/handlers/mod.rs +++ /dev/null @@ -1,2 +0,0 @@ -pub(super) mod account_deposit; -pub(super) mod account_open; diff --git a/tests/distributed_read_model/catalog_service/handlers/mod.rs b/tests/distributed_read_model/catalog_service/handlers/mod.rs new file mode 100644 index 0000000..ea05a43 --- /dev/null +++ b/tests/distributed_read_model/catalog_service/handlers/mod.rs @@ -0,0 +1,2 @@ +pub(super) mod product_add; +pub(super) mod product_reprice; diff --git a/tests/distributed_read_model/catalog_service/handlers/product_add.rs b/tests/distributed_read_model/catalog_service/handlers/product_add.rs new file mode 100644 index 0000000..fd5cf4e --- /dev/null +++ b/tests/distributed_read_model/catalog_service/handlers/product_add.rs @@ -0,0 +1,29 @@ +use serde_json::{json, Value}; +use sourced_rust::microsvc::{Context, HandlerError}; +use sourced_rust::{OutboxCommitExt, OutboxMessage}; + +use crate::catalog_service::{AddProduct, CatalogRepo, Product}; + +pub const COMMAND: &str = "product.add"; + +pub fn guard(ctx: &Context) -> bool { + ctx.has_fields(&["id", "name", "unit_cents"]) +} + +pub fn handle(ctx: &Context) -> Result { + let input = ctx.input::()?; + if ctx.repo().peek(&input.id)?.is_some() { + return Err(HandlerError::Rejected(format!( + "product {} already exists", + input.id + ))); + } + + let mut product = Product::default(); + product.add(input.id.clone(), input.name.clone(), input.unit_cents)?; + + let mut outbox = OutboxMessage::domain_event("product.added", &product)?; + ctx.repo().outbox(&mut outbox).commit(&mut product)?; + + Ok(json!({ "id": input.id })) +} diff --git a/tests/distributed_read_model/catalog_service/handlers/product_reprice.rs b/tests/distributed_read_model/catalog_service/handlers/product_reprice.rs new file mode 100644 index 0000000..51fa036 --- /dev/null +++ b/tests/distributed_read_model/catalog_service/handlers/product_reprice.rs @@ -0,0 +1,29 @@ +use serde_json::{json, Value}; +use sourced_rust::microsvc::{Context, HandlerError}; +use sourced_rust::{OutboxCommitExt, OutboxMessage}; + +use crate::catalog_service::{CatalogRepo, Product, RepriceProduct}; + +pub const COMMAND: &str = "product.reprice"; + +pub fn guard(ctx: &Context) -> bool { + ctx.has_fields(&["id", "unit_cents"]) +} + +pub fn handle(ctx: &Context) -> Result { + let input = ctx.input::()?; + if input.unit_cents <= 0 { + return Err(HandlerError::Rejected("price must be positive".to_string())); + } + + let mut product: Product = ctx + .repo() + .get(&input.id)? + .ok_or_else(|| HandlerError::NotFound(input.id.clone()))?; + product.reprice(input.unit_cents)?; + + let mut outbox = OutboxMessage::domain_event("product.repriced", &product)?; + ctx.repo().outbox(&mut outbox).commit(&mut product)?; + + Ok(json!({ "id": input.id, "unit_cents": product.unit_cents })) +} diff --git a/tests/distributed_read_model/catalog_service/mod.rs b/tests/distributed_read_model/catalog_service/mod.rs new file mode 100644 index 0000000..83a4e01 --- /dev/null +++ b/tests/distributed_read_model/catalog_service/mod.rs @@ -0,0 +1,15 @@ +//! Catalog write service: owns the `Product` aggregate and publishes product +//! snapshots through its outbox. It shares nothing with the order service except +//! the bus. + +pub mod models; + +mod handlers; +mod service; + +use sourced_rust::{AggregateRepository, HashMapRepository, QueuedRepository}; + +pub use models::{AddProduct, Product, ProductSnapshot, RepriceProduct}; +pub use service::model_service; + +pub type CatalogRepo = AggregateRepository, Product>; diff --git a/tests/distributed_read_model/catalog_service/models/mod.rs b/tests/distributed_read_model/catalog_service/models/mod.rs new file mode 100644 index 0000000..85830ba --- /dev/null +++ b/tests/distributed_read_model/catalog_service/models/mod.rs @@ -0,0 +1,3 @@ +pub mod product; + +pub use product::{AddProduct, Product, ProductSnapshot, RepriceProduct}; diff --git a/tests/distributed_read_model/catalog_service/models/product.rs b/tests/distributed_read_model/catalog_service/models/product.rs new file mode 100644 index 0000000..adf9796 --- /dev/null +++ b/tests/distributed_read_model/catalog_service/models/product.rs @@ -0,0 +1,37 @@ +use serde::{Deserialize, Serialize}; +use sourced_rust::{sourced, Entity, Snapshot}; + +#[derive(Default, Snapshot)] +pub struct Product { + pub entity: Entity, + pub name: String, + pub unit_cents: i64, +} + +#[sourced(entity)] +impl Product { + #[event("ProductAdded")] + pub fn add(&mut self, id: String, name: String, unit_cents: i64) { + self.entity.set_id(&id); + self.name = name; + self.unit_cents = unit_cents; + } + + #[event("ProductRepriced", when = unit_cents > 0)] + pub fn reprice(&mut self, unit_cents: i64) { + self.unit_cents = unit_cents; + } +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct AddProduct { + pub id: String, + pub name: String, + pub unit_cents: i64, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct RepriceProduct { + pub id: String, + pub unit_cents: i64, +} diff --git a/tests/distributed_read_model/catalog_service/service.rs b/tests/distributed_read_model/catalog_service/service.rs new file mode 100644 index 0000000..0c7ddcb --- /dev/null +++ b/tests/distributed_read_model/catalog_service/service.rs @@ -0,0 +1,13 @@ +use std::sync::Arc; + +use sourced_rust::microsvc::Service; + +use super::{handlers, CatalogRepo}; + +pub fn model_service(repo: CatalogRepo) -> Arc> { + Arc::new(sourced_rust::register_handlers!( + Service::new(repo), + handlers::product_add, + handlers::product_reprice, + )) +} diff --git a/tests/distributed_read_model/fulfillment.rs b/tests/distributed_read_model/fulfillment.rs new file mode 100644 index 0000000..b7ad7f6 --- /dev/null +++ b/tests/distributed_read_model/fulfillment.rs @@ -0,0 +1,58 @@ +//! Shared fulfillment-saga message contract. +//! +//! Saga steps are pub/sub JSON domain events (no outbox destination, fan-out via +//! the shared log). The orchestrator decides the next step; inventory/payment/ +//! order services react and report. Each handler publishes exactly one next +//! event, so a single outbox message per commit is enough. + +use serde::{Deserialize, Serialize}; +use sourced_rust::OutboxMessage; + +pub mod event { + pub const REQUESTED: &str = "fulfillment.requested"; + pub const RESERVE_INVENTORY: &str = "fulfillment.reserve_inventory"; + pub const INVENTORY_RESERVED: &str = "fulfillment.inventory_reserved"; + pub const CHARGE_PAYMENT: &str = "fulfillment.charge_payment"; + pub const PAYMENT_SUCCEEDED: &str = "fulfillment.payment_succeeded"; + pub const PAYMENT_DECLINED: &str = "fulfillment.payment_declined"; + pub const RELEASE_INVENTORY: &str = "fulfillment.release_inventory"; + pub const INVENTORY_RELEASED: &str = "fulfillment.inventory_released"; + pub const CONFIRM_ORDER: &str = "fulfillment.confirm_order"; + pub const CANCEL_ORDER: &str = "fulfillment.cancel_order"; +} + +/// Correlation payload carried through every fulfillment step. +#[derive(Clone, Debug, Default, Serialize, Deserialize)] +pub struct FulfillmentMsg { + pub order_id: String, + #[serde(default)] + pub sku: String, + #[serde(default)] + pub quantity: i64, + #[serde(default)] + pub amount_cents: i64, + #[serde(default)] + pub detail: String, +} + +/// Build a pub/sub (no-destination) JSON fulfillment event for the outbox. +pub fn fulfillment_event(event_type: &str, msg: &FulfillmentMsg) -> OutboxMessage { + let id = format!("{}:{}", msg.order_id, event_type); + let payload = serde_json::to_vec(msg).expect("fulfillment message should encode"); + OutboxMessage::create(id, event_type, payload).expect("fulfillment outbox should build") +} + +/// Decode a fulfillment event payload. +pub fn decode(event: &sourced_rust::bus::Event) -> FulfillmentMsg { + serde_json::from_slice(&event.payload).expect("fulfillment message should decode") +} + +/// Build the `fulfillment.requested` bus event that kicks off the saga. +pub fn requested_event(msg: &FulfillmentMsg) -> sourced_rust::bus::Event { + let payload = serde_json::to_vec(msg).expect("fulfillment message should encode"); + sourced_rust::bus::Event::new( + format!("{}:{}", msg.order_id, event::REQUESTED), + event::REQUESTED, + payload, + ) +} diff --git a/tests/distributed_read_model/inventory_service/handlers/mod.rs b/tests/distributed_read_model/inventory_service/handlers/mod.rs new file mode 100644 index 0000000..f79402b --- /dev/null +++ b/tests/distributed_read_model/inventory_service/handlers/mod.rs @@ -0,0 +1,2 @@ +pub(super) mod release; +pub(super) mod reserve; diff --git a/tests/distributed_read_model/inventory_service/handlers/release.rs b/tests/distributed_read_model/inventory_service/handlers/release.rs new file mode 100644 index 0000000..c327f33 --- /dev/null +++ b/tests/distributed_read_model/inventory_service/handlers/release.rs @@ -0,0 +1,33 @@ +use serde_json::{json, Value}; +use sourced_rust::microsvc::{Context, HandlerError}; +use sourced_rust::OutboxCommitExt; + +use crate::fulfillment::{self, event, FulfillmentMsg}; +use crate::inventory_service::InventoryRepo; + +pub const COMMAND: &str = event::RELEASE_INVENTORY; + +pub fn guard(ctx: &Context) -> bool { + ctx.has_fields(&["order_id", "sku", "quantity"]) +} + +pub fn handle(ctx: &Context) -> Result { + let msg = ctx.input::()?; + + let mut inventory = ctx + .repo() + .get(&msg.sku)? + .ok_or_else(|| HandlerError::NotFound(msg.sku.clone()))?; + inventory.release(msg.quantity)?; + + let mut out = fulfillment::fulfillment_event( + event::INVENTORY_RELEASED, + &FulfillmentMsg { + order_id: msg.order_id.clone(), + ..Default::default() + }, + ); + ctx.repo().outbox(&mut out).commit(&mut inventory)?; + + Ok(json!({ "order_id": msg.order_id })) +} diff --git a/tests/distributed_read_model/inventory_service/handlers/reserve.rs b/tests/distributed_read_model/inventory_service/handlers/reserve.rs new file mode 100644 index 0000000..ffc2797 --- /dev/null +++ b/tests/distributed_read_model/inventory_service/handlers/reserve.rs @@ -0,0 +1,33 @@ +use serde_json::{json, Value}; +use sourced_rust::microsvc::{Context, HandlerError}; +use sourced_rust::OutboxCommitExt; + +use crate::fulfillment::{self, event, FulfillmentMsg}; +use crate::inventory_service::InventoryRepo; + +pub const COMMAND: &str = event::RESERVE_INVENTORY; + +pub fn guard(ctx: &Context) -> bool { + ctx.has_fields(&["order_id", "sku", "quantity"]) +} + +pub fn handle(ctx: &Context) -> Result { + let msg = ctx.input::()?; + + let mut inventory = ctx + .repo() + .get(&msg.sku)? + .ok_or_else(|| HandlerError::NotFound(msg.sku.clone()))?; + inventory.reserve(msg.quantity)?; + + let mut out = fulfillment::fulfillment_event( + event::INVENTORY_RESERVED, + &FulfillmentMsg { + order_id: msg.order_id.clone(), + ..Default::default() + }, + ); + ctx.repo().outbox(&mut out).commit(&mut inventory)?; + + Ok(json!({ "order_id": msg.order_id })) +} diff --git a/tests/distributed_read_model/inventory_service/mod.rs b/tests/distributed_read_model/inventory_service/mod.rs new file mode 100644 index 0000000..de59125 --- /dev/null +++ b/tests/distributed_read_model/inventory_service/mod.rs @@ -0,0 +1,16 @@ +//! Inventory write service: reserves stock for the saga and releases it on +//! compensation. A `microsvc::Service` whose handlers react to +//! `fulfillment.reserve_inventory` / `fulfillment.release_inventory` and publish +//! the result through the outbox — same shape as every other service. + +pub mod models; + +mod handlers; +mod service; + +use sourced_rust::{AggregateRepository, HashMapRepository, QueuedRepository}; + +pub use models::Inventory; +pub use service::{model_service, seed_stock}; + +pub type InventoryRepo = AggregateRepository, Inventory>; diff --git a/tests/distributed_read_model/inventory_service/models/inventory.rs b/tests/distributed_read_model/inventory_service/models/inventory.rs new file mode 100644 index 0000000..06266d7 --- /dev/null +++ b/tests/distributed_read_model/inventory_service/models/inventory.rs @@ -0,0 +1,31 @@ +use sourced_rust::{sourced, Entity, Snapshot}; + +/// Stock per SKU. The saga reserves on the way in and releases on compensation. +#[derive(Default, Snapshot)] +pub struct Inventory { + pub entity: Entity, + pub available: i64, + pub reserved: i64, +} + +#[sourced(entity)] +impl Inventory { + #[event("StockSet")] + pub fn set_stock(&mut self, sku: String, quantity: i64) { + self.entity.set_id(&sku); + self.available = quantity; + self.reserved = 0; + } + + #[event("StockReserved", when = self.available >= quantity)] + pub fn reserve(&mut self, quantity: i64) { + self.available -= quantity; + self.reserved += quantity; + } + + #[event("StockReleased")] + pub fn release(&mut self, quantity: i64) { + self.available += quantity; + self.reserved = (self.reserved - quantity).max(0); + } +} diff --git a/tests/distributed_read_model/inventory_service/models/mod.rs b/tests/distributed_read_model/inventory_service/models/mod.rs new file mode 100644 index 0000000..64815fa --- /dev/null +++ b/tests/distributed_read_model/inventory_service/models/mod.rs @@ -0,0 +1,3 @@ +pub mod inventory; + +pub use inventory::Inventory; diff --git a/tests/distributed_read_model/inventory_service/service.rs b/tests/distributed_read_model/inventory_service/service.rs new file mode 100644 index 0000000..a6aa4e5 --- /dev/null +++ b/tests/distributed_read_model/inventory_service/service.rs @@ -0,0 +1,25 @@ +use std::sync::Arc; + +use sourced_rust::microsvc::Service; +use sourced_rust::{AggregateBuilder, HashMapRepository}; + +use super::{handlers, Inventory, InventoryRepo}; + +pub fn model_service(repo: InventoryRepo) -> Arc> { + Arc::new(sourced_rust::register_handlers!( + Service::new(repo), + handlers::reserve, + handlers::release, + )) +} + +/// Seed starting stock for a SKU before the service starts reacting. +pub fn seed_stock(store: &HashMapRepository, sku: &str, quantity: i64) { + let repo = store.clone().aggregate::(); + let mut inventory = Inventory::default(); + inventory + .set_stock(sku.to_string(), quantity) + .expect("seed stock should record"); + repo.commit(&mut inventory) + .expect("seed stock should commit"); +} diff --git a/tests/distributed_read_model/main.rs b/tests/distributed_read_model/main.rs index a97d098..10be480 100644 --- a/tests/distributed_read_model/main.rs +++ b/tests/distributed_read_model/main.rs @@ -1,238 +1,471 @@ -//! Distributed read-model service example. +//! Distributed read-model example over normalized relational tables. //! -//! This demonstrates a distributed CQRS deployment shape: -//! - the account model service owns the event-sourced aggregate and outbox -//! - the account summary projector owns read-model updates -//! - a separate query service reads from the projected read-model store -//! - the write side and projector are connected only through the bus +//! Deployment shape: +//! - the **catalog service** owns the `Product` aggregate and its outbox; +//! - the **order service** owns the `Order` aggregate (with line items as +//! aggregate state) and its outbox; +//! - two **projection services** consume the bus and reconcile normalized +//! `products`, `orders`, and `order_lines` rows in a shared read store; +//! - a **query service** reads the projected graph through primary-key loads +//! plus `has_many` / `belongs_to` relationship includes. //! -//! The test uses threads and `InMemoryQueue` as service stand-ins. In a real -//! deployment, each service would use its own process, a shared broker, and a -//! shared read-model database. A query API such as Hasura can sit in front of -//! that database while the read-model worker keeps the tables updated. -//! -//! The model service is a `microsvc::Service`, so the same command handlers can -//! be exposed through direct dispatch, HTTP, or gRPC. The query side is not a -//! command handler; it just reads the projected store. +//! The write services share nothing with the read side except the bus. The +//! order projector relies on `save_changes` collection sync: each order snapshot +//! is the desired state, so a removed line becomes a deleted `order_lines` row. +//! Threads and `InMemoryQueue` stand in for separate processes and a broker; in +//! production a query gateway such as Hasura would sit in front of the tables. -mod account_service; +mod catalog_service; +mod fulfillment; +mod inventory_service; +mod order_fulfillment_saga_service; +mod order_service; +mod payment_service; mod projections_service; mod query_service; +mod read_models; use std::thread; use std::time::{Duration, Instant}; -use account_service::{Account, DepositMoney, OpenAccount}; +use catalog_service::AddProduct; +use fulfillment::{requested_event, FulfillmentMsg}; +use inventory_service::Inventory; +use order_fulfillment_saga_service::OrderFulfillmentSaga; +use order_service::{AddLine, ChangeQuantity, PlaceOrder, RemoveLine, SubmitOrder}; +use payment_service::Payment; use projections_service::{ - start_account_summary_projection_service, wait_for_summary, ACCOUNT_SUMMARY_CONSUMER, + start_projection_service, CATALOG_CONSUMER, FULFILLMENT_CONSUMER, ORDER_CONSUMER, }; -use query_service::{AccountSummary, AccountSummaryQueryService}; -use sourced_rust::microsvc::Session; +use query_service::OrderQueryService; +use read_models::{register_schemas, OrderView}; +use serde::Serialize; +use sourced_rust::bus::{Bus, Subscribable}; +use sourced_rust::microsvc::{self, Service, Session}; use sourced_rust::{ AggregateBuilder, HashMapRepository, InMemoryQueue, InMemoryReadModelStore, OutboxWorkerThread, - Queueable, ReadModelSessionStore, ReadModelsExt, + Queueable, ReadModelSessionStore, }; -fn wait_for_published_events(queue: &InMemoryQueue, expected_count: usize) { +fn dispatch(service: &Service, command: &str, input: C) +where + R: Send + Sync + 'static, + C: Serialize, +{ + service + .dispatch( + command, + serde_json::to_value(input).expect("command should encode"), + Session::new(), + ) + .unwrap_or_else(|err| panic!("{command} should dispatch: {err:?}")); +} + +fn wait_for_order_state( + query: &OrderQueryService, + order_id: &str, + ready: impl Fn(&OrderView) -> bool, +) -> OrderView { let deadline = Instant::now() + Duration::from_secs(10); loop { - if queue.len() >= expected_count { - return; + if let Some(order) = query + .order_with_lines_and_steps(order_id) + .expect("query should succeed") + { + if ready(&order) { + return order; + } } assert!( Instant::now() < deadline, - "timed out waiting for outbox worker to publish account events" + "timed out waiting for order {order_id}" ); thread::sleep(Duration::from_millis(10)); } } #[test] -fn write_model_service_feeds_separate_read_model_service() { +fn catalog_and_order_services_feed_a_normalized_read_model() { let queue = InMemoryQueue::new(); - let write_store = HashMapRepository::new(); - let account_repo = write_store.clone().queued().aggregate::(); - let model_service = account_service::model_service(account_repo); + // Two independent write services, each with its own event store and outbox. + let catalog_store = HashMapRepository::new(); + let catalog_service = + catalog_service::model_service(catalog_store.clone().queued().aggregate()); + let order_store = HashMapRepository::new(); + let order_service = order_service::model_service(order_store.clone().queued().aggregate()); - let outbox_worker = - OutboxWorkerThread::spawn(write_store.clone(), queue.clone(), Duration::from_millis(5)); + let catalog_worker = OutboxWorkerThread::spawn( + catalog_store.clone(), + queue.clone(), + Duration::from_millis(5), + ); + let order_worker = + OutboxWorkerThread::spawn(order_store.clone(), queue.clone(), Duration::from_millis(5)); + // Shared downstream read store with normalized relational tables. let read_store = InMemoryReadModelStore::new(); - let projections_service = - start_account_summary_projection_service(queue.clone(), read_store.clone()); - let query_service = AccountSummaryQueryService::new(read_store.clone()); + register_schemas(&read_store).expect("relational schemas should register"); + let projection = start_projection_service(queue.clone(), read_store.clone()); + let query_service = OrderQueryService::new(read_store.clone()); - model_service - .dispatch( - "account.open", - serde_json::to_value(OpenAccount { - id: "acct-1".to_string(), - owner: "Ada Lovelace".to_string(), - }) - .expect("open command should encode"), - Session::new(), - ) - .expect("open command should dispatch"); - model_service - .dispatch( - "account.deposit", - serde_json::to_value(DepositMoney { - id: "acct-1".to_string(), - amount_cents: 2500, - }) - .expect("deposit command should encode"), - Session::new(), - ) - .expect("deposit command should dispatch"); + // Saga subsystem: inventory, payment, and the orchestrator are ordinary + // `microsvc::Service`s. Each publishes via its outbox worker and subscribes + // to the bus with `microsvc::subscribe`, dispatching events to handlers by + // type — the same shape as every other service. The order service also + // subscribes, so it reacts to the saga's confirm/cancel decisions. + let poll = Duration::from_millis(5); + + let inventory_store = HashMapRepository::new(); + inventory_service::seed_stock(&inventory_store, "W", 100); + let inventory_svc = + inventory_service::model_service(inventory_store.clone().queued().aggregate()); + let inventory_worker = OutboxWorkerThread::spawn(inventory_store.clone(), queue.clone(), poll); + let inventory_sub = microsvc::subscribe(inventory_svc.clone(), queue.new_subscriber(), poll); + + let payment_store = HashMapRepository::new(); + let payment_svc = payment_service::model_service(payment_store.clone().queued().aggregate()); + let payment_worker = OutboxWorkerThread::spawn(payment_store.clone(), queue.clone(), poll); + let payment_sub = microsvc::subscribe(payment_svc.clone(), queue.new_subscriber(), poll); + + let saga_store = HashMapRepository::new(); + let saga_svc = + order_fulfillment_saga_service::model_service(saga_store.clone().queued().aggregate()); + let saga_worker = OutboxWorkerThread::spawn(saga_store.clone(), queue.clone(), poll); + let saga_sub = microsvc::subscribe(saga_svc.clone(), queue.new_subscriber(), poll); - wait_for_published_events(&queue, 2); + let order_sub = microsvc::subscribe(order_service.clone(), queue.new_subscriber(), poll); - let summary = wait_for_summary(&read_store, "acct-1", |summary| { - summary.owner.as_deref() == Some("Ada Lovelace") - && summary.balance_cents == 2500 - && summary.deposit_count == 1 + // Catalog commands. + dispatch( + &catalog_service, + "product.add", + AddProduct { + id: "prod-widget".to_string(), + name: "Widget".to_string(), + unit_cents: 500, + }, + ); + dispatch( + &catalog_service, + "product.add", + AddProduct { + id: "prod-gadget".to_string(), + name: "Gadget".to_string(), + unit_cents: 1000, + }, + ); + + // Order commands: place, add two lines, change one, remove one, submit. + dispatch( + &order_service, + "order.place", + PlaceOrder { + id: "order-1".to_string(), + customer: "Ada Lovelace".to_string(), + }, + ); + dispatch( + &order_service, + "order.add_line", + AddLine { + id: "order-1".to_string(), + sku: "W".to_string(), + product_id: "prod-widget".to_string(), + unit_cents: 500, + quantity: 2, + }, + ); + dispatch( + &order_service, + "order.add_line", + AddLine { + id: "order-1".to_string(), + sku: "G".to_string(), + product_id: "prod-gadget".to_string(), + unit_cents: 1000, + quantity: 1, + }, + ); + dispatch( + &order_service, + "order.change_quantity", + ChangeQuantity { + id: "order-1".to_string(), + sku: "W".to_string(), + quantity: 3, + }, + ); + dispatch( + &order_service, + "order.remove_line", + RemoveLine { + id: "order-1".to_string(), + sku: "G".to_string(), + }, + ); + dispatch( + &order_service, + "order.submit", + SubmitOrder { + id: "order-1".to_string(), + }, + ); + + // Kick off fulfillment for the happy order (amount within the payment cap). + Bus::from_queue(queue.clone()) + .publish(requested_event(&FulfillmentMsg { + order_id: "order-1".to_string(), + sku: "W".to_string(), + quantity: 3, + amount_cents: 1500, + ..Default::default() + })) + .expect("fulfillment kickoff should publish"); + + // A second, expensive order the payment service declines, exercising the + // compensation path (release inventory, cancel the order). + dispatch( + &order_service, + "order.place", + PlaceOrder { + id: "order-2".to_string(), + customer: "Grace Hopper".to_string(), + }, + ); + dispatch( + &order_service, + "order.add_line", + AddLine { + id: "order-2".to_string(), + sku: "W".to_string(), + product_id: "prod-widget".to_string(), + unit_cents: 200_000, + quantity: 1, + }, + ); + dispatch( + &order_service, + "order.submit", + SubmitOrder { + id: "order-2".to_string(), + }, + ); + Bus::from_queue(queue.clone()) + .publish(requested_event(&FulfillmentMsg { + order_id: "order-2".to_string(), + sku: "W".to_string(), + quantity: 1, + amount_cents: 200_000, + ..Default::default() + })) + .expect("fulfillment kickoff should publish"); + + // === Happy path: the saga drives order-1 to confirmed === + let order = wait_for_order_state(&query_service, "order-1", |order| { + order.status == "confirmed" && order.fulfillment_steps.len() == 3 }); + assert_eq!(order.customer, "Ada Lovelace"); + assert_eq!(order.total_cents, 1500); + assert_eq!(order.lines.len(), 1, "removed line should be deleted"); + assert_eq!(order.lines[0].sku, "W"); + assert_eq!(order.lines[0].quantity, 3); + // JSONB column round-trips structured data alongside scalar columns. + assert_eq!( + order.metadata.get("source").map(String::as_str), + Some("order-service") + ); + // The saga audit trail is a has_many child, projected from saga events. + let mut steps: Vec<&str> = order + .fulfillment_steps + .iter() + .map(|step| step.step.as_str()) + .collect(); + steps.sort(); + assert_eq!( + steps, + vec!["inventory_reserved", "payment_succeeded", "requested"] + ); + + // belongs_to include joins the line to its catalog product (cross-service). + let line = query_service + .line_with_product("order-1", "W") + .expect("query should succeed") + .expect("line should exist"); + let product = line.product.expect("belongs_to product should hydrate"); + assert_eq!(product.name, "Widget"); + + // === Compensation path: the saga cancels order-2 === + let cancelled = wait_for_order_state(&query_service, "order-2", |order| { + order.status == "cancelled" && order.fulfillment_steps.len() == 4 + }); + let mut comp_steps: Vec<&str> = cancelled + .fulfillment_steps + .iter() + .map(|step| step.step.as_str()) + .collect(); + comp_steps.sort(); + assert_eq!( + comp_steps, + vec![ + "inventory_released", + "inventory_reserved", + "payment_declined", + "requested", + ] + ); + + // Write-side aggregates reflect the saga outcomes. + assert_eq!( + order_service + .repo() + .peek("order-1") + .unwrap() + .unwrap() + .status, + "confirmed" + ); + assert_eq!( + order_service + .repo() + .peek("order-2") + .unwrap() + .unwrap() + .status, + "cancelled" + ); + let saga_repo = saga_store + .clone() + .queued() + .aggregate::(); + assert_eq!( + saga_repo.peek("order-1").unwrap().unwrap().status, + "completed" + ); + assert_eq!( + saga_repo.peek("order-2").unwrap().unwrap().status, + "cancelled" + ); + + // Inventory: order-1 holds 3 reserved; order-2's reservation was released. + let inventory = inventory_store + .clone() + .queued() + .aggregate::() + .peek("W") + .unwrap() + .unwrap(); + assert_eq!(inventory.available, 97); + assert_eq!(inventory.reserved, 3); - assert_eq!(summary.owner.as_deref(), Some("Ada Lovelace")); - assert_eq!(summary.balance_cents, 2500); - assert_eq!(summary.deposit_count, 1); + // Payment: order-1 charged, order-2 declined. + let payment_repo = payment_store.clone().queued().aggregate::(); + assert_eq!( + payment_repo.peek("order-1").unwrap().unwrap().status, + "charged" + ); + assert!(payment_repo + .peek("order-2") + .unwrap() + .unwrap() + .status + .starts_with("declined")); + + // Idempotency: every projected event is marked processed by its consumer. for event in queue.events() { + let event_type = event.event_type.as_str(); + let consumer = if event_type.starts_with("product.") { + CATALOG_CONSUMER + } else if event_type.starts_with("order.") { + ORDER_CONSUMER + } else if matches!( + event_type, + "fulfillment.requested" + | "fulfillment.inventory_reserved" + | "fulfillment.payment_succeeded" + | "fulfillment.payment_declined" + | "fulfillment.inventory_released" + ) { + FULFILLMENT_CONSUMER + } else { + continue; + }; assert!( read_store - .is_processed(ACCOUNT_SUMMARY_CONSUMER, &event.id) - .expect("processed-message lookup should succeed"), - "read-model service should mark projected events processed before they are acknowledged" + .is_processed(consumer, &event.id) + .expect("processed lookup should succeed"), + "event {} should be marked processed before ack", + event.id ); } - let queried_summary = query_service - .get("acct-1") - .expect("query service should read projected account summary") - .expect("query service should find projected account summary"); - assert_eq!(queried_summary.owner.as_deref(), Some("Ada Lovelace")); - assert_eq!(queried_summary.balance_cents, 2500); - assert_eq!(queried_summary.deposit_count, 1); - assert!(query_service - .get("missing-account") - .expect("query service should read projected account summary") - .is_none()); - - let mut model_commands = model_service.commands(); - model_commands.sort(); - assert_eq!( - model_commands, - vec!["account.deposit", "account.open"], - "model service should expose only write-side commands" - ); - - let write_side_account = model_service - .repo() - .peek("acct-1") - .expect("write-side aggregate load should succeed") - .expect("write-side aggregate should exist"); - assert_eq!(write_side_account.balance_cents, 2500); - - let write_side_summary = write_store - .read_models::() - .get("acct-1") - .expect("write-side read model lookup should succeed"); - assert!( - write_side_summary.is_none(), - "write-side service should not own the account summary projection" - ); - - projections_service.stop(); - let worker_stats = outbox_worker - .stop() - .expect("outbox worker should stop cleanly"); - - assert!(worker_stats.messages_published >= 2); + let _ = order_sub.stop(); + let _ = saga_sub.stop(); + let _ = payment_sub.stop(); + let _ = inventory_sub.stop(); + projection.stop(); + let _ = saga_worker.stop(); + let _ = payment_worker.stop(); + let _ = inventory_worker.stop(); + let _ = order_worker.stop(); + let _ = catalog_worker.stop(); } #[cfg(feature = "http")] #[tokio::test] -async fn model_commands_can_be_http_service() { - let write_store = HashMapRepository::new(); - let account_repo = write_store.clone().queued().aggregate::(); - let model_service = account_service::model_service(account_repo); - let model_base = account_service::start_http_service(model_service.clone()).await; +async fn order_commands_can_be_http_service() { + let order_store = HashMapRepository::new(); + let order_service = order_service::model_service(order_store.clone().queued().aggregate()); + let base = order_service::start_http_service(order_service.clone()).await; let client = reqwest::Client::new(); - let open = client - .post(format!("{model_base}/account.open")) - .json(&OpenAccount { - id: "acct-http".to_string(), - owner: "Grace Hopper".to_string(), + let placed = client + .post(format!("{base}/order.place")) + .json(&PlaceOrder { + id: "order-http".to_string(), + customer: "Grace Hopper".to_string(), }) .send() .await - .expect("HTTP model service should accept open request"); - assert_eq!(open.status(), 200); - - let deposit = client - .post(format!("{model_base}/account.deposit")) - .json(&DepositMoney { - id: "acct-http".to_string(), - amount_cents: 4200, - }) - .send() - .await - .expect("HTTP model service should accept deposit request"); - assert_eq!(deposit.status(), 200); + .expect("HTTP order service should accept place request"); + assert_eq!(placed.status(), 200); - let account = model_service + let order = order_service .repo() - .peek("acct-http") - .expect("HTTP write-side aggregate load should succeed") - .expect("HTTP write-side aggregate should exist"); - assert_eq!(account.balance_cents, 4200); + .peek("order-http") + .expect("HTTP write-side load should succeed") + .expect("HTTP write-side order should exist"); + assert_eq!(order.status, "open"); } #[cfg(feature = "grpc")] #[tokio::test] -async fn model_commands_can_be_grpc_service() { - let write_store = HashMapRepository::new(); - let account_repo = write_store.clone().queued().aggregate::(); - let model_service = account_service::model_service(account_repo); - let mut model_client = account_service::start_grpc_service(model_service.clone()).await; - - let open = model_client - .dispatch(sourced_rust::microsvc::grpc::GrpcRequest { - command: "account.open".to_string(), - input: serde_json::to_string(&OpenAccount { - id: "acct-grpc".to_string(), - owner: "Katherine Johnson".to_string(), - }) - .expect("open command should encode"), - session_variables: Default::default(), - }) - .await - .expect("gRPC model service should accept open request") - .into_inner(); - assert_eq!(open.status, 200); +async fn order_commands_can_be_grpc_service() { + let order_store = HashMapRepository::new(); + let order_service = order_service::model_service(order_store.clone().queued().aggregate()); + let mut client = order_service::start_grpc_service(order_service.clone()).await; - let deposit = model_client + let placed = client .dispatch(sourced_rust::microsvc::grpc::GrpcRequest { - command: "account.deposit".to_string(), - input: serde_json::to_string(&DepositMoney { - id: "acct-grpc".to_string(), - amount_cents: 7300, + command: "order.place".to_string(), + input: serde_json::to_string(&PlaceOrder { + id: "order-grpc".to_string(), + customer: "Katherine Johnson".to_string(), }) - .expect("deposit command should encode"), + .expect("place command should encode"), session_variables: Default::default(), }) .await - .expect("gRPC model service should accept deposit request") + .expect("gRPC order service should accept place request") .into_inner(); - assert_eq!(deposit.status, 200); + assert_eq!(placed.status, 200); - let account = model_service + let order = order_service .repo() - .peek("acct-grpc") - .expect("gRPC write-side aggregate load should succeed") - .expect("gRPC write-side aggregate should exist"); - assert_eq!(account.balance_cents, 7300); + .peek("order-grpc") + .expect("gRPC write-side load should succeed") + .expect("gRPC write-side order should exist"); + assert_eq!(order.status, "open"); } diff --git a/tests/distributed_read_model/order_fulfillment_saga_service/handlers/mod.rs b/tests/distributed_read_model/order_fulfillment_saga_service/handlers/mod.rs new file mode 100644 index 0000000..b9397ac --- /dev/null +++ b/tests/distributed_read_model/order_fulfillment_saga_service/handlers/mod.rs @@ -0,0 +1,5 @@ +pub(super) mod on_inventory_released; +pub(super) mod on_inventory_reserved; +pub(super) mod on_payment_declined; +pub(super) mod on_payment_succeeded; +pub(super) mod on_requested; diff --git a/tests/distributed_read_model/order_fulfillment_saga_service/handlers/on_inventory_released.rs b/tests/distributed_read_model/order_fulfillment_saga_service/handlers/on_inventory_released.rs new file mode 100644 index 0000000..09c9a5c --- /dev/null +++ b/tests/distributed_read_model/order_fulfillment_saga_service/handlers/on_inventory_released.rs @@ -0,0 +1,34 @@ +use serde_json::{json, Value}; +use sourced_rust::microsvc::{Context, HandlerError}; +use sourced_rust::OutboxCommitExt; + +use crate::fulfillment::{self, event, FulfillmentMsg}; +use crate::order_fulfillment_saga_service::{OrderFulfillmentSaga, SagaRepo}; + +pub const COMMAND: &str = event::INVENTORY_RELEASED; + +pub fn guard(ctx: &Context) -> bool { + ctx.has_fields(&["order_id"]) +} + +pub fn handle(ctx: &Context) -> Result { + let msg = ctx.input::()?; + + let mut saga: OrderFulfillmentSaga = ctx + .repo() + .get(&msg.order_id)? + .ok_or_else(|| HandlerError::NotFound(msg.order_id.clone()))?; + saga.inventory_released()?; + saga.cancel()?; + + let mut out = fulfillment::fulfillment_event( + event::CANCEL_ORDER, + &FulfillmentMsg { + order_id: msg.order_id.clone(), + ..Default::default() + }, + ); + ctx.repo().outbox(&mut out).commit(&mut saga)?; + + Ok(json!({ "order_id": msg.order_id })) +} diff --git a/tests/distributed_read_model/order_fulfillment_saga_service/handlers/on_inventory_reserved.rs b/tests/distributed_read_model/order_fulfillment_saga_service/handlers/on_inventory_reserved.rs new file mode 100644 index 0000000..40026c7 --- /dev/null +++ b/tests/distributed_read_model/order_fulfillment_saga_service/handlers/on_inventory_reserved.rs @@ -0,0 +1,35 @@ +use serde_json::{json, Value}; +use sourced_rust::microsvc::{Context, HandlerError}; +use sourced_rust::OutboxCommitExt; + +use crate::fulfillment::{self, event, FulfillmentMsg}; +use crate::order_fulfillment_saga_service::{OrderFulfillmentSaga, SagaRepo}; + +pub const COMMAND: &str = event::INVENTORY_RESERVED; + +pub fn guard(ctx: &Context) -> bool { + ctx.has_fields(&["order_id"]) +} + +pub fn handle(ctx: &Context) -> Result { + let msg = ctx.input::()?; + + let mut saga: OrderFulfillmentSaga = ctx + .repo() + .get(&msg.order_id)? + .ok_or_else(|| HandlerError::NotFound(msg.order_id.clone()))?; + saga.inventory_reserved()?; + let amount_cents = saga.amount_cents; + + let mut out = fulfillment::fulfillment_event( + event::CHARGE_PAYMENT, + &FulfillmentMsg { + order_id: msg.order_id.clone(), + amount_cents, + ..Default::default() + }, + ); + ctx.repo().outbox(&mut out).commit(&mut saga)?; + + Ok(json!({ "order_id": msg.order_id })) +} diff --git a/tests/distributed_read_model/order_fulfillment_saga_service/handlers/on_payment_declined.rs b/tests/distributed_read_model/order_fulfillment_saga_service/handlers/on_payment_declined.rs new file mode 100644 index 0000000..c7adce7 --- /dev/null +++ b/tests/distributed_read_model/order_fulfillment_saga_service/handlers/on_payment_declined.rs @@ -0,0 +1,36 @@ +use serde_json::{json, Value}; +use sourced_rust::microsvc::{Context, HandlerError}; +use sourced_rust::OutboxCommitExt; + +use crate::fulfillment::{self, event, FulfillmentMsg}; +use crate::order_fulfillment_saga_service::{OrderFulfillmentSaga, SagaRepo}; + +pub const COMMAND: &str = event::PAYMENT_DECLINED; + +pub fn guard(ctx: &Context) -> bool { + ctx.has_fields(&["order_id"]) +} + +pub fn handle(ctx: &Context) -> Result { + let msg = ctx.input::()?; + + let mut saga: OrderFulfillmentSaga = ctx + .repo() + .get(&msg.order_id)? + .ok_or_else(|| HandlerError::NotFound(msg.order_id.clone()))?; + saga.compensate(msg.detail.clone())?; + let (sku, quantity) = (saga.sku.clone(), saga.quantity); + + let mut out = fulfillment::fulfillment_event( + event::RELEASE_INVENTORY, + &FulfillmentMsg { + order_id: msg.order_id.clone(), + sku, + quantity, + ..Default::default() + }, + ); + ctx.repo().outbox(&mut out).commit(&mut saga)?; + + Ok(json!({ "order_id": msg.order_id })) +} diff --git a/tests/distributed_read_model/order_fulfillment_saga_service/handlers/on_payment_succeeded.rs b/tests/distributed_read_model/order_fulfillment_saga_service/handlers/on_payment_succeeded.rs new file mode 100644 index 0000000..42a9c3b --- /dev/null +++ b/tests/distributed_read_model/order_fulfillment_saga_service/handlers/on_payment_succeeded.rs @@ -0,0 +1,33 @@ +use serde_json::{json, Value}; +use sourced_rust::microsvc::{Context, HandlerError}; +use sourced_rust::OutboxCommitExt; + +use crate::fulfillment::{self, event, FulfillmentMsg}; +use crate::order_fulfillment_saga_service::{OrderFulfillmentSaga, SagaRepo}; + +pub const COMMAND: &str = event::PAYMENT_SUCCEEDED; + +pub fn guard(ctx: &Context) -> bool { + ctx.has_fields(&["order_id"]) +} + +pub fn handle(ctx: &Context) -> Result { + let msg = ctx.input::()?; + + let mut saga: OrderFulfillmentSaga = ctx + .repo() + .get(&msg.order_id)? + .ok_or_else(|| HandlerError::NotFound(msg.order_id.clone()))?; + saga.complete()?; + + let mut out = fulfillment::fulfillment_event( + event::CONFIRM_ORDER, + &FulfillmentMsg { + order_id: msg.order_id.clone(), + ..Default::default() + }, + ); + ctx.repo().outbox(&mut out).commit(&mut saga)?; + + Ok(json!({ "order_id": msg.order_id })) +} diff --git a/tests/distributed_read_model/order_fulfillment_saga_service/handlers/on_requested.rs b/tests/distributed_read_model/order_fulfillment_saga_service/handlers/on_requested.rs new file mode 100644 index 0000000..9c95d1f --- /dev/null +++ b/tests/distributed_read_model/order_fulfillment_saga_service/handlers/on_requested.rs @@ -0,0 +1,37 @@ +use serde_json::{json, Value}; +use sourced_rust::microsvc::{Context, HandlerError}; +use sourced_rust::OutboxCommitExt; + +use crate::fulfillment::{self, event, FulfillmentMsg}; +use crate::order_fulfillment_saga_service::{OrderFulfillmentSaga, SagaRepo}; + +pub const COMMAND: &str = event::REQUESTED; + +pub fn guard(ctx: &Context) -> bool { + ctx.has_fields(&["order_id", "sku", "quantity", "amount_cents"]) +} + +pub fn handle(ctx: &Context) -> Result { + let msg = ctx.input::()?; + + let mut saga = OrderFulfillmentSaga::default(); + saga.start( + msg.order_id.clone(), + msg.sku.clone(), + msg.quantity, + msg.amount_cents, + )?; + + let mut out = fulfillment::fulfillment_event( + event::RESERVE_INVENTORY, + &FulfillmentMsg { + order_id: msg.order_id.clone(), + sku: msg.sku.clone(), + quantity: msg.quantity, + ..Default::default() + }, + ); + ctx.repo().outbox(&mut out).commit(&mut saga)?; + + Ok(json!({ "order_id": msg.order_id })) +} diff --git a/tests/distributed_read_model/order_fulfillment_saga_service/mod.rs b/tests/distributed_read_model/order_fulfillment_saga_service/mod.rs new file mode 100644 index 0000000..7502a2e --- /dev/null +++ b/tests/distributed_read_model/order_fulfillment_saga_service/mod.rs @@ -0,0 +1,16 @@ +//! Order fulfillment saga orchestrator. A `microsvc::Service` whose handlers +//! react to fulfillment result events and publish the next step's command — +//! reserve → charge → confirm; on decline → release → cancel. Just another +//! service combining message subscribe/publish, with the saga as its model. + +pub mod models; + +mod handlers; +mod service; + +use sourced_rust::{AggregateRepository, HashMapRepository, QueuedRepository}; + +pub use models::OrderFulfillmentSaga; +pub use service::model_service; + +pub type SagaRepo = AggregateRepository, OrderFulfillmentSaga>; diff --git a/tests/distributed_read_model/order_fulfillment_saga_service/models/mod.rs b/tests/distributed_read_model/order_fulfillment_saga_service/models/mod.rs new file mode 100644 index 0000000..7f5e600 --- /dev/null +++ b/tests/distributed_read_model/order_fulfillment_saga_service/models/mod.rs @@ -0,0 +1,3 @@ +pub mod saga; + +pub use saga::OrderFulfillmentSaga; diff --git a/tests/distributed_read_model/order_fulfillment_saga_service/models/saga.rs b/tests/distributed_read_model/order_fulfillment_saga_service/models/saga.rs new file mode 100644 index 0000000..fd1e6e9 --- /dev/null +++ b/tests/distributed_read_model/order_fulfillment_saga_service/models/saga.rs @@ -0,0 +1,54 @@ +use sourced_rust::{sourced, Entity, Snapshot}; + +/// Order fulfillment saga, keyed by order id. Tracks enough state to drive the +/// next step and to compensate. Internal digests are PascalCase (replay names); +/// the bus-facing steps are the lowercase `fulfillment.*` events. +#[derive(Default, Snapshot)] +pub struct OrderFulfillmentSaga { + pub entity: Entity, + pub order_id: String, + pub sku: String, + pub quantity: i64, + pub amount_cents: i64, + pub status: String, + pub inventory_reserved: bool, +} + +#[sourced(entity)] +impl OrderFulfillmentSaga { + #[event("SagaStarted")] + pub fn start(&mut self, order_id: String, sku: String, quantity: i64, amount_cents: i64) { + self.entity.set_id(&order_id); + self.order_id = order_id; + self.sku = sku; + self.quantity = quantity; + self.amount_cents = amount_cents; + self.status = "started".to_string(); + } + + #[event("SagaInventoryReserved")] + pub fn inventory_reserved(&mut self) { + self.status = "inventory_reserved".to_string(); + self.inventory_reserved = true; + } + + #[event("SagaCompleted")] + pub fn complete(&mut self) { + self.status = "completed".to_string(); + } + + #[event("SagaCompensating")] + pub fn compensate(&mut self, reason: String) { + self.status = format!("compensating: {reason}"); + } + + #[event("SagaInventoryReleased")] + pub fn inventory_released(&mut self) { + self.inventory_reserved = false; + } + + #[event("SagaCancelled")] + pub fn cancel(&mut self) { + self.status = "cancelled".to_string(); + } +} diff --git a/tests/distributed_read_model/order_fulfillment_saga_service/service.rs b/tests/distributed_read_model/order_fulfillment_saga_service/service.rs new file mode 100644 index 0000000..3b6ecab --- /dev/null +++ b/tests/distributed_read_model/order_fulfillment_saga_service/service.rs @@ -0,0 +1,16 @@ +use std::sync::Arc; + +use sourced_rust::microsvc::Service; + +use super::{handlers, SagaRepo}; + +pub fn model_service(repo: SagaRepo) -> Arc> { + Arc::new(sourced_rust::register_handlers!( + Service::new(repo), + handlers::on_requested, + handlers::on_inventory_reserved, + handlers::on_payment_succeeded, + handlers::on_payment_declined, + handlers::on_inventory_released, + )) +} diff --git a/tests/distributed_read_model/order_service/handlers/mod.rs b/tests/distributed_read_model/order_service/handlers/mod.rs new file mode 100644 index 0000000..8b365d6 --- /dev/null +++ b/tests/distributed_read_model/order_service/handlers/mod.rs @@ -0,0 +1,7 @@ +pub(super) mod order_add_line; +pub(super) mod order_cancel; +pub(super) mod order_change_quantity; +pub(super) mod order_confirm; +pub(super) mod order_place; +pub(super) mod order_remove_line; +pub(super) mod order_submit; diff --git a/tests/distributed_read_model/order_service/handlers/order_add_line.rs b/tests/distributed_read_model/order_service/handlers/order_add_line.rs new file mode 100644 index 0000000..b33199b --- /dev/null +++ b/tests/distributed_read_model/order_service/handlers/order_add_line.rs @@ -0,0 +1,34 @@ +use serde_json::{json, Value}; +use sourced_rust::microsvc::{Context, HandlerError}; +use sourced_rust::{OutboxCommitExt, OutboxMessage}; + +use crate::order_service::{AddLine, Order, OrderRepo}; + +pub const COMMAND: &str = "order.add_line"; + +pub fn guard(ctx: &Context) -> bool { + ctx.has_fields(&["id", "sku", "product_id", "unit_cents", "quantity"]) +} + +pub fn handle(ctx: &Context) -> Result { + let input = ctx.input::()?; + if input.quantity <= 0 || input.unit_cents < 0 { + return Err(HandlerError::Rejected("invalid line".to_string())); + } + + let mut order: Order = ctx + .repo() + .get(&input.id)? + .ok_or_else(|| HandlerError::NotFound(input.id.clone()))?; + order.add_line( + input.sku.clone(), + input.product_id.clone(), + input.unit_cents, + input.quantity, + )?; + + let mut outbox = OutboxMessage::domain_event("order.line_added", &order)?; + ctx.repo().outbox(&mut outbox).commit(&mut order)?; + + Ok(json!({ "id": input.id, "sku": input.sku })) +} diff --git a/tests/distributed_read_model/order_service/handlers/order_cancel.rs b/tests/distributed_read_model/order_service/handlers/order_cancel.rs new file mode 100644 index 0000000..11b9407 --- /dev/null +++ b/tests/distributed_read_model/order_service/handlers/order_cancel.rs @@ -0,0 +1,31 @@ +//! Reacts to the saga's `fulfillment.cancel_order` decision: transitions the +//! `Order` aggregate and publishes `order.cancelled` (a bitcode snapshot) so the +//! order projector updates `OrderView.status`. + +use serde_json::{json, Value}; +use sourced_rust::microsvc::{Context, HandlerError}; +use sourced_rust::{OutboxCommitExt, OutboxMessage}; + +use crate::fulfillment::{event, FulfillmentMsg}; +use crate::order_service::{Order, OrderRepo}; + +pub const COMMAND: &str = event::CANCEL_ORDER; + +pub fn guard(ctx: &Context) -> bool { + ctx.has_fields(&["order_id"]) +} + +pub fn handle(ctx: &Context) -> Result { + let msg = ctx.input::()?; + + let mut order: Order = ctx + .repo() + .get(&msg.order_id)? + .ok_or_else(|| HandlerError::NotFound(msg.order_id.clone()))?; + order.cancel()?; + + let mut out = OutboxMessage::domain_event("order.cancelled", &order)?; + ctx.repo().outbox(&mut out).commit(&mut order)?; + + Ok(json!({ "order_id": msg.order_id })) +} diff --git a/tests/distributed_read_model/order_service/handlers/order_change_quantity.rs b/tests/distributed_read_model/order_service/handlers/order_change_quantity.rs new file mode 100644 index 0000000..e58c9dd --- /dev/null +++ b/tests/distributed_read_model/order_service/handlers/order_change_quantity.rs @@ -0,0 +1,31 @@ +use serde_json::{json, Value}; +use sourced_rust::microsvc::{Context, HandlerError}; +use sourced_rust::{OutboxCommitExt, OutboxMessage}; + +use crate::order_service::{ChangeQuantity, Order, OrderRepo}; + +pub const COMMAND: &str = "order.change_quantity"; + +pub fn guard(ctx: &Context) -> bool { + ctx.has_fields(&["id", "sku", "quantity"]) +} + +pub fn handle(ctx: &Context) -> Result { + let input = ctx.input::()?; + if input.quantity <= 0 { + return Err(HandlerError::Rejected( + "quantity must be positive".to_string(), + )); + } + + let mut order: Order = ctx + .repo() + .get(&input.id)? + .ok_or_else(|| HandlerError::NotFound(input.id.clone()))?; + order.change_quantity(input.sku.clone(), input.quantity)?; + + let mut outbox = OutboxMessage::domain_event("order.line_quantity_changed", &order)?; + ctx.repo().outbox(&mut outbox).commit(&mut order)?; + + Ok(json!({ "id": input.id, "sku": input.sku })) +} diff --git a/tests/distributed_read_model/order_service/handlers/order_confirm.rs b/tests/distributed_read_model/order_service/handlers/order_confirm.rs new file mode 100644 index 0000000..e0fdc0e --- /dev/null +++ b/tests/distributed_read_model/order_service/handlers/order_confirm.rs @@ -0,0 +1,31 @@ +//! Reacts to the saga's `fulfillment.confirm_order` decision: transitions the +//! `Order` aggregate and publishes `order.confirmed` (a bitcode snapshot) so the +//! order projector updates `OrderView.status`. + +use serde_json::{json, Value}; +use sourced_rust::microsvc::{Context, HandlerError}; +use sourced_rust::{OutboxCommitExt, OutboxMessage}; + +use crate::fulfillment::{event, FulfillmentMsg}; +use crate::order_service::{Order, OrderRepo}; + +pub const COMMAND: &str = event::CONFIRM_ORDER; + +pub fn guard(ctx: &Context) -> bool { + ctx.has_fields(&["order_id"]) +} + +pub fn handle(ctx: &Context) -> Result { + let msg = ctx.input::()?; + + let mut order: Order = ctx + .repo() + .get(&msg.order_id)? + .ok_or_else(|| HandlerError::NotFound(msg.order_id.clone()))?; + order.confirm()?; + + let mut out = OutboxMessage::domain_event("order.confirmed", &order)?; + ctx.repo().outbox(&mut out).commit(&mut order)?; + + Ok(json!({ "order_id": msg.order_id })) +} diff --git a/tests/distributed_read_model/order_service/handlers/order_place.rs b/tests/distributed_read_model/order_service/handlers/order_place.rs new file mode 100644 index 0000000..eb7592e --- /dev/null +++ b/tests/distributed_read_model/order_service/handlers/order_place.rs @@ -0,0 +1,29 @@ +use serde_json::{json, Value}; +use sourced_rust::microsvc::{Context, HandlerError}; +use sourced_rust::{OutboxCommitExt, OutboxMessage}; + +use crate::order_service::{Order, OrderRepo, PlaceOrder}; + +pub const COMMAND: &str = "order.place"; + +pub fn guard(ctx: &Context) -> bool { + ctx.has_fields(&["id", "customer"]) +} + +pub fn handle(ctx: &Context) -> Result { + let input = ctx.input::()?; + if ctx.repo().peek(&input.id)?.is_some() { + return Err(HandlerError::Rejected(format!( + "order {} already exists", + input.id + ))); + } + + let mut order = Order::default(); + order.place(input.id.clone(), input.customer.clone())?; + + let mut outbox = OutboxMessage::domain_event("order.placed", &order)?; + ctx.repo().outbox(&mut outbox).commit(&mut order)?; + + Ok(json!({ "id": input.id })) +} diff --git a/tests/distributed_read_model/order_service/handlers/order_remove_line.rs b/tests/distributed_read_model/order_service/handlers/order_remove_line.rs new file mode 100644 index 0000000..f4f21fd --- /dev/null +++ b/tests/distributed_read_model/order_service/handlers/order_remove_line.rs @@ -0,0 +1,26 @@ +use serde_json::{json, Value}; +use sourced_rust::microsvc::{Context, HandlerError}; +use sourced_rust::{OutboxCommitExt, OutboxMessage}; + +use crate::order_service::{Order, OrderRepo, RemoveLine}; + +pub const COMMAND: &str = "order.remove_line"; + +pub fn guard(ctx: &Context) -> bool { + ctx.has_fields(&["id", "sku"]) +} + +pub fn handle(ctx: &Context) -> Result { + let input = ctx.input::()?; + + let mut order: Order = ctx + .repo() + .get(&input.id)? + .ok_or_else(|| HandlerError::NotFound(input.id.clone()))?; + order.remove_line(input.sku.clone())?; + + let mut outbox = OutboxMessage::domain_event("order.line_removed", &order)?; + ctx.repo().outbox(&mut outbox).commit(&mut order)?; + + Ok(json!({ "id": input.id, "sku": input.sku })) +} diff --git a/tests/distributed_read_model/order_service/handlers/order_submit.rs b/tests/distributed_read_model/order_service/handlers/order_submit.rs new file mode 100644 index 0000000..e6413cc --- /dev/null +++ b/tests/distributed_read_model/order_service/handlers/order_submit.rs @@ -0,0 +1,26 @@ +use serde_json::{json, Value}; +use sourced_rust::microsvc::{Context, HandlerError}; +use sourced_rust::{OutboxCommitExt, OutboxMessage}; + +use crate::order_service::{Order, OrderRepo, SubmitOrder}; + +pub const COMMAND: &str = "order.submit"; + +pub fn guard(ctx: &Context) -> bool { + ctx.has_fields(&["id"]) +} + +pub fn handle(ctx: &Context) -> Result { + let input = ctx.input::()?; + + let mut order: Order = ctx + .repo() + .get(&input.id)? + .ok_or_else(|| HandlerError::NotFound(input.id.clone()))?; + order.submit()?; + + let mut outbox = OutboxMessage::domain_event("order.submitted", &order)?; + ctx.repo().outbox(&mut outbox).commit(&mut order)?; + + Ok(json!({ "id": input.id, "status": order.status })) +} diff --git a/tests/distributed_read_model/order_service/mod.rs b/tests/distributed_read_model/order_service/mod.rs new file mode 100644 index 0000000..88121e5 --- /dev/null +++ b/tests/distributed_read_model/order_service/mod.rs @@ -0,0 +1,21 @@ +//! Order write service: owns the `Order` aggregate, which holds its line items +//! as aggregate state. Every command publishes the full order snapshot through +//! the outbox so the projector can reconcile normalized rows. + +pub mod models; + +mod handlers; +mod service; + +use sourced_rust::{AggregateRepository, HashMapRepository, QueuedRepository}; + +pub use models::{ + AddLine, ChangeQuantity, Order, OrderSnapshot, PlaceOrder, RemoveLine, SubmitOrder, +}; +pub use service::model_service; +#[cfg(feature = "grpc")] +pub use service::start_grpc_service; +#[cfg(feature = "http")] +pub use service::start_http_service; + +pub type OrderRepo = AggregateRepository, Order>; diff --git a/tests/distributed_read_model/order_service/models/mod.rs b/tests/distributed_read_model/order_service/models/mod.rs new file mode 100644 index 0000000..451a59d --- /dev/null +++ b/tests/distributed_read_model/order_service/models/mod.rs @@ -0,0 +1,5 @@ +pub mod order; + +pub use order::{ + AddLine, ChangeQuantity, Order, OrderSnapshot, PlaceOrder, RemoveLine, SubmitOrder, +}; diff --git a/tests/distributed_read_model/order_service/models/order.rs b/tests/distributed_read_model/order_service/models/order.rs new file mode 100644 index 0000000..c26d9e9 --- /dev/null +++ b/tests/distributed_read_model/order_service/models/order.rs @@ -0,0 +1,103 @@ +use serde::{Deserialize, Serialize}; +use sourced_rust::{sourced, Entity, Snapshot}; + +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +pub struct OrderLineState { + pub sku: String, + pub product_id: String, + pub unit_cents: i64, + pub quantity: i64, +} + +#[derive(Default, Snapshot)] +pub struct Order { + pub entity: Entity, + pub customer: String, + pub status: String, + pub lines: Vec, +} + +#[sourced(entity)] +impl Order { + #[event("OrderPlaced")] + pub fn place(&mut self, id: String, customer: String) { + self.entity.set_id(&id); + self.customer = customer; + self.status = "open".to_string(); + } + + #[event("LineAdded", when = self.status.as_str() == "open")] + pub fn add_line(&mut self, sku: String, product_id: String, unit_cents: i64, quantity: i64) { + if let Some(line) = self.lines.iter_mut().find(|line| line.sku == sku) { + line.quantity += quantity; + line.unit_cents = unit_cents; + } else { + self.lines.push(OrderLineState { + sku, + product_id, + unit_cents, + quantity, + }); + } + } + + #[event("LineQuantityChanged", when = quantity > 0)] + pub fn change_quantity(&mut self, sku: String, quantity: i64) { + if let Some(line) = self.lines.iter_mut().find(|line| line.sku == sku) { + line.quantity = quantity; + } + } + + #[event("LineRemoved")] + pub fn remove_line(&mut self, sku: String) { + self.lines.retain(|line| line.sku != sku); + } + + #[event("OrderSubmitted", when = self.status.as_str() == "open" && !self.lines.is_empty())] + pub fn submit(&mut self) { + self.status = "submitted".to_string(); + } + + #[event("OrderConfirmed", when = self.status.as_str() == "submitted")] + pub fn confirm(&mut self) { + self.status = "confirmed".to_string(); + } + + #[event("OrderCancelled", when = self.status.as_str() != "cancelled")] + pub fn cancel(&mut self) { + self.status = "cancelled".to_string(); + } +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct PlaceOrder { + pub id: String, + pub customer: String, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct AddLine { + pub id: String, + pub sku: String, + pub product_id: String, + pub unit_cents: i64, + pub quantity: i64, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct ChangeQuantity { + pub id: String, + pub sku: String, + pub quantity: i64, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct RemoveLine { + pub id: String, + pub sku: String, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct SubmitOrder { + pub id: String, +} diff --git a/tests/distributed_read_model/account_service/mod.rs b/tests/distributed_read_model/order_service/service.rs similarity index 74% rename from tests/distributed_read_model/account_service/mod.rs rename to tests/distributed_read_model/order_service/service.rs index 19a4325..fdcd2db 100644 --- a/tests/distributed_read_model/account_service/mod.rs +++ b/tests/distributed_read_model/order_service/service.rs @@ -1,25 +1,24 @@ -pub mod account; -mod handlers; - use std::sync::Arc; use sourced_rust::microsvc::Service; -use sourced_rust::{AggregateRepository, HashMapRepository, QueuedRepository}; - -pub use account::{Account, DepositMoney, OpenAccount}; -pub type AccountRepo = AggregateRepository, Account>; +use super::{handlers, OrderRepo}; -pub fn model_service(repo: AccountRepo) -> Arc> { +pub fn model_service(repo: OrderRepo) -> Arc> { Arc::new(sourced_rust::register_handlers!( Service::new(repo), - handlers::account_open, - handlers::account_deposit, + handlers::order_place, + handlers::order_add_line, + handlers::order_change_quantity, + handlers::order_remove_line, + handlers::order_submit, + handlers::order_confirm, + handlers::order_cancel, )) } #[cfg(feature = "http")] -pub async fn start_http_service(service: Arc>) -> String { +pub async fn start_http_service(service: Arc>) -> String { let app = sourced_rust::microsvc::router(service); let listener = tokio::net::TcpListener::bind("127.0.0.1:0") .await @@ -39,7 +38,7 @@ pub async fn start_http_service(service: Arc>) -> String { #[cfg(feature = "grpc")] pub async fn start_grpc_service( - service: Arc>, + service: Arc>, ) -> sourced_rust::microsvc::grpc::CommandServiceClient { let listener = tokio::net::TcpListener::bind("127.0.0.1:0") .await diff --git a/tests/distributed_read_model/payment_service/handlers/charge.rs b/tests/distributed_read_model/payment_service/handlers/charge.rs new file mode 100644 index 0000000..88b620d --- /dev/null +++ b/tests/distributed_read_model/payment_service/handlers/charge.rs @@ -0,0 +1,44 @@ +use serde_json::{json, Value}; +use sourced_rust::microsvc::{Context, HandlerError}; +use sourced_rust::OutboxCommitExt; + +use crate::fulfillment::{self, event, FulfillmentMsg}; +use crate::payment_service::PaymentRepo; + +pub const COMMAND: &str = event::CHARGE_PAYMENT; + +/// Amounts over this cap are declined (drives the compensation path). +const PAYMENT_CAP_CENTS: i64 = 100_000; + +pub fn guard(ctx: &Context) -> bool { + ctx.has_fields(&["order_id", "amount_cents"]) +} + +pub fn handle(ctx: &Context) -> Result { + let msg = ctx.input::()?; + let mut payment = ctx.repo().get(&msg.order_id)?.unwrap_or_default(); + + let mut out = if msg.amount_cents <= PAYMENT_CAP_CENTS { + payment.charge(msg.order_id.clone(), msg.amount_cents)?; + fulfillment::fulfillment_event( + event::PAYMENT_SUCCEEDED, + &FulfillmentMsg { + order_id: msg.order_id.clone(), + ..Default::default() + }, + ) + } else { + payment.decline(msg.order_id.clone(), "amount over limit".to_string())?; + fulfillment::fulfillment_event( + event::PAYMENT_DECLINED, + &FulfillmentMsg { + order_id: msg.order_id.clone(), + detail: "amount over limit".to_string(), + ..Default::default() + }, + ) + }; + ctx.repo().outbox(&mut out).commit(&mut payment)?; + + Ok(json!({ "order_id": msg.order_id })) +} diff --git a/tests/distributed_read_model/payment_service/handlers/mod.rs b/tests/distributed_read_model/payment_service/handlers/mod.rs new file mode 100644 index 0000000..76686ed --- /dev/null +++ b/tests/distributed_read_model/payment_service/handlers/mod.rs @@ -0,0 +1 @@ +pub(super) mod charge; diff --git a/tests/distributed_read_model/payment_service/mod.rs b/tests/distributed_read_model/payment_service/mod.rs new file mode 100644 index 0000000..2a412ed --- /dev/null +++ b/tests/distributed_read_model/payment_service/mod.rs @@ -0,0 +1,15 @@ +//! Payment write service: charges the order amount, declining over a cap to +//! drive the saga's compensation path. A `microsvc::Service` reacting to +//! `fulfillment.charge_payment`. + +pub mod models; + +mod handlers; +mod service; + +use sourced_rust::{AggregateRepository, HashMapRepository, QueuedRepository}; + +pub use models::Payment; +pub use service::model_service; + +pub type PaymentRepo = AggregateRepository, Payment>; diff --git a/tests/distributed_read_model/payment_service/models/mod.rs b/tests/distributed_read_model/payment_service/models/mod.rs new file mode 100644 index 0000000..5235330 --- /dev/null +++ b/tests/distributed_read_model/payment_service/models/mod.rs @@ -0,0 +1,3 @@ +pub mod payment; + +pub use payment::Payment; diff --git a/tests/distributed_read_model/payment_service/models/payment.rs b/tests/distributed_read_model/payment_service/models/payment.rs new file mode 100644 index 0000000..f14eec8 --- /dev/null +++ b/tests/distributed_read_model/payment_service/models/payment.rs @@ -0,0 +1,26 @@ +use sourced_rust::{sourced, Entity, Snapshot}; + +/// Payment per order. Declines amounts over the cap, which drives the saga's +/// compensation path. +#[derive(Default, Snapshot)] +pub struct Payment { + pub entity: Entity, + pub amount_cents: i64, + pub status: String, +} + +#[sourced(entity)] +impl Payment { + #[event("PaymentCharged")] + pub fn charge(&mut self, order_id: String, amount_cents: i64) { + self.entity.set_id(&order_id); + self.amount_cents = amount_cents; + self.status = "charged".to_string(); + } + + #[event("PaymentDeclined")] + pub fn decline(&mut self, order_id: String, reason: String) { + self.entity.set_id(&order_id); + self.status = format!("declined: {reason}"); + } +} diff --git a/tests/distributed_read_model/payment_service/service.rs b/tests/distributed_read_model/payment_service/service.rs new file mode 100644 index 0000000..307b24c --- /dev/null +++ b/tests/distributed_read_model/payment_service/service.rs @@ -0,0 +1,12 @@ +use std::sync::Arc; + +use sourced_rust::microsvc::Service; + +use super::{handlers, PaymentRepo}; + +pub fn model_service(repo: PaymentRepo) -> Arc> { + Arc::new(sourced_rust::register_handlers!( + Service::new(repo), + handlers::charge, + )) +} diff --git a/tests/distributed_read_model/projections_service/handlers/fulfillment.rs b/tests/distributed_read_model/projections_service/handlers/fulfillment.rs new file mode 100644 index 0000000..174186f --- /dev/null +++ b/tests/distributed_read_model/projections_service/handlers/fulfillment.rs @@ -0,0 +1,42 @@ +//! Projects saga progress into `order_fulfillment_steps` (a `has_many` child of +//! `OrderView`). Owns only the steps table — disjoint from the order handler, so +//! there is no optimistic-version contention on the order row. + +use sourced_rust::bus::Event; +use sourced_rust::{InMemoryReadModelStore, ReadModelCommitOutcome, ReadModelUnitOfWorkExt}; + +use crate::fulfillment::{self, event}; +use crate::read_models::OrderFulfillmentStepView; + +pub const CONSUMER: &str = "order-fulfillment-projection"; +pub const EVENTS: &[&str] = &[ + event::REQUESTED, + event::INVENTORY_RESERVED, + event::PAYMENT_SUCCEEDED, + event::PAYMENT_DECLINED, + event::INVENTORY_RELEASED, +]; + +pub fn handle(store: &InMemoryReadModelStore, evt: &Event) -> ReadModelCommitOutcome { + let msg = fulfillment::decode(evt); + let step = evt + .event_type + .strip_prefix("fulfillment.") + .unwrap_or(&evt.event_type) + .to_string(); + + let row = OrderFulfillmentStepView { + order_id: msg.order_id.clone(), + step, + detail: msg.detail.clone(), + }; + + let mut session = store.session(); + session + .save(&row) + .expect("fulfillment step save should stage") + .mark_processed(CONSUMER, &evt.id); + session + .commit() + .expect("fulfillment projection should commit") +} diff --git a/tests/distributed_read_model/projections_service/handlers/mod.rs b/tests/distributed_read_model/projections_service/handlers/mod.rs new file mode 100644 index 0000000..756f61f --- /dev/null +++ b/tests/distributed_read_model/projections_service/handlers/mod.rs @@ -0,0 +1,33 @@ +//! Projection handlers. Commands and events are both just messages, so the +//! projection service dispatches each event by type to the handler that owns the +//! matching read-model rows. + +pub mod fulfillment; +pub mod order; +pub mod product; + +use sourced_rust::bus::Event; +use sourced_rust::{InMemoryReadModelStore, ReadModelCommitOutcome}; + +/// Every event type any projection handler consumes. +pub fn event_types() -> Vec<&'static str> { + let mut types = Vec::new(); + types.extend_from_slice(product::EVENTS); + types.extend_from_slice(order::EVENTS); + types.extend_from_slice(fulfillment::EVENTS); + types +} + +/// Route one event to the handler that owns its rows. +pub fn project(store: &InMemoryReadModelStore, event: &Event) -> Option { + let event_type = event.event_type.as_str(); + if product::EVENTS.contains(&event_type) { + Some(product::handle(store, event)) + } else if order::EVENTS.contains(&event_type) { + Some(order::handle(store, event)) + } else if fulfillment::EVENTS.contains(&event_type) { + Some(fulfillment::handle(store, event)) + } else { + None + } +} diff --git a/tests/distributed_read_model/projections_service/handlers/order.rs b/tests/distributed_read_model/projections_service/handlers/order.rs new file mode 100644 index 0000000..fa0c8ec --- /dev/null +++ b/tests/distributed_read_model/projections_service/handlers/order.rs @@ -0,0 +1,101 @@ +//! Projects order events into `orders` + `order_lines`. The order snapshot is +//! the desired state, so `save_changes` collection sync reflects added/changed/ +//! removed lines as inserts/patches/deletes. A monotonic `source_version` guard +//! ignores stale snapshots under out-of-order delivery. Owns only `orders` and +//! `order_lines` (never `order_fulfillment_steps`), so it includes `lines` only. + +use std::collections::BTreeMap; + +use sourced_rust::bus::Event; +use sourced_rust::{InMemoryReadModelStore, ReadModelCommitOutcome, ReadModelUnitOfWorkExt}; + +use crate::order_service::OrderSnapshot; +use crate::read_models::{order_key, OrderLineView, OrderView}; + +pub const CONSUMER: &str = "order-detail-projection"; +pub const EVENTS: &[&str] = &[ + "order.placed", + "order.line_added", + "order.line_quantity_changed", + "order.line_removed", + "order.submitted", + "order.confirmed", + "order.cancelled", +]; + +pub fn handle(store: &InMemoryReadModelStore, event: &Event) -> ReadModelCommitOutcome { + let snapshot: OrderSnapshot = event.decode().expect("order snapshot should decode"); + let version = event_version(event); + let desired = desired_order_view(&snapshot, version); + + let mut session = store.session(); + let existing = session + .load::(order_key(&desired.order_id)) + .include("lines") + .one() + .expect("order load should succeed"); + + match existing { + Some(current) if current.data.source_version >= version => {} + Some(_) => { + session + .save_changes(desired) + .expect("order save_changes should stage"); + } + None => { + session + .save(&desired) + .expect("order root save should stage"); + for line in &desired.lines { + session.save(line).expect("order line save should stage"); + } + } + } + + session.mark_processed(CONSUMER, &event.id); + session.commit().expect("order projection should commit") +} + +fn desired_order_view(snapshot: &OrderSnapshot, version: i64) -> OrderView { + let lines: Vec = snapshot + .lines + .iter() + .map(|line| OrderLineView { + order_id: snapshot.id.clone(), + sku: line.sku.clone(), + product_id: line.product_id.clone(), + quantity: line.quantity, + line_total_cents: line.unit_cents * line.quantity, + product: None, + }) + .collect(); + let total_cents = lines.iter().map(|line| line.line_total_cents).sum(); + + let mut metadata = BTreeMap::new(); + metadata.insert("source".to_string(), "order-service".to_string()); + metadata.insert("line_count".to_string(), lines.len().to_string()); + + OrderView { + order_id: snapshot.id.clone(), + customer: snapshot.customer.clone(), + status: snapshot.status.clone(), + source_version: version, + total_cents, + metadata, + lines, + // Owned by the fulfillment projector; the order projector never includes + // or reconciles this relationship, so leaving it empty is correct. + fulfillment_steps: Vec::new(), + } +} + +/// The aggregate version is the trailing segment of the outbox event id +/// (`outbox:::`). +pub(super) fn event_version(event: &Event) -> i64 { + event + .id + .rsplit(':') + .next() + .and_then(|raw| raw.parse().ok()) + .unwrap_or(0) +} diff --git a/tests/distributed_read_model/projections_service/handlers/product.rs b/tests/distributed_read_model/projections_service/handlers/product.rs new file mode 100644 index 0000000..53c56b3 --- /dev/null +++ b/tests/distributed_read_model/projections_service/handlers/product.rs @@ -0,0 +1,30 @@ +use std::collections::BTreeMap; + +use sourced_rust::bus::Event; +use sourced_rust::{InMemoryReadModelStore, ReadModelCommitOutcome, ReadModelUnitOfWorkExt}; + +use crate::catalog_service::ProductSnapshot; +use crate::read_models::ProductView; + +pub const CONSUMER: &str = "product-catalog-projection"; +pub const EVENTS: &[&str] = &["product.added", "product.repriced"]; + +pub fn handle(store: &InMemoryReadModelStore, event: &Event) -> ReadModelCommitOutcome { + let snapshot: ProductSnapshot = event.decode().expect("product snapshot should decode"); + + let mut attributes = BTreeMap::new(); + attributes.insert("category".to_string(), "general".to_string()); + let view = ProductView { + product_id: snapshot.id.clone(), + name: snapshot.name.clone(), + unit_cents: snapshot.unit_cents, + attributes, + }; + + let mut session = store.session(); + session + .save(&view) + .expect("product projection should stage upsert") + .mark_processed(CONSUMER, &event.id); + session.commit().expect("product projection should commit") +} diff --git a/tests/distributed_read_model/projections_service/mod.rs b/tests/distributed_read_model/projections_service/mod.rs index a902ab7..b23454b 100644 --- a/tests/distributed_read_model/projections_service/mod.rs +++ b/tests/distributed_read_model/projections_service/mod.rs @@ -1,16 +1,20 @@ -use std::sync::mpsc::{self, TryRecvError}; -use std::thread; -use std::time::{Duration, Instant}; +//! One projection service. It subscribes to every event type the projection +//! handlers consume and dispatches each event (a message) to the handler that +//! owns the matching read-model rows. Handlers mark messages processed in the +//! same commit for idempotency. + +mod handlers; -use sourced_rust::bus::{Bus, Event}; -use sourced_rust::{ - InMemoryQueue, InMemoryReadModelStore, ReadModelCommitOutcome, ReadModelSession, ReadModelStore, -}; +pub use handlers::fulfillment::CONSUMER as FULFILLMENT_CONSUMER; +pub use handlers::order::CONSUMER as ORDER_CONSUMER; +pub use handlers::product::CONSUMER as CATALOG_CONSUMER; -use crate::account_service::account::AccountSnapshot; -use crate::query_service::AccountSummary; +use std::sync::mpsc::{self, TryRecvError}; +use std::thread; +use std::time::Duration; -pub const ACCOUNT_SUMMARY_CONSUMER: &str = "account-summary-projection"; +use sourced_rust::bus::Bus; +use sourced_rust::{InMemoryQueue, InMemoryReadModelStore}; pub struct ProjectionServiceHandle { stop_tx: mpsc::Sender<()>, @@ -26,7 +30,7 @@ impl ProjectionServiceHandle { } } -pub fn start_account_summary_projection_service( +pub fn start_projection_service( queue: InMemoryQueue, store: InMemoryReadModelStore, ) -> ProjectionServiceHandle { @@ -35,7 +39,8 @@ pub fn start_account_summary_projection_service( let handle = thread::spawn(move || { let bus = Bus::from_queue(queue); - let events = bus.subscribe(&["AccountOpened", "MoneyDeposited"]); + let event_types = handlers::event_types(); + let events = bus.subscribe(&event_types); ready_tx .send(()) .expect("projection service should signal readiness"); @@ -48,7 +53,7 @@ pub fn start_account_summary_projection_service( match events.recv(10) { Ok(Some(event)) => { - project_account_summary(&store, &event); + handlers::project(&store, &event); events .ack(&event.id) .expect("projection service should ack projected events"); @@ -65,66 +70,3 @@ pub fn start_account_summary_projection_service( ProjectionServiceHandle { stop_tx, handle } } - -fn load_summary(store: &InMemoryReadModelStore, account_id: &str) -> AccountSummary { - store - .get_by_primary_key::(account_id) - .expect("read model load should succeed") - .map(|view| view.data) - .unwrap_or_else(|| AccountSummary::empty(account_id)) -} - -fn project_account_summary( - store: &InMemoryReadModelStore, - event: &Event, -) -> ReadModelCommitOutcome { - let snapshot: AccountSnapshot = event.decode().expect("account snapshot should decode"); - let mut summary = load_summary(store, &snapshot.id); - - match event.event_type.as_str() { - "AccountOpened" => { - summary.owner = Some(snapshot.owner); - } - "MoneyDeposited" => { - summary.owner = Some(snapshot.owner); - summary.balance_cents = snapshot.balance_cents; - summary.deposit_count += 1; - } - other => panic!("unexpected account event: {other}"), - } - - let mut session = ReadModelSession::new(); - session - .document(&summary) - .expect("account summary projection should serialize") - .mark_processed(ACCOUNT_SUMMARY_CONSUMER, &event.id); - session - .commit(store) - .expect("account summary projection should persist") -} - -pub fn wait_for_summary( - store: &InMemoryReadModelStore, - account_id: &str, - ready: impl Fn(&AccountSummary) -> bool, -) -> AccountSummary { - let deadline = Instant::now() + Duration::from_secs(10); - - loop { - if let Some(summary) = store - .get_by_primary_key::(account_id) - .expect("read model load should succeed") - .map(|view| view.data) - { - if ready(&summary) { - return summary; - } - } - - assert!( - Instant::now() < deadline, - "timed out waiting for account summary projection" - ); - thread::sleep(Duration::from_millis(10)); - } -} diff --git a/tests/distributed_read_model/query_service/account_summary.rs b/tests/distributed_read_model/query_service/account_summary.rs deleted file mode 100644 index aa0551c..0000000 --- a/tests/distributed_read_model/query_service/account_summary.rs +++ /dev/null @@ -1,25 +0,0 @@ -use serde::{Deserialize, Serialize}; -use sourced_rust::ReadModel; - -#[derive(Clone, Debug, Deserialize, Serialize, ReadModel)] -#[collection("account_summaries")] -pub struct AccountSummary { - #[id] - pub account_id: String, - pub owner: Option, - pub balance_cents: i64, - pub deposit_count: u32, - projected_event_ids: Vec, -} - -impl AccountSummary { - pub fn empty(account_id: &str) -> Self { - Self { - account_id: account_id.to_string(), - owner: None, - balance_cents: 0, - deposit_count: 0, - projected_event_ids: Vec::new(), - } - } -} diff --git a/tests/distributed_read_model/query_service/mod.rs b/tests/distributed_read_model/query_service/mod.rs index d957be8..d590656 100644 --- a/tests/distributed_read_model/query_service/mod.rs +++ b/tests/distributed_read_model/query_service/mod.rs @@ -1,22 +1,49 @@ -use sourced_rust::{InMemoryReadModelStore, ReadModelError, ReadModelStore}; +//! Read-only query service. It owns no aggregate repository; it reads the +//! projected relational tables through primary-key loads plus explicit +//! relationship includes (the in-library equivalent of a Hasura object/array +//! relationship query). -pub mod account_summary; +use sourced_rust::{InMemoryReadModelStore, ReadModelError, ReadModelUnitOfWorkExt}; -pub use account_summary::AccountSummary; +use crate::read_models::{order_key, order_line_key, OrderLineView, OrderView}; #[derive(Clone)] -pub struct AccountSummaryQueryService { +pub struct OrderQueryService { store: InMemoryReadModelStore, } -impl AccountSummaryQueryService { +impl OrderQueryService { pub fn new(store: InMemoryReadModelStore) -> Self { Self { store } } - pub fn get(&self, account_id: &str) -> Result, ReadModelError> { - self.store - .get_by_primary_key::(account_id) - .map(|summary| summary.map(|view| view.data)) + /// Load an order with both its lines and its fulfillment steps (two includes + /// on one root — lines owned by the order projector, steps by the fulfillment + /// projector). + pub fn order_with_lines_and_steps( + &self, + order_id: &str, + ) -> Result, ReadModelError> { + let mut session = self.store.session(); + Ok(session + .load::(order_key(order_id)) + .include("lines") + .include("fulfillment_steps") + .one()? + .map(|view| view.data)) + } + + /// Load one line with its product (`belongs_to` include). + pub fn line_with_product( + &self, + order_id: &str, + sku: &str, + ) -> Result, ReadModelError> { + let mut session = self.store.session(); + Ok(session + .load::(order_line_key(order_id, sku)) + .include("product") + .one()? + .map(|view| view.data)) } } diff --git a/tests/distributed_read_model/read_models/mod.rs b/tests/distributed_read_model/read_models/mod.rs new file mode 100644 index 0000000..2dc8f79 --- /dev/null +++ b/tests/distributed_read_model/read_models/mod.rs @@ -0,0 +1,36 @@ +//! Normalized relational read models shared by the projector and query +//! services. `OrderView` has_many `OrderLineView`; `OrderLineView` belongs_to +//! `ProductView`. A Hasura-style gateway could expose these tables directly as +//! object/array relationships. + +mod order_fulfillment_step_view; +mod order_line_view; +mod order_view; +mod product_view; + +pub use order_fulfillment_step_view::OrderFulfillmentStepView; +pub use order_line_view::OrderLineView; +pub use order_view::OrderView; +pub use product_view::ProductView; + +use sourced_rust::{InMemoryReadModelStore, ReadModelError, RowKey, RowValue}; + +/// Register every relational schema this example reads or projects. +pub fn register_schemas(store: &InMemoryReadModelStore) -> Result<(), ReadModelError> { + store.register_schema::()?; + store.register_schema::()?; + store.register_schema::()?; + store.register_schema::()?; + Ok(()) +} + +pub fn order_key(order_id: &str) -> RowKey { + RowKey::new([("order_id", RowValue::String(order_id.into()))]) +} + +pub fn order_line_key(order_id: &str, sku: &str) -> RowKey { + RowKey::new([ + ("order_id", RowValue::String(order_id.into())), + ("sku", RowValue::String(sku.into())), + ]) +} diff --git a/tests/distributed_read_model/read_models/order_fulfillment_step_view.rs b/tests/distributed_read_model/read_models/order_fulfillment_step_view.rs new file mode 100644 index 0000000..c160cec --- /dev/null +++ b/tests/distributed_read_model/read_models/order_fulfillment_step_view.rs @@ -0,0 +1,14 @@ +use serde::{Deserialize, Serialize}; +use sourced_rust::ReadModel; + +/// One row per saga step, projected from `fulfillment.*` events. Composite +/// primary key `[order_id, step]`; `order_id` is a delegated foreign key from +/// the order. This is the saga's audit trail as a `has_many` child of `OrderView`. +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, ReadModel)] +#[readmodel(table = "order_fulfillment_steps", primary_key = ["order_id", "step"])] +pub struct OrderFulfillmentStepView { + #[readmodel(foreign_key = "orders.order_id", delegated_from = "OrderView.order_id")] + pub order_id: String, + pub step: String, + pub detail: String, +} diff --git a/tests/distributed_read_model/read_models/order_line_view.rs b/tests/distributed_read_model/read_models/order_line_view.rs new file mode 100644 index 0000000..26a68b7 --- /dev/null +++ b/tests/distributed_read_model/read_models/order_line_view.rs @@ -0,0 +1,20 @@ +use serde::{Deserialize, Serialize}; +use sourced_rust::ReadModel; + +use super::ProductView; + +/// One order line. Composite primary key `[order_id, sku]`; `order_id` is a +/// delegated foreign key filled from the parent order, and `product` is a +/// `belongs_to` include resolved against the catalog projection. +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, ReadModel)] +#[readmodel(table = "order_lines", primary_key = ["order_id", "sku"])] +pub struct OrderLineView { + #[readmodel(foreign_key = "orders.order_id", delegated_from = "OrderView.order_id")] + pub order_id: String, + pub sku: String, + pub product_id: String, + pub quantity: i64, + pub line_total_cents: i64, + #[readmodel(belongs_to = "ProductView", foreign_key = "product_id")] + pub product: Option, +} diff --git a/tests/distributed_read_model/read_models/order_view.rs b/tests/distributed_read_model/read_models/order_view.rs new file mode 100644 index 0000000..f1535b5 --- /dev/null +++ b/tests/distributed_read_model/read_models/order_view.rs @@ -0,0 +1,30 @@ +use std::collections::BTreeMap; + +use serde::{Deserialize, Serialize}; +use sourced_rust::ReadModel; + +use super::{OrderFulfillmentStepView, OrderLineView}; + +/// Order header row. `has_many` order lines and fulfillment steps, a +/// `total_cents` rollup, a JSONB `metadata` column, and `source_version` so the +/// projector can ignore stale snapshots under out-of-order delivery. +/// +/// `lines` are owned by the order projector; `fulfillment_steps` by the +/// fulfillment projector (disjoint ownership, no version contention). Both can +/// be requested in one multi-include query. +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, ReadModel)] +#[readmodel(table = "orders")] +pub struct OrderView { + #[readmodel(id, column = "order_id")] + pub order_id: String, + pub customer: String, + pub status: String, + pub source_version: i64, + pub total_cents: i64, + #[readmodel(jsonb)] + pub metadata: BTreeMap, + #[readmodel(has_many = "OrderLineView", foreign_key = "order_id")] + pub lines: Vec, + #[readmodel(has_many = "OrderFulfillmentStepView", foreign_key = "order_id")] + pub fulfillment_steps: Vec, +} diff --git a/tests/distributed_read_model/read_models/product_view.rs b/tests/distributed_read_model/read_models/product_view.rs new file mode 100644 index 0000000..bbeb4a8 --- /dev/null +++ b/tests/distributed_read_model/read_models/product_view.rs @@ -0,0 +1,17 @@ +use std::collections::BTreeMap; + +use serde::{Deserialize, Serialize}; +use sourced_rust::ReadModel; + +/// Catalog product row, projected by the catalog service. The `attributes` +/// column is JSONB alongside the scalar columns. +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, ReadModel)] +#[readmodel(table = "products")] +pub struct ProductView { + #[readmodel(id, column = "product_id")] + pub product_id: String, + pub name: String, + pub unit_cents: i64, + #[readmodel(jsonb)] + pub attributes: BTreeMap, +} diff --git a/tests/distributed_read_model_board/board_service/handlers/board_add_card.rs b/tests/distributed_read_model_board/board_service/handlers/board_add_card.rs new file mode 100644 index 0000000..c17dd5d --- /dev/null +++ b/tests/distributed_read_model_board/board_service/handlers/board_add_card.rs @@ -0,0 +1,32 @@ +use serde_json::{json, Value}; +use sourced_rust::microsvc::{Context, HandlerError}; +use sourced_rust::{OutboxCommitExt, OutboxMessage}; + +use crate::board_service::{AddCard, Board, BoardRepo}; + +pub const COMMAND: &str = "board.add_card"; + +pub fn guard(ctx: &Context) -> bool { + ctx.has_fields(&["id", "card_id", "column", "title"]) +} + +pub fn handle(ctx: &Context) -> Result { + let input = ctx.input::()?; + + let mut board: Board = ctx + .repo() + .get(&input.id)? + .ok_or_else(|| HandlerError::NotFound(input.id.clone()))?; + board.add_card( + input.card_id.clone(), + input.column.clone(), + input.title.clone(), + input.labels.clone(), + input.assignee.clone(), + )?; + + let mut outbox = OutboxMessage::domain_event("board.card_added", &board)?; + ctx.repo().outbox(&mut outbox).commit(&mut board)?; + + Ok(json!({ "id": input.id, "card_id": input.card_id })) +} diff --git a/tests/distributed_read_model_board/board_service/handlers/board_move_card.rs b/tests/distributed_read_model_board/board_service/handlers/board_move_card.rs new file mode 100644 index 0000000..c759faf --- /dev/null +++ b/tests/distributed_read_model_board/board_service/handlers/board_move_card.rs @@ -0,0 +1,26 @@ +use serde_json::{json, Value}; +use sourced_rust::microsvc::{Context, HandlerError}; +use sourced_rust::{OutboxCommitExt, OutboxMessage}; + +use crate::board_service::{Board, BoardRepo, MoveCard}; + +pub const COMMAND: &str = "board.move_card"; + +pub fn guard(ctx: &Context) -> bool { + ctx.has_fields(&["id", "card_id", "column"]) +} + +pub fn handle(ctx: &Context) -> Result { + let input = ctx.input::()?; + + let mut board: Board = ctx + .repo() + .get(&input.id)? + .ok_or_else(|| HandlerError::NotFound(input.id.clone()))?; + board.move_card(input.card_id.clone(), input.column.clone())?; + + let mut outbox = OutboxMessage::domain_event("board.card_moved", &board)?; + ctx.repo().outbox(&mut outbox).commit(&mut board)?; + + Ok(json!({ "id": input.id, "card_id": input.card_id, "column": input.column })) +} diff --git a/tests/distributed_read_model_board/board_service/handlers/board_open.rs b/tests/distributed_read_model_board/board_service/handlers/board_open.rs new file mode 100644 index 0000000..9741e69 --- /dev/null +++ b/tests/distributed_read_model_board/board_service/handlers/board_open.rs @@ -0,0 +1,29 @@ +use serde_json::{json, Value}; +use sourced_rust::microsvc::{Context, HandlerError}; +use sourced_rust::{OutboxCommitExt, OutboxMessage}; + +use crate::board_service::{Board, BoardRepo, OpenBoard}; + +pub const COMMAND: &str = "board.open"; + +pub fn guard(ctx: &Context) -> bool { + ctx.has_fields(&["id", "name"]) +} + +pub fn handle(ctx: &Context) -> Result { + let input = ctx.input::()?; + if ctx.repo().peek(&input.id)?.is_some() { + return Err(HandlerError::Rejected(format!( + "board {} already exists", + input.id + ))); + } + + let mut board = Board::default(); + board.open(input.id.clone(), input.name.clone())?; + + let mut outbox = OutboxMessage::domain_event("board.opened", &board)?; + ctx.repo().outbox(&mut outbox).commit(&mut board)?; + + Ok(json!({ "id": input.id })) +} diff --git a/tests/distributed_read_model_board/board_service/handlers/board_remove_card.rs b/tests/distributed_read_model_board/board_service/handlers/board_remove_card.rs new file mode 100644 index 0000000..ba28c7c --- /dev/null +++ b/tests/distributed_read_model_board/board_service/handlers/board_remove_card.rs @@ -0,0 +1,26 @@ +use serde_json::{json, Value}; +use sourced_rust::microsvc::{Context, HandlerError}; +use sourced_rust::{OutboxCommitExt, OutboxMessage}; + +use crate::board_service::{Board, BoardRepo, RemoveCard}; + +pub const COMMAND: &str = "board.remove_card"; + +pub fn guard(ctx: &Context) -> bool { + ctx.has_fields(&["id", "card_id"]) +} + +pub fn handle(ctx: &Context) -> Result { + let input = ctx.input::()?; + + let mut board: Board = ctx + .repo() + .get(&input.id)? + .ok_or_else(|| HandlerError::NotFound(input.id.clone()))?; + board.remove_card(input.card_id.clone())?; + + let mut outbox = OutboxMessage::domain_event("board.card_removed", &board)?; + ctx.repo().outbox(&mut outbox).commit(&mut board)?; + + Ok(json!({ "id": input.id, "card_id": input.card_id })) +} diff --git a/tests/distributed_read_model_board/board_service/handlers/mod.rs b/tests/distributed_read_model_board/board_service/handlers/mod.rs new file mode 100644 index 0000000..41f2fe0 --- /dev/null +++ b/tests/distributed_read_model_board/board_service/handlers/mod.rs @@ -0,0 +1,4 @@ +pub(super) mod board_add_card; +pub(super) mod board_move_card; +pub(super) mod board_open; +pub(super) mod board_remove_card; diff --git a/tests/distributed_read_model_board/board_service/mod.rs b/tests/distributed_read_model_board/board_service/mod.rs new file mode 100644 index 0000000..b8b9d4d --- /dev/null +++ b/tests/distributed_read_model_board/board_service/mod.rs @@ -0,0 +1,14 @@ +//! Board write service: owns the `Board` aggregate, which holds its cards as +//! aggregate state, and publishes board snapshots through its outbox. + +pub mod models; + +mod handlers; +mod service; + +use sourced_rust::{AggregateRepository, HashMapRepository, QueuedRepository}; + +pub use models::{AddCard, Board, BoardSnapshot, MoveCard, OpenBoard, RemoveCard}; +pub use service::model_service; + +pub type BoardRepo = AggregateRepository, Board>; diff --git a/tests/distributed_read_model_board/board_service/models/board.rs b/tests/distributed_read_model_board/board_service/models/board.rs new file mode 100644 index 0000000..a8cc1cf --- /dev/null +++ b/tests/distributed_read_model_board/board_service/models/board.rs @@ -0,0 +1,90 @@ +use serde::{Deserialize, Serialize}; +use sourced_rust::{sourced, Entity, Snapshot}; + +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +pub struct CardState { + pub card_id: String, + pub column: String, + pub title: String, + pub labels: Vec, + pub assignee: Option, +} + +#[derive(Default, Snapshot)] +pub struct Board { + pub entity: Entity, + pub name: String, + pub cards: Vec, +} + +#[sourced(entity)] +impl Board { + #[event("BoardOpened")] + pub fn open(&mut self, id: String, name: String) { + self.entity.set_id(&id); + self.name = name; + } + + #[event("CardAdded")] + pub fn add_card( + &mut self, + card_id: String, + column: String, + title: String, + labels: Vec, + assignee: Option, + ) { + if !self.cards.iter().any(|card| card.card_id == card_id) { + self.cards.push(CardState { + card_id, + column, + title, + labels, + assignee, + }); + } + } + + #[event("CardMoved")] + pub fn move_card(&mut self, card_id: String, column: String) { + if let Some(card) = self.cards.iter_mut().find(|card| card.card_id == card_id) { + card.column = column; + } + } + + #[event("CardRemoved")] + pub fn remove_card(&mut self, card_id: String) { + self.cards.retain(|card| card.card_id != card_id); + } +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct OpenBoard { + pub id: String, + pub name: String, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct AddCard { + pub id: String, + pub card_id: String, + pub column: String, + pub title: String, + #[serde(default)] + pub labels: Vec, + #[serde(default)] + pub assignee: Option, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct MoveCard { + pub id: String, + pub card_id: String, + pub column: String, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct RemoveCard { + pub id: String, + pub card_id: String, +} diff --git a/tests/distributed_read_model_board/board_service/models/mod.rs b/tests/distributed_read_model_board/board_service/models/mod.rs new file mode 100644 index 0000000..084e6f0 --- /dev/null +++ b/tests/distributed_read_model_board/board_service/models/mod.rs @@ -0,0 +1,3 @@ +pub mod board; + +pub use board::{AddCard, Board, BoardSnapshot, MoveCard, OpenBoard, RemoveCard}; diff --git a/tests/distributed_read_model_board/board_service/service.rs b/tests/distributed_read_model_board/board_service/service.rs new file mode 100644 index 0000000..f79b699 --- /dev/null +++ b/tests/distributed_read_model_board/board_service/service.rs @@ -0,0 +1,15 @@ +use std::sync::Arc; + +use sourced_rust::microsvc::Service; + +use super::{handlers, BoardRepo}; + +pub fn model_service(repo: BoardRepo) -> Arc> { + Arc::new(sourced_rust::register_handlers!( + Service::new(repo), + handlers::board_open, + handlers::board_add_card, + handlers::board_move_card, + handlers::board_remove_card, + )) +} diff --git a/tests/distributed_read_model_board/main.rs b/tests/distributed_read_model_board/main.rs new file mode 100644 index 0000000..3be6ab7 --- /dev/null +++ b/tests/distributed_read_model_board/main.rs @@ -0,0 +1,185 @@ +//! Distributed read-model example: a kanban board projected into normalized +//! `boards` + `cards` tables. +//! +//! - the **board service** owns the `Board` aggregate (cards are aggregate +//! state) and its outbox; +//! - a **projection service** reconciles the relational rows via `save_changes` +//! collection sync, so removing a card deletes its `cards` row, and each card +//! carries a JSONB `payload` column; +//! - a **query service** reads the board with cards (`has_many`) and a card with +//! its board (`belongs_to`). + +mod board_service; +mod projections_service; +mod query_service; +mod read_models; + +use std::thread; +use std::time::{Duration, Instant}; + +use board_service::{AddCard, MoveCard, OpenBoard, RemoveCard}; +use projections_service::{start_board_projection_service, wait_for_board, BOARD_CONSUMER}; +use query_service::BoardQueryService; +use read_models::register_schemas; +use serde::Serialize; +use sourced_rust::microsvc::{Service, Session}; +use sourced_rust::{ + AggregateBuilder, HashMapRepository, InMemoryQueue, InMemoryReadModelStore, OutboxWorkerThread, + Queueable, ReadModelSessionStore, +}; + +fn dispatch(service: &Service, command: &str, input: C) +where + R: Send + Sync + 'static, + C: Serialize, +{ + service + .dispatch( + command, + serde_json::to_value(input).expect("command should encode"), + Session::new(), + ) + .unwrap_or_else(|err| panic!("{command} should dispatch: {err:?}")); +} + +fn wait_for_published_events(queue: &InMemoryQueue, expected_count: usize) { + let deadline = Instant::now() + Duration::from_secs(10); + + loop { + if queue.len() >= expected_count { + return; + } + + assert!( + Instant::now() < deadline, + "timed out waiting for outbox worker to publish events" + ); + thread::sleep(Duration::from_millis(10)); + } +} + +#[test] +fn board_service_feeds_a_normalized_card_read_model() { + let queue = InMemoryQueue::new(); + + let board_store = HashMapRepository::new(); + let board_service = board_service::model_service(board_store.clone().queued().aggregate()); + let worker = + OutboxWorkerThread::spawn(board_store.clone(), queue.clone(), Duration::from_millis(5)); + + let read_store = InMemoryReadModelStore::new(); + register_schemas(&read_store).expect("relational schemas should register"); + let projection = start_board_projection_service(queue.clone(), read_store.clone()); + let query_service = BoardQueryService::new(read_store.clone()); + + dispatch( + &board_service, + "board.open", + OpenBoard { + id: "board-1".to_string(), + name: "Roadmap".to_string(), + }, + ); + dispatch( + &board_service, + "board.add_card", + AddCard { + id: "board-1".to_string(), + card_id: "card-spec".to_string(), + column: "todo".to_string(), + title: "Write spec".to_string(), + labels: vec!["design".to_string()], + assignee: Some("ada".to_string()), + }, + ); + dispatch( + &board_service, + "board.add_card", + AddCard { + id: "board-1".to_string(), + card_id: "card-impl".to_string(), + column: "todo".to_string(), + title: "Implement".to_string(), + labels: vec!["code".to_string()], + assignee: None, + }, + ); + dispatch( + &board_service, + "board.move_card", + MoveCard { + id: "board-1".to_string(), + card_id: "card-spec".to_string(), + column: "doing".to_string(), + }, + ); + dispatch( + &board_service, + "board.remove_card", + RemoveCard { + id: "board-1".to_string(), + card_id: "card-impl".to_string(), + }, + ); + + wait_for_published_events(&queue, 5); + + let board = wait_for_board(&read_store, "board-1", |board| { + board.cards.len() == 1 && board.cards[0].column == "doing" + }); + + assert_eq!(board.name, "Roadmap"); + assert_eq!(board.cards.len(), 1, "removed card should be deleted"); + let card = &board.cards[0]; + assert_eq!(card.card_id, "card-spec"); + assert_eq!(card.column, "doing"); + // JSONB payload round-trips structured data. + assert_eq!(card.payload.labels, vec!["design".to_string()]); + assert_eq!(card.payload.assignee.as_deref(), Some("ada")); + + // belongs_to include resolves the card's board. + let card_with_board = query_service + .card_with_board("board-1", "card-spec") + .expect("query should succeed") + .expect("card should exist"); + let parent = card_with_board + .board + .expect("belongs_to board should hydrate"); + assert_eq!(parent.board_id, "board-1"); + assert_eq!(parent.name, "Roadmap"); + + // The removed card's row is gone. + assert!(query_service + .board_with_cards("board-1") + .expect("query should succeed") + .expect("board should exist") + .cards + .iter() + .all(|card| card.card_id != "card-impl")); + assert!(query_service + .card_with_board("board-1", "card-impl") + .expect("query should succeed") + .is_none()); + + // Idempotency: every published event marked processed before ack. + for event in queue.events() { + assert!( + read_store + .is_processed(BOARD_CONSUMER, &event.id) + .expect("processed lookup should succeed"), + "event {} should be marked processed", + event.id + ); + } + + let write_side = board_service + .repo() + .peek("board-1") + .expect("write-side load should succeed") + .expect("write-side board should exist"); + assert_eq!(write_side.cards.len(), 1); + + projection.stop(); + let stats = worker.stop().expect("worker should stop cleanly"); + assert!(stats.messages_published >= 5); +} diff --git a/tests/distributed_read_model_board/projections_service/handlers/board.rs b/tests/distributed_read_model_board/projections_service/handlers/board.rs new file mode 100644 index 0000000..e8a968b --- /dev/null +++ b/tests/distributed_read_model_board/projections_service/handlers/board.rs @@ -0,0 +1,87 @@ +//! Projects board events into `boards` + `cards`. The board snapshot is the +//! desired state, so `save_changes` collection sync reflects added/moved/removed +//! cards as inserts/patches/deletes. A monotonic `source_version` guard ignores +//! stale snapshots under out-of-order delivery. + +use sourced_rust::bus::Event; +use sourced_rust::{InMemoryReadModelStore, ReadModelCommitOutcome, ReadModelUnitOfWorkExt}; + +use crate::board_service::BoardSnapshot; +use crate::read_models::{board_key, BoardView, CardPayload, CardView}; + +pub const CONSUMER: &str = "board-detail-projection"; +pub const EVENTS: &[&str] = &[ + "board.opened", + "board.card_added", + "board.card_moved", + "board.card_removed", +]; + +pub fn handle(store: &InMemoryReadModelStore, event: &Event) -> ReadModelCommitOutcome { + let snapshot: BoardSnapshot = event.decode().expect("board snapshot should decode"); + let version = event_version(event); + let desired = desired_board_view(&snapshot, version); + + let mut session = store.session(); + let existing = session + .load::(board_key(&desired.board_id)) + .include("cards") + .one() + .expect("board load should succeed"); + + match existing { + Some(current) if current.data.source_version >= version => {} + Some(_) => { + session + .save_changes(desired) + .expect("board save_changes should stage"); + } + None => { + session + .save(&desired) + .expect("board root save should stage"); + for card in &desired.cards { + session.save(card).expect("board card save should stage"); + } + } + } + + session.mark_processed(CONSUMER, &event.id); + session.commit().expect("board projection should commit") +} + +fn desired_board_view(snapshot: &BoardSnapshot, version: i64) -> BoardView { + let cards = snapshot + .cards + .iter() + .map(|card| CardView { + board_id: snapshot.id.clone(), + card_id: card.card_id.clone(), + column: card.column.clone(), + title: card.title.clone(), + payload: CardPayload { + labels: card.labels.clone(), + assignee: card.assignee.clone(), + }, + board: None, + }) + .collect(); + + BoardView { + board_id: snapshot.id.clone(), + name: snapshot.name.clone(), + source_version: version, + cards, + } +} + +/// The aggregate version is the trailing segment of the outbox event id +/// (`outbox:::`). +fn event_version(event: &Event) -> i64 { + event + .id + .rsplit(':') + .next() + .and_then(|raw| raw.parse().ok()) + .unwrap_or(0) +} diff --git a/tests/distributed_read_model_board/projections_service/handlers/mod.rs b/tests/distributed_read_model_board/projections_service/handlers/mod.rs new file mode 100644 index 0000000..fcf6701 --- /dev/null +++ b/tests/distributed_read_model_board/projections_service/handlers/mod.rs @@ -0,0 +1,21 @@ +//! Projection handlers. Each event is a message dispatched by type to the +//! handler that owns the matching read-model rows. + +pub mod board; + +use sourced_rust::bus::Event; +use sourced_rust::{InMemoryReadModelStore, ReadModelCommitOutcome}; + +/// Every event type any projection handler consumes. +pub fn event_types() -> Vec<&'static str> { + board::EVENTS.to_vec() +} + +/// Route one event to the handler that owns its rows. +pub fn project(store: &InMemoryReadModelStore, event: &Event) -> Option { + if board::EVENTS.contains(&event.event_type.as_str()) { + Some(board::handle(store, event)) + } else { + None + } +} diff --git a/tests/distributed_read_model_board/projections_service/mod.rs b/tests/distributed_read_model_board/projections_service/mod.rs new file mode 100644 index 0000000..88b7cad --- /dev/null +++ b/tests/distributed_read_model_board/projections_service/mod.rs @@ -0,0 +1,99 @@ +//! One projection service. Subscribes to every event type the projection +//! handlers consume and dispatches each event (a message) to the owning handler. + +mod handlers; + +pub use handlers::board::CONSUMER as BOARD_CONSUMER; + +use std::sync::mpsc::{self, TryRecvError}; +use std::thread; +use std::time::{Duration, Instant}; + +use sourced_rust::bus::Bus; +use sourced_rust::{InMemoryQueue, InMemoryReadModelStore, ReadModelUnitOfWorkExt}; + +use crate::read_models::{board_key, BoardView}; + +pub struct ProjectionServiceHandle { + stop_tx: mpsc::Sender<()>, + handle: thread::JoinHandle<()>, +} + +impl ProjectionServiceHandle { + pub fn stop(self) { + let _ = self.stop_tx.send(()); + self.handle + .join() + .expect("projection service should stop cleanly"); + } +} + +pub fn start_board_projection_service( + queue: InMemoryQueue, + store: InMemoryReadModelStore, +) -> ProjectionServiceHandle { + let (stop_tx, stop_rx) = mpsc::channel(); + let (ready_tx, ready_rx) = mpsc::channel(); + + let handle = thread::spawn(move || { + let bus = Bus::from_queue(queue); + let event_types = handlers::event_types(); + let events = bus.subscribe(&event_types); + ready_tx + .send(()) + .expect("projection service should signal readiness"); + + loop { + match stop_rx.try_recv() { + Ok(()) | Err(TryRecvError::Disconnected) => break, + Err(TryRecvError::Empty) => {} + } + + match events.recv(10) { + Ok(Some(event)) => { + handlers::project(&store, &event); + events + .ack(&event.id) + .expect("projection service should ack projected events"); + } + Ok(None) => {} + Err(err) => panic!("projection service failed to receive event: {err}"), + } + } + }); + + ready_rx + .recv_timeout(Duration::from_secs(3)) + .expect("projection service should subscribe before accepting writes"); + + ProjectionServiceHandle { stop_tx, handle } +} + +pub fn wait_for_board( + store: &InMemoryReadModelStore, + board_id: &str, + ready: impl Fn(&BoardView) -> bool, +) -> BoardView { + let deadline = Instant::now() + Duration::from_secs(10); + + loop { + let mut session = store.session(); + if let Some(board) = session + .load::(board_key(board_id)) + .include("cards") + .one() + .expect("board load should succeed") + .map(|view| view.data) + { + if ready(&board) { + return board; + } + } + + assert!( + Instant::now() < deadline, + "timed out waiting for board projection" + ); + thread::sleep(Duration::from_millis(10)); + } +} diff --git a/tests/distributed_read_model_board/query_service/mod.rs b/tests/distributed_read_model_board/query_service/mod.rs new file mode 100644 index 0000000..568404d --- /dev/null +++ b/tests/distributed_read_model_board/query_service/mod.rs @@ -0,0 +1,41 @@ +//! Read-only query service for the board read model. Primary-key loads plus +//! `has_many` / `belongs_to` relationship includes. + +use sourced_rust::{InMemoryReadModelStore, ReadModelError, ReadModelUnitOfWorkExt}; + +use crate::read_models::{board_key, card_key, BoardView, CardView}; + +#[derive(Clone)] +pub struct BoardQueryService { + store: InMemoryReadModelStore, +} + +impl BoardQueryService { + pub fn new(store: InMemoryReadModelStore) -> Self { + Self { store } + } + + /// Load a board with its cards (`has_many` include). + pub fn board_with_cards(&self, board_id: &str) -> Result, ReadModelError> { + let mut session = self.store.session(); + Ok(session + .load::(board_key(board_id)) + .include("cards") + .one()? + .map(|view| view.data)) + } + + /// Load one card with its board (`belongs_to` include). + pub fn card_with_board( + &self, + board_id: &str, + card_id: &str, + ) -> Result, ReadModelError> { + let mut session = self.store.session(); + Ok(session + .load::(card_key(board_id, card_id)) + .include("board") + .one()? + .map(|view| view.data)) + } +} diff --git a/tests/distributed_read_model_board/read_models/board_view.rs b/tests/distributed_read_model_board/read_models/board_view.rs new file mode 100644 index 0000000..071cb29 --- /dev/null +++ b/tests/distributed_read_model_board/read_models/board_view.rs @@ -0,0 +1,17 @@ +use serde::{Deserialize, Serialize}; +use sourced_rust::ReadModel; + +use super::CardView; + +/// Board header row. `has_many` cards and a `source_version` guard for +/// out-of-order delivery. +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, ReadModel)] +#[readmodel(table = "boards")] +pub struct BoardView { + #[readmodel(id, column = "board_id")] + pub board_id: String, + pub name: String, + pub source_version: i64, + #[readmodel(has_many = "CardView", foreign_key = "board_id")] + pub cards: Vec, +} diff --git a/tests/distributed_read_model_board/read_models/card_view.rs b/tests/distributed_read_model_board/read_models/card_view.rs new file mode 100644 index 0000000..a712d9d --- /dev/null +++ b/tests/distributed_read_model_board/read_models/card_view.rs @@ -0,0 +1,28 @@ +use serde::{Deserialize, Serialize}; +use sourced_rust::ReadModel; + +use super::BoardView; + +/// Structured JSONB payload stored in one column of the `cards` table. +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +pub struct CardPayload { + pub labels: Vec, + pub assignee: Option, +} + +/// One card. Composite primary key `[board_id, card_id]`; `board_id` is a +/// delegated foreign key from the board; `payload` is a JSONB column; `board` +/// is a `belongs_to` include. +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, ReadModel)] +#[readmodel(table = "cards", primary_key = ["board_id", "card_id"])] +pub struct CardView { + #[readmodel(foreign_key = "boards.board_id", delegated_from = "BoardView.board_id")] + pub board_id: String, + pub card_id: String, + pub column: String, + pub title: String, + #[readmodel(jsonb)] + pub payload: CardPayload, + #[readmodel(belongs_to = "BoardView", foreign_key = "board_id")] + pub board: Option, +} diff --git a/tests/distributed_read_model_board/read_models/mod.rs b/tests/distributed_read_model_board/read_models/mod.rs new file mode 100644 index 0000000..e360473 --- /dev/null +++ b/tests/distributed_read_model_board/read_models/mod.rs @@ -0,0 +1,28 @@ +//! Normalized relational read models for the board example. `BoardView` +//! has_many `CardView`; `CardView` belongs_to `BoardView` and stores a JSONB +//! payload column. + +mod board_view; +mod card_view; + +pub use board_view::BoardView; +pub use card_view::{CardPayload, CardView}; + +use sourced_rust::{InMemoryReadModelStore, ReadModelError, RowKey, RowValue}; + +pub fn register_schemas(store: &InMemoryReadModelStore) -> Result<(), ReadModelError> { + store.register_schema::()?; + store.register_schema::()?; + Ok(()) +} + +pub fn board_key(board_id: &str) -> RowKey { + RowKey::new([("board_id", RowValue::String(board_id.into()))]) +} + +pub fn card_key(board_id: &str, card_id: &str) -> RowKey { + RowKey::new([ + ("board_id", RowValue::String(board_id.into())), + ("card_id", RowValue::String(card_id.into())), + ]) +} From c73f330966a7c0d0788bf82648c2f7b8473b0719 Mon Sep 17 00:00:00 2001 From: Patrick Lee Scott Date: Sat, 23 May 2026 19:47:37 -0500 Subject: [PATCH 13/26] Refine distributed read model services --- .../catalog_service/mod.rs | 2 +- .../catalog_service/service.rs | 2 +- .../inventory_service/mod.rs | 2 +- .../inventory_service/service.rs | 2 +- tests/distributed_read_model/main.rs | 24 ++-- .../order_fulfillment_saga_service/mod.rs | 2 +- .../order_fulfillment_saga_service/service.rs | 2 +- .../order_service/mod.rs | 2 +- .../order_service/service.rs | 2 +- .../payment_service/mod.rs | 2 +- .../payment_service/service.rs | 2 +- .../handlers/fulfillment.rs | 24 ++-- .../projections_service/handlers/mod.rs | 52 +++++-- .../projections_service/handlers/order.rs | 33 +++-- .../projections_service/handlers/product.rs | 24 +++- .../projections_service/mod.rs | 71 +--------- .../projections_service/service.rs | 128 ++++++++++++++++++ 17 files changed, 247 insertions(+), 129 deletions(-) create mode 100644 tests/distributed_read_model/projections_service/service.rs diff --git a/tests/distributed_read_model/catalog_service/mod.rs b/tests/distributed_read_model/catalog_service/mod.rs index 83a4e01..5a8269c 100644 --- a/tests/distributed_read_model/catalog_service/mod.rs +++ b/tests/distributed_read_model/catalog_service/mod.rs @@ -10,6 +10,6 @@ mod service; use sourced_rust::{AggregateRepository, HashMapRepository, QueuedRepository}; pub use models::{AddProduct, Product, ProductSnapshot, RepriceProduct}; -pub use service::model_service; +pub use service::service; pub type CatalogRepo = AggregateRepository, Product>; diff --git a/tests/distributed_read_model/catalog_service/service.rs b/tests/distributed_read_model/catalog_service/service.rs index 0c7ddcb..5a3806b 100644 --- a/tests/distributed_read_model/catalog_service/service.rs +++ b/tests/distributed_read_model/catalog_service/service.rs @@ -4,7 +4,7 @@ use sourced_rust::microsvc::Service; use super::{handlers, CatalogRepo}; -pub fn model_service(repo: CatalogRepo) -> Arc> { +pub fn service(repo: CatalogRepo) -> Arc> { Arc::new(sourced_rust::register_handlers!( Service::new(repo), handlers::product_add, diff --git a/tests/distributed_read_model/inventory_service/mod.rs b/tests/distributed_read_model/inventory_service/mod.rs index de59125..2a65f7b 100644 --- a/tests/distributed_read_model/inventory_service/mod.rs +++ b/tests/distributed_read_model/inventory_service/mod.rs @@ -11,6 +11,6 @@ mod service; use sourced_rust::{AggregateRepository, HashMapRepository, QueuedRepository}; pub use models::Inventory; -pub use service::{model_service, seed_stock}; +pub use service::{seed_stock, service}; pub type InventoryRepo = AggregateRepository, Inventory>; diff --git a/tests/distributed_read_model/inventory_service/service.rs b/tests/distributed_read_model/inventory_service/service.rs index a6aa4e5..8324529 100644 --- a/tests/distributed_read_model/inventory_service/service.rs +++ b/tests/distributed_read_model/inventory_service/service.rs @@ -5,7 +5,7 @@ use sourced_rust::{AggregateBuilder, HashMapRepository}; use super::{handlers, Inventory, InventoryRepo}; -pub fn model_service(repo: InventoryRepo) -> Arc> { +pub fn service(repo: InventoryRepo) -> Arc> { Arc::new(sourced_rust::register_handlers!( Service::new(repo), handlers::reserve, diff --git a/tests/distributed_read_model/main.rs b/tests/distributed_read_model/main.rs index 10be480..7a3ff57 100644 --- a/tests/distributed_read_model/main.rs +++ b/tests/distributed_read_model/main.rs @@ -35,7 +35,8 @@ use order_fulfillment_saga_service::OrderFulfillmentSaga; use order_service::{AddLine, ChangeQuantity, PlaceOrder, RemoveLine, SubmitOrder}; use payment_service::Payment; use projections_service::{ - start_projection_service, CATALOG_CONSUMER, FULFILLMENT_CONSUMER, ORDER_CONSUMER, + service as projection_service, start_projection_service, ProjectionServiceHandle, + CATALOG_CONSUMER, FULFILLMENT_CONSUMER, ORDER_CONSUMER, }; use query_service::OrderQueryService; use read_models::{register_schemas, OrderView}; @@ -92,10 +93,9 @@ fn catalog_and_order_services_feed_a_normalized_read_model() { // Two independent write services, each with its own event store and outbox. let catalog_store = HashMapRepository::new(); - let catalog_service = - catalog_service::model_service(catalog_store.clone().queued().aggregate()); + let catalog_service = catalog_service::service(catalog_store.clone().queued().aggregate()); let order_store = HashMapRepository::new(); - let order_service = order_service::model_service(order_store.clone().queued().aggregate()); + let order_service = order_service::service(order_store.clone().queued().aggregate()); let catalog_worker = OutboxWorkerThread::spawn( catalog_store.clone(), @@ -108,7 +108,9 @@ fn catalog_and_order_services_feed_a_normalized_read_model() { // Shared downstream read store with normalized relational tables. let read_store = InMemoryReadModelStore::new(); register_schemas(&read_store).expect("relational schemas should register"); - let projection = start_projection_service(queue.clone(), read_store.clone()); + let projection_svc = projection_service(read_store.clone()); + let projection: ProjectionServiceHandle = + start_projection_service(queue.clone(), projection_svc); let query_service = OrderQueryService::new(read_store.clone()); // Saga subsystem: inventory, payment, and the orchestrator are ordinary @@ -120,19 +122,17 @@ fn catalog_and_order_services_feed_a_normalized_read_model() { let inventory_store = HashMapRepository::new(); inventory_service::seed_stock(&inventory_store, "W", 100); - let inventory_svc = - inventory_service::model_service(inventory_store.clone().queued().aggregate()); + let inventory_svc = inventory_service::service(inventory_store.clone().queued().aggregate()); let inventory_worker = OutboxWorkerThread::spawn(inventory_store.clone(), queue.clone(), poll); let inventory_sub = microsvc::subscribe(inventory_svc.clone(), queue.new_subscriber(), poll); let payment_store = HashMapRepository::new(); - let payment_svc = payment_service::model_service(payment_store.clone().queued().aggregate()); + let payment_svc = payment_service::service(payment_store.clone().queued().aggregate()); let payment_worker = OutboxWorkerThread::spawn(payment_store.clone(), queue.clone(), poll); let payment_sub = microsvc::subscribe(payment_svc.clone(), queue.new_subscriber(), poll); let saga_store = HashMapRepository::new(); - let saga_svc = - order_fulfillment_saga_service::model_service(saga_store.clone().queued().aggregate()); + let saga_svc = order_fulfillment_saga_service::service(saga_store.clone().queued().aggregate()); let saga_worker = OutboxWorkerThread::spawn(saga_store.clone(), queue.clone(), poll); let saga_sub = microsvc::subscribe(saga_svc.clone(), queue.new_subscriber(), poll); @@ -417,7 +417,7 @@ fn catalog_and_order_services_feed_a_normalized_read_model() { #[tokio::test] async fn order_commands_can_be_http_service() { let order_store = HashMapRepository::new(); - let order_service = order_service::model_service(order_store.clone().queued().aggregate()); + let order_service = order_service::service(order_store.clone().queued().aggregate()); let base = order_service::start_http_service(order_service.clone()).await; let client = reqwest::Client::new(); @@ -444,7 +444,7 @@ async fn order_commands_can_be_http_service() { #[tokio::test] async fn order_commands_can_be_grpc_service() { let order_store = HashMapRepository::new(); - let order_service = order_service::model_service(order_store.clone().queued().aggregate()); + let order_service = order_service::service(order_store.clone().queued().aggregate()); let mut client = order_service::start_grpc_service(order_service.clone()).await; let placed = client diff --git a/tests/distributed_read_model/order_fulfillment_saga_service/mod.rs b/tests/distributed_read_model/order_fulfillment_saga_service/mod.rs index 7502a2e..ffbe93d 100644 --- a/tests/distributed_read_model/order_fulfillment_saga_service/mod.rs +++ b/tests/distributed_read_model/order_fulfillment_saga_service/mod.rs @@ -11,6 +11,6 @@ mod service; use sourced_rust::{AggregateRepository, HashMapRepository, QueuedRepository}; pub use models::OrderFulfillmentSaga; -pub use service::model_service; +pub use service::service; pub type SagaRepo = AggregateRepository, OrderFulfillmentSaga>; diff --git a/tests/distributed_read_model/order_fulfillment_saga_service/service.rs b/tests/distributed_read_model/order_fulfillment_saga_service/service.rs index 3b6ecab..00f8735 100644 --- a/tests/distributed_read_model/order_fulfillment_saga_service/service.rs +++ b/tests/distributed_read_model/order_fulfillment_saga_service/service.rs @@ -4,7 +4,7 @@ use sourced_rust::microsvc::Service; use super::{handlers, SagaRepo}; -pub fn model_service(repo: SagaRepo) -> Arc> { +pub fn service(repo: SagaRepo) -> Arc> { Arc::new(sourced_rust::register_handlers!( Service::new(repo), handlers::on_requested, diff --git a/tests/distributed_read_model/order_service/mod.rs b/tests/distributed_read_model/order_service/mod.rs index 88121e5..d444fed 100644 --- a/tests/distributed_read_model/order_service/mod.rs +++ b/tests/distributed_read_model/order_service/mod.rs @@ -12,7 +12,7 @@ use sourced_rust::{AggregateRepository, HashMapRepository, QueuedRepository}; pub use models::{ AddLine, ChangeQuantity, Order, OrderSnapshot, PlaceOrder, RemoveLine, SubmitOrder, }; -pub use service::model_service; +pub use service::service; #[cfg(feature = "grpc")] pub use service::start_grpc_service; #[cfg(feature = "http")] diff --git a/tests/distributed_read_model/order_service/service.rs b/tests/distributed_read_model/order_service/service.rs index fdcd2db..50f83bc 100644 --- a/tests/distributed_read_model/order_service/service.rs +++ b/tests/distributed_read_model/order_service/service.rs @@ -4,7 +4,7 @@ use sourced_rust::microsvc::Service; use super::{handlers, OrderRepo}; -pub fn model_service(repo: OrderRepo) -> Arc> { +pub fn service(repo: OrderRepo) -> Arc> { Arc::new(sourced_rust::register_handlers!( Service::new(repo), handlers::order_place, diff --git a/tests/distributed_read_model/payment_service/mod.rs b/tests/distributed_read_model/payment_service/mod.rs index 2a412ed..2046c46 100644 --- a/tests/distributed_read_model/payment_service/mod.rs +++ b/tests/distributed_read_model/payment_service/mod.rs @@ -10,6 +10,6 @@ mod service; use sourced_rust::{AggregateRepository, HashMapRepository, QueuedRepository}; pub use models::Payment; -pub use service::model_service; +pub use service::service; pub type PaymentRepo = AggregateRepository, Payment>; diff --git a/tests/distributed_read_model/payment_service/service.rs b/tests/distributed_read_model/payment_service/service.rs index 307b24c..44b698a 100644 --- a/tests/distributed_read_model/payment_service/service.rs +++ b/tests/distributed_read_model/payment_service/service.rs @@ -4,7 +4,7 @@ use sourced_rust::microsvc::Service; use super::{handlers, PaymentRepo}; -pub fn model_service(repo: PaymentRepo) -> Arc> { +pub fn service(repo: PaymentRepo) -> Arc> { Arc::new(sourced_rust::register_handlers!( Service::new(repo), handlers::charge, diff --git a/tests/distributed_read_model/projections_service/handlers/fulfillment.rs b/tests/distributed_read_model/projections_service/handlers/fulfillment.rs index 174186f..3761cea 100644 --- a/tests/distributed_read_model/projections_service/handlers/fulfillment.rs +++ b/tests/distributed_read_model/projections_service/handlers/fulfillment.rs @@ -2,8 +2,9 @@ //! `OrderView`). Owns only the steps table — disjoint from the order handler, so //! there is no optimistic-version contention on the order row. -use sourced_rust::bus::Event; -use sourced_rust::{InMemoryReadModelStore, ReadModelCommitOutcome, ReadModelUnitOfWorkExt}; +use serde_json::{json, Value}; +use sourced_rust::microsvc::{Context, HandlerError}; +use sourced_rust::{InMemoryReadModelStore, ReadModelUnitOfWorkExt}; use crate::fulfillment::{self, event}; use crate::read_models::OrderFulfillmentStepView; @@ -17,8 +18,13 @@ pub const EVENTS: &[&str] = &[ event::INVENTORY_RELEASED, ]; -pub fn handle(store: &InMemoryReadModelStore, evt: &Event) -> ReadModelCommitOutcome { - let msg = fulfillment::decode(evt); +pub fn guard(ctx: &Context) -> bool { + ctx.has_fields(&["id", "event_type", "payload"]) +} + +pub fn handle(ctx: &Context) -> Result { + let evt = super::event(ctx)?; + let msg = fulfillment::decode(&evt); let step = evt .event_type .strip_prefix("fulfillment.") @@ -31,12 +37,12 @@ pub fn handle(store: &InMemoryReadModelStore, evt: &Event) -> ReadModelCommitOut detail: msg.detail.clone(), }; - let mut session = store.session(); + let mut session = ctx.repo().session(); session .save(&row) - .expect("fulfillment step save should stage") + .map_err(super::read_model_error)? .mark_processed(CONSUMER, &evt.id); - session - .commit() - .expect("fulfillment projection should commit") + session.commit().map_err(super::read_model_error)?; + + Ok(json!({ "event_id": evt.id })) } diff --git a/tests/distributed_read_model/projections_service/handlers/mod.rs b/tests/distributed_read_model/projections_service/handlers/mod.rs index 756f61f..3f75136 100644 --- a/tests/distributed_read_model/projections_service/handlers/mod.rs +++ b/tests/distributed_read_model/projections_service/handlers/mod.rs @@ -6,8 +6,40 @@ pub mod fulfillment; pub mod order; pub mod product; +use serde::{Deserialize, Serialize}; use sourced_rust::bus::Event; -use sourced_rust::{InMemoryReadModelStore, ReadModelCommitOutcome}; +use sourced_rust::microsvc::{Context, HandlerError}; +use sourced_rust::{InMemoryReadModelStore, ReadModelError}; + +#[derive(Debug, Deserialize, Serialize)] +pub struct ProjectionMessage { + pub id: String, + pub event_type: String, + pub payload: Vec, + pub metadata: Option>, +} + +impl From<&Event> for ProjectionMessage { + fn from(event: &Event) -> Self { + Self { + id: event.id.clone(), + event_type: event.event_type.clone(), + payload: event.payload.clone(), + metadata: event.metadata.clone(), + } + } +} + +impl From for Event { + fn from(message: ProjectionMessage) -> Self { + Self { + id: message.id, + event_type: message.event_type, + payload: message.payload, + metadata: message.metadata, + } + } +} /// Every event type any projection handler consumes. pub fn event_types() -> Vec<&'static str> { @@ -18,16 +50,10 @@ pub fn event_types() -> Vec<&'static str> { types } -/// Route one event to the handler that owns its rows. -pub fn project(store: &InMemoryReadModelStore, event: &Event) -> Option { - let event_type = event.event_type.as_str(); - if product::EVENTS.contains(&event_type) { - Some(product::handle(store, event)) - } else if order::EVENTS.contains(&event_type) { - Some(order::handle(store, event)) - } else if fulfillment::EVENTS.contains(&event_type) { - Some(fulfillment::handle(store, event)) - } else { - None - } +pub fn event(ctx: &Context) -> Result { + Ok(ctx.input::()?.into()) +} + +pub fn read_model_error(err: ReadModelError) -> HandlerError { + HandlerError::Repository(err.into()) } diff --git a/tests/distributed_read_model/projections_service/handlers/order.rs b/tests/distributed_read_model/projections_service/handlers/order.rs index fa0c8ec..0cfdf20 100644 --- a/tests/distributed_read_model/projections_service/handlers/order.rs +++ b/tests/distributed_read_model/projections_service/handlers/order.rs @@ -6,8 +6,10 @@ use std::collections::BTreeMap; +use serde_json::{json, Value}; use sourced_rust::bus::Event; -use sourced_rust::{InMemoryReadModelStore, ReadModelCommitOutcome, ReadModelUnitOfWorkExt}; +use sourced_rust::microsvc::{Context, HandlerError}; +use sourced_rust::{InMemoryReadModelStore, ReadModelUnitOfWorkExt}; use crate::order_service::OrderSnapshot; use crate::read_models::{order_key, OrderLineView, OrderView}; @@ -23,37 +25,44 @@ pub const EVENTS: &[&str] = &[ "order.cancelled", ]; -pub fn handle(store: &InMemoryReadModelStore, event: &Event) -> ReadModelCommitOutcome { - let snapshot: OrderSnapshot = event.decode().expect("order snapshot should decode"); - let version = event_version(event); +pub fn guard(ctx: &Context) -> bool { + ctx.has_fields(&["id", "event_type", "payload"]) +} + +pub fn handle(ctx: &Context) -> Result { + let event = super::event(ctx)?; + let snapshot: OrderSnapshot = event + .decode() + .map_err(|err| HandlerError::DecodeFailed(format!("order snapshot: {err}")))?; + let version = event_version(&event); let desired = desired_order_view(&snapshot, version); - let mut session = store.session(); + let mut session = ctx.repo().session(); let existing = session .load::(order_key(&desired.order_id)) .include("lines") .one() - .expect("order load should succeed"); + .map_err(super::read_model_error)?; match existing { Some(current) if current.data.source_version >= version => {} Some(_) => { session .save_changes(desired) - .expect("order save_changes should stage"); + .map_err(super::read_model_error)?; } None => { - session - .save(&desired) - .expect("order root save should stage"); + session.save(&desired).map_err(super::read_model_error)?; for line in &desired.lines { - session.save(line).expect("order line save should stage"); + session.save(line).map_err(super::read_model_error)?; } } } session.mark_processed(CONSUMER, &event.id); - session.commit().expect("order projection should commit") + session.commit().map_err(super::read_model_error)?; + + Ok(json!({ "event_id": event.id })) } fn desired_order_view(snapshot: &OrderSnapshot, version: i64) -> OrderView { diff --git a/tests/distributed_read_model/projections_service/handlers/product.rs b/tests/distributed_read_model/projections_service/handlers/product.rs index 53c56b3..1e24cd6 100644 --- a/tests/distributed_read_model/projections_service/handlers/product.rs +++ b/tests/distributed_read_model/projections_service/handlers/product.rs @@ -1,7 +1,8 @@ use std::collections::BTreeMap; -use sourced_rust::bus::Event; -use sourced_rust::{InMemoryReadModelStore, ReadModelCommitOutcome, ReadModelUnitOfWorkExt}; +use serde_json::{json, Value}; +use sourced_rust::microsvc::{Context, HandlerError}; +use sourced_rust::{InMemoryReadModelStore, ReadModelUnitOfWorkExt}; use crate::catalog_service::ProductSnapshot; use crate::read_models::ProductView; @@ -9,8 +10,15 @@ use crate::read_models::ProductView; pub const CONSUMER: &str = "product-catalog-projection"; pub const EVENTS: &[&str] = &["product.added", "product.repriced"]; -pub fn handle(store: &InMemoryReadModelStore, event: &Event) -> ReadModelCommitOutcome { - let snapshot: ProductSnapshot = event.decode().expect("product snapshot should decode"); +pub fn guard(ctx: &Context) -> bool { + ctx.has_fields(&["id", "event_type", "payload"]) +} + +pub fn handle(ctx: &Context) -> Result { + let event = super::event(ctx)?; + let snapshot: ProductSnapshot = event + .decode() + .map_err(|err| HandlerError::DecodeFailed(format!("product snapshot: {err}")))?; let mut attributes = BTreeMap::new(); attributes.insert("category".to_string(), "general".to_string()); @@ -21,10 +29,12 @@ pub fn handle(store: &InMemoryReadModelStore, event: &Event) -> ReadModelCommitO attributes, }; - let mut session = store.session(); + let mut session = ctx.repo().session(); session .save(&view) - .expect("product projection should stage upsert") + .map_err(super::read_model_error)? .mark_processed(CONSUMER, &event.id); - session.commit().expect("product projection should commit") + session.commit().map_err(super::read_model_error)?; + + Ok(json!({ "event_id": event.id })) } diff --git a/tests/distributed_read_model/projections_service/mod.rs b/tests/distributed_read_model/projections_service/mod.rs index b23454b..e6c672d 100644 --- a/tests/distributed_read_model/projections_service/mod.rs +++ b/tests/distributed_read_model/projections_service/mod.rs @@ -1,72 +1,11 @@ -//! One projection service. It subscribes to every event type the projection -//! handlers consume and dispatches each event (a message) to the handler that -//! owns the matching read-model rows. Handlers mark messages processed in the -//! same commit for idempotency. +//! Projection service. It subscribes to every event type the projection handlers +//! consume and dispatches each event through `microsvc::Service`. Handlers mark +//! messages processed in the same commit for idempotency. mod handlers; +mod service; pub use handlers::fulfillment::CONSUMER as FULFILLMENT_CONSUMER; pub use handlers::order::CONSUMER as ORDER_CONSUMER; pub use handlers::product::CONSUMER as CATALOG_CONSUMER; - -use std::sync::mpsc::{self, TryRecvError}; -use std::thread; -use std::time::Duration; - -use sourced_rust::bus::Bus; -use sourced_rust::{InMemoryQueue, InMemoryReadModelStore}; - -pub struct ProjectionServiceHandle { - stop_tx: mpsc::Sender<()>, - handle: thread::JoinHandle<()>, -} - -impl ProjectionServiceHandle { - pub fn stop(self) { - let _ = self.stop_tx.send(()); - self.handle - .join() - .expect("projection service should stop cleanly"); - } -} - -pub fn start_projection_service( - queue: InMemoryQueue, - store: InMemoryReadModelStore, -) -> ProjectionServiceHandle { - let (stop_tx, stop_rx) = mpsc::channel(); - let (ready_tx, ready_rx) = mpsc::channel(); - - let handle = thread::spawn(move || { - let bus = Bus::from_queue(queue); - let event_types = handlers::event_types(); - let events = bus.subscribe(&event_types); - ready_tx - .send(()) - .expect("projection service should signal readiness"); - - loop { - match stop_rx.try_recv() { - Ok(()) | Err(TryRecvError::Disconnected) => break, - Err(TryRecvError::Empty) => {} - } - - match events.recv(10) { - Ok(Some(event)) => { - handlers::project(&store, &event); - events - .ack(&event.id) - .expect("projection service should ack projected events"); - } - Ok(None) => {} - Err(err) => panic!("projection service failed to receive event: {err}"), - } - } - }); - - ready_rx - .recv_timeout(Duration::from_secs(3)) - .expect("projection service should subscribe before accepting writes"); - - ProjectionServiceHandle { stop_tx, handle } -} +pub use service::{service, start_projection_service, ProjectionServiceHandle}; diff --git a/tests/distributed_read_model/projections_service/service.rs b/tests/distributed_read_model/projections_service/service.rs new file mode 100644 index 0000000..57da55f --- /dev/null +++ b/tests/distributed_read_model/projections_service/service.rs @@ -0,0 +1,128 @@ +use std::collections::HashMap; +use std::sync::mpsc::{self, TryRecvError}; +use std::sync::Arc; +use std::thread; +use std::time::Duration; + +use serde_json::Value; +use sourced_rust::bus::{Bus, Event}; +use sourced_rust::microsvc::{Context, HandlerError, Service, Session}; +use sourced_rust::{InMemoryQueue, InMemoryReadModelStore}; + +use super::handlers::{self, ProjectionMessage}; + +type ProjectionGuard = fn(&Context) -> bool; +type ProjectionHandler = fn(&Context) -> Result; + +pub struct ProjectionServiceHandle { + stop_tx: mpsc::Sender<()>, + handle: thread::JoinHandle<()>, +} + +impl ProjectionServiceHandle { + pub fn stop(self) { + let _ = self.stop_tx.send(()); + self.handle + .join() + .expect("projection service should stop cleanly"); + } +} + +pub fn service(store: InMemoryReadModelStore) -> Arc> { + let service = Service::new(store); + let service = register_handler_events( + service, + handlers::product::EVENTS, + handlers::product::guard, + handlers::product::handle, + ); + let service = register_handler_events( + service, + handlers::order::EVENTS, + handlers::order::guard, + handlers::order::handle, + ); + let service = register_handler_events( + service, + handlers::fulfillment::EVENTS, + handlers::fulfillment::guard, + handlers::fulfillment::handle, + ); + + Arc::new(service) +} + +pub fn start_projection_service( + queue: InMemoryQueue, + service: Arc>, +) -> ProjectionServiceHandle { + let (stop_tx, stop_rx) = mpsc::channel(); + let (ready_tx, ready_rx) = mpsc::channel(); + + let handle = thread::spawn(move || { + let bus = Bus::from_queue(queue); + let event_types = handlers::event_types(); + let events = bus.subscribe(&event_types); + ready_tx + .send(()) + .expect("projection service should signal readiness"); + + loop { + match stop_rx.try_recv() { + Ok(()) | Err(TryRecvError::Disconnected) => break, + Err(TryRecvError::Empty) => {} + } + + match events.recv(10) { + Ok(Some(event)) => { + let input = serde_json::to_value(ProjectionMessage::from(&event)) + .expect("projection event envelope should encode"); + service + .dispatch(&event.event_type, input, session_from_event(&event)) + .unwrap_or_else(|err| { + panic!( + "projection service failed to dispatch {}: {err}", + event.event_type + ) + }); + events + .ack(&event.id) + .expect("projection service should ack projected events"); + } + Ok(None) => {} + Err(err) => panic!("projection service failed to receive event: {err}"), + } + } + }); + + ready_rx + .recv_timeout(Duration::from_secs(3)) + .expect("projection service should subscribe before accepting writes"); + + ProjectionServiceHandle { stop_tx, handle } +} + +fn register_handler_events( + mut service: Service, + events: &[&str], + guard: ProjectionGuard, + handle: ProjectionHandler, +) -> Service { + for event in events { + service = service.command_guarded(event, guard, handle); + } + service +} + +fn session_from_event(event: &Event) -> Session { + let Some(metadata) = &event.metadata else { + return Session::new(); + }; + + Session::from_map( + metadata + .iter() + .map(|(key, value)| (key.clone(), value.clone())) + .collect::>(), + ) +} From 7b719b658d0a7b57db7537327d2f98fa6b131c94 Mon Sep 17 00:00:00 2001 From: Patrick Lee Scott Date: Sat, 23 May 2026 20:23:55 -0500 Subject: [PATCH 14/26] Align distributed saga event flow --- tests/distributed_read_model/fulfillment.rs | 52 ++++---- .../inventory_service/handlers/release.rs | 8 +- .../inventory_service/handlers/reserve.rs | 10 +- .../inventory_service/mod.rs | 7 +- tests/distributed_read_model/main.rs | 78 ++++++------ .../handlers/mod.rs | 10 +- ...leased.rs => record_inventory_released.rs} | 8 +- ...served.rs => record_inventory_reserved.rs} | 13 +- ...declined.rs => record_payment_declined.rs} | 9 +- ...cceeded.rs => record_payment_succeeded.rs} | 8 +- .../handlers/{on_requested.rs => start.rs} | 9 +- .../order_fulfillment_saga_service/mod.rs | 8 +- .../models/saga.rs | 4 +- .../order_fulfillment_saga_service/service.rs | 10 +- .../order_service/handlers/order_cancel.rs | 8 +- .../order_service/handlers/order_confirm.rs | 8 +- .../payment_service/handlers/charge.rs | 12 +- .../payment_service/mod.rs | 6 +- .../handlers/fulfillment.rs | 8 +- .../projections_service/handlers/mod.rs | 9 -- .../projections_service/mod.rs | 2 +- .../projections_service/service.rs | 111 ++++++------------ 22 files changed, 175 insertions(+), 223 deletions(-) rename tests/distributed_read_model/order_fulfillment_saga_service/handlers/{on_inventory_released.rs => record_inventory_released.rs} (81%) rename tests/distributed_read_model/order_fulfillment_saga_service/handlers/{on_inventory_reserved.rs => record_inventory_reserved.rs} (71%) rename tests/distributed_read_model/order_fulfillment_saga_service/handlers/{on_payment_declined.rs => record_payment_declined.rs} (79%) rename tests/distributed_read_model/order_fulfillment_saga_service/handlers/{on_payment_succeeded.rs => record_payment_succeeded.rs} (80%) rename tests/distributed_read_model/order_fulfillment_saga_service/handlers/{on_requested.rs => start.rs} (80%) diff --git a/tests/distributed_read_model/fulfillment.rs b/tests/distributed_read_model/fulfillment.rs index b7ad7f6..981201b 100644 --- a/tests/distributed_read_model/fulfillment.rs +++ b/tests/distributed_read_model/fulfillment.rs @@ -1,24 +1,31 @@ //! Shared fulfillment-saga message contract. //! -//! Saga steps are pub/sub JSON domain events (no outbox destination, fan-out via -//! the shared log). The orchestrator decides the next step; inventory/payment/ -//! order services react and report. Each handler publishes exactly one next -//! event, so a single outbox message per commit is enough. +//! The saga starts from a command. Every cross-service notification after that +//! is a pub/sub JSON domain event on the shared broker. use serde::{Deserialize, Serialize}; use sourced_rust::OutboxMessage; +pub mod command { + pub const START: &str = "fulfillment.start"; +} + pub mod event { - pub const REQUESTED: &str = "fulfillment.requested"; - pub const RESERVE_INVENTORY: &str = "fulfillment.reserve_inventory"; + pub const STARTED: &str = "fulfillment.started"; pub const INVENTORY_RESERVED: &str = "fulfillment.inventory_reserved"; - pub const CHARGE_PAYMENT: &str = "fulfillment.charge_payment"; - pub const PAYMENT_SUCCEEDED: &str = "fulfillment.payment_succeeded"; - pub const PAYMENT_DECLINED: &str = "fulfillment.payment_declined"; - pub const RELEASE_INVENTORY: &str = "fulfillment.release_inventory"; - pub const INVENTORY_RELEASED: &str = "fulfillment.inventory_released"; - pub const CONFIRM_ORDER: &str = "fulfillment.confirm_order"; - pub const CANCEL_ORDER: &str = "fulfillment.cancel_order"; + pub const COMPLETED: &str = "fulfillment.completed"; + pub const COMPENSATING: &str = "fulfillment.compensating"; + pub const CANCELLED: &str = "fulfillment.cancelled"; +} + +pub mod inventory_event { + pub const RESERVED: &str = "inventory.reserved"; + pub const RELEASED: &str = "inventory.released"; +} + +pub mod payment_event { + pub const SUCCEEDED: &str = "payment.succeeded"; + pub const DECLINED: &str = "payment.declined"; } /// Correlation payload carried through every fulfillment step. @@ -35,8 +42,13 @@ pub struct FulfillmentMsg { pub detail: String, } -/// Build a pub/sub (no-destination) JSON fulfillment event for the outbox. -pub fn fulfillment_event(event_type: &str, msg: &FulfillmentMsg) -> OutboxMessage { +/// Build a pub/sub JSON saga domain event for the outbox. +pub fn saga_event(event_type: &str, msg: &FulfillmentMsg) -> OutboxMessage { + domain_event(event_type, msg) +} + +/// Build a pub/sub JSON domain event for the outbox. +pub fn domain_event(event_type: &str, msg: &FulfillmentMsg) -> OutboxMessage { let id = format!("{}:{}", msg.order_id, event_type); let payload = serde_json::to_vec(msg).expect("fulfillment message should encode"); OutboxMessage::create(id, event_type, payload).expect("fulfillment outbox should build") @@ -46,13 +58,3 @@ pub fn fulfillment_event(event_type: &str, msg: &FulfillmentMsg) -> OutboxMessag pub fn decode(event: &sourced_rust::bus::Event) -> FulfillmentMsg { serde_json::from_slice(&event.payload).expect("fulfillment message should decode") } - -/// Build the `fulfillment.requested` bus event that kicks off the saga. -pub fn requested_event(msg: &FulfillmentMsg) -> sourced_rust::bus::Event { - let payload = serde_json::to_vec(msg).expect("fulfillment message should encode"); - sourced_rust::bus::Event::new( - format!("{}:{}", msg.order_id, event::REQUESTED), - event::REQUESTED, - payload, - ) -} diff --git a/tests/distributed_read_model/inventory_service/handlers/release.rs b/tests/distributed_read_model/inventory_service/handlers/release.rs index c327f33..b31c65c 100644 --- a/tests/distributed_read_model/inventory_service/handlers/release.rs +++ b/tests/distributed_read_model/inventory_service/handlers/release.rs @@ -2,10 +2,10 @@ use serde_json::{json, Value}; use sourced_rust::microsvc::{Context, HandlerError}; use sourced_rust::OutboxCommitExt; -use crate::fulfillment::{self, event, FulfillmentMsg}; +use crate::fulfillment::{self, event, inventory_event, FulfillmentMsg}; use crate::inventory_service::InventoryRepo; -pub const COMMAND: &str = event::RELEASE_INVENTORY; +pub const COMMAND: &str = event::COMPENSATING; pub fn guard(ctx: &Context) -> bool { ctx.has_fields(&["order_id", "sku", "quantity"]) @@ -20,8 +20,8 @@ pub fn handle(ctx: &Context) -> Result { .ok_or_else(|| HandlerError::NotFound(msg.sku.clone()))?; inventory.release(msg.quantity)?; - let mut out = fulfillment::fulfillment_event( - event::INVENTORY_RELEASED, + let mut out = fulfillment::domain_event( + inventory_event::RELEASED, &FulfillmentMsg { order_id: msg.order_id.clone(), ..Default::default() diff --git a/tests/distributed_read_model/inventory_service/handlers/reserve.rs b/tests/distributed_read_model/inventory_service/handlers/reserve.rs index ffc2797..38fdad5 100644 --- a/tests/distributed_read_model/inventory_service/handlers/reserve.rs +++ b/tests/distributed_read_model/inventory_service/handlers/reserve.rs @@ -2,10 +2,10 @@ use serde_json::{json, Value}; use sourced_rust::microsvc::{Context, HandlerError}; use sourced_rust::OutboxCommitExt; -use crate::fulfillment::{self, event, FulfillmentMsg}; +use crate::fulfillment::{self, event, inventory_event, FulfillmentMsg}; use crate::inventory_service::InventoryRepo; -pub const COMMAND: &str = event::RESERVE_INVENTORY; +pub const COMMAND: &str = event::STARTED; pub fn guard(ctx: &Context) -> bool { ctx.has_fields(&["order_id", "sku", "quantity"]) @@ -20,10 +20,12 @@ pub fn handle(ctx: &Context) -> Result { .ok_or_else(|| HandlerError::NotFound(msg.sku.clone()))?; inventory.reserve(msg.quantity)?; - let mut out = fulfillment::fulfillment_event( - event::INVENTORY_RESERVED, + let mut out = fulfillment::domain_event( + inventory_event::RESERVED, &FulfillmentMsg { order_id: msg.order_id.clone(), + sku: msg.sku.clone(), + quantity: msg.quantity, ..Default::default() }, ); diff --git a/tests/distributed_read_model/inventory_service/mod.rs b/tests/distributed_read_model/inventory_service/mod.rs index 2a65f7b..5b78df6 100644 --- a/tests/distributed_read_model/inventory_service/mod.rs +++ b/tests/distributed_read_model/inventory_service/mod.rs @@ -1,7 +1,6 @@ -//! Inventory write service: reserves stock for the saga and releases it on -//! compensation. A `microsvc::Service` whose handlers react to -//! `fulfillment.reserve_inventory` / `fulfillment.release_inventory` and publish -//! the result through the outbox — same shape as every other service. +//! Inventory write service: reserves stock when the saga starts and releases it +//! on compensation. It reacts to saga domain events and publishes inventory +//! domain events after updating stock. pub mod models; diff --git a/tests/distributed_read_model/main.rs b/tests/distributed_read_model/main.rs index 7a3ff57..8d4913f 100644 --- a/tests/distributed_read_model/main.rs +++ b/tests/distributed_read_model/main.rs @@ -4,8 +4,9 @@ //! - the **catalog service** owns the `Product` aggregate and its outbox; //! - the **order service** owns the `Order` aggregate (with line items as //! aggregate state) and its outbox; -//! - two **projection services** consume the bus and reconcile normalized -//! `products`, `orders`, and `order_lines` rows in a shared read store; +//! - the **projection service** consumes the bus and reconciles normalized +//! `products`, `orders`, fulfillment steps, and `order_lines` rows in a shared +//! read store; //! - a **query service** reads the projected graph through primary-key loads //! plus `has_many` / `belongs_to` relationship includes. //! @@ -29,19 +30,19 @@ use std::thread; use std::time::{Duration, Instant}; use catalog_service::AddProduct; -use fulfillment::{requested_event, FulfillmentMsg}; +use fulfillment::{command, FulfillmentMsg}; use inventory_service::Inventory; use order_fulfillment_saga_service::OrderFulfillmentSaga; use order_service::{AddLine, ChangeQuantity, PlaceOrder, RemoveLine, SubmitOrder}; use payment_service::Payment; use projections_service::{ - service as projection_service, start_projection_service, ProjectionServiceHandle, - CATALOG_CONSUMER, FULFILLMENT_CONSUMER, ORDER_CONSUMER, + service as projection_service, subscriber as projection_subscriber, CATALOG_CONSUMER, + FULFILLMENT_CONSUMER, ORDER_CONSUMER, }; use query_service::OrderQueryService; use read_models::{register_schemas, OrderView}; use serde::Serialize; -use sourced_rust::bus::{Bus, Subscribable}; +use sourced_rust::bus::Subscribable; use sourced_rust::microsvc::{self, Service, Session}; use sourced_rust::{ AggregateBuilder, HashMapRepository, InMemoryQueue, InMemoryReadModelStore, OutboxWorkerThread, @@ -109,17 +110,18 @@ fn catalog_and_order_services_feed_a_normalized_read_model() { let read_store = InMemoryReadModelStore::new(); register_schemas(&read_store).expect("relational schemas should register"); let projection_svc = projection_service(read_store.clone()); - let projection: ProjectionServiceHandle = - start_projection_service(queue.clone(), projection_svc); let query_service = OrderQueryService::new(read_store.clone()); // Saga subsystem: inventory, payment, and the orchestrator are ordinary - // `microsvc::Service`s. Each publishes via its outbox worker and subscribes - // to the bus with `microsvc::subscribe`, dispatching events to handlers by - // type — the same shape as every other service. The order service also - // subscribes, so it reacts to the saga's confirm/cancel decisions. + // `microsvc::Service`s sharing one broker. Services publish domain events; + // each subscriber reacts only to the facts it owns. let poll = Duration::from_millis(5); + let saga_store = HashMapRepository::new(); + let saga_svc = order_fulfillment_saga_service::service(saga_store.clone().queued().aggregate()); + let saga_worker = OutboxWorkerThread::spawn(saga_store.clone(), queue.clone(), poll); + let saga_sub = microsvc::subscribe(saga_svc.clone(), queue.new_subscriber(), poll); + let inventory_store = HashMapRepository::new(); inventory_service::seed_stock(&inventory_store, "W", 100); let inventory_svc = inventory_service::service(inventory_store.clone().queued().aggregate()); @@ -131,12 +133,12 @@ fn catalog_and_order_services_feed_a_normalized_read_model() { let payment_worker = OutboxWorkerThread::spawn(payment_store.clone(), queue.clone(), poll); let payment_sub = microsvc::subscribe(payment_svc.clone(), queue.new_subscriber(), poll); - let saga_store = HashMapRepository::new(); - let saga_svc = order_fulfillment_saga_service::service(saga_store.clone().queued().aggregate()); - let saga_worker = OutboxWorkerThread::spawn(saga_store.clone(), queue.clone(), poll); - let saga_sub = microsvc::subscribe(saga_svc.clone(), queue.new_subscriber(), poll); - let order_sub = microsvc::subscribe(order_service.clone(), queue.new_subscriber(), poll); + let projection_sub = microsvc::subscribe( + projection_svc.clone(), + projection_subscriber(queue.new_subscriber()), + poll, + ); // Catalog commands. dispatch( @@ -215,15 +217,17 @@ fn catalog_and_order_services_feed_a_normalized_read_model() { ); // Kick off fulfillment for the happy order (amount within the payment cap). - Bus::from_queue(queue.clone()) - .publish(requested_event(&FulfillmentMsg { + dispatch( + &saga_svc, + command::START, + FulfillmentMsg { order_id: "order-1".to_string(), sku: "W".to_string(), quantity: 3, amount_cents: 1500, ..Default::default() - })) - .expect("fulfillment kickoff should publish"); + }, + ); // A second, expensive order the payment service declines, exercising the // compensation path (release inventory, cancel the order). @@ -253,15 +257,17 @@ fn catalog_and_order_services_feed_a_normalized_read_model() { id: "order-2".to_string(), }, ); - Bus::from_queue(queue.clone()) - .publish(requested_event(&FulfillmentMsg { + dispatch( + &saga_svc, + command::START, + FulfillmentMsg { order_id: "order-2".to_string(), sku: "W".to_string(), quantity: 1, amount_cents: 200_000, ..Default::default() - })) - .expect("fulfillment kickoff should publish"); + }, + ); // === Happy path: the saga drives order-1 to confirmed === let order = wait_for_order_state(&query_service, "order-1", |order| { @@ -284,10 +290,7 @@ fn catalog_and_order_services_feed_a_normalized_read_model() { .map(|step| step.step.as_str()) .collect(); steps.sort(); - assert_eq!( - steps, - vec!["inventory_reserved", "payment_succeeded", "requested"] - ); + assert_eq!(steps, vec!["completed", "inventory_reserved", "started"]); // belongs_to include joins the line to its catalog product (cross-service). let line = query_service @@ -309,12 +312,7 @@ fn catalog_and_order_services_feed_a_normalized_read_model() { comp_steps.sort(); assert_eq!( comp_steps, - vec![ - "inventory_released", - "inventory_reserved", - "payment_declined", - "requested", - ] + vec!["cancelled", "compensating", "inventory_reserved", "started"] ); // Write-side aggregates reflect the saga outcomes. @@ -382,11 +380,11 @@ fn catalog_and_order_services_feed_a_normalized_read_model() { ORDER_CONSUMER } else if matches!( event_type, - "fulfillment.requested" + "fulfillment.started" | "fulfillment.inventory_reserved" - | "fulfillment.payment_succeeded" - | "fulfillment.payment_declined" - | "fulfillment.inventory_released" + | "fulfillment.completed" + | "fulfillment.compensating" + | "fulfillment.cancelled" ) { FULFILLMENT_CONSUMER } else { @@ -405,7 +403,7 @@ fn catalog_and_order_services_feed_a_normalized_read_model() { let _ = saga_sub.stop(); let _ = payment_sub.stop(); let _ = inventory_sub.stop(); - projection.stop(); + let _ = projection_sub.stop(); let _ = saga_worker.stop(); let _ = payment_worker.stop(); let _ = inventory_worker.stop(); diff --git a/tests/distributed_read_model/order_fulfillment_saga_service/handlers/mod.rs b/tests/distributed_read_model/order_fulfillment_saga_service/handlers/mod.rs index b9397ac..3ebeeec 100644 --- a/tests/distributed_read_model/order_fulfillment_saga_service/handlers/mod.rs +++ b/tests/distributed_read_model/order_fulfillment_saga_service/handlers/mod.rs @@ -1,5 +1,5 @@ -pub(super) mod on_inventory_released; -pub(super) mod on_inventory_reserved; -pub(super) mod on_payment_declined; -pub(super) mod on_payment_succeeded; -pub(super) mod on_requested; +pub(super) mod record_inventory_released; +pub(super) mod record_inventory_reserved; +pub(super) mod record_payment_declined; +pub(super) mod record_payment_succeeded; +pub(super) mod start; diff --git a/tests/distributed_read_model/order_fulfillment_saga_service/handlers/on_inventory_released.rs b/tests/distributed_read_model/order_fulfillment_saga_service/handlers/record_inventory_released.rs similarity index 81% rename from tests/distributed_read_model/order_fulfillment_saga_service/handlers/on_inventory_released.rs rename to tests/distributed_read_model/order_fulfillment_saga_service/handlers/record_inventory_released.rs index 09c9a5c..9ff315e 100644 --- a/tests/distributed_read_model/order_fulfillment_saga_service/handlers/on_inventory_released.rs +++ b/tests/distributed_read_model/order_fulfillment_saga_service/handlers/record_inventory_released.rs @@ -2,10 +2,10 @@ use serde_json::{json, Value}; use sourced_rust::microsvc::{Context, HandlerError}; use sourced_rust::OutboxCommitExt; -use crate::fulfillment::{self, event, FulfillmentMsg}; +use crate::fulfillment::{self, event, inventory_event, FulfillmentMsg}; use crate::order_fulfillment_saga_service::{OrderFulfillmentSaga, SagaRepo}; -pub const COMMAND: &str = event::INVENTORY_RELEASED; +pub const COMMAND: &str = inventory_event::RELEASED; pub fn guard(ctx: &Context) -> bool { ctx.has_fields(&["order_id"]) @@ -21,8 +21,8 @@ pub fn handle(ctx: &Context) -> Result { saga.inventory_released()?; saga.cancel()?; - let mut out = fulfillment::fulfillment_event( - event::CANCEL_ORDER, + let mut out = fulfillment::saga_event( + event::CANCELLED, &FulfillmentMsg { order_id: msg.order_id.clone(), ..Default::default() diff --git a/tests/distributed_read_model/order_fulfillment_saga_service/handlers/on_inventory_reserved.rs b/tests/distributed_read_model/order_fulfillment_saga_service/handlers/record_inventory_reserved.rs similarity index 71% rename from tests/distributed_read_model/order_fulfillment_saga_service/handlers/on_inventory_reserved.rs rename to tests/distributed_read_model/order_fulfillment_saga_service/handlers/record_inventory_reserved.rs index 40026c7..b4cec83 100644 --- a/tests/distributed_read_model/order_fulfillment_saga_service/handlers/on_inventory_reserved.rs +++ b/tests/distributed_read_model/order_fulfillment_saga_service/handlers/record_inventory_reserved.rs @@ -2,10 +2,10 @@ use serde_json::{json, Value}; use sourced_rust::microsvc::{Context, HandlerError}; use sourced_rust::OutboxCommitExt; -use crate::fulfillment::{self, event, FulfillmentMsg}; +use crate::fulfillment::{self, event, inventory_event, FulfillmentMsg}; use crate::order_fulfillment_saga_service::{OrderFulfillmentSaga, SagaRepo}; -pub const COMMAND: &str = event::INVENTORY_RESERVED; +pub const COMMAND: &str = inventory_event::RESERVED; pub fn guard(ctx: &Context) -> bool { ctx.has_fields(&["order_id"]) @@ -19,13 +19,14 @@ pub fn handle(ctx: &Context) -> Result { .get(&msg.order_id)? .ok_or_else(|| HandlerError::NotFound(msg.order_id.clone()))?; saga.inventory_reserved()?; - let amount_cents = saga.amount_cents; - let mut out = fulfillment::fulfillment_event( - event::CHARGE_PAYMENT, + let mut out = fulfillment::saga_event( + event::INVENTORY_RESERVED, &FulfillmentMsg { order_id: msg.order_id.clone(), - amount_cents, + sku: saga.sku.clone(), + quantity: saga.quantity, + amount_cents: saga.amount_cents, ..Default::default() }, ); diff --git a/tests/distributed_read_model/order_fulfillment_saga_service/handlers/on_payment_declined.rs b/tests/distributed_read_model/order_fulfillment_saga_service/handlers/record_payment_declined.rs similarity index 79% rename from tests/distributed_read_model/order_fulfillment_saga_service/handlers/on_payment_declined.rs rename to tests/distributed_read_model/order_fulfillment_saga_service/handlers/record_payment_declined.rs index c7adce7..715c123 100644 --- a/tests/distributed_read_model/order_fulfillment_saga_service/handlers/on_payment_declined.rs +++ b/tests/distributed_read_model/order_fulfillment_saga_service/handlers/record_payment_declined.rs @@ -2,10 +2,10 @@ use serde_json::{json, Value}; use sourced_rust::microsvc::{Context, HandlerError}; use sourced_rust::OutboxCommitExt; -use crate::fulfillment::{self, event, FulfillmentMsg}; +use crate::fulfillment::{self, event, payment_event, FulfillmentMsg}; use crate::order_fulfillment_saga_service::{OrderFulfillmentSaga, SagaRepo}; -pub const COMMAND: &str = event::PAYMENT_DECLINED; +pub const COMMAND: &str = payment_event::DECLINED; pub fn guard(ctx: &Context) -> bool { ctx.has_fields(&["order_id"]) @@ -21,12 +21,13 @@ pub fn handle(ctx: &Context) -> Result { saga.compensate(msg.detail.clone())?; let (sku, quantity) = (saga.sku.clone(), saga.quantity); - let mut out = fulfillment::fulfillment_event( - event::RELEASE_INVENTORY, + let mut out = fulfillment::saga_event( + event::COMPENSATING, &FulfillmentMsg { order_id: msg.order_id.clone(), sku, quantity, + detail: msg.detail.clone(), ..Default::default() }, ); diff --git a/tests/distributed_read_model/order_fulfillment_saga_service/handlers/on_payment_succeeded.rs b/tests/distributed_read_model/order_fulfillment_saga_service/handlers/record_payment_succeeded.rs similarity index 80% rename from tests/distributed_read_model/order_fulfillment_saga_service/handlers/on_payment_succeeded.rs rename to tests/distributed_read_model/order_fulfillment_saga_service/handlers/record_payment_succeeded.rs index 42a9c3b..babd68c 100644 --- a/tests/distributed_read_model/order_fulfillment_saga_service/handlers/on_payment_succeeded.rs +++ b/tests/distributed_read_model/order_fulfillment_saga_service/handlers/record_payment_succeeded.rs @@ -2,10 +2,10 @@ use serde_json::{json, Value}; use sourced_rust::microsvc::{Context, HandlerError}; use sourced_rust::OutboxCommitExt; -use crate::fulfillment::{self, event, FulfillmentMsg}; +use crate::fulfillment::{self, event, payment_event, FulfillmentMsg}; use crate::order_fulfillment_saga_service::{OrderFulfillmentSaga, SagaRepo}; -pub const COMMAND: &str = event::PAYMENT_SUCCEEDED; +pub const COMMAND: &str = payment_event::SUCCEEDED; pub fn guard(ctx: &Context) -> bool { ctx.has_fields(&["order_id"]) @@ -20,8 +20,8 @@ pub fn handle(ctx: &Context) -> Result { .ok_or_else(|| HandlerError::NotFound(msg.order_id.clone()))?; saga.complete()?; - let mut out = fulfillment::fulfillment_event( - event::CONFIRM_ORDER, + let mut out = fulfillment::saga_event( + event::COMPLETED, &FulfillmentMsg { order_id: msg.order_id.clone(), ..Default::default() diff --git a/tests/distributed_read_model/order_fulfillment_saga_service/handlers/on_requested.rs b/tests/distributed_read_model/order_fulfillment_saga_service/handlers/start.rs similarity index 80% rename from tests/distributed_read_model/order_fulfillment_saga_service/handlers/on_requested.rs rename to tests/distributed_read_model/order_fulfillment_saga_service/handlers/start.rs index 9c95d1f..c776405 100644 --- a/tests/distributed_read_model/order_fulfillment_saga_service/handlers/on_requested.rs +++ b/tests/distributed_read_model/order_fulfillment_saga_service/handlers/start.rs @@ -2,10 +2,10 @@ use serde_json::{json, Value}; use sourced_rust::microsvc::{Context, HandlerError}; use sourced_rust::OutboxCommitExt; -use crate::fulfillment::{self, event, FulfillmentMsg}; +use crate::fulfillment::{self, command, event, FulfillmentMsg}; use crate::order_fulfillment_saga_service::{OrderFulfillmentSaga, SagaRepo}; -pub const COMMAND: &str = event::REQUESTED; +pub const COMMAND: &str = command::START; pub fn guard(ctx: &Context) -> bool { ctx.has_fields(&["order_id", "sku", "quantity", "amount_cents"]) @@ -22,12 +22,13 @@ pub fn handle(ctx: &Context) -> Result { msg.amount_cents, )?; - let mut out = fulfillment::fulfillment_event( - event::RESERVE_INVENTORY, + let mut out = fulfillment::saga_event( + event::STARTED, &FulfillmentMsg { order_id: msg.order_id.clone(), sku: msg.sku.clone(), quantity: msg.quantity, + amount_cents: msg.amount_cents, ..Default::default() }, ); diff --git a/tests/distributed_read_model/order_fulfillment_saga_service/mod.rs b/tests/distributed_read_model/order_fulfillment_saga_service/mod.rs index ffbe93d..ba443b7 100644 --- a/tests/distributed_read_model/order_fulfillment_saga_service/mod.rs +++ b/tests/distributed_read_model/order_fulfillment_saga_service/mod.rs @@ -1,7 +1,7 @@ -//! Order fulfillment saga orchestrator. A `microsvc::Service` whose handlers -//! react to fulfillment result events and publish the next step's command — -//! reserve → charge → confirm; on decline → release → cancel. Just another -//! service combining message subscribe/publish, with the saga as its model. +//! Order fulfillment saga orchestrator. A `microsvc::Service` whose command +//! handlers mutate the saga model and publish saga domain events. Inventory, +//! payment, order, and projection services react to those events; the saga also +//! reacts to inventory/payment domain events to advance its own state. pub mod models; diff --git a/tests/distributed_read_model/order_fulfillment_saga_service/models/saga.rs b/tests/distributed_read_model/order_fulfillment_saga_service/models/saga.rs index fd1e6e9..9bb11bf 100644 --- a/tests/distributed_read_model/order_fulfillment_saga_service/models/saga.rs +++ b/tests/distributed_read_model/order_fulfillment_saga_service/models/saga.rs @@ -1,8 +1,8 @@ use sourced_rust::{sourced, Entity, Snapshot}; /// Order fulfillment saga, keyed by order id. Tracks enough state to drive the -/// next step and to compensate. Internal digests are PascalCase (replay names); -/// the bus-facing steps are the lowercase `fulfillment.*` events. +/// next step and to compensate. Internal digests are PascalCase replay names; +/// bus-facing commands and events live in `fulfillment.rs`. #[derive(Default, Snapshot)] pub struct OrderFulfillmentSaga { pub entity: Entity, diff --git a/tests/distributed_read_model/order_fulfillment_saga_service/service.rs b/tests/distributed_read_model/order_fulfillment_saga_service/service.rs index 00f8735..b2f2c41 100644 --- a/tests/distributed_read_model/order_fulfillment_saga_service/service.rs +++ b/tests/distributed_read_model/order_fulfillment_saga_service/service.rs @@ -7,10 +7,10 @@ use super::{handlers, SagaRepo}; pub fn service(repo: SagaRepo) -> Arc> { Arc::new(sourced_rust::register_handlers!( Service::new(repo), - handlers::on_requested, - handlers::on_inventory_reserved, - handlers::on_payment_succeeded, - handlers::on_payment_declined, - handlers::on_inventory_released, + handlers::start, + handlers::record_inventory_reserved, + handlers::record_payment_succeeded, + handlers::record_payment_declined, + handlers::record_inventory_released, )) } diff --git a/tests/distributed_read_model/order_service/handlers/order_cancel.rs b/tests/distributed_read_model/order_service/handlers/order_cancel.rs index 11b9407..48ddf05 100644 --- a/tests/distributed_read_model/order_service/handlers/order_cancel.rs +++ b/tests/distributed_read_model/order_service/handlers/order_cancel.rs @@ -1,6 +1,6 @@ -//! Reacts to the saga's `fulfillment.cancel_order` decision: transitions the -//! `Order` aggregate and publishes `order.cancelled` (a bitcode snapshot) so the -//! order projector updates `OrderView.status`. +//! Reacts to the saga's cancelled event: transitions the `Order` aggregate and +//! publishes `order.cancelled` (a bitcode snapshot) so the order projector +//! updates `OrderView.status`. use serde_json::{json, Value}; use sourced_rust::microsvc::{Context, HandlerError}; @@ -9,7 +9,7 @@ use sourced_rust::{OutboxCommitExt, OutboxMessage}; use crate::fulfillment::{event, FulfillmentMsg}; use crate::order_service::{Order, OrderRepo}; -pub const COMMAND: &str = event::CANCEL_ORDER; +pub const COMMAND: &str = event::CANCELLED; pub fn guard(ctx: &Context) -> bool { ctx.has_fields(&["order_id"]) diff --git a/tests/distributed_read_model/order_service/handlers/order_confirm.rs b/tests/distributed_read_model/order_service/handlers/order_confirm.rs index e0fdc0e..f2e0ed7 100644 --- a/tests/distributed_read_model/order_service/handlers/order_confirm.rs +++ b/tests/distributed_read_model/order_service/handlers/order_confirm.rs @@ -1,6 +1,6 @@ -//! Reacts to the saga's `fulfillment.confirm_order` decision: transitions the -//! `Order` aggregate and publishes `order.confirmed` (a bitcode snapshot) so the -//! order projector updates `OrderView.status`. +//! Reacts to the saga's completed event: transitions the `Order` aggregate and +//! publishes `order.confirmed` (a bitcode snapshot) so the order projector +//! updates `OrderView.status`. use serde_json::{json, Value}; use sourced_rust::microsvc::{Context, HandlerError}; @@ -9,7 +9,7 @@ use sourced_rust::{OutboxCommitExt, OutboxMessage}; use crate::fulfillment::{event, FulfillmentMsg}; use crate::order_service::{Order, OrderRepo}; -pub const COMMAND: &str = event::CONFIRM_ORDER; +pub const COMMAND: &str = event::COMPLETED; pub fn guard(ctx: &Context) -> bool { ctx.has_fields(&["order_id"]) diff --git a/tests/distributed_read_model/payment_service/handlers/charge.rs b/tests/distributed_read_model/payment_service/handlers/charge.rs index 88b620d..dc6db0e 100644 --- a/tests/distributed_read_model/payment_service/handlers/charge.rs +++ b/tests/distributed_read_model/payment_service/handlers/charge.rs @@ -2,10 +2,10 @@ use serde_json::{json, Value}; use sourced_rust::microsvc::{Context, HandlerError}; use sourced_rust::OutboxCommitExt; -use crate::fulfillment::{self, event, FulfillmentMsg}; +use crate::fulfillment::{self, event, payment_event, FulfillmentMsg}; use crate::payment_service::PaymentRepo; -pub const COMMAND: &str = event::CHARGE_PAYMENT; +pub const COMMAND: &str = event::INVENTORY_RESERVED; /// Amounts over this cap are declined (drives the compensation path). const PAYMENT_CAP_CENTS: i64 = 100_000; @@ -20,8 +20,8 @@ pub fn handle(ctx: &Context) -> Result { let mut out = if msg.amount_cents <= PAYMENT_CAP_CENTS { payment.charge(msg.order_id.clone(), msg.amount_cents)?; - fulfillment::fulfillment_event( - event::PAYMENT_SUCCEEDED, + fulfillment::domain_event( + payment_event::SUCCEEDED, &FulfillmentMsg { order_id: msg.order_id.clone(), ..Default::default() @@ -29,8 +29,8 @@ pub fn handle(ctx: &Context) -> Result { ) } else { payment.decline(msg.order_id.clone(), "amount over limit".to_string())?; - fulfillment::fulfillment_event( - event::PAYMENT_DECLINED, + fulfillment::domain_event( + payment_event::DECLINED, &FulfillmentMsg { order_id: msg.order_id.clone(), detail: "amount over limit".to_string(), diff --git a/tests/distributed_read_model/payment_service/mod.rs b/tests/distributed_read_model/payment_service/mod.rs index 2046c46..5120ade 100644 --- a/tests/distributed_read_model/payment_service/mod.rs +++ b/tests/distributed_read_model/payment_service/mod.rs @@ -1,6 +1,6 @@ -//! Payment write service: charges the order amount, declining over a cap to -//! drive the saga's compensation path. A `microsvc::Service` reacting to -//! `fulfillment.charge_payment`. +//! Payment write service: charges after the saga records reserved inventory, +//! declining over a cap to drive the saga's compensation path. It publishes +//! payment domain events after updating the payment model. pub mod models; diff --git a/tests/distributed_read_model/projections_service/handlers/fulfillment.rs b/tests/distributed_read_model/projections_service/handlers/fulfillment.rs index 3761cea..c579e6c 100644 --- a/tests/distributed_read_model/projections_service/handlers/fulfillment.rs +++ b/tests/distributed_read_model/projections_service/handlers/fulfillment.rs @@ -11,11 +11,11 @@ use crate::read_models::OrderFulfillmentStepView; pub const CONSUMER: &str = "order-fulfillment-projection"; pub const EVENTS: &[&str] = &[ - event::REQUESTED, + event::STARTED, event::INVENTORY_RESERVED, - event::PAYMENT_SUCCEEDED, - event::PAYMENT_DECLINED, - event::INVENTORY_RELEASED, + event::COMPLETED, + event::COMPENSATING, + event::CANCELLED, ]; pub fn guard(ctx: &Context) -> bool { diff --git a/tests/distributed_read_model/projections_service/handlers/mod.rs b/tests/distributed_read_model/projections_service/handlers/mod.rs index 3f75136..c6a994e 100644 --- a/tests/distributed_read_model/projections_service/handlers/mod.rs +++ b/tests/distributed_read_model/projections_service/handlers/mod.rs @@ -41,15 +41,6 @@ impl From for Event { } } -/// Every event type any projection handler consumes. -pub fn event_types() -> Vec<&'static str> { - let mut types = Vec::new(); - types.extend_from_slice(product::EVENTS); - types.extend_from_slice(order::EVENTS); - types.extend_from_slice(fulfillment::EVENTS); - types -} - pub fn event(ctx: &Context) -> Result { Ok(ctx.input::()?.into()) } diff --git a/tests/distributed_read_model/projections_service/mod.rs b/tests/distributed_read_model/projections_service/mod.rs index e6c672d..e2f9e2c 100644 --- a/tests/distributed_read_model/projections_service/mod.rs +++ b/tests/distributed_read_model/projections_service/mod.rs @@ -8,4 +8,4 @@ mod service; pub use handlers::fulfillment::CONSUMER as FULFILLMENT_CONSUMER; pub use handlers::order::CONSUMER as ORDER_CONSUMER; pub use handlers::product::CONSUMER as CATALOG_CONSUMER; -pub use service::{service, start_projection_service, ProjectionServiceHandle}; +pub use service::{service, subscriber}; diff --git a/tests/distributed_read_model/projections_service/service.rs b/tests/distributed_read_model/projections_service/service.rs index 57da55f..12dc819 100644 --- a/tests/distributed_read_model/projections_service/service.rs +++ b/tests/distributed_read_model/projections_service/service.rs @@ -1,30 +1,43 @@ -use std::collections::HashMap; -use std::sync::mpsc::{self, TryRecvError}; use std::sync::Arc; -use std::thread; -use std::time::Duration; use serde_json::Value; -use sourced_rust::bus::{Bus, Event}; -use sourced_rust::microsvc::{Context, HandlerError, Service, Session}; -use sourced_rust::{InMemoryQueue, InMemoryReadModelStore}; +use sourced_rust::bus::{Event, PublishError, Subscriber}; +use sourced_rust::microsvc::{Context, HandlerError, Service}; +use sourced_rust::InMemoryReadModelStore; use super::handlers::{self, ProjectionMessage}; type ProjectionGuard = fn(&Context) -> bool; type ProjectionHandler = fn(&Context) -> Result; -pub struct ProjectionServiceHandle { - stop_tx: mpsc::Sender<()>, - handle: thread::JoinHandle<()>, +pub struct ProjectionSubscriber { + inner: S, } -impl ProjectionServiceHandle { - pub fn stop(self) { - let _ = self.stop_tx.send(()); - self.handle - .join() - .expect("projection service should stop cleanly"); +impl Subscriber for ProjectionSubscriber +where + S: Subscriber, +{ + fn poll(&self, timeout_ms: u64) -> Result, PublishError> { + self.inner.poll(timeout_ms).and_then(|event| { + event + .map(|event| { + let payload = serde_json::to_vec(&ProjectionMessage::from(&event)) + .map_err(|err| PublishError::SerializationFailed(err.to_string()))?; + let mut wrapped = Event::new(event.id, event.event_type, payload); + wrapped.metadata = event.metadata; + Ok(wrapped) + }) + .transpose() + }) + } + + fn ack(&self, event_id: &str) -> Result<(), PublishError> { + self.inner.ack(event_id) + } + + fn nack(&self, event_id: &str, reason: &str) -> Result<(), PublishError> { + self.inner.nack(event_id, reason) } } @@ -52,54 +65,11 @@ pub fn service(store: InMemoryReadModelStore) -> Arc>, -) -> ProjectionServiceHandle { - let (stop_tx, stop_rx) = mpsc::channel(); - let (ready_tx, ready_rx) = mpsc::channel(); - - let handle = thread::spawn(move || { - let bus = Bus::from_queue(queue); - let event_types = handlers::event_types(); - let events = bus.subscribe(&event_types); - ready_tx - .send(()) - .expect("projection service should signal readiness"); - - loop { - match stop_rx.try_recv() { - Ok(()) | Err(TryRecvError::Disconnected) => break, - Err(TryRecvError::Empty) => {} - } - - match events.recv(10) { - Ok(Some(event)) => { - let input = serde_json::to_value(ProjectionMessage::from(&event)) - .expect("projection event envelope should encode"); - service - .dispatch(&event.event_type, input, session_from_event(&event)) - .unwrap_or_else(|err| { - panic!( - "projection service failed to dispatch {}: {err}", - event.event_type - ) - }); - events - .ack(&event.id) - .expect("projection service should ack projected events"); - } - Ok(None) => {} - Err(err) => panic!("projection service failed to receive event: {err}"), - } - } - }); - - ready_rx - .recv_timeout(Duration::from_secs(3)) - .expect("projection service should subscribe before accepting writes"); - - ProjectionServiceHandle { stop_tx, handle } +pub fn subscriber(subscriber: S) -> ProjectionSubscriber +where + S: Subscriber, +{ + ProjectionSubscriber { inner: subscriber } } fn register_handler_events( @@ -113,16 +83,3 @@ fn register_handler_events( } service } - -fn session_from_event(event: &Event) -> Session { - let Some(metadata) = &event.metadata else { - return Session::new(); - }; - - Session::from_map( - metadata - .iter() - .map(|(key, value)| (key.clone(), value.clone())) - .collect::>(), - ) -} From 58c6516fb96bbfedf55e707989be1fede7288e31 Mon Sep 17 00:00:00 2001 From: Patrick Lee Scott Date: Sat, 23 May 2026 20:35:44 -0500 Subject: [PATCH 15/26] fix: store binary read-model rows as bytes --- sourced_rust_macros/src/read_model.rs | 55 ++++++++++++++++++++++----- tests/read_model_metadata/main.rs | 38 ++++++++++++++++++ 2 files changed, 84 insertions(+), 9 deletions(-) diff --git a/sourced_rust_macros/src/read_model.rs b/sourced_rust_macros/src/read_model.rs index 303f4e3..5114ae5 100644 --- a/sourced_rust_macros/src/read_model.rs +++ b/sourced_rust_macros/src/read_model.rs @@ -177,20 +177,32 @@ fn expand_relational_read_model( } }); - row_inserts.push(quote! { - row.insert_serde(#column_name, &self.#ident)?; - }); + if let Some(value) = bytes_row_value_tokens(&field.ty, quote! { self.#ident }) { + row_inserts.push(quote! { + row.insert(#column_name, #value); + }); + } else { + row_inserts.push(quote! { + row.insert_serde(#column_name, &self.#ident)?; + }); + } row_fields.push(quote! { #ident: row.get_serde(#column_name)? }); if primary_key { - key_inserts.push(quote! { - key.values.insert( - #column_name.to_string(), - sourced_rust::RowValue::from_serde(&self.#ident)?, - ); - }); + if let Some(value) = bytes_row_value_tokens(&field.ty, quote! { self.#ident }) { + key_inserts.push(quote! { + key.values.insert(#column_name.to_string(), #value); + }); + } else { + key_inserts.push(quote! { + key.values.insert( + #column_name.to_string(), + sourced_rust::RowValue::from_serde(&self.#ident)?, + ); + }); + } } if attrs.indexed || attrs.unique { @@ -935,6 +947,31 @@ fn column_type_tokens(ty: &Type, jsonb: bool) -> proc_macro2::TokenStream { quote! { sourced_rust::ColumnType::Unsupported(#type_name.to_string()) } } +fn bytes_row_value_tokens( + ty: &Type, + value: proc_macro2::TokenStream, +) -> Option { + let option_inner = option_inner_type(ty); + let ty = option_inner.unwrap_or(ty); + let segment = last_type_segment(ty)?; + if segment.ident != "Vec" || !vec_inner_is_u8(segment) { + return None; + } + + if option_inner.is_some() { + Some(quote! { + match &#value { + Some(value) => sourced_rust::RowValue::Bytes(value.clone()), + None => sourced_rust::RowValue::Null, + } + }) + } else { + Some(quote! { + sourced_rust::RowValue::Bytes(#value.clone()) + }) + } +} + fn option_inner_type(ty: &Type) -> Option<&Type> { let segment = last_type_segment(ty)?; if segment.ident != "Option" { diff --git a/tests/read_model_metadata/main.rs b/tests/read_model_metadata/main.rs index 5e12bb6..56a54e4 100644 --- a/tests/read_model_metadata/main.rs +++ b/tests/read_model_metadata/main.rs @@ -66,6 +66,15 @@ struct DirectTableView { value: i32, } +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, ReadModel)] +#[table("binary_assets")] +struct BinaryAsset { + #[id("asset_id")] + id: String, + payload: Vec, + optional_payload: Option>, +} + #[test] fn derive_allows_table_models_with_string_ids_to_use_document_rows() { let summary = AccountSummary { @@ -143,6 +152,35 @@ fn row_conversion_round_trips_scalar_option_and_jsonb_fields() { assert_eq!(round_trip, summary); } +#[test] +fn row_conversion_keeps_binary_columns_as_bytes_values() { + let asset = BinaryAsset { + id: "asset-1".into(), + payload: vec![0, 1, 255], + optional_payload: Some(vec![2, 3, 5]), + }; + + let row = asset.to_row().unwrap(); + + assert_eq!(row.get("payload"), Some(&RowValue::Bytes(vec![0, 1, 255]))); +} + +#[test] +fn row_conversion_keeps_optional_binary_columns_as_bytes_values() { + let asset = BinaryAsset { + id: "asset-1".into(), + payload: vec![0, 1, 255], + optional_payload: Some(vec![2, 3, 5]), + }; + + let row = asset.to_row().unwrap(); + + assert_eq!( + row.get("optional_payload"), + Some(&RowValue::Bytes(vec![2, 3, 5])) + ); +} + #[test] fn derive_represents_relationships_composite_keys_and_delegated_foreign_keys() { let player_schema = Player::schema(); From 856d9e39b1164ae58195b1ebb62a68a25043ecbb Mon Sep 17 00:00:00 2001 From: Patrick Lee Scott Date: Sat, 23 May 2026 20:36:35 -0500 Subject: [PATCH 16/26] fix: reject duplicate read-model relationship attrs --- sourced_rust_macros/src/read_model.rs | 59 ++++++++++++++++++++++++++- 1 file changed, 57 insertions(+), 2 deletions(-) diff --git a/sourced_rust_macros/src/read_model.rs b/sourced_rust_macros/src/read_model.rs index 5114ae5..ecf9cf4 100644 --- a/sourced_rust_macros/src/read_model.rs +++ b/sourced_rust_macros/src/read_model.rs @@ -519,7 +519,15 @@ impl FieldAttrs { } else if meta.path.is_ident("foreign_key") { let value = meta.value()?.parse::()?.value(); if attrs.relationship.is_some() { - relationship_mut(&mut attrs, "foreign_key")?.foreign_key = Some(value); + let relationship = relationship_mut(&mut attrs, "foreign_key")?; + if relationship.foreign_key.is_some() { + return Err( + meta.error("relationship foreign_key declared more than once") + ); + } + relationship.foreign_key = Some(value); + } else if pending_foreign_key.is_some() { + return Err(meta.error("relationship foreign_key declared more than once")); } else { pending_foreign_key = Some(value); } @@ -552,7 +560,13 @@ impl FieldAttrs { } else if meta.path.is_ident("through") { let through = meta.value()?.parse::()?.value(); if attrs.relationship.is_some() { - relationship_mut(&mut attrs, "through")?.through = Some(through); + let relationship = relationship_mut(&mut attrs, "through")?; + if relationship.through.is_some() { + return Err(meta.error("relationship through declared more than once")); + } + relationship.through = Some(through); + } else if pending_through.is_some() { + return Err(meta.error("relationship through declared more than once")); } else { pending_through = Some(through); } @@ -1430,6 +1444,47 @@ mod tests { ); } + #[test] + fn expand_read_model_rejects_duplicate_relationship_foreign_keys() { + let input: DeriveInput = syn::parse_quote! { + #[readmodel(table = "players")] + struct Player { + #[readmodel(id)] + player_id: String, + #[readmodel(has_many = "PlayerWeapon", foreign_key = "player_id", foreign_key = "owner_id")] + weapons: Vec, + } + }; + + let err = expand_read_model(input).expect_err("duplicate relationship foreign key"); + + assert!( + err.to_string() + .contains("foreign_key declared more than once"), + "unexpected error: {err}" + ); + } + + #[test] + fn expand_read_model_rejects_duplicate_pending_relationship_through_attrs() { + let input: DeriveInput = syn::parse_quote! { + #[readmodel(table = "players")] + struct Player { + #[readmodel(id)] + player_id: String, + #[readmodel(through = "player_weapon_links", through = "weapon_players", many_to_many = "Weapon")] + weapons: Vec, + } + }; + + let err = expand_read_model(input).expect_err("duplicate pending relationship through"); + + assert!( + err.to_string().contains("through declared more than once"), + "unexpected error: {err}" + ); + } + #[test] fn snake_case_preserves_multi_char_lowercase_mapping() { assert_eq!(to_snake_case("İdView"), "i\u{307}d_view"); From 743f4d9288d48d3787e22da78ba8f425996b678a Mon Sep 17 00:00:00 2001 From: Patrick Lee Scott Date: Sat, 23 May 2026 20:38:06 -0500 Subject: [PATCH 17/26] fix: fail document plans on unsupported mutations --- src/commit_builder/mod.rs | 6 +- src/read_model/in_memory.rs | 60 +++++++++++++++++++ tests/read_model_document_conformance/main.rs | 12 ++-- 3 files changed, 69 insertions(+), 9 deletions(-) diff --git a/src/commit_builder/mod.rs b/src/commit_builder/mod.rs index 5d36506..7b539f9 100644 --- a/src/commit_builder/mod.rs +++ b/src/commit_builder/mod.rs @@ -762,9 +762,9 @@ mod tests { .commit(&mut agg) .unwrap_err(); - assert!( - matches!(err, RepositoryError::Model(message) if message.contains("relational row writes")) - ); + assert!(matches!(err, RepositoryError::Model(message) + if message.contains("apply_document_write_plan") + && message.contains("ReadModelMutation::UpsertRow"))); assert_eq!(agg.entity().committed_version(), 0); assert!(repo.get("agg-1").unwrap().is_none()); } diff --git a/src/read_model/in_memory.rs b/src/read_model/in_memory.rs index d7d3a0e..d59cef9 100644 --- a/src/read_model/in_memory.rs +++ b/src/read_model/in_memory.rs @@ -52,6 +52,7 @@ pub(crate) fn apply_document_write_plan( staged_processed_messages: &mut ProcessedMessageSet, ) -> Result { let capabilities = document_capabilities(); + reject_non_document_mutations(&plan)?; plan.validate_for(&capabilities)?; let mut marks_in_plan = HashSet::with_capacity(plan.processed_messages.len()); @@ -83,6 +84,23 @@ pub(crate) fn apply_document_write_plan( Ok(ReadModelCommitOutcome::applied()) } +fn reject_non_document_mutations(plan: &ReadModelWritePlan) -> Result<(), ReadModelError> { + for mutation in &plan.mutations { + let mutation_name = match mutation { + ReadModelMutation::Document(_) => continue, + ReadModelMutation::UpsertRow(_) => "ReadModelMutation::UpsertRow", + ReadModelMutation::PatchRow(_) => "ReadModelMutation::PatchRow", + ReadModelMutation::DeleteRow(_) => "ReadModelMutation::DeleteRow", + }; + + return Err(ReadModelError::Metadata(format!( + "apply_document_write_plan supports only ReadModelMutation::Document with document_capabilities; received {mutation_name}" + ))); + } + + Ok(()) +} + pub(crate) fn document_capabilities() -> ReadModelAdapterCapabilities { ReadModelAdapterCapabilities { relational_rows: false, @@ -883,6 +901,7 @@ impl ReadModelStore for InMemoryReadModelStore { #[cfg(test)] mod tests { use super::*; + use crate::{ColumnDef, ColumnType, PrimaryKey, RowMutation}; use serde::{Deserialize, Serialize}; #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] @@ -898,6 +917,47 @@ mod tests { } } + fn test_row_schema() -> ReadModelSchema { + ReadModelSchema { + model_name: "TestRow".into(), + table_name: "test_rows".into(), + columns: vec![ColumnDef::new("id", "id", ColumnType::Text)], + primary_key: PrimaryKey::new(["id"]), + version_column: None, + foreign_keys: Vec::new(), + indexes: Vec::new(), + relationships: Vec::new(), + } + } + + #[test] + fn apply_document_write_plan_rejects_non_document_mutations() { + let key = RowKey::new([("id", RowValue::String("row-1".into()))]); + let mut values = RowValues::new(); + values.insert("id", RowValue::String("row-1".into())); + let plan = ReadModelWritePlan::new( + vec![ReadModelMutation::UpsertRow(RowMutation { + schema: test_row_schema(), + key, + values, + expected_version: ExpectedVersion::Any, + mode: RowWriteMode::Upsert, + })], + Vec::new(), + ); + let mut staged_models = HashMap::new(); + let mut staged_processed_messages = HashSet::new(); + + let err = + apply_document_write_plan(plan, &mut staged_models, &mut staged_processed_messages) + .unwrap_err(); + + assert!(matches!(err, ReadModelError::Metadata(message) + if message.contains("apply_document_write_plan") + && message.contains("ReadModelMutation::UpsertRow") + && message.contains("document_capabilities"))); + } + #[test] fn upsert_and_get() { let store = InMemoryReadModelStore::new(); diff --git a/tests/read_model_document_conformance/main.rs b/tests/read_model_document_conformance/main.rs index 0831227..66c0a58 100644 --- a/tests/read_model_document_conformance/main.rs +++ b/tests/read_model_document_conformance/main.rs @@ -73,9 +73,9 @@ fn unsupported_row_plan_rejection_does_not_apply_prior_document_write() { let err = repo.read_models(session).commit_all().unwrap_err(); - assert!( - matches!(err, sourced_rust::RepositoryError::Model(message) if message.contains("relational row writes")) - ); + assert!(matches!(err, sourced_rust::RepositoryError::Model(message) + if message.contains("apply_document_write_plan") + && message.contains("ReadModelMutation::UpsertRow"))); assert!(repo.get_model::("mixed").unwrap().is_none()); } @@ -202,9 +202,9 @@ fn queued_session_commit_failure_keeps_lock_until_explicit_abort() { let err = store.read_models(session).commit_all().unwrap_err(); - assert!( - matches!(err, sourced_rust::RepositoryError::Model(message) if message.contains("relational row writes")) - ); + assert!(matches!(err, sourced_rust::RepositoryError::Model(message) + if message.contains("apply_document_write_plan") + && message.contains("ReadModelMutation::UpsertRow"))); let lock = store .lock_manager() .get_lock("document_views:failed") From 04154ff5cebac2953cf526ec154bec602bc1cdb3 Mon Sep 17 00:00:00 2001 From: Patrick Lee Scott Date: Sat, 23 May 2026 20:39:06 -0500 Subject: [PATCH 18/26] fix: reject duplicate read-model index names --- src/read_model/metadata.rs | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/src/read_model/metadata.rs b/src/read_model/metadata.rs index b5ebbb3..cb341fd 100644 --- a/src/read_model/metadata.rs +++ b/src/read_model/metadata.rs @@ -239,7 +239,16 @@ impl ReadModelSchema { validate_foreign_key(&self.model_name, foreign_key)?; } + let mut index_names = BTreeSet::new(); for index in &self.indexes { + if let Some(name) = index.name.as_deref() { + if !index_names.insert(name) { + return Err(ReadModelError::Metadata(format!( + "read model `{}` declares duplicate index name `{}`", + self.model_name, name + ))); + } + } if index.columns.is_empty() { return Err(ReadModelError::Metadata(format!( "read model `{}` declares an index with no columns", @@ -508,6 +517,28 @@ mod tests { ); } + #[test] + fn validate_rejects_duplicate_explicit_index_names() { + let mut schema = valid_schema(); + schema.indexes = vec![ + IndexDef { + name: Some("idx_player_weapons_player_id".into()), + columns: vec!["player_id".into()], + unique: false, + }, + IndexDef { + name: Some("idx_player_weapons_player_id".into()), + columns: vec!["weapon_id".into()], + unique: false, + }, + ]; + + let err = schema.validate().unwrap_err(); + + assert!(matches!(err, ReadModelError::Metadata(message) + if message.contains("duplicate index name `idx_player_weapons_player_id`"))); + } + #[test] fn row_values_round_trip_scalar_and_json_values() { let mut row = RowValues::new(); From 1d48d4a970c09558ad772e8c97b44629f1a3bf6d Mon Sep 17 00:00:00 2001 From: Patrick Lee Scott Date: Sat, 23 May 2026 21:22:39 -0500 Subject: [PATCH 19/26] fix: fingerprint document read-model keys --- src/commit_builder/mod.rs | 18 ++++- src/hashmap_repo/repository.rs | 16 ++-- src/read_model/in_memory.rs | 22 ++++-- src/read_model/mod.rs | 2 +- src/read_model/queued.rs | 7 +- src/read_model/session.rs | 30 +++++++- tests/read_model_document_conformance/main.rs | 73 ++++++++++++++++++- 7 files changed, 139 insertions(+), 29 deletions(-) diff --git a/src/commit_builder/mod.rs b/src/commit_builder/mod.rs index 7b539f9..b4c2f6e 100644 --- a/src/commit_builder/mod.rs +++ b/src/commit_builder/mod.rs @@ -408,6 +408,15 @@ mod tests { session } + fn test_view_key(id: &str) -> String { + crate::read_model::DocumentMutation { + collection: TestView::COLLECTION.into(), + id: id.into(), + bytes: Vec::new(), + } + .key() + } + #[test] fn commit_readmodel_and_aggregate() { let repo = HashMapRepository::new(); @@ -688,7 +697,7 @@ mod tests { assert_eq!( repo.read_model_keys.borrow().as_slice(), - &["test_view:staged-multi".to_string()] + &[test_view_key("staged-multi")] ); assert_eq!( repo.entity_ids.borrow().as_slice(), @@ -722,7 +731,10 @@ mod tests { .commit(&mut agg) .unwrap(); - let lock = repo.lock_manager().get_lock("test_view:locked").unwrap(); + let lock = repo + .lock_manager() + .get_lock(&test_view_key("locked")) + .unwrap(); assert!(lock.try_lock().unwrap()); lock.unlock().unwrap(); } @@ -795,7 +807,7 @@ mod tests { assert_eq!(agg.entity().new_events().len(), 1); assert_eq!( repo.read_model_keys.borrow().as_slice(), - &["test_view:rollback".to_string()] + &[test_view_key("rollback")] ); assert!(repo.entity_ids.borrow().iter().any(|id| id == "agg-1")); assert!(repo diff --git a/src/hashmap_repo/repository.rs b/src/hashmap_repo/repository.rs index 9448893..2f54790 100644 --- a/src/hashmap_repo/repository.rs +++ b/src/hashmap_repo/repository.rs @@ -346,7 +346,12 @@ mod tests { #[test] fn document_plan_rejects_version_overflow_without_writing() { let repo = HashMapRepository::new(); - let key = "test_models:plan-overflow".to_string(); + let mutation = DocumentMutation { + collection: "test_models".into(), + id: "plan-overflow".into(), + bytes: b"new".to_vec(), + }; + let key = mutation.key(); let original_bytes = b"old".to_vec(); repo.model_store.storage.write().unwrap().insert( key.clone(), @@ -355,14 +360,7 @@ mod tests { version: u64::MAX, }, ); - let plan = ReadModelWritePlan::new( - vec![ReadModelMutation::Document(DocumentMutation { - collection: "test_models".into(), - id: "plan-overflow".into(), - bytes: b"new".to_vec(), - })], - Vec::new(), - ); + let plan = ReadModelWritePlan::new(vec![ReadModelMutation::Document(mutation)], Vec::new()); let err = repo .commit_batch(CommitBatch { diff --git a/src/read_model/in_memory.rs b/src/read_model/in_memory.rs index d59cef9..01a3cda 100644 --- a/src/read_model/in_memory.rs +++ b/src/read_model/in_memory.rs @@ -3,7 +3,10 @@ use std::collections::{BTreeMap, HashMap, HashSet}; use std::sync::{Arc, RwLock}; -use super::session::{column_name_for, key_fingerprint, validate_key, validate_row_values}; +use super::session::{ + column_name_for, document_key, document_key_prefix, key_fingerprint, validate_key, + validate_row_values, +}; use super::{ ExpectedVersion, PatchMode, ProcessedMessageMark, ReadModel, ReadModelAdapterCapabilities, ReadModelCommitOutcome, ReadModelError, ReadModelIncludeRows, ReadModelLoadGraph, @@ -327,7 +330,7 @@ fn concurrency_conflict( /// In-memory read model store backed by a HashMap. /// -/// Storage key is `"TABLE:id"`. Clone-friendly via Arc. +/// Clone-friendly via Arc. #[derive(Clone)] pub struct InMemoryReadModelStore { pub(crate) storage: Arc>>, @@ -354,7 +357,7 @@ impl InMemoryReadModelStore { } fn make_key(table: &str, id: &str) -> String { - format!("{}:{}", table, id) + document_key(table, id) } /// Register a relational read-model schema for explicit include execution. @@ -850,7 +853,7 @@ impl ReadModelStore for InMemoryReadModelStore { .read() .map_err(|_| ReadModelError::Storage("lock poisoned".into()))?; - let prefix = format!("{}:", M::COLLECTION); + let prefix = document_key_prefix(M::COLLECTION); let mut results = Vec::new(); for (key, stored) in storage.iter() { @@ -878,7 +881,7 @@ impl ReadModelStore for InMemoryReadModelStore { .read() .map_err(|_| ReadModelError::Storage("lock poisoned".into()))?; - let prefix = format!("{}:", M::COLLECTION); + let prefix = document_key_prefix(M::COLLECTION); let mut matched = None; for (key, stored) in storage.iter() { @@ -1199,8 +1202,9 @@ mod tests { #[test] fn find_models_returns_error_for_corrupted_rows() { let store = InMemoryReadModelStore::new(); + let key = InMemoryReadModelStore::make_key(TestModel::COLLECTION, "bad"); store - .save_document_bytes("test_models:bad", b"not valid json".to_vec()) + .save_document_bytes(&key, b"not valid json".to_vec()) .unwrap(); let err = store.find_models::(&|_| true).unwrap_err(); @@ -1211,8 +1215,9 @@ mod tests { #[test] fn find_one_model_returns_error_for_corrupted_rows() { let store = InMemoryReadModelStore::new(); + let key = InMemoryReadModelStore::make_key(TestModel::COLLECTION, "bad"); store - .save_document_bytes("test_models:bad", b"not valid json".to_vec()) + .save_document_bytes(&key, b"not valid json".to_vec()) .unwrap(); let err = store.find_one_model::(&|_| true).unwrap_err(); @@ -1229,8 +1234,9 @@ mod tests { value: 20, }) .unwrap(); + let key = InMemoryReadModelStore::make_key(TestModel::COLLECTION, "bad"); store - .save_document_bytes("test_models:bad", b"not valid json".to_vec()) + .save_document_bytes(&key, b"not valid json".to_vec()) .unwrap(); let err = store diff --git a/src/read_model/mod.rs b/src/read_model/mod.rs index 6ec0dae..e0eb9b2 100644 --- a/src/read_model/mod.rs +++ b/src/read_model/mod.rs @@ -2,7 +2,7 @@ //! //! The crate supports one read-model write-plan surface with two row shapes: //! -//! - document rows through [`ReadModelStore`] and `collection:id` JSON payloads; +//! - document rows through [`ReadModelStore`] and collection/id JSON payloads; //! - normalized relational rows through [`RelationalReadModel`], //! [`ReadModelSession`], [`ReadModelWritePlan`], and schema metadata. //! diff --git a/src/read_model/queued.rs b/src/read_model/queued.rs index ac0c647..90aff06 100644 --- a/src/read_model/queued.rs +++ b/src/read_model/queued.rs @@ -11,6 +11,7 @@ use crate::lock::{InMemoryLockManager, Lock, LockManager}; use crate::queued_repo::ReadOpts; use crate::repository::{Commit, CommitBatch, RepositoryError, TransactionalCommit}; +use super::session::document_key; use super::{ ReadModel, ReadModelAdapterCapabilities, ReadModelCommitOutcome, ReadModelError, ReadModelSessionStore, ReadModelStore, ReadModelWritePlan, Versioned, @@ -23,8 +24,8 @@ use super::{ /// - `upsert` / `insert` / `update` / `delete` release the lock on success /// - `unlock` / `abort` release the lock manually /// -/// Lock keys are `"collection:id"`, so different read model types with the same ID -/// do not contend with each other. +/// Lock keys include the collection and id, so different read model types with +/// the same ID do not contend with each other. pub struct QueuedReadModelStore { inner: S, lock_manager: L, @@ -105,7 +106,7 @@ impl QueuedReadModelStore { } fn make_key(collection: &str, id: &str) -> String { - format!("{}:{}", collection, id) + document_key(collection, id) } } diff --git a/src/read_model/session.rs b/src/read_model/session.rs index b0cad02..bcf0b69 100644 --- a/src/read_model/session.rs +++ b/src/read_model/session.rs @@ -217,7 +217,7 @@ pub struct DocumentMutation { impl DocumentMutation { pub fn key(&self) -> String { - format!("{}:{}", self.collection, self.id) + document_key(&self.collection, &self.id) } } @@ -1441,6 +1441,18 @@ pub(crate) fn key_fingerprint(key: &RowKey) -> String { fingerprint } +pub(crate) fn document_key(collection: &str, id: &str) -> String { + let mut key = document_key_prefix(collection); + push_fingerprint_part(&mut key, id); + key +} + +pub(crate) fn document_key_prefix(collection: &str) -> String { + let mut prefix = String::new(); + push_fingerprint_part(&mut prefix, collection); + prefix +} + fn push_fingerprint_part(fingerprint: &mut String, part: &str) { fingerprint.push_str(&part.len().to_string()); fingerprint.push(':'); @@ -1489,4 +1501,20 @@ mod tests { assert_ne!(key_fingerprint(&integer), key_fingerprint(&string)); } + + #[test] + fn document_key_distinguishes_delimiter_collisions() { + let left = DocumentMutation { + collection: "a:b".into(), + id: "c".into(), + bytes: Vec::new(), + }; + let right = DocumentMutation { + collection: "a".into(), + id: "b:c".into(), + bytes: Vec::new(), + }; + + assert_ne!(left.key(), right.key()); + } } diff --git a/tests/read_model_document_conformance/main.rs b/tests/read_model_document_conformance/main.rs index 66c0a58..8fe7a92 100644 --- a/tests/read_model_document_conformance/main.rs +++ b/tests/read_model_document_conformance/main.rs @@ -29,6 +29,36 @@ struct RelationalDocumentView { value: i32, } +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, ReadModel)] +#[readmodel(collection = "a:b")] +struct ColonCollectionDocumentView { + #[readmodel(id)] + id: String, + value: i32, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, ReadModel)] +#[readmodel(collection = "a")] +struct PlainCollectionDocumentView { + #[readmodel(id)] + id: String, + value: i32, +} + +fn document_key(collection: &str, id: &str) -> String { + let mut key = String::new(); + push_key_part(&mut key, collection); + push_key_part(&mut key, id); + key +} + +fn push_key_part(key: &mut String, part: &str) { + key.push_str(&part.len().to_string()); + key.push(':'); + key.push_str(part); + key.push(';'); +} + fn document_view(id: &str, value: i32, category: &str) -> DocumentView { DocumentView { id: id.into(), @@ -60,6 +90,41 @@ fn document_session_plan_uses_key_value_store_and_shared_clone_storage() { assert_eq!(loaded.data, view); } +#[test] +fn document_session_keys_distinguish_colons_in_collection_and_id() { + let store = InMemoryReadModelStore::new(); + let left = ColonCollectionDocumentView { + id: "c".into(), + value: 1, + }; + let right = PlainCollectionDocumentView { + id: "b:c".into(), + value: 2, + }; + let mut session = ReadModelSession::new(); + session.document(&left).unwrap(); + session.document(&right).unwrap(); + + session.commit(&store).unwrap(); + + assert_eq!( + store.get_model::("c").unwrap(), + Some(sourced_rust::Versioned { + data: left, + version: 1, + }) + ); + assert_eq!( + store + .get_model::("b:c") + .unwrap(), + Some(sourced_rust::Versioned { + data: right, + version: 1, + }) + ); +} + #[test] fn unsupported_row_plan_rejection_does_not_apply_prior_document_write() { let repo = HashMapRepository::new(); @@ -143,7 +208,7 @@ fn queued_load_for_update_no_lock_read_and_session_commit_release_lock() { let lock = store .lock_manager() - .get_lock("document_views:locked") + .get_lock(&document_key("document_views", "locked")) .unwrap(); assert!(lock.try_lock().unwrap()); lock.unlock().unwrap(); @@ -173,11 +238,11 @@ fn queued_abort_unlocks_and_same_id_different_models_are_independent() { let document_lock = store .lock_manager() - .get_lock("document_views:same") + .get_lock(&document_key("document_views", "same")) .unwrap(); let other_lock = store .lock_manager() - .get_lock("other_document_views:same") + .get_lock(&document_key("other_document_views", "same")) .unwrap(); assert!(document_lock.try_lock().unwrap()); assert!(other_lock.try_lock().unwrap()); @@ -207,7 +272,7 @@ fn queued_session_commit_failure_keeps_lock_until_explicit_abort() { && message.contains("ReadModelMutation::UpsertRow"))); let lock = store .lock_manager() - .get_lock("document_views:failed") + .get_lock(&document_key("document_views", "failed")) .unwrap(); assert!(!lock.try_lock().unwrap()); store.abort::("failed").unwrap(); From 5dc2b77dec8acbd2504b790e2615e762786b565f Mon Sep 17 00:00:00 2001 From: Patrick Lee Scott Date: Sat, 23 May 2026 21:24:31 -0500 Subject: [PATCH 20/26] fix: release queued read-model locks once --- src/read_model/queued.rs | 87 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 85 insertions(+), 2 deletions(-) diff --git a/src/read_model/queued.rs b/src/read_model/queued.rs index 90aff06..8f5c8bf 100644 --- a/src/read_model/queued.rs +++ b/src/read_model/queued.rs @@ -270,11 +270,13 @@ impl ReadModelSessionStore &self, plan: ReadModelWritePlan, ) -> Result { - let read_model_keys: Vec = plan + let mut read_model_keys: Vec = plan .mutations .iter() .map(|mutation| mutation.lock_key()) .collect(); + read_model_keys.sort_unstable(); + read_model_keys.dedup(); let _locks = self.lock_ids_in_order(&read_model_keys)?; let result = self.inner.commit_write_plan(plan); @@ -360,8 +362,11 @@ impl QueuedReadModelStore { mod tests { use super::*; use crate::read_model::InMemoryReadModelStore; + use crate::{DocumentMutation, LockError, ReadModelMutation}; use serde::{Deserialize, Serialize}; - use std::sync::Arc; + use std::collections::HashMap; + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::sync::{Arc, Mutex}; use std::thread; use std::time::Duration; @@ -378,6 +383,54 @@ mod tests { } } + #[derive(Default)] + struct CountingLock { + lock_count: AtomicUsize, + unlock_count: AtomicUsize, + } + + impl Lock for CountingLock { + fn lock(&self) -> Result<(), LockError> { + self.lock_count.fetch_add(1, Ordering::SeqCst); + Ok(()) + } + + fn try_lock(&self) -> Result { + Ok(true) + } + + fn unlock(&self) -> Result<(), LockError> { + self.unlock_count.fetch_add(1, Ordering::SeqCst); + Ok(()) + } + } + + #[derive(Clone, Default)] + struct CountingLockManager { + locks: Arc>>>, + } + + impl CountingLockManager { + fn unlock_count(&self, key: &str) -> usize { + self.get_lock(key) + .unwrap() + .unlock_count + .load(Ordering::SeqCst) + } + } + + impl LockManager for CountingLockManager { + type Lock = CountingLock; + + fn get_lock(&self, id: &str) -> Result, LockError> { + let mut locks = self + .locks + .lock() + .map_err(|_| LockError::Poisoned("counting lock manager".into()))?; + Ok(locks.entry(id.to_string()).or_default().clone()) + } + } + #[test] fn get_locks_upsert_unlocks() { let store = QueuedReadModelStore::new(InMemoryReadModelStore::new()); @@ -613,4 +666,34 @@ mod tests { // cleanup store.unlock::("2").unwrap(); } + + #[test] + fn commit_write_plan_releases_duplicate_document_lock_once() { + let lock_manager = CountingLockManager::default(); + let store = QueuedReadModelStore::with_lock_manager( + InMemoryReadModelStore::new(), + lock_manager.clone(), + ); + let mutation = DocumentMutation { + collection: TestModel::COLLECTION.into(), + id: "1".into(), + bytes: serde_json::to_vec(&TestModel { + id: "1".into(), + value: 1, + }) + .unwrap(), + }; + let key = mutation.key(); + let plan = ReadModelWritePlan::new( + vec![ + ReadModelMutation::Document(mutation.clone()), + ReadModelMutation::Document(mutation), + ], + Vec::new(), + ); + + store.commit_write_plan(plan).unwrap(); + + assert_eq!(lock_manager.unlock_count(&key), 1); + } } From ac49477f6a5c48ad271eb03a8e34be5f949d8659 Mon Sep 17 00:00:00 2001 From: Patrick Lee Scott Date: Sat, 23 May 2026 21:57:16 -0500 Subject: [PATCH 21/26] fix: fail board projection on malformed event versions --- .../projections_service/handlers/board.rs | 35 +++++++++++++++++-- 1 file changed, 33 insertions(+), 2 deletions(-) diff --git a/tests/distributed_read_model_board/projections_service/handlers/board.rs b/tests/distributed_read_model_board/projections_service/handlers/board.rs index e8a968b..936a58d 100644 --- a/tests/distributed_read_model_board/projections_service/handlers/board.rs +++ b/tests/distributed_read_model_board/projections_service/handlers/board.rs @@ -82,6 +82,37 @@ fn event_version(event: &Event) -> i64 { .id .rsplit(':') .next() - .and_then(|raw| raw.parse().ok()) - .unwrap_or(0) + .expect("board projection event id should include a version segment") + .parse() + .expect("board projection event id should end with a numeric aggregate version") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn event_version_parses_trailing_outbox_segment() { + let event = Event::with_string_payload( + "outbox:board-1:board.card_added:42", + "board.card_added", + "{}", + ); + + assert_eq!(event_version(&event), 42); + } + + #[test] + #[should_panic( + expected = "board projection event id should end with a numeric aggregate version" + )] + fn event_version_panics_on_malformed_outbox_segment() { + let event = Event::with_string_payload( + "outbox:board-1:board.card_added:bad", + "board.card_added", + "{}", + ); + + event_version(&event); + } } From d7a6b41734557926cf960c07c5fe639da0af72f9 Mon Sep 17 00:00:00 2001 From: Patrick Lee Scott Date: Sat, 23 May 2026 21:58:48 -0500 Subject: [PATCH 22/26] fix: reject non-positive product creation prices --- .../catalog_service/handlers/product_add.rs | 34 +++++++++++++++++++ .../catalog_service/models/product.rs | 20 ++++++++++- 2 files changed, 53 insertions(+), 1 deletion(-) diff --git a/tests/distributed_read_model/catalog_service/handlers/product_add.rs b/tests/distributed_read_model/catalog_service/handlers/product_add.rs index fd5cf4e..a542dcd 100644 --- a/tests/distributed_read_model/catalog_service/handlers/product_add.rs +++ b/tests/distributed_read_model/catalog_service/handlers/product_add.rs @@ -12,6 +12,10 @@ pub fn guard(ctx: &Context) -> bool { pub fn handle(ctx: &Context) -> Result { let input = ctx.input::()?; + if input.unit_cents <= 0 { + return Err(HandlerError::Rejected("price must be positive".to_string())); + } + if ctx.repo().peek(&input.id)?.is_some() { return Err(HandlerError::Rejected(format!( "product {} already exists", @@ -27,3 +31,33 @@ pub fn handle(ctx: &Context) -> Result { Ok(json!({ "id": input.id })) } + +#[cfg(test)] +mod tests { + use super::*; + use sourced_rust::microsvc::Session; + use sourced_rust::{AggregateBuilder, HashMapRepository, Queueable}; + + #[test] + fn handle_rejects_non_positive_unit_cents() { + let store = HashMapRepository::new(); + let service = crate::catalog_service::service(store.clone().queued().aggregate()); + + let err = service + .dispatch( + COMMAND, + json!({ + "id": "prod-widget", + "name": "Widget", + "unit_cents": 0, + }), + Session::new(), + ) + .unwrap_err(); + + assert!(matches!( + err, + HandlerError::Rejected(ref message) if message == "price must be positive" + )); + } +} diff --git a/tests/distributed_read_model/catalog_service/models/product.rs b/tests/distributed_read_model/catalog_service/models/product.rs index adf9796..906fe15 100644 --- a/tests/distributed_read_model/catalog_service/models/product.rs +++ b/tests/distributed_read_model/catalog_service/models/product.rs @@ -10,7 +10,7 @@ pub struct Product { #[sourced(entity)] impl Product { - #[event("ProductAdded")] + #[event("ProductAdded", when = unit_cents > 0)] pub fn add(&mut self, id: String, name: String, unit_cents: i64) { self.entity.set_id(&id); self.name = name; @@ -35,3 +35,21 @@ pub struct RepriceProduct { pub id: String, pub unit_cents: i64, } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn add_ignores_non_positive_unit_cents() { + let mut product = Product::default(); + + product + .add("prod-widget".to_string(), "Widget".to_string(), 0) + .unwrap(); + + assert!(product.entity.id().is_empty()); + assert_eq!(product.unit_cents, 0); + assert!(product.entity.events().is_empty()); + } +} From bc714c81a0c0792bc3c22e4b476a1562bc0c2fc0 Mon Sep 17 00:00:00 2001 From: Patrick Lee Scott Date: Sat, 23 May 2026 22:00:06 -0500 Subject: [PATCH 23/26] fix: validate distributed inventory quantities --- .../inventory_service/handlers/release.rs | 11 +++ .../inventory_service/handlers/reserve.rs | 9 +++ .../inventory_service/models/inventory.rs | 76 ++++++++++++++++++- 3 files changed, 92 insertions(+), 4 deletions(-) diff --git a/tests/distributed_read_model/inventory_service/handlers/release.rs b/tests/distributed_read_model/inventory_service/handlers/release.rs index b31c65c..f252f5c 100644 --- a/tests/distributed_read_model/inventory_service/handlers/release.rs +++ b/tests/distributed_read_model/inventory_service/handlers/release.rs @@ -13,11 +13,22 @@ pub fn guard(ctx: &Context) -> bool { pub fn handle(ctx: &Context) -> Result { let msg = ctx.input::()?; + if msg.quantity <= 0 { + return Err(HandlerError::Rejected( + "quantity must be positive".to_string(), + )); + } let mut inventory = ctx .repo() .get(&msg.sku)? .ok_or_else(|| HandlerError::NotFound(msg.sku.clone()))?; + if inventory.reserved < msg.quantity { + return Err(HandlerError::Rejected( + "reserved stock must cover release".to_string(), + )); + } + inventory.release(msg.quantity)?; let mut out = fulfillment::domain_event( diff --git a/tests/distributed_read_model/inventory_service/handlers/reserve.rs b/tests/distributed_read_model/inventory_service/handlers/reserve.rs index 38fdad5..3197ad8 100644 --- a/tests/distributed_read_model/inventory_service/handlers/reserve.rs +++ b/tests/distributed_read_model/inventory_service/handlers/reserve.rs @@ -13,11 +13,20 @@ pub fn guard(ctx: &Context) -> bool { pub fn handle(ctx: &Context) -> Result { let msg = ctx.input::()?; + if msg.quantity <= 0 { + return Err(HandlerError::Rejected( + "quantity must be positive".to_string(), + )); + } let mut inventory = ctx .repo() .get(&msg.sku)? .ok_or_else(|| HandlerError::NotFound(msg.sku.clone()))?; + if inventory.available < msg.quantity { + return Err(HandlerError::Rejected("insufficient stock".to_string())); + } + inventory.reserve(msg.quantity)?; let mut out = fulfillment::domain_event( diff --git a/tests/distributed_read_model/inventory_service/models/inventory.rs b/tests/distributed_read_model/inventory_service/models/inventory.rs index 06266d7..df52aed 100644 --- a/tests/distributed_read_model/inventory_service/models/inventory.rs +++ b/tests/distributed_read_model/inventory_service/models/inventory.rs @@ -10,22 +10,90 @@ pub struct Inventory { #[sourced(entity)] impl Inventory { - #[event("StockSet")] + #[event("StockSet", when = quantity >= 0)] pub fn set_stock(&mut self, sku: String, quantity: i64) { self.entity.set_id(&sku); self.available = quantity; self.reserved = 0; } - #[event("StockReserved", when = self.available >= quantity)] + #[event("StockReserved", when = quantity > 0 && self.available >= quantity)] pub fn reserve(&mut self, quantity: i64) { self.available -= quantity; self.reserved += quantity; } - #[event("StockReleased")] + #[event("StockReleased", when = quantity > 0 && self.reserved >= quantity)] pub fn release(&mut self, quantity: i64) { self.available += quantity; - self.reserved = (self.reserved - quantity).max(0); + self.reserved -= quantity; + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn stocked_inventory() -> Inventory { + let mut inventory = Inventory::default(); + inventory.set_stock("W".to_string(), 10).unwrap(); + inventory + } + + #[test] + fn set_stock_ignores_negative_quantity() { + let mut inventory = Inventory::default(); + + inventory.set_stock("W".to_string(), -1).unwrap(); + + assert!(inventory.entity.id().is_empty()); + assert_eq!(inventory.available, 0); + assert!(inventory.entity.events().is_empty()); + } + + #[test] + fn reserve_ignores_non_positive_quantity() { + let mut inventory = stocked_inventory(); + + inventory.reserve(0).unwrap(); + + assert_eq!(inventory.available, 10); + assert_eq!(inventory.reserved, 0); + assert_eq!(inventory.entity.events().len(), 1); + } + + #[test] + fn reserve_ignores_quantities_above_available() { + let mut inventory = stocked_inventory(); + + inventory.reserve(11).unwrap(); + + assert_eq!(inventory.available, 10); + assert_eq!(inventory.reserved, 0); + assert_eq!(inventory.entity.events().len(), 1); + } + + #[test] + fn release_ignores_quantities_above_reserved() { + let mut inventory = stocked_inventory(); + inventory.reserve(3).unwrap(); + + inventory.release(4).unwrap(); + + assert_eq!(inventory.available, 7); + assert_eq!(inventory.reserved, 3); + assert_eq!(inventory.entity.events().len(), 2); + } + + #[test] + fn release_subtracts_reserved_stock_without_clamping() { + let mut inventory = stocked_inventory(); + inventory.reserve(3).unwrap(); + + inventory.release(2).unwrap(); + + assert_eq!(inventory.available, 9); + assert_eq!(inventory.reserved, 1); + assert_eq!(inventory.entity.events().len(), 3); } } From aa848da84ea36ecfc9759e9884b6fed5da9b4967 Mon Sep 17 00:00:00 2001 From: Patrick Lee Scott Date: Sat, 23 May 2026 22:01:15 -0500 Subject: [PATCH 24/26] fix: guard distributed order line edits --- .../order_service/handlers/order_add_line.rs | 6 +- .../handlers/order_change_quantity.rs | 4 + .../handlers/order_remove_line.rs | 4 + .../order_service/models/order.rs | 89 ++++++++++++++++++- 4 files changed, 99 insertions(+), 4 deletions(-) diff --git a/tests/distributed_read_model/order_service/handlers/order_add_line.rs b/tests/distributed_read_model/order_service/handlers/order_add_line.rs index b33199b..60205fb 100644 --- a/tests/distributed_read_model/order_service/handlers/order_add_line.rs +++ b/tests/distributed_read_model/order_service/handlers/order_add_line.rs @@ -12,7 +12,7 @@ pub fn guard(ctx: &Context) -> bool { pub fn handle(ctx: &Context) -> Result { let input = ctx.input::()?; - if input.quantity <= 0 || input.unit_cents < 0 { + if input.quantity <= 0 || input.unit_cents <= 0 { return Err(HandlerError::Rejected("invalid line".to_string())); } @@ -20,6 +20,10 @@ pub fn handle(ctx: &Context) -> Result { .repo() .get(&input.id)? .ok_or_else(|| HandlerError::NotFound(input.id.clone()))?; + if order.status.as_str() != "open" { + return Err(HandlerError::Rejected("order is not open".to_string())); + } + order.add_line( input.sku.clone(), input.product_id.clone(), diff --git a/tests/distributed_read_model/order_service/handlers/order_change_quantity.rs b/tests/distributed_read_model/order_service/handlers/order_change_quantity.rs index e58c9dd..262d6c9 100644 --- a/tests/distributed_read_model/order_service/handlers/order_change_quantity.rs +++ b/tests/distributed_read_model/order_service/handlers/order_change_quantity.rs @@ -22,6 +22,10 @@ pub fn handle(ctx: &Context) -> Result { .repo() .get(&input.id)? .ok_or_else(|| HandlerError::NotFound(input.id.clone()))?; + if order.status.as_str() != "open" { + return Err(HandlerError::Rejected("order is not open".to_string())); + } + order.change_quantity(input.sku.clone(), input.quantity)?; let mut outbox = OutboxMessage::domain_event("order.line_quantity_changed", &order)?; diff --git a/tests/distributed_read_model/order_service/handlers/order_remove_line.rs b/tests/distributed_read_model/order_service/handlers/order_remove_line.rs index f4f21fd..831f1bc 100644 --- a/tests/distributed_read_model/order_service/handlers/order_remove_line.rs +++ b/tests/distributed_read_model/order_service/handlers/order_remove_line.rs @@ -17,6 +17,10 @@ pub fn handle(ctx: &Context) -> Result { .repo() .get(&input.id)? .ok_or_else(|| HandlerError::NotFound(input.id.clone()))?; + if order.status.as_str() != "open" { + return Err(HandlerError::Rejected("order is not open".to_string())); + } + order.remove_line(input.sku.clone())?; let mut outbox = OutboxMessage::domain_event("order.line_removed", &order)?; diff --git a/tests/distributed_read_model/order_service/models/order.rs b/tests/distributed_read_model/order_service/models/order.rs index c26d9e9..9db5399 100644 --- a/tests/distributed_read_model/order_service/models/order.rs +++ b/tests/distributed_read_model/order_service/models/order.rs @@ -26,7 +26,10 @@ impl Order { self.status = "open".to_string(); } - #[event("LineAdded", when = self.status.as_str() == "open")] + #[event( + "LineAdded", + when = self.status.as_str() == "open" && unit_cents > 0 && quantity > 0 + )] pub fn add_line(&mut self, sku: String, product_id: String, unit_cents: i64, quantity: i64) { if let Some(line) = self.lines.iter_mut().find(|line| line.sku == sku) { line.quantity += quantity; @@ -41,14 +44,14 @@ impl Order { } } - #[event("LineQuantityChanged", when = quantity > 0)] + #[event("LineQuantityChanged", when = self.status.as_str() == "open" && quantity > 0)] pub fn change_quantity(&mut self, sku: String, quantity: i64) { if let Some(line) = self.lines.iter_mut().find(|line| line.sku == sku) { line.quantity = quantity; } } - #[event("LineRemoved")] + #[event("LineRemoved", when = self.status.as_str() == "open")] pub fn remove_line(&mut self, sku: String) { self.lines.retain(|line| line.sku != sku); } @@ -101,3 +104,83 @@ pub struct RemoveLine { pub struct SubmitOrder { pub id: String, } + +#[cfg(test)] +mod tests { + use super::*; + + fn open_order() -> Order { + let mut order = Order::default(); + order + .place("order-1".to_string(), "Ada Lovelace".to_string()) + .unwrap(); + order + } + + fn submitted_order() -> Order { + let mut order = open_order(); + order + .add_line("W".to_string(), "prod-widget".to_string(), 500, 2) + .unwrap(); + order.submit().unwrap(); + order + } + + #[test] + fn add_line_ignores_non_positive_unit_cents() { + let mut order = open_order(); + + order + .add_line("W".to_string(), "prod-widget".to_string(), 0, 1) + .unwrap(); + + assert!(order.lines.is_empty()); + assert_eq!(order.entity.events().len(), 1); + } + + #[test] + fn add_line_ignores_non_positive_quantity() { + let mut order = open_order(); + + order + .add_line("W".to_string(), "prod-widget".to_string(), 500, 0) + .unwrap(); + + assert!(order.lines.is_empty()); + assert_eq!(order.entity.events().len(), 1); + } + + #[test] + fn add_line_ignores_closed_orders() { + let mut order = submitted_order(); + + order + .add_line("G".to_string(), "prod-gadget".to_string(), 1000, 1) + .unwrap(); + + assert_eq!(order.lines.len(), 1); + assert_eq!(order.lines[0].sku, "W"); + assert_eq!(order.entity.events().len(), 3); + } + + #[test] + fn change_quantity_ignores_closed_orders() { + let mut order = submitted_order(); + + order.change_quantity("W".to_string(), 4).unwrap(); + + assert_eq!(order.lines[0].quantity, 2); + assert_eq!(order.entity.events().len(), 3); + } + + #[test] + fn remove_line_ignores_closed_orders() { + let mut order = submitted_order(); + + order.remove_line("W".to_string()).unwrap(); + + assert_eq!(order.lines.len(), 1); + assert_eq!(order.lines[0].sku, "W"); + assert_eq!(order.entity.events().len(), 3); + } +} From bb0d6af978db07a34b6bea2ac7119bede554f793 Mon Sep 17 00:00:00 2001 From: Patrick Lee Scott Date: Sat, 23 May 2026 22:02:13 -0500 Subject: [PATCH 25/26] fix: fail order projection on malformed event versions --- .../projections_service/handlers/order.rs | 47 +++++++++++++++++-- 1 file changed, 42 insertions(+), 5 deletions(-) diff --git a/tests/distributed_read_model/projections_service/handlers/order.rs b/tests/distributed_read_model/projections_service/handlers/order.rs index 0cfdf20..73eb5fc 100644 --- a/tests/distributed_read_model/projections_service/handlers/order.rs +++ b/tests/distributed_read_model/projections_service/handlers/order.rs @@ -34,7 +34,7 @@ pub fn handle(ctx: &Context) -> Result OrderView { /// The aggregate version is the trailing segment of the outbox event id /// (`outbox:::`). -pub(super) fn event_version(event: &Event) -> i64 { - event +pub(super) fn event_version(event: &Event) -> Result { + let raw = event .id .rsplit(':') .next() - .and_then(|raw| raw.parse().ok()) - .unwrap_or(0) + .ok_or_else(|| HandlerError::Rejected("event id is missing a version".to_string()))?; + + raw.parse().map_err(|_| { + HandlerError::Rejected(format!( + "event id {} should end with a numeric aggregate version", + event.id + )) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn event_version_parses_trailing_outbox_segment() { + let event = Event::with_string_payload( + "outbox:order-1:order.line_added:42", + "order.line_added", + "{}", + ); + + assert_eq!(event_version(&event).unwrap(), 42); + } + + #[test] + fn event_version_rejects_malformed_outbox_segment() { + let event = Event::with_string_payload( + "outbox:order-1:order.line_added:bad", + "order.line_added", + "{}", + ); + + let err = event_version(&event).unwrap_err(); + + assert!( + matches!(err, HandlerError::Rejected(message) if message.contains("numeric aggregate version")) + ); + } } From 8b1ce83d5cf75e74af89aa7c43612662bde7f783 Mon Sep 17 00:00:00 2001 From: Patrick Lee Scott Date: Sat, 23 May 2026 22:03:10 -0500 Subject: [PATCH 26/26] test: assert idempotency not-found variants --- tests/read_model_distributed_idempotency/main.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/read_model_distributed_idempotency/main.rs b/tests/read_model_distributed_idempotency/main.rs index 4156d7a..d96b1b0 100644 --- a/tests/read_model_distributed_idempotency/main.rs +++ b/tests/read_model_distributed_idempotency/main.rs @@ -1,8 +1,8 @@ use serde::{Deserialize, Serialize}; use sourced_rust::bus::{Event, Publisher, Subscriber}; use sourced_rust::{ - InMemoryQueue, InMemoryReadModelStore, ReadModel, ReadModelSession, ReadModelSessionStore, - ReadModelStore, RowKey, RowValue, + InMemoryQueue, InMemoryReadModelStore, ReadModel, ReadModelError, ReadModelSession, + ReadModelSessionStore, ReadModelStore, RowKey, RowValue, }; const CONSUMER: &str = "counter-projection"; @@ -114,7 +114,7 @@ fn read_model_write_and_processed_mark_are_atomic() { let err = session.commit(&store).unwrap_err(); - assert!(err.to_string().contains("not found")); + assert!(matches!(err, ReadModelError::NotFound { .. })); assert!(!store.is_processed(CONSUMER, "message-1").unwrap()); assert!(store .get_by_primary_key::("counter-1") @@ -149,7 +149,7 @@ fn ack_happens_only_after_successful_standalone_commit() { let err = failed_session.commit(&store).unwrap_err(); - assert!(err.to_string().contains("not found")); + assert!(matches!(err, ReadModelError::NotFound { .. })); assert!(queue.acknowledged().is_empty()); assert!(!store.is_processed(CONSUMER, &failed.id).unwrap());