diff --git a/README.md b/README.md index edbe04d..31fc5bc 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 @@ -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, @@ -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..02bfbab 100644 --- a/docs/read-models.md +++ b/docs/read-models.md @@ -1,24 +1,21 @@ # 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 | +| 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 -## Defining a Read Model - -Derive `ReadModel` on any serializable struct: +Derive `ReadModel` on any serializable document view: ```rust use serde::{Deserialize, Serialize}; @@ -27,305 +24,264 @@ 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, - 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. +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, +} +``` -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 -``` +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. -### How it works +## Relational Models -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). +A model opts into relational metadata with `#[readmodel(table = "...")]` and +field attributes. Common collection, table, column, id, index, and unique +metadata also have direct helper attributes: ```rust -use sourced_rust::{CommitBuilderExt, OutboxMessage}; - -// After handling a command... -counter.increment(5); +use serde::{Deserialize, Serialize}; +use sourced_rust::ReadModel; -let outbox = OutboxMessage::encode( - "counter-1:incremented", - "CounterIncremented", - &counter.value(), -)?; +#[derive(Clone, Debug, Serialize, Deserialize, ReadModel)] +#[table("players")] +pub struct PlayerView { + #[id("player_id")] + pub id: String, + pub display_name: String, + #[readmodel(jsonb)] + pub counters_by_game: std::collections::HashMap, + #[readmodel(has_many = "PlayerWeaponView", foreign_key = "player_id")] + pub weapons: Vec, +} -repo.outbox(outbox).commit(&mut counter)?; +#[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, + #[index] + pub acquired_at: String, +} ``` -On the consumption side: +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. -```rust -use sourced_rust::{OutboxWorker, LogPublisher}; +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. -let mut worker = OutboxWorker::new(LogPublisher::new()) - .with_batch_size(100) - .with_max_attempts(5); +## Explicit Relationship Includes -// In a loop or background task: -let mut messages = repo.claim_outbox_messages("worker-1", 100, lease)?; -let result = worker.process_batch(&mut messages); -``` +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: -### When to use it - -- 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 +```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()?; +``` -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. +`has_many` relationships hydrate `Vec` fields. `belongs_to` relationships +hydrate `Option` fields. -This is the **default recommendation**. Start here unless you have a -specific reason not to. +`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)`. -## 2. Atomic Commits (The Gaming Pattern) +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. -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. +## Command-Side Atomic Writes -`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)?; + +repo.outbox(message) + .read_models(read_models) + .commit(&mut aggregate)?; + +repo.aggregate(&mut aggregate) + .read_models(read_models) + .outbox(message) + .commit()?; ``` -Order doesn't matter — the commit writes everything in one transactional batch -when the repository implements `TransactionalCommit`. - -### Standalone read model writes - -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 +## Standalone Distributed Projectors -- 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 +A read-model service can commit a session without owning an aggregate +repository: -### Why you don't need read model locking here - -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. ---- +## Schema Registry And Bootstrap -## 3. QueuedReadModelStore — The Escape Hatch - -`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/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 38c1aad..ecf9cf4 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, Attribute, Data, DeriveInput, Expr, ExprArray, ExprLit, Field, Fields, + GenericArgument, Lit, LitStr, Meta, 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); - // Extract #[readmodel(collection = "...")] from struct-level attributes - let collection = extract_collection(&input); + 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()))); - // Extract the field marked with #[readmodel(id)] or default to "id" - let id_field = extract_id_field(&input)?; + let read_model_impl = if let Some(id_field) = &id_field { + Some(quote! { + impl sourced_rust::ReadModel for #name { + const COLLECTION: &'static str = #collection; - Ok(quote! { - impl sourced_rust::ReadModel for #name { - const COLLECTION: &'static str = #collection; - - fn id(&self) -> &str { - &self.#id_field - } - } - }) -} - -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()); + fn id(&self) -> &str { + &self.#id_field + } } - Ok(()) - }); + }) + } 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)]", + )); + }; - if let Some(c) = collection { - return c; - } - } + let relational_impl = if relational { + Some(expand_relational_read_model( + name, + &struct_attrs, + &fields.named, + &field_attrs, + id_field.as_ref(), + )?) + } else { + None + }; - // Default: snake_case struct name + "s" - let name = input.ident.to_string(); - format!("{}s", to_snake_case(&name)) + Ok(quote! { + #read_model_impl + #relational_impl + }) } -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,980 @@ 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(); + 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 + .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); + 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; + } + + 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, + } + }); + + 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 { + 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 { + let index_columns = vec![column_name.clone()]; + let index_name = attrs + .index_name + .clone() + .unwrap_or_else(|| default_index_name(&table_name, &index_columns, attrs.unique)); + let unique = attrs.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 { + 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),* + }) + } + } + + 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 + ))), + } + } + } + }) +} + +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| resolve_column_reference(key, fields, field_attrs)) + .collect(); + } + + id_field + .map(|id| { + let id_name = id.to_string(); + 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; + } + + 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)?; + } else { + return Err(meta.error("unknown readmodel struct attribute")); + } + Ok(()) + })?; + } + Ok(attrs) + } + + fn is_relational(&self) -> bool { + self.table.is_some() || !self.primary_key.is_empty() || !self.indexes.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(); + let mut pending_foreign_key: Option = None; + let mut pending_through: Option = None; 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("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; + } + + 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()); + } + } else if meta.path.is_ident("foreign_key") { + let value = meta.value()?.parse::()?.value(); + if attrs.relationship.is_some() { + 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); + } + } 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(); + if attrs.relationship.is_some() { + 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); } - explicit_id = Some(ident); + } 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) } - 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_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>( + 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_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( + 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 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" { + 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 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(), + 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 +1112,177 @@ mod tests { } #[test] - fn expand_read_model_rejects_missing_id_field() { + 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! { struct CounterView { value: i32, @@ -185,6 +1297,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! { @@ -206,6 +1335,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! { @@ -220,6 +1424,67 @@ 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 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"); diff --git a/src/commit_builder/mod.rs b/src/commit_builder/mod.rs index 389b84c..b4c2f6e 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,21 @@ mod tests { } } + fn raw_session(view: &TestView) -> crate::read_model::ReadModelSession { + let mut session = crate::read_model::ReadModelSession::new(); + session.document(view).unwrap(); + 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(); @@ -253,11 +432,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 +501,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 +533,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 +560,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 +585,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 +619,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 +632,155 @@ 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_key("staged-multi")] + ); + 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_key("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("apply_document_write_plan") + && message.contains("ReadModelMutation::UpsertRow"))); + 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 { @@ -429,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 987eef5..2f54790 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 commit_write_plan( + &self, + plan: ReadModelWritePlan, + ) -> Result { + self.model_store.commit_write_plan(plan) + } - fn upsert_raw(&self, key: &str, bytes: Vec) -> Result<(), ReadModelError> { - self.model_store.upsert_raw(key, bytes) + 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,14 @@ 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 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(), @@ -323,14 +360,12 @@ mod tests { version: u64::MAX, }, ); + let plan = ReadModelWritePlan::new(vec![ReadModelMutation::Document(mutation)], 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..0f82aa7 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,25 @@ 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, 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, }; // 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..01a3cda 100644 --- a/src/read_model/in_memory.rs +++ b/src/read_model/in_memory.rs @@ -1,9 +1,20 @@ //! InMemoryReadModelStore - HashMap-backed read model store for testing and development. -use std::collections::HashMap; +use std::collections::{BTreeMap, HashMap, HashSet}; use std::sync::{Arc, RwLock}; -use super::{ReadModel, ReadModelError, ReadModelStore, Versioned}; +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, + ReadModelLoadRequest, ReadModelMutation, ReadModelQueryCapabilities, ReadModelSchema, + ReadModelSchemaRegistry, ReadModelSessionStore, ReadModelStore, ReadModelWritePlan, + RelationalReadModel, RelationalReadModelQueryStore, RelationshipDef, RelationshipKind, RowKey, + RowValue, RowValues, RowWriteMode, Versioned, +}; /// Internal stored representation of a read model. #[derive(Clone)] @@ -12,6 +23,14 @@ 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; /// Return the next optimistic version for a read model row. @@ -30,12 +49,294 @@ 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(); + reject_non_document_mutations(&plan)?; + 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()) +} + +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, + document_rows: true, + sparse_patches: false, + deletes: false, + processed_messages: true, + } +} + +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) => { + 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) => { + let values = row_values_from_key_and_patch( + &mutation.schema, + &mutation.key, + mutation.patch.into_values(), + )?; + staged_rows.insert( + key.clone(), + StoredRow { + 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 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()); + } + 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); + } + Ok(()) +} + +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. +/// 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 { @@ -49,15 +350,49 @@ 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())), } } 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. + 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(()) } - /// Save a raw read model entry (used by CommitBuilder for type-erased writes). - pub(crate) fn save_raw(&self, key: &str, bytes: Vec) -> Result { + /// 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( + &self, + key: &str, + bytes: Vec, + ) -> Result { let mut storage = self .storage .write() @@ -77,6 +412,304 @@ impl InMemoryReadModelStore { } } +impl ReadModelSessionStore for InMemoryReadModelStore { + fn read_model_capabilities(&self) -> ReadModelAdapterCapabilities { + relational_capabilities() + } + + fn commit_write_plan( + &self, + plan: ReadModelWritePlan, + ) -> Result { + let mut storage = self + .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_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; + } + + 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()))) + } +} + +#[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 Err(ReadModelError::Metadata(format!( + "belongs_to target `{}` must have a single-column primary key to load from `{}`", + target_schema.model_name, source_column + ))); + } + + Ok(target_schema.primary_key.columns[0].clone()) +} + +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); @@ -220,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() { @@ -248,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() { @@ -266,16 +899,12 @@ impl ReadModelStore for InMemoryReadModelStore { Ok(matched) } - - fn upsert_raw(&self, key: &str, bytes: Vec) -> Result<(), ReadModelError> { - self.save_raw(key, bytes)?; - Ok(()) - } } #[cfg(test)] mod tests { use super::*; + use crate::{ColumnDef, ColumnType, PrimaryKey, RowMutation}; use serde::{Deserialize, Serialize}; #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] @@ -291,6 +920,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(); @@ -326,7 +996,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 +1012,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")) @@ -532,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_raw("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(); @@ -544,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_raw("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(); @@ -562,8 +1234,9 @@ mod tests { value: 20, }) .unwrap(); + let key = InMemoryReadModelStore::make_key(TestModel::COLLECTION, "bad"); store - .save_raw("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/metadata.rs b/src/read_model/metadata.rs new file mode 100644 index 0000000..cb341fd --- /dev/null +++ b/src/read_model/metadata.rs @@ -0,0 +1,557 @@ +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)?; + } + + 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", + 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; +} + +/// 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::*; + + 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 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(); + 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..e0eb9b2 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}; @@ -40,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, @@ -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,24 @@ impl From for ReadModelError { } pub use in_memory::InMemoryReadModelStore; +pub use metadata::{ + ColumnDef, ColumnType, ForeignKey, IndexDef, PrimaryKey, ReadModelSchema, RelationalReadModel, + RelationalReadModelIncludes, 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, + ReadModelIncludeRows, ReadModelLoadGraph, ReadModelLoadRequest, ReadModelMutation, + ReadModelQueryCapabilities, ReadModelSession, ReadModelSessionStore, + ReadModelSessionUnitOfWork, ReadModelUnitOfWorkExt, ReadModelWritePlan, + RelationalReadModelQueryStore, RowMutation, RowPatch, RowWriteMode, +}; pub use store::ReadModelStore; diff --git a/src/read_model/queued.rs b/src/read_model/queued.rs index 93f4d1c..8f5c8bf 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,17 +11,21 @@ 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::session::document_key; +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 -/// 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, @@ -102,7 +106,7 @@ impl QueuedReadModelStore { } fn make_key(collection: &str, id: &str) -> String { - format!("{}:{}", collection, id) + document_key(collection, id) } } @@ -118,6 +122,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 +228,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 +243,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 +259,65 @@ 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 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); + 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, @@ -305,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; @@ -323,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()); @@ -560,33 +668,32 @@ mod tests { } #[test] - fn upsert_raw_releases_lock() { - let store = QueuedReadModelStore::new(InMemoryReadModelStore::new()); - let key = "test_models:1"; - - // Seed via inner - store - .inner() - .upsert(&TestModel { + 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: 10, + value: 1, }) - .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(); + .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); } } 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..746ce20 --- /dev/null +++ b/src/read_model/schema.rs @@ -0,0 +1,382 @@ +use std::collections::{BTreeMap, BTreeSet}; + +use super::{ReadModelError, ReadModelSchema, RelationalReadModel, RelationshipKind}; + +/// 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::>(); + 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, &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, + table_names: &BTreeSet, + ) -> Result<(), ReadModelError> { + for relationship in &schema.relationships { + 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) { + return Err(ReadModelError::Metadata(format!( + "read model `{}` relationship `{}` references unregistered join table `{}`", + schema.model_name, relationship.field_name, through + ))); + } + } + + 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(()) + } + + 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(()) + } +} + +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 { + 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..bcf0b69 --- /dev/null +++ b/src/read_model/session.rs @@ -0,0 +1,1520 @@ +use std::collections::{BTreeMap, BTreeSet}; +use std::marker::PhantomData; + +use serde::Serialize; + +use super::{ + ReadModel, ReadModelError, ReadModelSchema, RelationalReadModel, RelationalReadModelIncludes, + RelationshipDef, RelationshipKind, 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, +} + +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 { + 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 { + document_key(&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) + } +} + +#[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 + ))); + } + + let mut current_fingerprints = BTreeSet::new(); + 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); + current_fingerprints.insert(fingerprint.clone()); + 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)?; + } + } + + // `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(()) + } + + 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(()) + } + + 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. +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, +{ + 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(()) +} + +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", + 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(()) +} + +pub(crate) 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(()) +} + +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(|| { + 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(()) +} + +pub(crate) 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()) +} + +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); + push_fingerprint_part(&mut fingerprint, &value_fingerprint(value)); + } + 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(':'); + fingerprint.push_str(part); + fingerprint.push(';'); +} + +fn value_fingerprint(value: &RowValue) -> String { + match value { + RowValue::Null => "null".into(), + 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)); + } + + #[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/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/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..a542dcd --- /dev/null +++ b/tests/distributed_read_model/catalog_service/handlers/product_add.rs @@ -0,0 +1,63 @@ +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 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", + 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 })) +} + +#[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/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..5a8269c --- /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::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..906fe15 --- /dev/null +++ b/tests/distributed_read_model/catalog_service/models/product.rs @@ -0,0 +1,55 @@ +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", when = unit_cents > 0)] + 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, +} + +#[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()); + } +} 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..5a3806b --- /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 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..981201b --- /dev/null +++ b/tests/distributed_read_model/fulfillment.rs @@ -0,0 +1,60 @@ +//! Shared fulfillment-saga message contract. +//! +//! 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 STARTED: &str = "fulfillment.started"; + pub const INVENTORY_RESERVED: &str = "fulfillment.inventory_reserved"; + 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. +#[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 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") +} + +/// 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") +} diff --git a/tests/distributed_read_model/handlers/account_deposit.rs b/tests/distributed_read_model/handlers/account_deposit.rs deleted file mode 100644 index e904829..0000000 --- a/tests/distributed_read_model/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::models::aggregates::account::{Account, DepositMoney}; -use crate::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/handlers/account_open.rs b/tests/distributed_read_model/handlers/account_open.rs deleted file mode 100644 index b0e4462..0000000 --- a/tests/distributed_read_model/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::models::aggregates::account::{Account, OpenAccount}; -use crate::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/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/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..f252f5c --- /dev/null +++ b/tests/distributed_read_model/inventory_service/handlers/release.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, inventory_event, FulfillmentMsg}; +use crate::inventory_service::InventoryRepo; + +pub const COMMAND: &str = event::COMPENSATING; + +pub fn guard(ctx: &Context) -> bool { + ctx.has_fields(&["order_id", "sku", "quantity"]) +} + +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( + inventory_event::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..3197ad8 --- /dev/null +++ b/tests/distributed_read_model/inventory_service/handlers/reserve.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, inventory_event, FulfillmentMsg}; +use crate::inventory_service::InventoryRepo; + +pub const COMMAND: &str = event::STARTED; + +pub fn guard(ctx: &Context) -> bool { + ctx.has_fields(&["order_id", "sku", "quantity"]) +} + +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( + inventory_event::RESERVED, + &FulfillmentMsg { + order_id: msg.order_id.clone(), + sku: msg.sku.clone(), + quantity: msg.quantity, + ..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..5b78df6 --- /dev/null +++ b/tests/distributed_read_model/inventory_service/mod.rs @@ -0,0 +1,15 @@ +//! 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; + +mod handlers; +mod service; + +use sourced_rust::{AggregateRepository, HashMapRepository, QueuedRepository}; + +pub use models::Inventory; +pub use service::{seed_stock, service}; + +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..df52aed --- /dev/null +++ b/tests/distributed_read_model/inventory_service/models/inventory.rs @@ -0,0 +1,99 @@ +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", 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 = quantity > 0 && self.available >= quantity)] + pub fn reserve(&mut self, quantity: i64) { + self.available -= quantity; + self.reserved += quantity; + } + + #[event("StockReleased", when = quantity > 0 && self.reserved >= quantity)] + pub fn release(&mut self, quantity: i64) { + self.available += quantity; + 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); + } +} 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..8324529 --- /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 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 b2f6284..8d4913f 100644 --- a/tests/distributed_read_model/main.rs +++ b/tests/distributed_read_model/main.rs @@ -1,292 +1,469 @@ -//! 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 process 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; +//! - 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. //! -//! The test uses threads and `InMemoryQueue` as process 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. - -mod handlers; -mod models; -mod query_process; -mod read_model; +//! 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 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::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}; -use sourced_rust::microsvc::{Service, Session}; +use catalog_service::AddProduct; +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, 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::Subscribable; +use sourced_rust::microsvc::{self, Service, Session}; use sourced_rust::{ - AggregateBuilder, AggregateRepository, GetAggregate, HashMapRepository, InMemoryQueue, - OutboxWorkerThread, Queueable, QueuedRepository, ReadModelsExt, + AggregateBuilder, HashMapRepository, InMemoryQueue, InMemoryReadModelStore, OutboxWorkerThread, + Queueable, ReadModelSessionStore, }; -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 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) { +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)); } } -#[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() { +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_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::service(catalog_store.clone().queued().aggregate()); + let order_store = HashMapRepository::new(); + let order_service = order_service::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(); + register_schemas(&read_store).expect("relational schemas should register"); + let projection_svc = projection_service(read_store.clone()); + let query_service = OrderQueryService::new(read_store.clone()); + + // Saga subsystem: inventory, payment, and the orchestrator are ordinary + // `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()); + 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::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 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, + ); - let read_store = HashMapRepository::new(); - let read_model_service = start_account_summary_service(queue.clone(), read_store.clone()); - let query_process = AccountSummaryQueryProcess::new(read_store.clone()); + // 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, + }, + ); - 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"); + // 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(), + }, + ); - wait_for_published_events(&queue, 2); + // Kick off fulfillment for the happy order (amount within the payment cap). + dispatch( + &saga_svc, + command::START, + FulfillmentMsg { + order_id: "order-1".to_string(), + sku: "W".to_string(), + quantity: 3, + amount_cents: 1500, + ..Default::default() + }, + ); - 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 - }); + // 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(), + }, + ); + dispatch( + &saga_svc, + command::START, + FulfillmentMsg { + order_id: "order-2".to_string(), + sku: "W".to_string(), + quantity: 1, + amount_cents: 200_000, + ..Default::default() + }, + ); - assert_eq!(summary.owner.as_deref(), Some("Ada Lovelace")); - assert_eq!(summary.balance_cents, 2500); - assert_eq!(summary.deposit_count, 1); - - let queried_summary = query_process - .get("acct-1") - .expect("query process should read projected account summary") - .expect("query process 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 - .get("missing-account") - .expect("query process should read projected account summary") - .is_none()); - - let mut model_commands = model_service.commands(); - model_commands.sort(); + // === 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!( - model_commands, - vec!["account.deposit", "account.open"], - "model service should expose only write-side commands" + order.metadata.get("source").map(String::as_str), + Some("order-service") ); - - 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" + // 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!["completed", "inventory_reserved", "started"]); + + // 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!["cancelled", "compensating", "inventory_reserved", "started"] ); - 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" + // 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" ); - read_model_service.stop(); - let worker_stats = outbox_worker - .stop() - .expect("outbox worker should stop cleanly"); + // 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); + + // 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.started" + | "fulfillment.inventory_reserved" + | "fulfillment.completed" + | "fulfillment.compensating" + | "fulfillment.cancelled" + ) { + FULFILLMENT_CONSUMER + } else { + continue; + }; + assert!( + read_store + .is_processed(consumer, &event.id) + .expect("processed lookup should succeed"), + "event {} should be marked processed before ack", + event.id + ); + } - assert!(worker_stats.messages_published >= 2); + let _ = order_sub.stop(); + let _ = saga_sub.stop(); + let _ = payment_sub.stop(); + let _ = inventory_sub.stop(); + let _ = projection_sub.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_model_service(account_repo); - let model_base = 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::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_model_service(account_repo); - let mut model_client = 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::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/models/aggregates/account.rs b/tests/distributed_read_model/models/aggregates/account.rs deleted file mode 100644 index 4f3eb09..0000000 --- a/tests/distributed_read_model/models/aggregates/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/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/account_summary.rs b/tests/distributed_read_model/models/readmodels/account_summary.rs deleted file mode 100644 index 2e2fa97..0000000 --- a/tests/distributed_read_model/models/readmodels/account_summary.rs +++ /dev/null @@ -1,33 +0,0 @@ -use serde::{Deserialize, Serialize}; -use sourced_rust::ReadModel; - -#[derive(Clone, Debug, Deserialize, Serialize, ReadModel)] -#[readmodel(collection = "account_summaries")] -pub struct AccountSummary { - #[readmodel(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(), - } - } - - 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/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/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..3ebeeec --- /dev/null +++ b/tests/distributed_read_model/order_fulfillment_saga_service/handlers/mod.rs @@ -0,0 +1,5 @@ +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/record_inventory_released.rs b/tests/distributed_read_model/order_fulfillment_saga_service/handlers/record_inventory_released.rs new file mode 100644 index 0000000..9ff315e --- /dev/null +++ b/tests/distributed_read_model/order_fulfillment_saga_service/handlers/record_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, inventory_event, FulfillmentMsg}; +use crate::order_fulfillment_saga_service::{OrderFulfillmentSaga, SagaRepo}; + +pub const COMMAND: &str = inventory_event::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::saga_event( + event::CANCELLED, + &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/record_inventory_reserved.rs b/tests/distributed_read_model/order_fulfillment_saga_service/handlers/record_inventory_reserved.rs new file mode 100644 index 0000000..b4cec83 --- /dev/null +++ b/tests/distributed_read_model/order_fulfillment_saga_service/handlers/record_inventory_reserved.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, inventory_event, FulfillmentMsg}; +use crate::order_fulfillment_saga_service::{OrderFulfillmentSaga, SagaRepo}; + +pub const COMMAND: &str = inventory_event::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 mut out = fulfillment::saga_event( + event::INVENTORY_RESERVED, + &FulfillmentMsg { + order_id: msg.order_id.clone(), + sku: saga.sku.clone(), + quantity: saga.quantity, + amount_cents: saga.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/record_payment_declined.rs b/tests/distributed_read_model/order_fulfillment_saga_service/handlers/record_payment_declined.rs new file mode 100644 index 0000000..715c123 --- /dev/null +++ b/tests/distributed_read_model/order_fulfillment_saga_service/handlers/record_payment_declined.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, payment_event, FulfillmentMsg}; +use crate::order_fulfillment_saga_service::{OrderFulfillmentSaga, SagaRepo}; + +pub const COMMAND: &str = payment_event::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::saga_event( + event::COMPENSATING, + &FulfillmentMsg { + order_id: msg.order_id.clone(), + sku, + quantity, + detail: msg.detail.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/record_payment_succeeded.rs b/tests/distributed_read_model/order_fulfillment_saga_service/handlers/record_payment_succeeded.rs new file mode 100644 index 0000000..babd68c --- /dev/null +++ b/tests/distributed_read_model/order_fulfillment_saga_service/handlers/record_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, payment_event, FulfillmentMsg}; +use crate::order_fulfillment_saga_service::{OrderFulfillmentSaga, SagaRepo}; + +pub const COMMAND: &str = payment_event::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::saga_event( + event::COMPLETED, + &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/start.rs b/tests/distributed_read_model/order_fulfillment_saga_service/handlers/start.rs new file mode 100644 index 0000000..c776405 --- /dev/null +++ b/tests/distributed_read_model/order_fulfillment_saga_service/handlers/start.rs @@ -0,0 +1,38 @@ +use serde_json::{json, Value}; +use sourced_rust::microsvc::{Context, HandlerError}; +use sourced_rust::OutboxCommitExt; + +use crate::fulfillment::{self, command, event, FulfillmentMsg}; +use crate::order_fulfillment_saga_service::{OrderFulfillmentSaga, SagaRepo}; + +pub const COMMAND: &str = command::START; + +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::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() + }, + ); + 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..ba443b7 --- /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 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; + +mod handlers; +mod service; + +use sourced_rust::{AggregateRepository, HashMapRepository, QueuedRepository}; + +pub use models::OrderFulfillmentSaga; +pub use service::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..9bb11bf --- /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; +/// bus-facing commands and events live in `fulfillment.rs`. +#[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..b2f2c41 --- /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 service(repo: SagaRepo) -> Arc> { + Arc::new(sourced_rust::register_handlers!( + Service::new(repo), + 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/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..60205fb --- /dev/null +++ b/tests/distributed_read_model/order_service/handlers/order_add_line.rs @@ -0,0 +1,38 @@ +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()))?; + 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(), + 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..48ddf05 --- /dev/null +++ b/tests/distributed_read_model/order_service/handlers/order_cancel.rs @@ -0,0 +1,31 @@ +//! 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}; +use sourced_rust::{OutboxCommitExt, OutboxMessage}; + +use crate::fulfillment::{event, FulfillmentMsg}; +use crate::order_service::{Order, OrderRepo}; + +pub const COMMAND: &str = event::CANCELLED; + +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..262d6c9 --- /dev/null +++ b/tests/distributed_read_model/order_service/handlers/order_change_quantity.rs @@ -0,0 +1,35 @@ +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()))?; + 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)?; + 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..f2e0ed7 --- /dev/null +++ b/tests/distributed_read_model/order_service/handlers/order_confirm.rs @@ -0,0 +1,31 @@ +//! 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}; +use sourced_rust::{OutboxCommitExt, OutboxMessage}; + +use crate::fulfillment::{event, FulfillmentMsg}; +use crate::order_service::{Order, OrderRepo}; + +pub const COMMAND: &str = event::COMPLETED; + +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..831f1bc --- /dev/null +++ b/tests/distributed_read_model/order_service/handlers/order_remove_line.rs @@ -0,0 +1,30 @@ +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()))?; + 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)?; + 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..d444fed --- /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::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..9db5399 --- /dev/null +++ b/tests/distributed_read_model/order_service/models/order.rs @@ -0,0 +1,186 @@ +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" && 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; + line.unit_cents = unit_cents; + } else { + self.lines.push(OrderLineState { + sku, + product_id, + unit_cents, + quantity, + }); + } + } + + #[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", when = self.status.as_str() == "open")] + 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, +} + +#[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); + } +} diff --git a/tests/distributed_read_model/order_service/service.rs b/tests/distributed_read_model/order_service/service.rs new file mode 100644 index 0000000..50f83bc --- /dev/null +++ b/tests/distributed_read_model/order_service/service.rs @@ -0,0 +1,62 @@ +use std::sync::Arc; + +use sourced_rust::microsvc::Service; + +use super::{handlers, OrderRepo}; + +pub fn service(repo: OrderRepo) -> Arc> { + Arc::new(sourced_rust::register_handlers!( + Service::new(repo), + 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 { + 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/payment_service/handlers/charge.rs b/tests/distributed_read_model/payment_service/handlers/charge.rs new file mode 100644 index 0000000..dc6db0e --- /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, payment_event, FulfillmentMsg}; +use crate::payment_service::PaymentRepo; + +pub const COMMAND: &str = event::INVENTORY_RESERVED; + +/// 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::domain_event( + payment_event::SUCCEEDED, + &FulfillmentMsg { + order_id: msg.order_id.clone(), + ..Default::default() + }, + ) + } else { + payment.decline(msg.order_id.clone(), "amount over limit".to_string())?; + fulfillment::domain_event( + payment_event::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..5120ade --- /dev/null +++ b/tests/distributed_read_model/payment_service/mod.rs @@ -0,0 +1,15 @@ +//! 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; + +mod handlers; +mod service; + +use sourced_rust::{AggregateRepository, HashMapRepository, QueuedRepository}; + +pub use models::Payment; +pub use service::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..44b698a --- /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 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..c579e6c --- /dev/null +++ b/tests/distributed_read_model/projections_service/handlers/fulfillment.rs @@ -0,0 +1,48 @@ +//! 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 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; + +pub const CONSUMER: &str = "order-fulfillment-projection"; +pub const EVENTS: &[&str] = &[ + event::STARTED, + event::INVENTORY_RESERVED, + event::COMPLETED, + event::COMPENSATING, + event::CANCELLED, +]; + +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.") + .unwrap_or(&evt.event_type) + .to_string(); + + let row = OrderFulfillmentStepView { + order_id: msg.order_id.clone(), + step, + detail: msg.detail.clone(), + }; + + let mut session = ctx.repo().session(); + session + .save(&row) + .map_err(super::read_model_error)? + .mark_processed(CONSUMER, &evt.id); + 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 new file mode 100644 index 0000000..c6a994e --- /dev/null +++ b/tests/distributed_read_model/projections_service/handlers/mod.rs @@ -0,0 +1,50 @@ +//! 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 serde::{Deserialize, Serialize}; +use sourced_rust::bus::Event; +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, + } + } +} + +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 new file mode 100644 index 0000000..73eb5fc --- /dev/null +++ b/tests/distributed_read_model/projections_service/handlers/order.rs @@ -0,0 +1,147 @@ +//! 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 serde_json::{json, Value}; +use sourced_rust::bus::Event; +use sourced_rust::microsvc::{Context, HandlerError}; +use sourced_rust::{InMemoryReadModelStore, 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 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 = ctx.repo().session(); + let existing = session + .load::(order_key(&desired.order_id)) + .include("lines") + .one() + .map_err(super::read_model_error)?; + + match existing { + Some(current) if current.data.source_version >= version => {} + Some(_) => { + session + .save_changes(desired) + .map_err(super::read_model_error)?; + } + None => { + session.save(&desired).map_err(super::read_model_error)?; + for line in &desired.lines { + session.save(line).map_err(super::read_model_error)?; + } + } + } + + session.mark_processed(CONSUMER, &event.id); + session.commit().map_err(super::read_model_error)?; + + Ok(json!({ "event_id": event.id })) +} + +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) -> Result { + let raw = event + .id + .rsplit(':') + .next() + .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")) + ); + } +} 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..1e24cd6 --- /dev/null +++ b/tests/distributed_read_model/projections_service/handlers/product.rs @@ -0,0 +1,40 @@ +use std::collections::BTreeMap; + +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; + +pub const CONSUMER: &str = "product-catalog-projection"; +pub const EVENTS: &[&str] = &["product.added", "product.repriced"]; + +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()); + let view = ProductView { + product_id: snapshot.id.clone(), + name: snapshot.name.clone(), + unit_cents: snapshot.unit_cents, + attributes, + }; + + let mut session = ctx.repo().session(); + session + .save(&view) + .map_err(super::read_model_error)? + .mark_processed(CONSUMER, &event.id); + 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 new file mode 100644 index 0000000..e2f9e2c --- /dev/null +++ b/tests/distributed_read_model/projections_service/mod.rs @@ -0,0 +1,11 @@ +//! 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; +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 new file mode 100644 index 0000000..12dc819 --- /dev/null +++ b/tests/distributed_read_model/projections_service/service.rs @@ -0,0 +1,85 @@ +use std::sync::Arc; + +use serde_json::Value; +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 ProjectionSubscriber { + inner: S, +} + +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) + } +} + +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 subscriber(subscriber: S) -> ProjectionSubscriber +where + S: Subscriber, +{ + ProjectionSubscriber { inner: subscriber } +} + +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 +} diff --git a/tests/distributed_read_model/query_process.rs b/tests/distributed_read_model/query_process.rs deleted file mode 100644 index 07b4d74..0000000 --- a/tests/distributed_read_model/query_process.rs +++ /dev/null @@ -1,21 +0,0 @@ -use sourced_rust::{HashMapRepository, ReadModelError, ReadModelsExt}; - -use crate::models::readmodels::account_summary::AccountSummary; - -#[derive(Clone)] -pub struct AccountSummaryQueryProcess { - store: HashMapRepository, -} - -impl AccountSummaryQueryProcess { - pub fn new(store: HashMapRepository) -> Self { - Self { store } - } - - pub fn get(&self, account_id: &str) -> Result, ReadModelError> { - self.store - .read_models::() - .get(account_id) - .map(|summary| summary.map(|view| view.data)) - } -} diff --git a/tests/distributed_read_model/query_service/mod.rs b/tests/distributed_read_model/query_service/mod.rs new file mode 100644 index 0000000..d590656 --- /dev/null +++ b/tests/distributed_read_model/query_service/mod.rs @@ -0,0 +1,49 @@ +//! 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). + +use sourced_rust::{InMemoryReadModelStore, ReadModelError, ReadModelUnitOfWorkExt}; + +use crate::read_models::{order_key, order_line_key, OrderLineView, OrderView}; + +#[derive(Clone)] +pub struct OrderQueryService { + store: InMemoryReadModelStore, +} + +impl OrderQueryService { + pub fn new(store: InMemoryReadModelStore) -> Self { + Self { store } + } + + /// 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_model.rs b/tests/distributed_read_model/read_model.rs deleted file mode 100644 index dd5b07a..0000000 --- a/tests/distributed_read_model/read_model.rs +++ /dev/null @@ -1,126 +0,0 @@ -use std::sync::mpsc::{self, TryRecvError}; -use std::thread; -use std::time::{Duration, Instant}; - -use sourced_rust::bus::{Bus, Event}; -use sourced_rust::{HashMapRepository, InMemoryQueue, ReadModelsExt}; - -use crate::models::aggregates::account::AccountSnapshot; -use crate::models::readmodels::account_summary::AccountSummary; - -pub struct ReadModelServiceHandle { - stop_tx: mpsc::Sender<()>, - handle: thread::JoinHandle<()>, -} - -impl ReadModelServiceHandle { - pub fn stop(self) { - let _ = self.stop_tx.send(()); - self.handle - .join() - .expect("read model service should stop cleanly"); - } -} - -pub fn start_account_summary_service( - queue: InMemoryQueue, - store: HashMapRepository, -) -> ReadModelServiceHandle { - 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 events = bus.subscribe(&["AccountOpened", "MoneyDeposited"]); - ready_tx - .send(()) - .expect("read model 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)) => { - project_account_summary(&store, &event); - events - .ack(&event.id) - .expect("read model service should ack projected events"); - } - Ok(None) => {} - Err(err) => panic!("read model service failed to receive event: {err}"), - } - } - }); - - ready_rx - .recv_timeout(Duration::from_secs(3)) - .expect("read model service should subscribe before accepting writes"); - - ReadModelServiceHandle { stop_tx, handle } -} - -fn load_summary(store: &HashMapRepository, account_id: &str) -> AccountSummary { - store - .read_models::() - .get(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) { - 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); - } - "MoneyDeposited" => { - summary.owner = Some(snapshot.owner); - summary.balance_cents = snapshot.balance_cents; - summary.deposit_count += 1; - } - other => panic!("unexpected account event: {other}"), - } - - summary.mark_projected(&event.id); - store - .read_models::() - .upsert(&summary) - .expect("account summary projection should persist"); -} - -pub fn wait_for_summary( - store: &HashMapRepository, - account_id: &str, - ready: impl Fn(&AccountSummary) -> bool, -) -> AccountSummary { - let deadline = Instant::now() + Duration::from_secs(10); - - loop { - if let Some(summary) = store - .read_models::() - .get(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/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..936a58d --- /dev/null +++ b/tests/distributed_read_model_board/projections_service/handlers/board.rs @@ -0,0 +1,118 @@ +//! 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() + .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); + } +} 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())), + ]) +} 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..d96b1b0 --- /dev/null +++ b/tests/read_model_distributed_idempotency/main.rs @@ -0,0 +1,177 @@ +use serde::{Deserialize, Serialize}; +use sourced_rust::bus::{Event, Publisher, Subscriber}; +use sourced_rust::{ + InMemoryQueue, InMemoryReadModelStore, ReadModel, ReadModelError, ReadModelSession, + ReadModelSessionStore, ReadModelStore, RowKey, RowValue, +}; + +const CONSUMER: &str = "counter-projection"; + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, ReadModel)] +#[collection("counter_views")] +struct CounterView { + #[id] + id: String, + value: i32, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, ReadModel)] +#[table("relational_counters")] +struct RelationalCounter { + #[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 +} + +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(); + 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") + .expect_version::(relational_counter_key("counter-1"), 99) + .unwrap() + .save(&row) + .unwrap(); + + let err = session.commit(&store).unwrap_err(); + + assert!(matches!(err, ReadModelError::NotFound { .. })); + 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 + .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!(matches!(err, ReadModelError::NotFound { .. })); + 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..8fe7a92 --- /dev/null +++ b/tests/read_model_document_conformance/main.rs @@ -0,0 +1,295 @@ +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, +} + +#[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(), + 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 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(); + 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("apply_document_write_plan") + && message.contains("ReadModelMutation::UpsertRow"))); + 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_key("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_key("document_views", "same")) + .unwrap(); + let other_lock = store + .lock_manager() + .get_lock(&document_key("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("apply_document_write_plan") + && message.contains("ReadModelMutation::UpsertRow"))); + let lock = store + .lock_manager() + .get_lock(&document_key("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..56a54e4 --- /dev/null +++ b/tests/read_model_metadata/main.rs @@ -0,0 +1,268 @@ +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, + #[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, +} + +#[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, +} + +#[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 { + 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 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(); + 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"))); +} + +#[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_relationship_includes/main.rs b/tests/read_model_relationship_includes/main.rs new file mode 100644 index 0000000..9c35542 --- /dev/null +++ b/tests/read_model_relationship_includes/main.rs @@ -0,0 +1,437 @@ +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, +} + +#[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, +} + +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_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"), + ]); + 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(), 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] +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")) + ); +} + +#[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_schema_bootstrap/main.rs b/tests/read_model_schema_bootstrap/main.rs new file mode 100644 index 0000000..4ae6136 --- /dev/null +++ b/tests/read_model_schema_bootstrap/main.rs @@ -0,0 +1,302 @@ +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, + #[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, + #[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 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(); + 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..1a19685 --- /dev/null +++ b/tests/read_model_session/main.rs @@ -0,0 +1,369 @@ +use std::collections::HashMap; + +use serde::{Deserialize, Serialize}; +use sourced_rust::{ + ExpectedVersion, InMemoryReadModelStore, PatchMode, ReadModel, ReadModelAdapterCapabilities, + ReadModelError, ReadModelMutation, ReadModelSession, ReadModelUnitOfWorkExt, 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, + #[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 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)) + .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_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(); + store.register_schema::().unwrap(); + 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`")) + ); + + let mut read_models = store.session(); + let loaded = read_models + .load::(account_key("acct-1")) + .one() + .unwrap(); + assert!(loaded.is_none()); +} + +#[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 { + 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();