diff --git a/distributed_macros/src/read_model.rs b/distributed_macros/src/read_model.rs index aa2a4ed49..5d4059f78 100644 --- a/distributed_macros/src/read_model.rs +++ b/distributed_macros/src/read_model.rs @@ -119,6 +119,7 @@ fn expand_relational_read_model( let mut relationships = Vec::new(); let mut hydrate_include_arms = Vec::new(); let mut include_rows_arms = Vec::new(); + let mut include_schema_arms = Vec::new(); for (field, attrs) in fields.iter().zip(field_attrs) { let ident = field @@ -129,10 +130,11 @@ fn expand_relational_read_model( if let Some(relationship) = attrs.relationship_tokens(&field_name)? { relationships.push(relationship); - let (hydrate_arm, include_rows_arm) = + let (hydrate_arm, include_rows_arm, include_schema_arm) = attrs.relationship_include_tokens(field, &field_name)?; hydrate_include_arms.push(hydrate_arm); include_rows_arms.push(include_rows_arm); + include_schema_arms.push(include_schema_arm); row_fields.push(quote! { #ident: ::core::default::Default::default() }); continue; } @@ -162,7 +164,7 @@ fn expand_relational_read_model( let jsonb = attrs.jsonb; column_defs.push(quote! { - distributed::ColumnDef { + distributed::TableColumn { field_name: #field_name.to_string(), column_name: #column_name.to_string(), column_type: #column_type, @@ -231,34 +233,36 @@ fn expand_relational_read_model( Ok(quote! { impl distributed::RelationalReadModel for #name { - fn schema() -> distributed::ReadModelSchema { - distributed::ReadModelSchema { - model_name: #model_name.to_string(), - table_name: #table_name.to_string(), - columns: vec![#(#column_defs),*], - primary_key: distributed::PrimaryKey { - columns: vec![#(#primary_key_columns),*], - }, - version_column: Some(distributed::DEFAULT_READ_MODEL_VERSION_COLUMN.to_string()), - foreign_keys: vec![#(#foreign_keys),*], - indexes: vec![#(#indexes),*], - relationships: vec![#(#relationships),*], - } + fn schema() -> &'static distributed::TableSchema { + static SCHEMA: ::std::sync::LazyLock = + ::std::sync::LazyLock::new(|| distributed::TableSchema { + model_name: #model_name.to_string(), + table_name: #table_name.to_string(), + columns: vec![#(#column_defs),*], + primary_key: distributed::PrimaryKey { + columns: vec![#(#primary_key_columns),*], + }, + version_column: Some(distributed::DEFAULT_TABLE_VERSION_COLUMN.to_string()), + foreign_keys: vec![#(#foreign_keys),*], + indexes: vec![#(#indexes),*], + relationships: vec![#(#relationships),*], + }); + &SCHEMA } - fn primary_key(&self) -> Result { + fn primary_key(&self) -> Result { let mut key = distributed::RowKey::default(); #(#key_inserts)* Ok(key) } - fn to_row(&self) -> Result { + fn to_row(&self) -> Result { let mut row = distributed::RowValues::new(); #(#row_inserts)* Ok(row) } - fn from_row(row: distributed::RowValues) -> Result { + fn from_row(row: distributed::RowValues) -> Result { Ok(Self { #(#row_fields),* }) @@ -270,10 +274,10 @@ fn expand_relational_read_model( &mut self, include: &str, rows: Vec, - ) -> Result<(), distributed::ReadModelError> { + ) -> Result<(), distributed::TableStoreError> { match include { #(#hydrate_include_arms,)* - _ => Err(distributed::ReadModelError::Metadata(format!( + _ => Err(distributed::TableStoreError::Metadata(format!( "read model `{}` has no hydratable relationship `{}`", #model_name, include @@ -284,10 +288,23 @@ fn expand_relational_read_model( fn include_rows( &self, include: &str, - ) -> Result, distributed::ReadModelError> { + ) -> Result, distributed::TableStoreError> { match include { #(#include_rows_arms,)* - _ => Err(distributed::ReadModelError::Metadata(format!( + _ => Err(distributed::TableStoreError::Metadata(format!( + "read model `{}` has no tracked relationship `{}`", + #model_name, + include + ))), + } + } + + fn include_target_schema( + include: &str, + ) -> Result<&'static distributed::TableSchema, distributed::TableStoreError> { + match include { + #(#include_schema_arms,)* + _ => Err(distributed::TableStoreError::Metadata(format!( "read model `{}` has no tracked relationship `{}`", #model_name, include @@ -356,7 +373,7 @@ fn index_def_tokens( .collect::>(); quote! { - distributed::IndexDef { + distributed::TableIndex { name: Some(#index_name.to_string()), columns: vec![#(#columns),*], unique: #unique, @@ -661,7 +678,11 @@ impl FieldAttrs { &self, field: &Field, field_name: &str, - ) -> syn::Result<(proc_macro2::TokenStream, proc_macro2::TokenStream)> { + ) -> syn::Result<( + proc_macro2::TokenStream, + 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") })?; @@ -689,7 +710,7 @@ impl FieldAttrs { self.#ident = rows .into_iter() .map(<#inner as distributed::RelationalReadModel>::from_row) - .collect::, distributed::ReadModelError>>()?; + .collect::, distributed::TableStoreError>>()?; Ok(()) } }; @@ -698,9 +719,12 @@ impl FieldAttrs { .#ident .iter() .map(distributed::RelationalReadModel::to_row) - .collect::, distributed::ReadModelError>>() + .collect::, distributed::TableStoreError>>() + }; + let include_schema = quote! { + #field_name => Ok(<#inner as distributed::RelationalReadModel>::schema()) }; - Ok((hydrate, include_rows)) + Ok((hydrate, include_rows, include_schema)) } RelationshipKindAttr::BelongsTo => { let inner = option_inner_type(&field.ty).ok_or_else(|| { @@ -725,7 +749,7 @@ impl FieldAttrs { None => None, }; if rows.next().is_some() { - return Err(distributed::ReadModelError::Metadata(format!( + return Err(distributed::TableStoreError::Metadata(format!( "belongs_to relationship `{}` returned more than one row", #field_name ))); @@ -742,7 +766,10 @@ impl FieldAttrs { Ok(rows) } }; - Ok((hydrate, include_rows)) + let include_schema = quote! { + #field_name => Ok(<#inner as distributed::RelationalReadModel>::schema()) + }; + Ok((hydrate, include_rows, include_schema)) } } } diff --git a/src/commit_builder/mod.rs b/src/commit_builder/mod.rs index 0a0c7e202..97c21569a 100644 --- a/src/commit_builder/mod.rs +++ b/src/commit_builder/mod.rs @@ -27,7 +27,8 @@ use crate::aggregate::Aggregate; use crate::entity::Entity; use crate::outbox::OutboxMessage; -use crate::read_model::{ReadModelWritePlan, ReadModelWritePlanBuilder}; +use crate::read_model::ReadModelWritePlanBuilder; +use crate::table::TableWritePlan; use crate::repository::{ CommitBatch, RepositoryError, StreamIdentity, StreamWrite, TransactionalCommit, }; @@ -97,7 +98,7 @@ pub struct CommitBuilder<'a, R> { streams: Vec>, outbox_messages: Vec, outbox_source: StagedOutboxSource, - read_model_plans: Vec, + read_model_plans: Vec, error: Option, } diff --git a/src/hashmap_repo/repository.rs b/src/hashmap_repo/repository.rs index 14adcb1fe..87b7c1795 100644 --- a/src/hashmap_repo/repository.rs +++ b/src/hashmap_repo/repository.rs @@ -11,8 +11,7 @@ use crate::entity::{Entity, EventRecord}; use crate::outbox::OutboxMessage; use crate::read_model::in_memory::apply_read_model_write_plan; use crate::read_model::{ - InMemoryReadModelStore, ReadModelAdapterCapabilities, ReadModelCommitOutcome, ReadModelError, - ReadModelLoadGraph, ReadModelLoadRequest, ReadModelQueryCapabilities, ReadModelWritePlan, + InMemoryReadModelStore, ReadModelLoadGraph, ReadModelLoadRequest, ReadModelQueryCapabilities, }; use crate::repository::{ reject_duplicate_outbox_messages, reject_duplicate_streams, @@ -22,6 +21,7 @@ use crate::repository::{ TransactionalCommit, }; use crate::snapshot::{InMemorySnapshotStore, SnapshotRecord}; +use crate::table::{TableAdapterCapabilities, TableCommitOutcome, TableStoreError, TableWritePlan}; /// In-memory repository implementation using HashMap. /// @@ -344,14 +344,14 @@ fn stored_stream_version(events: Option<&Vec>) -> u64 { } impl ReadModelWritePlanStore for HashMapRepository { - fn read_model_capabilities(&self) -> ReadModelAdapterCapabilities { + fn read_model_capabilities(&self) -> TableAdapterCapabilities { self.model_store.read_model_capabilities() } fn commit_write_plan( &self, - plan: ReadModelWritePlan, - ) -> impl Future> + Send + '_ { + plan: TableWritePlan, + ) -> impl Future> + Send + '_ { self.model_store.commit_write_plan(plan) } } @@ -364,7 +364,7 @@ impl RelationalReadModelQueryStore for HashMapRepository { fn load_graph( &self, request: ReadModelLoadRequest, - ) -> impl Future> + Send + '_ { + ) -> impl Future> + Send + '_ { self.model_store.load_graph(request) } } diff --git a/src/lib.rs b/src/lib.rs index 0d738c377..0dc6e41e2 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -94,29 +94,26 @@ pub use queued_repo::{ // Read models: projections and read-optimized views pub use read_model::{ - ColumnDef, ColumnType, DeleteRowMutation, ExpectedVersion, ForeignKey, InMemoryReadModelStore, - IndexDef, PatchMode, PatchRowMutation, PrimaryKey, ReadModel, ReadModelAdapterCapabilities, - ReadModelCommitOutcome, ReadModelError, ReadModelIncludeRows, ReadModelLoadBuilder, - ReadModelLoadGraph, ReadModelLoadRequest, ReadModelMigrationArtifact, ReadModelMutation, - ReadModelQueryCapabilities, ReadModelSchema, ReadModelSchemaAdapter, - ReadModelSchemaAdapterCapabilities, ReadModelSchemaBootstrap, ReadModelSchemaIssue, - ReadModelSchemaIssueKind, ReadModelSchemaRegistry, ReadModelSchemaVerification, - ReadModelWorkspace, ReadModelWorkspaceExt, ReadModelWritePlan, ReadModelWritePlanBuilder, - RelationalReadModel, RelationalReadModelIncludes, RelationshipDef, RelationshipKind, RowKey, - RowMutation, RowPatch, RowValue, RowValues, RowWriteMode, Versioned, - DEFAULT_READ_MODEL_VERSION_COLUMN, + InMemoryReadModelStore, ReadModel, ReadModelIncludeRows, ReadModelLoadBuilder, + ReadModelLoadGraph, ReadModelLoadRequest, ReadModelQueryCapabilities, ReadModelWorkspace, + ReadModelWorkspaceExt, ReadModelWritePlanBuilder, RelationalReadModel, + RelationalReadModelIncludes, Versioned, }; -// Neutral table/row primitives shared by read models and operational tables stay -// reachable under their module path (`distributed::table::*`). They are low-level -// schema/adapter plumbing, not part of the quick-start surface, so they are not -// re-exported at the crate root. -// -// Exception: `TableSchemaRegistry` is the entry point for registering operational -// table schemas (it is what callers build before bootstrapping migrations), so it -// is consumed directly by downstream users and integration tests. It stays at the -// crate root as part of the public surface. -pub use table::TableSchemaRegistry; +// Neutral table/row primitives: the canonical schema, row, mutation, write-plan, +// and error vocabulary shared by read models and operational tables (outbox, +// inbox/checkpoint, and future operational tables). Read models build on these, +// so they are part of the crate-root surface; SQL rendering helpers stay under +// `distributed::table::*`. +pub use table::{ + ColumnType, DeleteTableRowMutation, ExpectedVersion, ForeignKey, PatchMode, + PatchTableRowMutation, PrimaryKey, RelationshipDef, RelationshipKind, RowKey, RowPatch, + RowValue, RowValues, RowWriteMode, TableAdapterCapabilities, TableColumn, TableCommitOutcome, + TableIndex, TableMigrationArtifact, TableModel, TableMutation, TableRowMutation, TableSchema, + TableSchemaAdapter, TableSchemaAdapterCapabilities, TableSchemaBootstrap, TableSchemaIssue, + TableSchemaIssueKind, TableSchemaRegistry, TableSchemaRegistryExt, TableSchemaVerification, + TableStoreError, TableWritePlan, DEFAULT_TABLE_VERSION_COLUMN, +}; pub use manifest::{ DistributedManifestEnvelope, DistributedProjectManifest, MessageEndpointManifest, diff --git a/src/manifest.rs b/src/manifest.rs index ef0759fe1..09139601d 100644 --- a/src/manifest.rs +++ b/src/manifest.rs @@ -4,7 +4,7 @@ use crate::table::{ generate_table_migration_artifacts, table_schema_statements, TableSchema, TableSchemaRegistry, TableSqlDialect, }; -use crate::{ReadModelError, ReadModelMigrationArtifact, RelationalReadModel}; +use crate::{RelationalReadModel, TableMigrationArtifact, TableStoreError}; pub const DISTRIBUTED_MANIFEST_SCHEMA_VERSION: u32 = 1; @@ -48,7 +48,7 @@ impl DistributedProjectManifest { self } - pub fn try_read_model(mut self) -> Result + pub fn try_read_model(mut self) -> Result where M: RelationalReadModel, { @@ -56,11 +56,11 @@ impl DistributedProjectManifest { Ok(self) } - pub fn try_register_read_model(&mut self) -> Result<&mut Self, ReadModelError> + pub fn try_register_read_model(&mut self) -> Result<&mut Self, TableStoreError> where M: RelationalReadModel, { - self.try_register_table_schema(M::schema()) + self.try_register_table_schema(M::schema().clone()) } pub fn table_schema(mut self, schema: TableSchema) -> Self { @@ -69,7 +69,7 @@ impl DistributedProjectManifest { self } - pub fn try_table_schema(mut self, schema: TableSchema) -> Result { + pub fn try_table_schema(mut self, schema: TableSchema) -> Result { self.try_register_table_schema(schema)?; Ok(self) } @@ -77,7 +77,7 @@ impl DistributedProjectManifest { pub fn try_register_table_schema( &mut self, schema: TableSchema, - ) -> Result<&mut Self, ReadModelError> { + ) -> Result<&mut Self, TableStoreError> { let mut registry = self.table_registry()?; registry.register_schema(schema.clone())?; self.tables.push(schema); @@ -89,7 +89,7 @@ impl DistributedProjectManifest { self } - pub fn table_registry(&self) -> Result { + pub fn table_registry(&self) -> Result { let mut registry = TableSchemaRegistry::new(); for schema in &self.tables { registry.register_schema(schema.clone())?; @@ -97,14 +97,14 @@ impl DistributedProjectManifest { Ok(registry) } - pub fn sql_statements(&self, dialect: TableSqlDialect) -> Result, ReadModelError> { + pub fn sql_statements(&self, dialect: TableSqlDialect) -> Result, TableStoreError> { table_schema_statements(&self.table_registry()?, dialect) } pub fn sql_migration_artifacts( &self, dialect: TableSqlDialect, - ) -> Result, ReadModelError> { + ) -> Result, TableStoreError> { generate_table_migration_artifacts(&self.table_registry()?, dialect) } @@ -192,7 +192,7 @@ mod tests { fn manifest_collects_schema_service_metadata_and_renders_sql() { let manifest = DistributedProjectManifest::new("checkout") .read_model::() - .table_schema(outbox_message_schema()) + .table_schema(outbox_message_schema().clone()) .service( ServiceManifest::new("checkout-saga") .command("checkout.start") diff --git a/src/outbox/commit.rs b/src/outbox/commit.rs index 29bb87116..107acc067 100644 --- a/src/outbox/commit.rs +++ b/src/outbox/commit.rs @@ -5,10 +5,11 @@ use std::time::{Duration, SystemTime}; use crate::aggregate::{Aggregate, AggregateRepository}; use crate::outbox::OutboxMessage; -use crate::read_model::{ReadModelWritePlan, ReadModelWritePlanBuilder}; +use crate::read_model::ReadModelWritePlanBuilder; use crate::repository::{ CommitBatch, RepositoryError, StreamIdentity, StreamWrite, TransactionalCommit, }; +use crate::table::TableWritePlan; /// Publishes already-committed, claimed outbox rows and settles their claims. /// @@ -88,7 +89,7 @@ impl CommitReceipt { pub struct AggregateCommit<'a, R, A> { repo: &'a AggregateRepository, outbox_messages: Vec, - read_model_plans: Vec, + read_model_plans: Vec, error: Option, } diff --git a/src/outbox/table.rs b/src/outbox/table.rs index 9492e52d3..47c54712d 100644 --- a/src/outbox/table.rs +++ b/src/outbox/table.rs @@ -10,8 +10,8 @@ use crate::table::{ pub const OUTBOX_MESSAGES_TABLE: &str = "outbox_messages"; /// Schema for the durable outbox delivery table. -pub fn outbox_message_schema() -> TableSchema { - TableSchema { +pub fn outbox_message_schema() -> &'static TableSchema { + static SCHEMA: std::sync::LazyLock = std::sync::LazyLock::new(|| TableSchema { model_name: "OutboxMessage".into(), table_name: OUTBOX_MESSAGES_TABLE.into(), columns: vec![ @@ -57,7 +57,8 @@ pub fn outbox_message_schema() -> TableSchema { named_index("outbox_messages_destination_idx", ["destination", "status"]), ], relationships: Vec::new(), - } + }); + &SCHEMA } pub fn outbox_message_insert_plan( @@ -133,7 +134,7 @@ pub fn outbox_message_row_values(message: &OutboxMessage) -> Result TableSchema { + fn table_schema() -> &'static TableSchema { outbox_message_schema() } diff --git a/src/postgres_repo/mod.rs b/src/postgres_repo/mod.rs index efe9c89ff..470480b0c 100644 --- a/src/postgres_repo/mod.rs +++ b/src/postgres_repo/mod.rs @@ -20,11 +20,7 @@ use crate::entity::Entity; use crate::entity::EventRecord; use crate::outbox::{OutboxMessage, OutboxMessageStatus}; use crate::outbox_worker::{ensure_active_claim, ClaimOutboxMessages, OutboxClaimRef, OutboxStore}; -use crate::read_model::{ - ColumnDef, ColumnType, ReadModelAdapterCapabilities, ReadModelCommitOutcome, ReadModelError, - ReadModelLoadGraph, ReadModelLoadRequest, ReadModelQueryCapabilities, ReadModelWritePlan, - RowValue, -}; +use crate::read_model::{ReadModelLoadGraph, ReadModelLoadRequest, ReadModelQueryCapabilities}; use crate::repository::{ reject_duplicate_outbox_messages, reject_duplicate_streams, validate_entity_id_matches_identity, validate_prepared_appends, validate_snapshot_identity, @@ -53,6 +49,9 @@ use crate::table::{ TableMigrationArtifact, TableSchemaBootstrap, TableSchemaRegistry, TableSqlDialect, TableSqlSchemaAdapter, TableStoreError, }; +use crate::table::{ + ColumnType, RowValue, TableAdapterCapabilities, TableColumn, TableCommitOutcome, TableWritePlan, +}; const POSTGRES_SCHEMA: &str = include_str!("../../migrations/postgres/0001_initial.sql"); const POSTGRES_BACKEND: &str = "postgres"; @@ -485,14 +484,14 @@ impl InboxStore for PostgresRepository { } impl ReadModelWritePlanStore for PostgresRepository { - fn read_model_capabilities(&self) -> ReadModelAdapterCapabilities { + fn read_model_capabilities(&self) -> TableAdapterCapabilities { sql_read_model_capabilities() } fn commit_write_plan( &self, - plan: ReadModelWritePlan, - ) -> impl Future> + Send + '_ { + plan: TableWritePlan, + ) -> impl Future> + Send + '_ { async move { commit_read_model_write_plan(&self.pool, plan).await } } } @@ -505,7 +504,7 @@ impl RelationalReadModelQueryStore for PostgresRepository { fn load_graph( &self, request: ReadModelLoadRequest, - ) -> impl Future> + Send + '_ { + ) -> impl Future> + Send + '_ { async move { load_read_model_graph( &self.pool, @@ -968,8 +967,8 @@ impl crate::sqlx_repo::read_model::SqlxReadModelBackend for Postgres { fn push_row_value_bind( builder: &mut QueryBuilder, value: RowValue, - column: &ColumnDef, - ) -> Result<(), ReadModelError> { + column: &TableColumn, + ) -> Result<(), TableStoreError> { match value { RowValue::Null => Self::push_null_bind(builder, column)?, RowValue::Bool(value) => { @@ -997,7 +996,7 @@ impl crate::sqlx_repo::read_model::SqlxReadModelBackend for Postgres { } RowValue::Json(value) => { let payload = serde_json::to_string(&value) - .map_err(|err| ReadModelError::Serde(err.to_string()))?; + .map_err(|err| TableStoreError::Serde(err.to_string()))?; builder.push_bind(payload); } } @@ -1007,8 +1006,8 @@ impl crate::sqlx_repo::read_model::SqlxReadModelBackend for Postgres { fn push_null_bind( builder: &mut QueryBuilder, - column: &ColumnDef, - ) -> Result<(), ReadModelError> { + column: &TableColumn, + ) -> Result<(), TableStoreError> { match &column.column_type { ColumnType::Text | ColumnType::Json | ColumnType::Timestamp => { builder.push_bind(Option::::None); @@ -1026,7 +1025,7 @@ impl crate::sqlx_repo::read_model::SqlxReadModelBackend for Postgres { builder.push_bind(Option::>::None); } ColumnType::Unsupported(type_name) => { - return Err(ReadModelError::Metadata(format!( + return Err(TableStoreError::Metadata(format!( "read model `{}` column `{}` has unsupported type `{}`", column.field_name, column.column_name, type_name ))); @@ -1039,7 +1038,7 @@ impl crate::sqlx_repo::read_model::SqlxReadModelBackend for Postgres { result.rows_affected() } - fn push_select_column(builder: &mut QueryBuilder, column: &ColumnDef) { + fn push_select_column(builder: &mut QueryBuilder, column: &TableColumn) { builder.push(quote_identifier(&column.column_name)); if matches!(column.column_type, ColumnType::Json | ColumnType::Timestamp) { builder.push("::text"); @@ -1048,7 +1047,7 @@ impl crate::sqlx_repo::read_model::SqlxReadModelBackend for Postgres { builder.push(quote_identifier(&column.column_name)); } - fn row_value(row: &PgRow, column: &ColumnDef) -> Result { + fn row_value(row: &PgRow, column: &TableColumn) -> Result { Ok(match column.column_type { ColumnType::Text | ColumnType::Timestamp => row .try_get::, _>(column.column_name.as_str()) @@ -1096,12 +1095,12 @@ impl crate::sqlx_repo::read_model::SqlxReadModelBackend for Postgres { .map(|payload| { serde_json::from_str(&payload) .map(RowValue::Json) - .map_err(|err| ReadModelError::Serde(err.to_string())) + .map_err(|err| TableStoreError::Serde(err.to_string())) }) .transpose()? .unwrap_or(RowValue::Null), ColumnType::Unsupported(ref type_name) => { - return Err(ReadModelError::Metadata(format!( + return Err(TableStoreError::Metadata(format!( "read model `{}` column `{}` has unsupported type `{}`", column.field_name, column.column_name, type_name ))); @@ -1110,7 +1109,7 @@ impl crate::sqlx_repo::read_model::SqlxReadModelBackend for Postgres { } } -fn push_postgres_type_cast(builder: &mut QueryBuilder, column: &ColumnDef) { +fn push_postgres_type_cast(builder: &mut QueryBuilder, column: &TableColumn) { match column.column_type { ColumnType::Json => { builder.push("::jsonb"); @@ -1773,7 +1772,7 @@ fn repository_storage_error(operation: &str, err: sqlx::Error) -> RepositoryErro sqlx_repo::repository_storage_error(POSTGRES_BACKEND, operation, err) } -fn read_model_storage_error(operation: &str, err: sqlx::Error) -> ReadModelError { +fn read_model_storage_error(operation: &str, err: sqlx::Error) -> TableStoreError { sqlx_repo::read_model_storage_error(POSTGRES_BACKEND, operation, err) } diff --git a/src/queued_repo/repository.rs b/src/queued_repo/repository.rs index bd1edecf3..4ba00078f 100644 --- a/src/queued_repo/repository.rs +++ b/src/queued_repo/repository.rs @@ -8,15 +8,13 @@ use std::sync::Arc; use crate::entity::Entity; use crate::lock::{InMemoryLockManager, Lock, LockManager}; -use crate::read_model::{ - ReadModelAdapterCapabilities, ReadModelCommitOutcome, ReadModelError, ReadModelLoadGraph, - ReadModelLoadRequest, ReadModelQueryCapabilities, ReadModelWritePlan, -}; +use crate::read_model::{ReadModelLoadGraph, ReadModelLoadRequest, ReadModelQueryCapabilities}; use crate::repository::{ CommitBatch, GetStream, InboxStore, ReadModelWritePlanStore, RelationalReadModelQueryStore, RepositoryError, SnapshotStore, StreamIdentity, TransactionalCommit, }; use crate::snapshot::SnapshotRecord; +use crate::table::{TableAdapterCapabilities, TableCommitOutcome, TableStoreError, TableWritePlan}; /// Options for read operations. #[derive(Debug, Clone, Copy)] @@ -240,14 +238,14 @@ where R: ReadModelWritePlanStore, L: LockManager, { - fn read_model_capabilities(&self) -> ReadModelAdapterCapabilities { + fn read_model_capabilities(&self) -> TableAdapterCapabilities { self.inner.read_model_capabilities() } fn commit_write_plan( &self, - plan: ReadModelWritePlan, - ) -> impl Future> + Send + '_ { + plan: TableWritePlan, + ) -> impl Future> + Send + '_ { self.inner.commit_write_plan(plan) } } @@ -264,7 +262,7 @@ where fn load_graph( &self, request: ReadModelLoadRequest, - ) -> impl Future> + Send + '_ { + ) -> impl Future> + Send + '_ { self.inner.load_graph(request) } } diff --git a/src/read_model/capabilities.rs b/src/read_model/capabilities.rs new file mode 100644 index 000000000..a3bf2508b --- /dev/null +++ b/src/read_model/capabilities.rs @@ -0,0 +1,15 @@ +//! Capability descriptors read-model adapters advertise for loads. + +/// 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, + } + } +} diff --git a/src/read_model/in_memory.rs b/src/read_model/in_memory.rs index ff73b89be..b32255e3c 100644 --- a/src/read_model/in_memory.rs +++ b/src/read_model/in_memory.rs @@ -8,15 +8,17 @@ use std::collections::{BTreeMap, HashMap}; use std::future::Future; use std::sync::{Arc, RwLock}; -use super::session::{column_name_for, key_fingerprint, validate_key, validate_row_values}; use super::{ - ExpectedVersion, PatchMode, ReadModelAdapterCapabilities, ReadModelCommitOutcome, - ReadModelError, ReadModelIncludeRows, ReadModelLoadGraph, ReadModelLoadRequest, - ReadModelMutation, ReadModelQueryCapabilities, ReadModelSchema, ReadModelSchemaRegistry, - ReadModelWritePlan, RelationalReadModel, RelationshipDef, RelationshipKind, RowKey, RowValue, - RowValues, RowWriteMode, Versioned, + ReadModelIncludeRows, ReadModelLoadGraph, ReadModelLoadRequest, ReadModelQueryCapabilities, + RelationalReadModel, Versioned, }; use crate::repository::{ReadModelWritePlanStore, RelationalReadModelQueryStore}; +use crate::table::{column_name_for, key_fingerprint, validate_key, validate_row_values}; +use crate::table::{ + ExpectedVersion, PatchMode, RelationshipDef, RelationshipKind, RowKey, RowValue, RowValues, + RowWriteMode, TableAdapterCapabilities, TableCommitOutcome, TableMutation, TableSchema, + TableSchemaRegistry, TableStoreError, TableWritePlan, +}; #[derive(Clone)] pub(crate) struct StoredRow { @@ -33,39 +35,39 @@ pub(crate) const INITIAL_MODEL_VERSION: u64 = 1; pub(crate) fn next_model_version( key: &str, current_version: Option, -) -> Result { +) -> Result { match current_version { Some(version) => version.checked_add(1).ok_or_else(|| { - ReadModelError::Storage(format!("read model version overflow for {key}")) + TableStoreError::Storage(format!("read model version overflow for {key}")) }), None => Ok(INITIAL_MODEL_VERSION), } } -fn relational_capabilities() -> ReadModelAdapterCapabilities { - ReadModelAdapterCapabilities::default() +fn relational_capabilities() -> TableAdapterCapabilities { + TableAdapterCapabilities::default() } pub(crate) fn apply_read_model_write_plan( - plan: ReadModelWritePlan, + plan: TableWritePlan, staged_rows: &mut HashMap, -) -> Result { +) -> Result { plan.validate_for(&relational_capabilities())?; for mutation in plan.mutations { match mutation { - ReadModelMutation::UpsertRow(mutation) => { + TableMutation::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.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.schema, &mutation.key, 0, current_version.unwrap_or_default(), @@ -81,11 +83,11 @@ pub(crate) fn apply_read_model_write_plan( }, ); } - ReadModelMutation::PatchRow(mutation) => { + TableMutation::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.schema, &mutation.key, &mutation.expected_version, current_version, @@ -94,7 +96,7 @@ pub(crate) fn apply_read_model_write_plan( match staged_rows.get_mut(&key) { Some(row) => { apply_patch_values_preserving_key( - &mutation.schema, + mutation.schema, &mutation.key, &mut row.values, mutation.patch.into_values(), @@ -103,7 +105,7 @@ pub(crate) fn apply_read_model_write_plan( } None if matches!(mutation.mode, PatchMode::InsertMissing) => { let values = row_values_from_key_and_patch( - &mutation.schema, + mutation.schema, &mutation.key, mutation.patch.into_values(), )?; @@ -116,18 +118,18 @@ pub(crate) fn apply_read_model_write_plan( ); } None => { - return Err(ReadModelError::NotFound { - collection: mutation.schema.table_name, + return Err(TableStoreError::NotFound { + collection: mutation.schema.table_name.clone(), id: key_fingerprint(&mutation.key), }); } } } - ReadModelMutation::DeleteRow(mutation) => { + TableMutation::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.schema, &mutation.key, &mutation.expected_version, current_version, @@ -137,7 +139,7 @@ pub(crate) fn apply_read_model_write_plan( } } - Ok(ReadModelCommitOutcome::applied()) + Ok(TableCommitOutcome::applied()) } fn relational_storage_key(table_name: &str, key: &RowKey) -> String { @@ -145,10 +147,10 @@ fn relational_storage_key(table_name: &str, key: &RowKey) -> String { } fn row_values_from_key_and_patch( - schema: &ReadModelSchema, + schema: &TableSchema, key: &RowKey, patch_values: RowValues, -) -> Result { +) -> Result { let mut values = RowValues::new(); for (column, value) in key.iter() { values.insert(column.to_string(), value.clone()); @@ -159,11 +161,11 @@ fn row_values_from_key_and_patch( } fn apply_patch_values_preserving_key( - schema: &ReadModelSchema, + schema: &TableSchema, key: &RowKey, values: &mut RowValues, patch_values: RowValues, -) -> Result<(), ReadModelError> { +) -> Result<(), TableStoreError> { for (column, value) in patch_values { if schema .primary_key @@ -172,13 +174,13 @@ fn apply_patch_values_preserving_key( .any(|primary_key| primary_key == &column) { let key_value = key.get(&column).ok_or_else(|| { - ReadModelError::Metadata(format!( + TableStoreError::Metadata(format!( "read model `{}` row key is missing primary-key column `{}`", schema.model_name, column )) })?; if key_value != &value { - return Err(ReadModelError::Metadata(format!( + return Err(TableStoreError::Metadata(format!( "read model `{}` patch cannot change primary-key column `{}`", schema.model_name, column ))); @@ -190,18 +192,18 @@ fn apply_patch_values_preserving_key( } fn validate_row_expected_version( - schema: &ReadModelSchema, + schema: &TableSchema, key: &RowKey, expected_version: &ExpectedVersion, current_version: Option, -) -> Result<(), ReadModelError> { +) -> Result<(), TableStoreError> { 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 { + (ExpectedVersion::Exact(_), None) => Err(TableStoreError::NotFound { collection: schema.table_name.clone(), id: key_fingerprint(key), }), @@ -213,12 +215,12 @@ fn validate_row_expected_version( } fn concurrency_conflict( - schema: &ReadModelSchema, + schema: &TableSchema, key: &RowKey, expected: u64, actual: u64, -) -> ReadModelError { - ReadModelError::ConcurrencyConflict { +) -> TableStoreError { + TableStoreError::ConcurrencyConflict { collection: schema.table_name.clone(), id: key_fingerprint(key), expected, @@ -232,7 +234,7 @@ fn concurrency_conflict( #[derive(Clone)] pub struct InMemoryReadModelStore { pub(crate) relational_rows: Arc>>, - schema_registry: Arc>, + schema_registry: Arc>, } impl Default for InMemoryReadModelStore { @@ -246,51 +248,48 @@ impl InMemoryReadModelStore { pub fn new() -> Self { Self { relational_rows: Arc::new(RwLock::new(HashMap::new())), - schema_registry: Arc::new(RwLock::new(ReadModelSchemaRegistry::new())), + schema_registry: Arc::new(RwLock::new(TableSchemaRegistry::new())), } } /// Register a relational read-model schema for explicit include execution. - pub fn register_schema(&self) -> Result<(), ReadModelError> + pub fn register_schema(&self) -> Result<(), TableStoreError> where M: RelationalReadModel, { let mut registry = self .schema_registry .write() - .map_err(|_| ReadModelError::Storage("schema registry lock poisoned".into()))?; + .map_err(|_| TableStoreError::Storage("schema registry lock poisoned".into()))?; registry.register::()?; Ok(()) } /// Register an already-built relational read-model schema. - pub fn register_read_model_schema( - &self, - schema: ReadModelSchema, - ) -> Result<(), ReadModelError> { + pub fn register_read_model_schema(&self, schema: TableSchema) -> Result<(), TableStoreError> { let mut registry = self .schema_registry .write() - .map_err(|_| ReadModelError::Storage("schema registry lock poisoned".into()))?; + .map_err(|_| TableStoreError::Storage("schema registry lock poisoned".into()))?; registry.register_schema(schema)?; Ok(()) } } impl ReadModelWritePlanStore for InMemoryReadModelStore { - fn read_model_capabilities(&self) -> ReadModelAdapterCapabilities { + fn read_model_capabilities(&self) -> TableAdapterCapabilities { relational_capabilities() } fn commit_write_plan( &self, - plan: ReadModelWritePlan, - ) -> impl Future> + Send + '_ { + plan: TableWritePlan, + ) -> impl Future> + Send + '_ { async move { let mut relational_rows = self .relational_rows .write() - .map_err(|_| ReadModelError::Storage("lock poisoned".into()))?; + .map_err(|_| TableStoreError::Storage("lock poisoned".into()))?; let mut staged_rows = relational_rows.clone(); let outcome = apply_read_model_write_plan(plan, &mut staged_rows)?; @@ -308,7 +307,7 @@ impl ReadModelWritePlanStore for InMemoryReadModelStore { struct IncludeSpec { name: String, relationship: RelationshipDef, - target_schema: ReadModelSchema, + target_schema: TableSchema, } impl RelationalReadModelQueryStore for InMemoryReadModelStore { @@ -319,15 +318,14 @@ impl RelationalReadModelQueryStore for InMemoryReadModelStore { fn load_graph( &self, request: ReadModelLoadRequest, - ) -> impl Future> + Send + '_ { + ) -> impl Future> + Send + '_ { async move { 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()))?; + let registry = self.schema_registry.read().map_err(|_| { + TableStoreError::Storage("schema registry lock poisoned".into()) + })?; resolve_request_schemas(®istry, &request)? }; validate_key(&root_schema, &request.key)?; @@ -335,7 +333,7 @@ impl RelationalReadModelQueryStore for InMemoryReadModelStore { let rows = self .relational_rows .read() - .map_err(|_| ReadModelError::Storage("lock poisoned".into()))?; + .map_err(|_| TableStoreError::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()); @@ -367,9 +365,9 @@ impl RelationalReadModelQueryStore for InMemoryReadModelStore { } fn resolve_request_schemas( - registry: &ReadModelSchemaRegistry, + registry: &TableSchemaRegistry, request: &ReadModelLoadRequest, -) -> Result<(ReadModelSchema, Vec), ReadModelError> { +) -> Result<(TableSchema, Vec), TableStoreError> { let root_schema = registry .schema_for_model(&request.schema.model_name) .cloned() @@ -381,13 +379,13 @@ fn resolve_request_schemas( } }) .ok_or_else(|| { - ReadModelError::Metadata(format!( + TableStoreError::Metadata(format!( "read model `{}` is not registered for relationship includes", request.schema.model_name )) })?; if root_schema != request.schema { - return Err(ReadModelError::Metadata(format!( + return Err(TableStoreError::Metadata(format!( "read model `{}` load request does not match registered schema", request.schema.model_name ))); @@ -400,13 +398,13 @@ fn resolve_request_schemas( .iter() .find(|relationship| relationship.field_name == *include_name) .ok_or_else(|| { - ReadModelError::Metadata(format!( + TableStoreError::Metadata(format!( "read model `{}` has no relationship `{}`", root_schema.model_name, include_name )) })?; if matches!(relationship.kind, RelationshipKind::ManyToMany) { - return Err(ReadModelError::Metadata(format!( + return Err(TableStoreError::Metadata(format!( "many-to-many relationship `{}` includes are not supported until join metadata declares source and target keys", relationship.field_name ))); @@ -414,7 +412,7 @@ fn resolve_request_schemas( let target_schema = registry .schema_for_model(&relationship.target_model) .ok_or_else(|| { - ReadModelError::Metadata(format!( + TableStoreError::Metadata(format!( "read model `{}` relationship `{}` targets unregistered model `{}`", root_schema.model_name, relationship.field_name, relationship.target_model )) @@ -432,14 +430,14 @@ fn resolve_request_schemas( fn load_relationship_rows( rows: &HashMap, - root_schema: &ReadModelSchema, + root_schema: &TableSchema, root_row: &RowValues, spec: &IncludeSpec, -) -> Result>, ReadModelError> { +) -> Result>, TableStoreError> { 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!( + RelationshipKind::ManyToMany => Err(TableStoreError::Metadata(format!( "many-to-many relationship `{}` includes are not supported yet", spec.relationship.field_name ))), @@ -448,18 +446,18 @@ fn load_relationship_rows( fn load_has_many_rows( rows: &HashMap, - root_schema: &ReadModelSchema, + root_schema: &TableSchema, root_row: &RowValues, spec: &IncludeSpec, -) -> Result>, ReadModelError> { +) -> Result>, TableStoreError> { let foreign_key = spec.relationship.foreign_key.as_deref().ok_or_else(|| { - ReadModelError::Metadata(format!( + TableStoreError::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!( + TableStoreError::Metadata(format!( "relationship `{}` foreign key `{}` is not a target column", spec.relationship.field_name, foreign_key )) @@ -467,13 +465,13 @@ fn load_has_many_rows( 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!( + TableStoreError::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!( + TableStoreError::Metadata(format!( "read model `{}` root row is missing relationship key `{}`", root_schema.model_name, root_column )) @@ -488,25 +486,25 @@ fn load_has_many_rows( fn load_belongs_to_rows( rows: &HashMap, - root_schema: &ReadModelSchema, + root_schema: &TableSchema, root_row: &RowValues, spec: &IncludeSpec, -) -> Result>, ReadModelError> { +) -> Result>, TableStoreError> { let foreign_key = spec.relationship.foreign_key.as_deref().ok_or_else(|| { - ReadModelError::Metadata(format!( + TableStoreError::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!( + TableStoreError::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!( + TableStoreError::Metadata(format!( "read model `{}` root row is missing relationship key `{}`", root_schema.model_name, source_column )) @@ -525,11 +523,11 @@ fn load_belongs_to_rows( } fn belongs_to_target_column( - target_schema: &ReadModelSchema, + target_schema: &TableSchema, source_column: &str, -) -> Result { +) -> Result { if target_schema.primary_key.columns.len() != 1 { - return Err(ReadModelError::Metadata(format!( + return Err(TableStoreError::Metadata(format!( "belongs_to target `{}` must have a single-column primary key to load from `{}`", target_schema.model_name, source_column ))); @@ -566,21 +564,23 @@ fn rows_matching_column( mod tests { use super::*; use crate::{ - ColumnDef, ColumnType, DeleteRowMutation, PatchRowMutation, PrimaryKey, RowMutation, - RowPatch, + ColumnType, DeleteTableRowMutation, PatchTableRowMutation, PrimaryKey, RowPatch, + TableColumn, TableRowMutation, }; - 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(), - } + fn test_row_schema() -> &'static TableSchema { + static SCHEMA: std::sync::LazyLock = + std::sync::LazyLock::new(|| TableSchema { + model_name: "TestRow".into(), + table_name: "test_rows".into(), + columns: vec![TableColumn::new("id", "id", ColumnType::Text)], + primary_key: PrimaryKey::new(["id"]), + version_column: None, + foreign_keys: Vec::new(), + indexes: Vec::new(), + relationships: Vec::new(), + }); + &SCHEMA } #[tokio::test] @@ -592,9 +592,9 @@ mod tests { values.insert("id", RowValue::String("row-1".into())); let outcome = store - .commit_write_plan(ReadModelWritePlan::new(vec![ReadModelMutation::UpsertRow( - RowMutation { - schema: schema.clone(), + .commit_write_plan(TableWritePlan::new(vec![TableMutation::UpsertRow( + TableRowMutation { + schema, key: key.clone(), values, expected_version: ExpectedVersion::Any, @@ -628,9 +628,9 @@ mod tests { values.insert("id", RowValue::String("row-1".into())); store - .commit_write_plan(ReadModelWritePlan::new(vec![ReadModelMutation::UpsertRow( - RowMutation { - schema: schema.clone(), + .commit_write_plan(TableWritePlan::new(vec![TableMutation::UpsertRow( + TableRowMutation { + schema, key: key.clone(), values, expected_version: ExpectedVersion::Any, @@ -640,9 +640,9 @@ mod tests { .await .unwrap(); store - .commit_write_plan(ReadModelWritePlan::new(vec![ReadModelMutation::PatchRow( - PatchRowMutation { - schema: schema.clone(), + .commit_write_plan(TableWritePlan::new(vec![TableMutation::PatchRow( + PatchTableRowMutation { + schema, key: key.clone(), patch: RowPatch::new().set("id", RowValue::String("row-1".into())), expected_version: ExpectedVersion::Exact(1), @@ -661,9 +661,9 @@ mod tests { assert_eq!(version, 2); store - .commit_write_plan(ReadModelWritePlan::new(vec![ReadModelMutation::DeleteRow( - DeleteRowMutation { - schema: schema.clone(), + .commit_write_plan(TableWritePlan::new(vec![TableMutation::DeleteRow( + DeleteTableRowMutation { + schema, key: key.clone(), expected_version: ExpectedVersion::Exact(2), }, diff --git a/src/read_model/load.rs b/src/read_model/load.rs new file mode 100644 index 000000000..a133ab705 --- /dev/null +++ b/src/read_model/load.rs @@ -0,0 +1,99 @@ +//! Explicit primary-key load requests and the untyped graphs adapters return. + +use std::collections::BTreeMap; +use std::marker::PhantomData; + +use crate::repository::{ReadModelWritePlanStore, RelationalReadModelQueryStore}; + +use super::workspace::ReadModelWorkspace; +use super::{ + ReadModelQueryCapabilities, RelationalReadModel, RelationalReadModelIncludes, Versioned, +}; +use crate::table::{RelationshipDef, RowKey, RowValues, TableSchema, TableStoreError}; + +/// A request an adapter can satisfy with a primary-key read plus explicit includes. +#[derive(Clone, Debug, PartialEq)] +pub struct ReadModelLoadRequest { + pub schema: TableSchema, + pub key: RowKey, + pub includes: Vec, +} + +impl ReadModelLoadRequest { + pub fn validate_for_query_capabilities( + &self, + capabilities: &ReadModelQueryCapabilities, + ) -> Result<(), TableStoreError> { + if !self.includes.is_empty() && !capabilities.relationship_includes { + return Err(TableStoreError::Metadata( + "read-model adapter does not support relationship includes".into(), + )); + } + + Ok(()) + } +} + +/// Rows loaded for one requested relationship include. +#[derive(Clone, Debug, PartialEq)] +pub struct ReadModelIncludeRows { + pub relationship: RelationshipDef, + pub target_schema: TableSchema, + 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, +} + +/// Builder for one explicit primary-key read-model load over the async store traits. +pub struct ReadModelLoadBuilder<'workspace, 'store, S, M> +where + S: ReadModelWritePlanStore + RelationalReadModelQueryStore, +{ + pub(super) unit: &'workspace mut ReadModelWorkspace<'store, S>, + pub(super) key: RowKey, + pub(super) includes: Vec, + pub(super) _marker: PhantomData, +} + +impl<'workspace, 'store, S, M> ReadModelLoadBuilder<'workspace, 'store, S, M> +where + S: ReadModelWritePlanStore + RelationalReadModelQueryStore, + M: RelationalReadModel + RelationalReadModelIncludes, +{ + pub fn include(mut self, relationship: impl Into) -> Self { + self.includes.push(relationship.into()); + self + } + + pub async fn one(self) -> Result>, TableStoreError> { + let request = self + .unit + .writes + .load_with::(self.key, self.includes)?; + let graph = self.unit.store.load_graph(request.clone()).await?; + 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::(root.clone(), graph.includes)?; + Ok(Some(Versioned { + data: model, + version: root.version, + })) + } +} diff --git a/src/read_model/mod.rs b/src/read_model/mod.rs index 693dcd142..f422f1083 100644 --- a/src/read_model/mod.rs +++ b/src/read_model/mod.rs @@ -25,13 +25,15 @@ //! let outcome = read_models.commit(&read_store)?; //! ``` +mod capabilities; pub(crate) mod in_memory; -mod metadata; -mod schema; -mod session; +mod load; +mod plan; +mod workspace; use serde::{de::DeserializeOwned, Serialize}; -use std::fmt; + +use crate::table::{RowKey, RowValues, TableSchema, TableStoreError}; /// Trait implemented by the derive macro for read-model identity metadata. pub trait ReadModel: Serialize + DeserializeOwned + Clone + Send + Sync { @@ -49,78 +51,35 @@ pub struct Versioned { pub version: u64, } -/// Error type for read model store operations. -#[derive(Debug, Clone, PartialEq, Eq)] -#[non_exhaustive] -pub enum ReadModelError { - /// Optimistic concurrency conflict. - ConcurrencyConflict { - collection: String, - id: String, - expected: u64, - actual: u64, - }, - /// Serialization/deserialization error. - Serde(String), - /// Storage-level error. - Storage(String), - /// Read model not found. - NotFound { collection: String, id: String }, - /// Lock error. - Lock(crate::lock::LockError), - /// Relational read-model metadata error. - Metadata(String), +/// Opt-in trait for table-mapped relational read models. +pub trait RelationalReadModel: Clone + Send + Sync + Sized { + /// The model's schema. Static because a model's schema is fixed at compile + /// time; the derive macro backs this with a `LazyLock` so staging mutations + /// never rebuilds or clones schema metadata. + fn schema() -> &'static TableSchema; + fn primary_key(&self) -> Result; + fn to_row(&self) -> Result; + fn from_row(row: RowValues) -> Result; } -impl fmt::Display for ReadModelError { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - ReadModelError::ConcurrencyConflict { - collection, - id, - expected, - actual, - } => write!( - f, - "concurrency conflict on {}:{} (expected version {}, actual {})", - collection, id, expected, actual - ), - ReadModelError::Serde(msg) => write!(f, "read model serialization error: {}", msg), - ReadModelError::Storage(msg) => write!(f, "read model storage error: {}", msg), - ReadModelError::NotFound { collection, id } => { - 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), - } - } -} +/// Relationship hydration hooks generated for table-mapped read models. +pub trait RelationalReadModelIncludes: RelationalReadModel { + fn hydrate_include( + &mut self, + include: &str, + rows: Vec, + ) -> Result<(), TableStoreError>; -impl std::error::Error for ReadModelError {} + fn include_rows(&self, include: &str) -> Result, TableStoreError>; -impl From for ReadModelError { - fn from(err: crate::lock::LockError) -> Self { - ReadModelError::Lock(err) - } + /// Schema of the model targeted by the named relationship. + fn include_target_schema(include: &str) -> Result<&'static TableSchema, TableStoreError>; } +pub use capabilities::ReadModelQueryCapabilities; 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 schema::{ - ReadModelMigrationArtifact, ReadModelSchemaAdapter, ReadModelSchemaAdapterCapabilities, - ReadModelSchemaBootstrap, ReadModelSchemaIssue, ReadModelSchemaIssueKind, - ReadModelSchemaRegistry, ReadModelSchemaVerification, -}; -#[cfg(any(feature = "postgres", feature = "sqlite"))] -pub(crate) use session::{column_name_for, key_fingerprint, validate_key, validate_row_values}; -pub use session::{ - DeleteRowMutation, ExpectedVersion, PatchMode, PatchRowMutation, ReadModelAdapterCapabilities, - ReadModelCommitOutcome, ReadModelIncludeRows, ReadModelLoadBuilder, ReadModelLoadGraph, - ReadModelLoadRequest, ReadModelMutation, ReadModelQueryCapabilities, ReadModelWorkspace, - ReadModelWorkspaceExt, ReadModelWritePlan, ReadModelWritePlanBuilder, RowMutation, RowPatch, - RowWriteMode, +pub use load::{ + ReadModelIncludeRows, ReadModelLoadBuilder, ReadModelLoadGraph, ReadModelLoadRequest, }; +pub use plan::ReadModelWritePlanBuilder; +pub use workspace::{ReadModelWorkspace, ReadModelWorkspaceExt}; diff --git a/src/read_model/plan.rs b/src/read_model/plan.rs new file mode 100644 index 000000000..d4938c4ca --- /dev/null +++ b/src/read_model/plan.rs @@ -0,0 +1,440 @@ +//! Detached builder that stages read-model mutations into a write plan. + +use std::cmp::Ordering; +use std::collections::BTreeMap; + +use crate::repository::ReadModelWritePlanStore; +use crate::table::{ + column_name_for, key_fingerprint, key_from_row, validate_expected_version, validate_key, + DeleteTableRowMutation, ExpectedVersion, PatchMode, PatchTableRowMutation, RelationshipDef, + RowKey, RowPatch, RowValues, RowWriteMode, TableCommitOutcome, TableMutation, TableRowMutation, + TableSchema, TableStoreError, TableWritePlan, +}; + +use super::{ReadModelLoadRequest, RelationalReadModel, Versioned}; + +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)] +pub(super) struct RowIdentity { + pub(super) table_name: String, + pub(super) key: String, +} + +#[derive(Clone, Debug)] +struct StagedMutation { + sequence: u64, + mutation: TableMutation, +} + +/// Detached builder for read-model write plans that are applied at commit. +#[derive(Clone, Debug, Default)] +pub struct ReadModelWritePlanBuilder { + mutations: Vec, + pub(super) expected_versions: BTreeMap, + next_sequence: u64, +} + +impl ReadModelWritePlanBuilder { + pub fn new() -> Self { + Self::default() + } + + pub fn is_empty(&self) -> bool { + self.mutations.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(TableStoreError::Metadata(format!( + "read model `{}` has no relationship `{}`", + schema.model_name, include + ))); + } + } + + Ok(ReadModelLoadRequest { + schema: schema.clone(), + key, + includes, + }) + } + + pub fn track_loaded( + &mut self, + versioned: &Versioned, + ) -> Result<&mut Self, TableStoreError> + 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, TableStoreError> + 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.clone(), + key: key_fingerprint(&key), + }, + expected_version, + ); + Ok(self) + } + + pub fn insert(&mut self, model: &M) -> Result<&mut Self, TableStoreError> + where + M: RelationalReadModel, + { + self.stage_full_row( + model, + RowWriteMode::Insert, + Some(ExpectedVersion::NotExists), + ) + } + + pub fn upsert(&mut self, model: &M) -> Result<&mut Self, TableStoreError> + where + M: RelationalReadModel, + { + self.stage_full_row(model, RowWriteMode::Upsert, None) + } + + pub fn insert_related( + &mut self, + parent: &P, + relationship_field: &str, + child: &C, + ) -> Result<&mut Self, TableStoreError> + where + P: RelationalReadModel, + C: RelationalReadModel, + { + self.stage_related_row(parent, relationship_field, child, RowWriteMode::Insert) + } + + pub fn upsert_related( + &mut self, + parent: &P, + relationship_field: &str, + child: &C, + ) -> Result<&mut Self, TableStoreError> + 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, TableStoreError> + where + M: RelationalReadModel, + { + self.stage_patch::(key, patch, PatchMode::UpdateExisting) + } + + pub fn upsert_patch( + &mut self, + key: RowKey, + patch: RowPatch, + ) -> Result<&mut Self, TableStoreError> + where + M: RelationalReadModel, + { + self.stage_patch::(key, patch, PatchMode::InsertMissing) + } + + pub fn delete(&mut self, key: RowKey) -> Result<&mut Self, TableStoreError> + where + M: RelationalReadModel, + { + let schema = validated_schema::()?; + validate_key(schema, &key)?; + let expected_version = self.expected_for(schema, &key); + let mutation = DeleteTableRowMutation { + schema, + key, + expected_version, + }; + self.push(TableMutation::DeleteRow(mutation)); + Ok(self) + } + + pub fn delete_model(&mut self, model: &M) -> Result<&mut Self, TableStoreError> + where + M: RelationalReadModel, + { + self.delete::(model.primary_key()?) + } + + pub fn into_write_plan(self) -> Result { + // Precompute each mutation's sort key once; building the formatted key + // inside the comparator would allocate two Strings per comparison. + let mut mutations = self + .mutations + .into_iter() + .map(|staged| (staged.mutation.sort_key(), staged)) + .collect::>(); + mutations.sort_by(|(left_key, left), (right_key, right)| { + left.mutation + .operation_rank() + .cmp(&right.mutation.operation_rank()) + .then_with(|| { + left.mutation + .dependency_order(&right.mutation) + .unwrap_or(Ordering::Equal) + }) + .then_with(|| left_key.cmp(right_key)) + .then(left.sequence.cmp(&right.sequence)) + }); + let mutations = mutations + .into_iter() + .map(|(_, staged)| staged.mutation) + .collect::>(); + let plan = TableWritePlan::new(mutations); + plan.validate()?; + Ok(plan) + } + + pub async fn commit(self, store: &S) -> Result + where + S: ReadModelWritePlanStore + ?Sized, + { + store.commit_write_plan(self.into_write_plan()?).await + } + + fn stage_full_row( + &mut self, + model: &M, + mode: RowWriteMode, + expected_version: Option, + ) -> Result<&mut Self, TableStoreError> + 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 = TableRowMutation { + schema, + key, + values, + expected_version, + mode, + }; + self.push(TableMutation::UpsertRow(mutation)); + Ok(self) + } + + fn stage_related_row( + &mut self, + parent: &P, + relationship_field: &str, + child: &C, + mode: RowWriteMode, + ) -> Result<&mut Self, TableStoreError> + 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(|| { + TableStoreError::Metadata(format!( + "read model `{}` has no relationship `{}`", + parent_schema.model_name, relationship_field + )) + })?; + + if relationship.target_model != child_schema.model_name { + return Err(TableStoreError::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 = TableRowMutation { + schema: child_schema, + key, + values: child_row, + expected_version, + mode, + }; + self.push(TableMutation::UpsertRow(mutation)); + Ok(self) + } + + fn stage_patch( + &mut self, + key: RowKey, + patch: RowPatch, + mode: PatchMode, + ) -> Result<&mut Self, TableStoreError> + where + M: RelationalReadModel, + { + let schema = validated_schema::()?; + validate_key(schema, &key)?; + let expected_version = self.expected_for(schema, &key); + let mutation = PatchTableRowMutation { + schema, + key, + patch, + expected_version, + mode, + }; + self.push(TableMutation::PatchRow(mutation)); + Ok(self) + } + + pub(super) fn push(&mut self, mutation: TableMutation) { + 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: &TableSchema, 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) + } +} + +pub(super) fn validated_schema() -> Result<&'static TableSchema, TableStoreError> +where + M: RelationalReadModel, +{ + let schema = M::schema(); + schema.validate()?; + Ok(schema) +} + +pub(super) fn populate_delegated_relationship_values( + parent_schema: &TableSchema, + parent_row: &RowValues, + relationship: &RelationshipDef, + child_schema: &TableSchema, + child_row: &mut RowValues, +) -> Result<(), TableStoreError> { + 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(TableStoreError::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(|| { + TableStoreError::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(|| { + TableStoreError::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(|| { + TableStoreError::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(|| { + TableStoreError::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(|| { + TableStoreError::Metadata(format!( + "relationship `{}` has no parent key to delegate", + relationship.field_name + )) + })?; + let value = parent_row.get(&parent_column).cloned().ok_or_else(|| { + TableStoreError::Metadata(format!( + "read model `{}` parent row is missing relationship key `{}`", + parent_schema.model_name, parent_column + )) + })?; + child_row.insert(child_column, value); + } + + Ok(()) +} diff --git a/src/read_model/session.rs b/src/read_model/session.rs deleted file mode 100644 index f09e5664a..000000000 --- a/src/read_model/session.rs +++ /dev/null @@ -1,1507 +0,0 @@ -use std::cmp::Ordering; -use std::collections::{BTreeMap, BTreeSet}; -use std::marker::PhantomData; - -use serde::Serialize; - -use crate::repository::{ReadModelWritePlanStore, RelationalReadModelQueryStore}; - -use super::{ - 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 sparse_patches: bool, - pub deletes: bool, -} - -impl Default for ReadModelAdapterCapabilities { - fn default() -> Self { - Self { - relational_rows: true, - sparse_patches: true, - deletes: true, - } - } -} - -/// Result of applying a standalone read-model write plan. -/// -/// This is intentionally a stub: it carries no skipped/replay state and -/// [`was_applied`](Self::was_applied) is always `true`. The earlier -/// `read_model_processed_messages` dedupe table and `skipped_duplicate` outcome -/// were **deliberately removed** (see `specs/consumer-inbox-design.md`, decision -/// 2026-05-28) because coupling delivery-level dedupe to the read-model -/// projection contract was the wrong boundary. Replay safety is now a projection -/// convention — handlers make their writes idempotent so a redelivered event -/// re-converges (plus per-row `ExpectedVersion` optimistic concurrency). A -/// first-class replay barrier returns with the consumer inbox (an operational -/// `consumer_inbox` table committed as a `CommitBatch` participant), tracked -/// under `tasks/build-transport-bus-facade`; the variant set will grow then. -#[derive(Clone, Debug, Default, PartialEq, Eq)] -pub struct ReadModelCommitOutcome; - -impl ReadModelCommitOutcome { - /// The write plan was applied. Currently the only outcome (see the type docs). - pub fn applied() -> Self { - Self - } - - /// Always `true` today — see the type docs for why there is no skipped variant. - pub fn was_applied(&self) -> bool { - true - } -} - -/// 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, -} - -/// 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 - } -} - -/// 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 { - UpsertRow(RowMutation), - PatchRow(PatchRowMutation), - DeleteRow(DeleteRowMutation), -} - -impl ReadModelMutation { - pub fn table_name(&self) -> &str { - self.schema().table_name.as_str() - } - - pub fn lock_key(&self) -> String { - format!("{}:{}", self.table_name(), key_fingerprint(self.key())) - } - - fn key(&self) -> &RowKey { - match self { - ReadModelMutation::UpsertRow(mutation) => &mutation.key, - ReadModelMutation::PatchRow(mutation) => &mutation.key, - ReadModelMutation::DeleteRow(mutation) => &mutation.key, - } - } - - fn operation_rank(&self) -> u8 { - match self { - ReadModelMutation::UpsertRow(_) => 1, - ReadModelMutation::PatchRow(_) => 2, - ReadModelMutation::DeleteRow(_) => 3, - } - } - - fn schema(&self) -> &ReadModelSchema { - match self { - ReadModelMutation::UpsertRow(mutation) => &mutation.schema, - ReadModelMutation::PatchRow(mutation) => &mutation.schema, - ReadModelMutation::DeleteRow(mutation) => &mutation.schema, - } - } - - fn depends_on_table(&self, table_name: &str) -> bool { - let schema = self.schema(); - schema - .foreign_keys - .iter() - .any(|foreign_key| foreign_key.table == table_name) - || schema.columns.iter().any(|column| { - column - .foreign_key - .as_ref() - .is_some_and(|foreign_key| foreign_key.table == table_name) - }) - } - - fn dependency_order(&self, other: &Self) -> Option { - let self_depends_on_other = self.depends_on_table(other.table_name()); - let other_depends_on_self = other.depends_on_table(self.table_name()); - - match (self_depends_on_other, other_depends_on_self) { - (true, false) if self.operation_rank() == 3 && other.operation_rank() == 3 => { - Some(Ordering::Less) - } - (true, false) => Some(Ordering::Greater), - (false, true) if self.operation_rank() == 3 && other.operation_rank() == 3 => { - Some(Ordering::Greater) - } - (false, true) => Some(Ordering::Less), - _ => None, - } - } - - fn sort_key(&self) -> String { - format!( - "{}|{}|{}", - self.operation_rank(), - self.table_name(), - key_fingerprint(self.key()) - ) - } -} - -/// Deterministic unit-of-work output for relational read-model adapters. -#[derive(Clone, Debug, Default, PartialEq)] -pub struct ReadModelWritePlan { - pub mutations: Vec, -} - -impl ReadModelWritePlan { - pub fn new(mutations: Vec) -> Self { - Self { mutations } - } - - pub fn is_empty(&self) -> bool { - self.mutations.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::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)?; - } - } - } - - Ok(()) - } -} - -#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)] -struct RowIdentity { - table_name: String, - key: String, -} - -#[derive(Clone, Debug)] -struct StagedMutation { - sequence: u64, - mutation: ReadModelMutation, -} - -/// Detached builder for read-model write plans that are applied at commit. -#[derive(Clone, Debug, Default)] -pub struct ReadModelWritePlanBuilder { - mutations: Vec, - expected_versions: BTreeMap, - next_sequence: u64, -} - -impl ReadModelWritePlanBuilder { - pub fn new() -> Self { - Self::default() - } - - pub fn is_empty(&self) -> bool { - self.mutations.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 upsert(&mut self, model: &M) -> Result<&mut Self, ReadModelError> - where - M: RelationalReadModel, - { - self.stage_full_row(model, RowWriteMode::Upsert, None) - } - - 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 upsert_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 into_write_plan(self) -> Result { - let mut mutations = self.mutations; - mutations.sort_by(|left, right| { - left.mutation - .operation_rank() - .cmp(&right.mutation.operation_rank()) - .then_with(|| { - left.mutation - .dependency_order(&right.mutation) - .unwrap_or(Ordering::Equal) - }) - .then_with(|| 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); - plan.validate()?; - Ok(plan) - } - - pub async fn commit(self, store: &S) -> Result - where - S: ReadModelWritePlanStore + ?Sized, - { - store.commit_write_plan(self.into_write_plan()?).await - } - - 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, -} - -const INITIAL_TRACKED_ROW_VERSION: u64 = 1; - -/// Store-bound read-model workspace for load, mutate, sync, commit workflows. -/// -/// The mutation/sync/diff surface is store-independent; `load`/`commit` -/// are provided by the async-store impl block below. -pub struct ReadModelWorkspace<'a, S> { - store: &'a S, - writes: ReadModelWritePlanBuilder, - baselines: Vec, -} - -impl<'a, S> ReadModelWorkspace<'a, S> { - pub fn new(store: &'a S) -> Self { - Self { - store, - writes: ReadModelWritePlanBuilder::new(), - baselines: Vec::new(), - } - } - - pub fn is_empty(&self) -> bool { - self.writes.is_empty() - } - - pub fn sync(&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_index = self - .baselines - .iter() - .position(|baseline| { - baseline.root_schema.table_name == identity.table_name - && key_fingerprint(&baseline.root_key) == identity.key - }) - .ok_or_else(|| { - ReadModelError::Metadata(format!( - "read model `{}` has no tracked baseline for sync", - schema.model_name - )) - })?; - let baseline = self.baselines[baseline_index].clone(); - let current_row = model.to_row()?; - - let root_version = self - .stage_row_diff( - schema.clone(), - key.clone(), - &baseline.root_row, - ¤t_row, - baseline.root_version, - )? - .unwrap_or(baseline.root_version); - - let mut refreshed_includes = BTreeMap::new(); - for (include_name, include) in &baseline.includes { - let current_rows = model.include_rows(include_name)?; - let refreshed_include = - self.stage_include_changes(&schema, ¤t_row, include, current_rows)?; - refreshed_includes.insert(include_name.clone(), refreshed_include); - } - - self.writes.expected_versions.insert(identity, root_version); - self.baselines[baseline_index] = TrackedModelBaseline { - root_schema: schema, - root_key: key, - root_row: current_row, - root_version, - includes: refreshed_includes, - }; - - Ok(self) - } - - pub fn upsert(&mut self, model: &M) -> Result<&mut Self, ReadModelError> - where - M: RelationalReadModel, - { - self.writes.upsert(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 upsert_related( - &mut self, - parent: &P, - relationship_field: &str, - child: &C, - ) -> Result<&mut Self, ReadModelError> - where - P: RelationalReadModel, - C: RelationalReadModel, - { - self.writes - .upsert_related(parent, relationship_field, child)?; - Ok(self) - } - - pub fn insert_related( - &mut self, - parent: &P, - relationship_field: &str, - child: &C, - ) -> Result<&mut Self, ReadModelError> - where - P: RelationalReadModel, - C: RelationalReadModel, - { - self.writes - .insert_related(parent, relationship_field, child)?; - 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 upsert_patch( - &mut self, - key: RowKey, - patch: RowPatch, - ) -> Result<&mut Self, ReadModelError> - where - M: RelationalReadModel, - { - self.writes.upsert_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 delete_model(&mut self, model: &M) -> Result<&mut Self, ReadModelError> - where - M: RelationalReadModel, - { - self.writes.delete_model(model)?; - Ok(self) - } - - pub fn into_write_plan(self) -> Result { - self.writes.into_write_plan() - } - - 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 { - if matches!(baseline.relationship.kind, RelationshipKind::BelongsTo) - && current_rows.len() > 1 - { - return Err(ReadModelError::Metadata(format!( - "belongs_to relationship `{}` can sync at most one related row", - baseline.relationship.field_name - ))); - } - - let mut current_fingerprints = BTreeSet::new(); - let mut refreshed_rows = BTreeMap::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) { - let version = self - .stage_row_diff( - baseline.target_schema.clone(), - loaded.key.clone(), - &loaded.row, - ¤t_row, - loaded.version, - )? - .unwrap_or(loaded.version); - refreshed_rows.insert( - fingerprint, - TrackedRowBaseline { - key, - row: current_row, - version, - }, - ); - } else { - self.stage_upsert_row( - baseline.target_schema.clone(), - key.clone(), - current_row.clone(), - )?; - refreshed_rows.insert( - fingerprint, - TrackedRowBaseline { - key, - row: current_row, - version: INITIAL_TRACKED_ROW_VERSION, - }, - ); - } - } - - // `sync` 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, - )?; - } - } - } else { - for (fingerprint, loaded) in &baseline.rows { - if !current_fingerprints.contains(fingerprint) { - refreshed_rows.insert(fingerprint.clone(), loaded.clone()); - } - } - } - - Ok(TrackedIncludeBaseline { - relationship: baseline.relationship.clone(), - target_schema: baseline.target_schema.clone(), - rows: refreshed_rows, - }) - } - - 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(None); - } - let next_version = next_tracked_version(&schema, &key, expected_version)?; - - 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(Some(next_version)) - } - - 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(()) - } -} - -impl<'a, S> ReadModelWorkspace<'a, S> -where - S: ReadModelWritePlanStore + RelationalReadModelQueryStore, -{ - /// Begin a tracked load against the asynchronous store traits. - pub fn load(&mut self, key: RowKey) -> ReadModelLoadBuilder<'_, 'a, S, M> - where - M: RelationalReadModel + RelationalReadModelIncludes, - { - ReadModelLoadBuilder { - unit: self, - key, - includes: Vec::new(), - _marker: PhantomData, - } - } - - /// Commit the staged write plan through the asynchronous store. - pub async fn commit(self) -> Result { - self.writes.commit(self.store).await - } -} - -/// Builder for one explicit primary-key read-model load over the async store traits. -pub struct ReadModelLoadBuilder<'workspace, 'store, S, M> -where - S: ReadModelWritePlanStore + RelationalReadModelQueryStore, -{ - unit: &'workspace mut ReadModelWorkspace<'store, S>, - key: RowKey, - includes: Vec, - _marker: PhantomData, -} - -impl<'workspace, 'store, S, M> ReadModelLoadBuilder<'workspace, 'store, S, M> -where - S: ReadModelWritePlanStore + RelationalReadModelQueryStore, - M: RelationalReadModel + RelationalReadModelIncludes, -{ - pub fn include(mut self, relationship: impl Into) -> Self { - self.includes.push(relationship.into()); - self - } - - pub async 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()).await?; - 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 tracked read-model workspace from an async store. -pub trait ReadModelWorkspaceExt: - ReadModelWritePlanStore + RelationalReadModelQueryStore + Sized -{ - fn workspace(&self) -> ReadModelWorkspace<'_, Self> { - ReadModelWorkspace::new(self) - } -} - -impl ReadModelWorkspaceExt for S where S: ReadModelWritePlanStore + 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 next_tracked_version( - schema: &ReadModelSchema, - key: &RowKey, - current_version: u64, -) -> Result { - current_version.checked_add(1).ok_or_else(|| { - ReadModelError::Storage(format!( - "read model version overflow for {}:{}", - schema.table_name, - key_fingerprint(key) - )) - }) -} - -fn validated_schema() -> Result -where - M: RelationalReadModel, -{ - let schema = M::schema(); - schema.validate()?; - Ok(schema) -} - -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 -} - -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)); - } -} diff --git a/src/read_model/workspace.rs b/src/read_model/workspace.rs new file mode 100644 index 000000000..54de7864d --- /dev/null +++ b/src/read_model/workspace.rs @@ -0,0 +1,487 @@ +//! Store-bound read-model workspace for load, mutate, sync, commit workflows. + +use std::collections::{BTreeMap, BTreeSet}; +use std::marker::PhantomData; + +use crate::repository::{ReadModelWritePlanStore, RelationalReadModelQueryStore}; + +use super::plan::{populate_delegated_relationship_values, validated_schema, RowIdentity}; +use super::{ + ReadModelIncludeRows, ReadModelLoadBuilder, ReadModelWritePlanBuilder, RelationalReadModel, + RelationalReadModelIncludes, Versioned, +}; +use crate::table::{ + key_fingerprint, key_from_row, validate_delete_mutation, validate_key, validate_patch_mutation, + validate_row_mutation, +}; +use crate::table::{ + DeleteTableRowMutation, ExpectedVersion, PatchMode, PatchTableRowMutation, RelationshipDef, + RelationshipKind, RowKey, RowPatch, RowValues, RowWriteMode, TableCommitOutcome, TableMutation, + TableRowMutation, TableSchema, TableStoreError, TableWritePlan, +}; + +#[derive(Clone, Debug)] +struct TrackedRowBaseline { + key: RowKey, + row: RowValues, + version: u64, +} + +#[derive(Clone, Debug)] +struct TrackedIncludeBaseline { + relationship: RelationshipDef, + target_schema: &'static TableSchema, + rows: BTreeMap, +} + +#[derive(Clone, Debug)] +struct TrackedModelBaseline { + root_schema: &'static TableSchema, + root_key: RowKey, + root_row: RowValues, + root_version: u64, + includes: BTreeMap, +} + +const INITIAL_TRACKED_ROW_VERSION: u64 = 1; + +/// Store-bound read-model workspace for load, mutate, sync, commit workflows. +/// +/// The mutation/sync/diff surface is store-independent; `load`/`commit` +/// are provided by the async-store impl block below. +pub struct ReadModelWorkspace<'a, S> { + pub(super) store: &'a S, + pub(super) writes: ReadModelWritePlanBuilder, + baselines: Vec, +} + +impl<'a, S> ReadModelWorkspace<'a, S> { + pub fn new(store: &'a S) -> Self { + Self { + store, + writes: ReadModelWritePlanBuilder::new(), + baselines: Vec::new(), + } + } + + pub fn is_empty(&self) -> bool { + self.writes.is_empty() + } + + pub fn sync(&mut self, model: M) -> Result<&mut Self, TableStoreError> + 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_index = self + .baselines + .iter() + .position(|baseline| { + baseline.root_schema.table_name == identity.table_name + && key_fingerprint(&baseline.root_key) == identity.key + }) + .ok_or_else(|| { + TableStoreError::Metadata(format!( + "read model `{}` has no tracked baseline for sync", + schema.model_name + )) + })?; + let baseline = self.baselines[baseline_index].clone(); + let current_row = model.to_row()?; + + let root_version = self + .stage_row_diff( + schema, + key.clone(), + &baseline.root_row, + ¤t_row, + baseline.root_version, + )? + .unwrap_or(baseline.root_version); + + let mut refreshed_includes = BTreeMap::new(); + for (include_name, include) in &baseline.includes { + let current_rows = model.include_rows(include_name)?; + let refreshed_include = + self.stage_include_changes(schema, ¤t_row, include, current_rows)?; + refreshed_includes.insert(include_name.clone(), refreshed_include); + } + + self.writes.expected_versions.insert(identity, root_version); + self.baselines[baseline_index] = TrackedModelBaseline { + root_schema: schema, + root_key: key, + root_row: current_row, + root_version, + includes: refreshed_includes, + }; + + Ok(self) + } + + pub fn upsert(&mut self, model: &M) -> Result<&mut Self, TableStoreError> + where + M: RelationalReadModel, + { + self.writes.upsert(model)?; + Ok(self) + } + + pub fn insert(&mut self, model: &M) -> Result<&mut Self, TableStoreError> + where + M: RelationalReadModel, + { + self.writes.insert(model)?; + Ok(self) + } + + pub fn upsert_related( + &mut self, + parent: &P, + relationship_field: &str, + child: &C, + ) -> Result<&mut Self, TableStoreError> + where + P: RelationalReadModel, + C: RelationalReadModel, + { + self.writes + .upsert_related(parent, relationship_field, child)?; + Ok(self) + } + + pub fn insert_related( + &mut self, + parent: &P, + relationship_field: &str, + child: &C, + ) -> Result<&mut Self, TableStoreError> + where + P: RelationalReadModel, + C: RelationalReadModel, + { + self.writes + .insert_related(parent, relationship_field, child)?; + Ok(self) + } + + pub fn patch(&mut self, key: RowKey, patch: RowPatch) -> Result<&mut Self, TableStoreError> + where + M: RelationalReadModel, + { + self.writes.patch::(key, patch)?; + Ok(self) + } + + pub fn upsert_patch( + &mut self, + key: RowKey, + patch: RowPatch, + ) -> Result<&mut Self, TableStoreError> + where + M: RelationalReadModel, + { + self.writes.upsert_patch::(key, patch)?; + Ok(self) + } + + pub fn delete(&mut self, key: RowKey) -> Result<&mut Self, TableStoreError> + where + M: RelationalReadModel, + { + self.writes.delete::(key)?; + Ok(self) + } + + pub fn delete_model(&mut self, model: &M) -> Result<&mut Self, TableStoreError> + where + M: RelationalReadModel, + { + self.writes.delete_model(model)?; + Ok(self) + } + + pub fn into_write_plan(self) -> Result { + self.writes.into_write_plan() + } + + pub(super) fn track_graph( + &mut self, + root: Versioned, + includes: BTreeMap, + ) -> Result<(), TableStoreError> + where + M: RelationalReadModel + RelationalReadModelIncludes, + { + let schema = M::schema(); + 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 target_schema = M::include_target_schema(&include_name)?; + let mut rows = BTreeMap::new(); + for row in include_rows.rows { + let key = key_from_row(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, + 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: &TableSchema, + root_row: &RowValues, + baseline: &TrackedIncludeBaseline, + current_rows: Vec, + ) -> Result { + if matches!(baseline.relationship.kind, RelationshipKind::BelongsTo) + && current_rows.len() > 1 + { + return Err(TableStoreError::Metadata(format!( + "belongs_to relationship `{}` can sync at most one related row", + baseline.relationship.field_name + ))); + } + + let mut current_fingerprints = BTreeSet::new(); + let mut refreshed_rows = BTreeMap::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(TableStoreError::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) { + let version = self + .stage_row_diff( + baseline.target_schema, + loaded.key.clone(), + &loaded.row, + ¤t_row, + loaded.version, + )? + .unwrap_or(loaded.version); + refreshed_rows.insert( + fingerprint, + TrackedRowBaseline { + key, + row: current_row, + version, + }, + ); + } else { + self.stage_upsert_row(baseline.target_schema, key.clone(), current_row.clone())?; + refreshed_rows.insert( + fingerprint, + TrackedRowBaseline { + key, + row: current_row, + version: INITIAL_TRACKED_ROW_VERSION, + }, + ); + } + } + + // `sync` 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, + loaded.key.clone(), + loaded.version, + )?; + } + } + } else { + for (fingerprint, loaded) in &baseline.rows { + if !current_fingerprints.contains(fingerprint) { + refreshed_rows.insert(fingerprint.clone(), loaded.clone()); + } + } + } + + Ok(TrackedIncludeBaseline { + relationship: baseline.relationship.clone(), + target_schema: baseline.target_schema, + rows: refreshed_rows, + }) + } + + fn stage_row_diff( + &mut self, + schema: &'static TableSchema, + key: RowKey, + before: &RowValues, + after: &RowValues, + expected_version: u64, + ) -> Result, TableStoreError> { + let patch = diff_rows(before, after); + if patch.is_empty() { + return Ok(None); + } + let next_version = next_tracked_version(schema, &key, expected_version)?; + + let mutation = PatchTableRowMutation { + schema, + key, + patch, + expected_version: ExpectedVersion::Exact(expected_version), + mode: PatchMode::UpdateExisting, + }; + validate_patch_mutation(&mutation)?; + self.writes.push(TableMutation::PatchRow(mutation)); + Ok(Some(next_version)) + } + + fn stage_upsert_row( + &mut self, + schema: &'static TableSchema, + key: RowKey, + values: RowValues, + ) -> Result<(), TableStoreError> { + let mutation = TableRowMutation { + schema, + key, + values, + expected_version: ExpectedVersion::Any, + mode: RowWriteMode::Upsert, + }; + validate_row_mutation(&mutation)?; + self.writes.push(TableMutation::UpsertRow(mutation)); + Ok(()) + } + + fn stage_delete_row( + &mut self, + schema: &'static TableSchema, + key: RowKey, + expected_version: u64, + ) -> Result<(), TableStoreError> { + let mutation = DeleteTableRowMutation { + schema, + key, + expected_version: ExpectedVersion::Exact(expected_version), + }; + validate_delete_mutation(&mutation)?; + self.writes.push(TableMutation::DeleteRow(mutation)); + Ok(()) + } +} + +impl<'a, S> ReadModelWorkspace<'a, S> +where + S: ReadModelWritePlanStore + RelationalReadModelQueryStore, +{ + /// Begin a tracked load against the asynchronous store traits. + pub fn load(&mut self, key: RowKey) -> ReadModelLoadBuilder<'_, 'a, S, M> + where + M: RelationalReadModel + RelationalReadModelIncludes, + { + ReadModelLoadBuilder { + unit: self, + key, + includes: Vec::new(), + _marker: PhantomData, + } + } + + /// Commit the staged write plan through the asynchronous store. + pub async fn commit(self) -> Result { + self.writes.commit(self.store).await + } +} + +/// Extension trait that starts a tracked read-model workspace from an async store. +pub trait ReadModelWorkspaceExt: + ReadModelWritePlanStore + RelationalReadModelQueryStore + Sized +{ + fn workspace(&self) -> ReadModelWorkspace<'_, Self> { + ReadModelWorkspace::new(self) + } +} + +impl ReadModelWorkspaceExt for S where S: ReadModelWritePlanStore + 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 next_tracked_version( + schema: &TableSchema, + key: &RowKey, + current_version: u64, +) -> Result { + current_version.checked_add(1).ok_or_else(|| { + TableStoreError::Storage(format!( + "read model version overflow for {}:{}", + schema.table_name, + key_fingerprint(key) + )) + }) +} diff --git a/src/repository/error.rs b/src/repository/error.rs index 454393bcc..7e79441c7 100644 --- a/src/repository/error.rs +++ b/src/repository/error.rs @@ -2,7 +2,7 @@ use std::error::Error; use std::fmt; use crate::lock::{LockError, RetryClass}; -use crate::read_model::ReadModelError; +use crate::table::TableStoreError; use crate::EventRecordError; #[derive(Debug)] @@ -238,18 +238,18 @@ impl From for RepositoryError { } } -impl From for RepositoryError { - fn from(err: ReadModelError) -> Self { +impl From for RepositoryError { + fn from(err: TableStoreError) -> Self { // Map to `Storage` so the read-model error keeps a retry signal and its // source instead of collapsing to an opaque `Model` string. Only a lock // failure carries a transient/permanent distinction we can recover here; // every other read-model variant is deterministic (a concurrency // conflict, serde/metadata fault, or not-found will fail the same way on - // redelivery). `ReadModelError::Storage` is itself a stringified backend + // redelivery). `TableStoreError::Storage` is itself a stringified backend // error with no preserved retry signal — without changing `read_model` // it is classified permanent, which is the safe default (it cannot loop // forever; it surfaces to the failure policy). - let retryable = matches!(&err, ReadModelError::Lock(lock) if lock.is_retryable()); + let retryable = matches!(&err, TableStoreError::Lock(lock) if lock.is_retryable()); RepositoryError::Storage { operation: "read model".into(), retryable, diff --git a/src/repository/traits.rs b/src/repository/traits.rs index 7f0b99c83..ad2b9aa2a 100644 --- a/src/repository/traits.rs +++ b/src/repository/traits.rs @@ -2,11 +2,9 @@ use std::future::Future; use crate::entity::{Entity, EventRecord}; use crate::outbox::OutboxMessage; -use crate::read_model::{ - ReadModelAdapterCapabilities, ReadModelCommitOutcome, ReadModelError, ReadModelLoadGraph, - ReadModelLoadRequest, ReadModelQueryCapabilities, ReadModelWritePlan, -}; +use crate::read_model::{ReadModelLoadGraph, ReadModelLoadRequest, ReadModelQueryCapabilities}; use crate::snapshot::SnapshotRecord; +use crate::table::{TableAdapterCapabilities, TableCommitOutcome, TableStoreError, TableWritePlan}; use super::inbox::InboxReceipt; use super::{RepositoryError, StreamIdentity}; @@ -36,7 +34,7 @@ pub enum SnapshotWrite { pub struct CommitBatch<'a> { pub streams: Vec>, pub outbox_messages: Vec, - pub read_model_plans: Vec, + pub read_model_plans: Vec, pub snapshots: Vec, /// Consumer inbox receipts to record in the same transaction (the optional /// effectively-once effect fence). Empty for the default idempotent path. @@ -166,12 +164,12 @@ impl Repository for T where T: GetStream + TransactionalCommit {} /// Adapter contract for committing read-model write plans. pub trait ReadModelWritePlanStore: Send + Sync { - fn read_model_capabilities(&self) -> ReadModelAdapterCapabilities; + fn read_model_capabilities(&self) -> TableAdapterCapabilities; fn commit_write_plan( &self, - plan: ReadModelWritePlan, - ) -> impl Future> + Send + '_; + plan: TableWritePlan, + ) -> impl Future> + Send + '_; } /// Primary-key relational read-model query contract. @@ -181,7 +179,7 @@ pub trait RelationalReadModelQueryStore: Send + Sync { fn load_graph( &self, request: ReadModelLoadRequest, - ) -> impl Future> + Send + '_; + ) -> impl Future> + Send + '_; } /// Snapshot persistence keyed by full stream identity. diff --git a/src/sqlite_repo/mod.rs b/src/sqlite_repo/mod.rs index 736bf690c..bafcaf59d 100644 --- a/src/sqlite_repo/mod.rs +++ b/src/sqlite_repo/mod.rs @@ -19,11 +19,7 @@ use sqlx::{QueryBuilder, Row, Sqlite, SqlitePool, Transaction}; use crate::entity::{Entity, EventRecord}; use crate::outbox::{OutboxMessage, OutboxMessageStatus}; use crate::outbox_worker::{ensure_active_claim, ClaimOutboxMessages, OutboxClaimRef, OutboxStore}; -use crate::read_model::{ - ColumnDef, ColumnType, ReadModelAdapterCapabilities, ReadModelCommitOutcome, ReadModelError, - ReadModelLoadGraph, ReadModelLoadRequest, ReadModelQueryCapabilities, ReadModelWritePlan, - RowValue, -}; +use crate::read_model::{ReadModelLoadGraph, ReadModelLoadRequest, ReadModelQueryCapabilities}; use crate::repository::{ reject_duplicate_outbox_messages, reject_duplicate_streams, validate_entity_id_matches_identity, validate_prepared_appends, validate_snapshot_identity, @@ -50,6 +46,9 @@ use crate::table::{ TableMigrationArtifact, TableSchemaBootstrap, TableSchemaRegistry, TableSqlDialect, TableSqlSchemaAdapter, TableStoreError, }; +use crate::table::{ + ColumnType, RowValue, TableAdapterCapabilities, TableColumn, TableCommitOutcome, TableWritePlan, +}; const SQLITE_SCHEMA: &str = include_str!("../../migrations/sqlite/0001_initial.sql"); const SQLITE_BACKEND: &str = "sqlite"; @@ -466,14 +465,14 @@ impl InboxStore for SqliteRepository { } impl ReadModelWritePlanStore for SqliteRepository { - fn read_model_capabilities(&self) -> ReadModelAdapterCapabilities { + fn read_model_capabilities(&self) -> TableAdapterCapabilities { sql_read_model_capabilities() } fn commit_write_plan( &self, - plan: ReadModelWritePlan, - ) -> impl Future> + Send + '_ { + plan: TableWritePlan, + ) -> impl Future> + Send + '_ { async move { commit_read_model_write_plan(&self.pool, plan).await } } } @@ -486,7 +485,7 @@ impl RelationalReadModelQueryStore for SqliteRepository { fn load_graph( &self, request: ReadModelLoadRequest, - ) -> impl Future> + Send + '_ { + ) -> impl Future> + Send + '_ { async move { load_read_model_graph( &self.pool, @@ -1436,8 +1435,8 @@ impl crate::sqlx_repo::read_model::SqlxReadModelBackend for Sqlite { fn push_row_value_bind( builder: &mut QueryBuilder, value: RowValue, - column: &ColumnDef, - ) -> Result<(), ReadModelError> { + column: &TableColumn, + ) -> Result<(), TableStoreError> { match value { RowValue::Null => Self::push_null_bind(builder, column)?, RowValue::Bool(value) => { @@ -1465,7 +1464,7 @@ impl crate::sqlx_repo::read_model::SqlxReadModelBackend for Sqlite { } RowValue::Json(value) => { let payload = serde_json::to_string(&value) - .map_err(|err| ReadModelError::Serde(err.to_string()))?; + .map_err(|err| TableStoreError::Serde(err.to_string()))?; builder.push_bind(payload); } } @@ -1474,8 +1473,8 @@ impl crate::sqlx_repo::read_model::SqlxReadModelBackend for Sqlite { fn push_null_bind( builder: &mut QueryBuilder, - column: &ColumnDef, - ) -> Result<(), ReadModelError> { + column: &TableColumn, + ) -> Result<(), TableStoreError> { match &column.column_type { ColumnType::Text | ColumnType::Json | ColumnType::Timestamp => { builder.push_bind(Option::::None); @@ -1490,7 +1489,7 @@ impl crate::sqlx_repo::read_model::SqlxReadModelBackend for Sqlite { builder.push_bind(Option::>::None); } ColumnType::Unsupported(type_name) => { - return Err(ReadModelError::Metadata(format!( + return Err(TableStoreError::Metadata(format!( "read model column `{}` has unsupported type `{}`", column.column_name, type_name ))); @@ -1503,11 +1502,11 @@ impl crate::sqlx_repo::read_model::SqlxReadModelBackend for Sqlite { result.rows_affected() } - fn push_select_column(builder: &mut QueryBuilder, column: &ColumnDef) { + fn push_select_column(builder: &mut QueryBuilder, column: &TableColumn) { builder.push(quote_identifier(&column.column_name)); } - fn row_value(row: &SqliteRow, column: &ColumnDef) -> Result { + fn row_value(row: &SqliteRow, column: &TableColumn) -> Result { Ok(match column.column_type { ColumnType::Text | ColumnType::Timestamp => row .try_get::, _>(column.column_name.as_str()) @@ -1551,12 +1550,12 @@ impl crate::sqlx_repo::read_model::SqlxReadModelBackend for Sqlite { .map(|payload| { serde_json::from_str(&payload) .map(RowValue::Json) - .map_err(|err| ReadModelError::Serde(err.to_string())) + .map_err(|err| TableStoreError::Serde(err.to_string())) }) .transpose()? .unwrap_or(RowValue::Null), ColumnType::Unsupported(ref type_name) => { - return Err(ReadModelError::Metadata(format!( + return Err(TableStoreError::Metadata(format!( "read model `{}` column `{}` has unsupported type `{}`", column.field_name, column.column_name, type_name ))); @@ -1711,7 +1710,7 @@ fn repository_storage_error(operation: &str, err: sqlx::Error) -> RepositoryErro sqlx_repo::repository_storage_error(SQLITE_BACKEND, operation, err) } -fn read_model_storage_error(operation: &str, err: sqlx::Error) -> ReadModelError { +fn read_model_storage_error(operation: &str, err: sqlx::Error) -> TableStoreError { sqlx_repo::read_model_storage_error(SQLITE_BACKEND, operation, err) } diff --git a/src/sqlx_repo/mod.rs b/src/sqlx_repo/mod.rs index 7634202b9..0d12ac028 100644 --- a/src/sqlx_repo/mod.rs +++ b/src/sqlx_repo/mod.rs @@ -1,8 +1,8 @@ use std::collections::HashMap; -#[cfg(any(feature = "postgres", feature = "sqlite"))] -use crate::read_model::ReadModelError; use crate::repository::RepositoryError; +#[cfg(any(feature = "postgres", feature = "sqlite"))] +use crate::table::TableStoreError; #[cfg(any(feature = "postgres", feature = "sqlite"))] pub(crate) mod read_model; @@ -89,9 +89,9 @@ pub(crate) fn read_model_i64_from_u64( value: u64, field: &str, storage: &str, -) -> Result { +) -> Result { i64::try_from(value).map_err(|_| { - ReadModelError::Storage(format!("{backend} {field} value {value} exceeds {storage}")) + TableStoreError::Storage(format!("{backend} {field} value {value} exceeds {storage}")) }) } @@ -100,9 +100,9 @@ pub(crate) fn read_model_u64_from_i64( backend: &str, value: i64, field: &str, -) -> Result { +) -> Result { u64::try_from(value).map_err(|_| { - ReadModelError::Storage(format!("{backend} {field} value {value} is negative")) + TableStoreError::Storage(format!("{backend} {field} value {value} is negative")) }) } @@ -213,6 +213,6 @@ pub(crate) fn read_model_storage_error( backend: &str, operation: &str, err: sqlx::Error, -) -> ReadModelError { - ReadModelError::Storage(format!("{backend} {operation} failed: {err}")) +) -> TableStoreError { + TableStoreError::Storage(format!("{backend} {operation} failed: {err}")) } diff --git a/src/sqlx_repo/read_model.rs b/src/sqlx_repo/read_model.rs index 8866e0f4f..8e4b05709 100644 --- a/src/sqlx_repo/read_model.rs +++ b/src/sqlx_repo/read_model.rs @@ -18,14 +18,16 @@ use std::sync::RwLock; use sqlx::{Database, Encode, Executor, IntoArguments, QueryBuilder, Row, Transaction, Type}; use crate::read_model::{ - key_fingerprint, validate_key, validate_row_values, ColumnDef, DeleteRowMutation, - ExpectedVersion, PatchMode, PatchRowMutation, ReadModelAdapterCapabilities, - ReadModelCommitOutcome, ReadModelError, ReadModelIncludeRows, ReadModelLoadGraph, - ReadModelLoadRequest, ReadModelMutation, ReadModelQueryCapabilities, ReadModelSchema, - ReadModelWritePlan, RelationshipDef, RelationshipKind, RowKey, RowMutation, RowValue, - RowValues, RowWriteMode, Versioned, + ReadModelIncludeRows, ReadModelLoadGraph, ReadModelLoadRequest, ReadModelQueryCapabilities, + Versioned, }; use crate::table::TableSchemaRegistry; +use crate::table::{ + key_fingerprint, validate_key, validate_row_values, DeleteTableRowMutation, ExpectedVersion, + PatchMode, PatchTableRowMutation, RelationshipDef, RelationshipKind, RowKey, RowValue, + RowValues, RowWriteMode, TableAdapterCapabilities, TableColumn, TableCommitOutcome, + TableMutation, TableRowMutation, TableSchema, TableStoreError, TableWritePlan, +}; /// A resolved relationship include: the relationship metadata plus the registered /// schema of the target model, ready for the relational load path to query. @@ -33,21 +35,21 @@ use crate::table::TableSchemaRegistry; pub(crate) struct IncludeSpec { pub(crate) name: String, pub(crate) relationship: RelationshipDef, - pub(crate) target_schema: ReadModelSchema, + pub(crate) target_schema: TableSchema, } pub(crate) fn remember_read_model_schemas( stored: &RwLock, registry: &TableSchemaRegistry, -) -> Result<(), ReadModelError> { +) -> Result<(), TableStoreError> { let mut stored = stored .write() - .map_err(|_| ReadModelError::Storage("read-model schema registry lock poisoned".into()))?; + .map_err(|_| TableStoreError::Storage("read-model schema registry lock poisoned".into()))?; for schema in registry.schemas() { if let Some(existing) = stored.schema_for_table(&schema.table_name) { if existing != schema { - return Err(ReadModelError::Metadata(format!( + return Err(TableStoreError::Metadata(format!( "read-model schema registry already contains table `{}` with different metadata", schema.table_name ))); @@ -63,25 +65,25 @@ pub(crate) fn remember_read_model_schemas( pub(crate) fn resolve_registered_read_model_schemas( registry: &RwLock, request: &ReadModelLoadRequest, -) -> Result<(ReadModelSchema, Vec), ReadModelError> { +) -> Result<(TableSchema, Vec), TableStoreError> { if request.includes.is_empty() { return Ok((request.schema.clone(), Vec::new())); } let registry = registry .read() - .map_err(|_| ReadModelError::Storage("read-model schema registry lock poisoned".into()))?; + .map_err(|_| TableStoreError::Storage("read-model schema registry lock poisoned".into()))?; let root_schema = registry .schema_for_model(&request.schema.model_name) .cloned() .ok_or_else(|| { - ReadModelError::Metadata(format!( + TableStoreError::Metadata(format!( "read model `{}` is not registered for relationship includes", request.schema.model_name )) })?; if root_schema != request.schema { - return Err(ReadModelError::Metadata(format!( + return Err(TableStoreError::Metadata(format!( "read model `{}` load request does not match registered schema", request.schema.model_name ))); @@ -94,13 +96,13 @@ pub(crate) fn resolve_registered_read_model_schemas( .iter() .find(|relationship| relationship.field_name == *include_name) .ok_or_else(|| { - ReadModelError::Metadata(format!( + TableStoreError::Metadata(format!( "read model `{}` has no relationship `{}`", root_schema.model_name, include_name )) })?; if matches!(relationship.kind, RelationshipKind::ManyToMany) { - return Err(ReadModelError::Metadata(format!( + return Err(TableStoreError::Metadata(format!( "many-to-many relationship `{}` includes are not supported until join metadata declares source and target keys", relationship.field_name ))); @@ -108,7 +110,7 @@ pub(crate) fn resolve_registered_read_model_schemas( let target_schema = registry .schema_for_model(&relationship.target_model) .ok_or_else(|| { - ReadModelError::Metadata(format!( + TableStoreError::Metadata(format!( "read model `{}` relationship `{}` targets unregistered model `{}`", root_schema.model_name, relationship.field_name, relationship.target_model )) @@ -124,15 +126,15 @@ pub(crate) fn resolve_registered_read_model_schemas( Ok((root_schema, include_specs)) } -pub(crate) fn sql_read_model_capabilities() -> ReadModelAdapterCapabilities { - ReadModelAdapterCapabilities { +pub(crate) fn sql_read_model_capabilities() -> TableAdapterCapabilities { + TableAdapterCapabilities { relational_rows: true, sparse_patches: true, deletes: true, } } -pub(crate) fn validate_sql_write_plan(plan: &ReadModelWritePlan) -> Result<(), ReadModelError> { +pub(crate) fn validate_sql_write_plan(plan: &TableWritePlan) -> Result<(), TableStoreError> { plan.validate_for(&sql_read_model_capabilities()) } @@ -140,36 +142,19 @@ pub(crate) fn initial_row_version() -> u64 { 1 } -pub(crate) fn next_row_version( - schema: &ReadModelSchema, - key: &RowKey, - current_version: Option, -) -> Result { - match current_version { - Some(version) => version.checked_add(1).ok_or_else(|| { - ReadModelError::Storage(format!( - "read model version overflow for {}:{}", - schema.table_name, - key_fingerprint(key) - )) - }), - None => Ok(initial_row_version()), - } -} - pub(crate) fn validate_row_expected_version( - schema: &ReadModelSchema, + schema: &TableSchema, key: &RowKey, expected_version: &ExpectedVersion, current_version: Option, -) -> Result<(), ReadModelError> { +) -> Result<(), TableStoreError> { match (expected_version, current_version) { (ExpectedVersion::Any, _) => Ok(()), (ExpectedVersion::Exact(expected), Some(actual)) if expected == &actual => Ok(()), (ExpectedVersion::Exact(expected), Some(actual)) => { Err(row_concurrency_conflict(schema, key, *expected, actual)) } - (ExpectedVersion::Exact(_), None) => Err(ReadModelError::NotFound { + (ExpectedVersion::Exact(_), None) => Err(TableStoreError::NotFound { collection: schema.table_name.clone(), id: key_fingerprint(key), }), @@ -181,12 +166,12 @@ pub(crate) fn validate_row_expected_version( } pub(crate) fn row_concurrency_conflict( - schema: &ReadModelSchema, + schema: &TableSchema, key: &RowKey, expected: u64, actual: u64, -) -> ReadModelError { - ReadModelError::ConcurrencyConflict { +) -> TableStoreError { + TableStoreError::ConcurrencyConflict { collection: schema.table_name.clone(), id: key_fingerprint(key), expected, @@ -195,10 +180,10 @@ pub(crate) fn row_concurrency_conflict( } pub(crate) fn row_values_from_key_and_patch( - schema: &ReadModelSchema, + schema: &TableSchema, key: &RowKey, - patch: crate::read_model::RowPatch, -) -> Result { + patch: crate::table::RowPatch, +) -> Result { let mut values = RowValues::new(); for (column, value) in key.iter() { values.insert(column.to_string(), value.clone()); @@ -211,13 +196,13 @@ pub(crate) fn row_values_from_key_and_patch( .any(|primary_key| primary_key == &column) { let key_value = key.get(&column).ok_or_else(|| { - ReadModelError::Metadata(format!( + TableStoreError::Metadata(format!( "read model `{}` row key is missing primary-key column `{}`", schema.model_name, column )) })?; if key_value != &value { - return Err(ReadModelError::Metadata(format!( + return Err(TableStoreError::Metadata(format!( "read model `{}` patch cannot change primary-key column `{}`", schema.model_name, column ))); @@ -231,53 +216,53 @@ pub(crate) fn row_values_from_key_and_patch( } pub(crate) fn patch_values_preserving_key<'schema>( - schema: &'schema ReadModelSchema, + schema: &'schema TableSchema, key: &RowKey, - patch: crate::read_model::RowPatch, -) -> Result, ReadModelError> { + patch: &crate::table::RowPatch, +) -> Result, TableStoreError> { let mut values = Vec::new(); - for (column_name, value) in patch.into_values() { - let column = column_by_name(schema, &column_name)?; + for (column_name, value) in patch.iter() { + let column = column_by_name(schema, column_name)?; if column.primary_key { - let key_value = key.get(&column_name).ok_or_else(|| { - ReadModelError::Metadata(format!( + let key_value = key.get(column_name).ok_or_else(|| { + TableStoreError::Metadata(format!( "read model `{}` row key is missing primary-key column `{}`", schema.model_name, column_name )) })?; - if key_value != &value { - return Err(ReadModelError::Metadata(format!( + if key_value != value { + return Err(TableStoreError::Metadata(format!( "read model `{}` patch cannot change primary-key column `{}`", schema.model_name, column_name ))); } continue; } - values.push((column, value)); + values.push((column, value.clone())); } Ok(values) } pub(crate) fn validate_values_match_key( - schema: &ReadModelSchema, + schema: &TableSchema, key: &RowKey, values: &RowValues, -) -> Result<(), ReadModelError> { +) -> Result<(), TableStoreError> { for column in &schema.primary_key.columns { let key_value = key.get(column).ok_or_else(|| { - ReadModelError::Metadata(format!( + TableStoreError::Metadata(format!( "read model `{}` row key is missing primary-key column `{}`", schema.model_name, column )) })?; let row_value = values.get(column).ok_or_else(|| { - ReadModelError::Metadata(format!( + TableStoreError::Metadata(format!( "read model `{}` row is missing primary-key column `{}`", schema.model_name, column )) })?; if row_value != key_value { - return Err(ReadModelError::Metadata(format!( + return Err(TableStoreError::Metadata(format!( "read model `{}` row values cannot change primary-key column `{}`", schema.model_name, column ))); @@ -287,11 +272,11 @@ pub(crate) fn validate_values_match_key( } pub(crate) fn belongs_to_target_column( - target_schema: &ReadModelSchema, + target_schema: &TableSchema, source_column: &str, -) -> Result { +) -> Result { if target_schema.primary_key.columns.len() != 1 { - return Err(ReadModelError::Metadata(format!( + return Err(TableStoreError::Metadata(format!( "belongs_to target `{}` must have a single-column primary key to load from `{}`", target_schema.model_name, source_column ))); @@ -309,9 +294,9 @@ pub(crate) fn empty_string_as_none(value: &str) -> Option<&str> { } pub(crate) fn row_write_values<'schema>( - schema: &'schema ReadModelSchema, + schema: &'schema TableSchema, values: &RowValues, -) -> Result, ReadModelError> { +) -> Result, TableStoreError> { values .iter() .map(|(column_name, value)| Ok((column_by_name(schema, column_name)?, value.clone()))) @@ -319,24 +304,24 @@ pub(crate) fn row_write_values<'schema>( } pub(crate) fn column_by_name<'schema>( - schema: &'schema ReadModelSchema, + schema: &'schema TableSchema, column_name: &str, -) -> Result<&'schema ColumnDef, ReadModelError> { +) -> Result<&'schema TableColumn, TableStoreError> { schema .columns .iter() .find(|column| column.column_name == column_name) .ok_or_else(|| { - ReadModelError::Metadata(format!( + TableStoreError::Metadata(format!( "read model `{}` write references missing column `{}`", schema.model_name, column_name )) }) } -pub(crate) fn version_column(schema: &ReadModelSchema) -> Result<&str, ReadModelError> { +pub(crate) fn version_column(schema: &TableSchema) -> Result<&str, TableStoreError> { schema.version_column.as_deref().ok_or_else(|| { - ReadModelError::Metadata(format!( + TableStoreError::Metadata(format!( "read model `{}` requires a version column for SQL write-plan persistence", schema.model_name )) @@ -369,15 +354,15 @@ pub(crate) trait SqlxReadModelBackend: Database { fn push_row_value_bind( builder: &mut QueryBuilder, value: RowValue, - column: &ColumnDef, - ) -> Result<(), ReadModelError>; + column: &TableColumn, + ) -> Result<(), TableStoreError>; /// Bind a typed `NULL` for the column's type (Postgres needs the concrete /// `Option::::None` per type so `$N` infers correctly). fn push_null_bind( builder: &mut QueryBuilder, - column: &ColumnDef, - ) -> Result<(), ReadModelError>; + column: &TableColumn, + ) -> Result<(), TableStoreError>; /// Affected-row count of a write result. `sqlx` exposes `rows_affected` only as /// an inherent method on each backend's `QueryResult`, not via a shared trait, @@ -387,17 +372,17 @@ pub(crate) trait SqlxReadModelBackend: Database { /// Render one `SELECT`-list column. Postgres casts JSON/Timestamp to `::text` /// so they decode as `String`; SQLite stores them as text already, so it just /// pushes the quoted column. (Reading is the inverse of `push_row_value_bind`.) - fn push_select_column(builder: &mut QueryBuilder, column: &ColumnDef); + fn push_select_column(builder: &mut QueryBuilder, column: &TableColumn); /// Decode one fetched column into a `RowValue`. The one genuinely dialect- /// specific read: Postgres has a native `BOOLEAN`, SQLite stores booleans as /// `INTEGER` and decodes `value != 0`. - fn row_value(row: &Self::Row, column: &ColumnDef) -> Result; + fn row_value(row: &Self::Row, column: &TableColumn) -> Result; } pub(crate) async fn begin_read_model_tx( pool: &sqlx::Pool, -) -> Result, ReadModelError> { +) -> Result, TableStoreError> { pool.begin() .await .map_err(|err| read_model_storage_error(DB::BACKEND, "begin transaction", err)) @@ -405,7 +390,7 @@ pub(crate) async fn begin_read_model_tx( pub(crate) async fn commit_read_model_tx( tx: Transaction<'_, DB>, -) -> Result<(), ReadModelError> { +) -> Result<(), TableStoreError> { tx.commit() .await .map_err(|err| read_model_storage_error(DB::BACKEND, "commit transaction", err)) @@ -413,8 +398,8 @@ pub(crate) async fn commit_read_model_tx( pub(crate) async fn commit_read_model_write_plan( pool: &sqlx::Pool, - plan: ReadModelWritePlan, -) -> Result + plan: TableWritePlan, +) -> Result where DB: SqlxReadModelBackend, for<'c> &'c mut ::Connection: Executor<'c, Database = DB>, @@ -431,8 +416,8 @@ where pub(crate) async fn apply_read_model_write_plan_in_tx( tx: &mut Transaction<'_, DB>, - plan: ReadModelWritePlan, -) -> Result + plan: TableWritePlan, +) -> Result where DB: SqlxReadModelBackend, for<'c> &'c mut ::Connection: Executor<'c, Database = DB>, @@ -444,25 +429,25 @@ where for mutation in plan.mutations { match mutation { - ReadModelMutation::UpsertRow(mutation) => { + TableMutation::UpsertRow(mutation) => { upsert_relational_row_in_tx(tx, mutation).await?; } - ReadModelMutation::PatchRow(mutation) => { + TableMutation::PatchRow(mutation) => { patch_relational_row_in_tx(tx, mutation).await?; } - ReadModelMutation::DeleteRow(mutation) => { + TableMutation::DeleteRow(mutation) => { delete_relational_row_in_tx(tx, mutation).await?; } } } - Ok(ReadModelCommitOutcome::applied()) + Ok(TableCommitOutcome::applied()) } pub(crate) async fn upsert_relational_row_in_tx( tx: &mut Transaction<'_, DB>, - mutation: RowMutation, -) -> Result<(), ReadModelError> + mutation: TableRowMutation, +) -> Result<(), TableStoreError> where DB: SqlxReadModelBackend, for<'c> &'c mut ::Connection: Executor<'c, Database = DB>, @@ -470,20 +455,30 @@ where for<'q> i64: Encode<'q, DB> + Type + sqlx::Decode<'q, DB>, for<'r> &'r str: sqlx::ColumnIndex<::Row>, { - validate_key(&mutation.schema, &mutation.key)?; - validate_row_values(&mutation.schema, &mutation.values, true)?; - validate_values_match_key(&mutation.schema, &mutation.key, &mutation.values)?; + validate_key(mutation.schema, &mutation.key)?; + validate_row_values(mutation.schema, &mutation.values, true)?; + validate_values_match_key(mutation.schema, &mutation.key, &mutation.values)?; + + // The common case — upsert without an optimistic-version check — is a single + // `INSERT ... ON CONFLICT (pk) DO UPDATE` round trip. Only version-checked + // writes need to observe the current row first. + if matches!(mutation.mode, RowWriteMode::Upsert) + && matches!(mutation.expected_version, ExpectedVersion::Any) + { + return upsert_relational_row_on_conflict_in_tx(tx, mutation.schema, &mutation.values) + .await; + } - let current_version = row_version_in_tx(tx, &mutation.schema, &mutation.key).await?; + let current_version = row_version_in_tx(tx, mutation.schema, &mutation.key).await?; validate_row_expected_version( - &mutation.schema, + mutation.schema, &mutation.key, &mutation.expected_version, current_version, )?; if matches!(mutation.mode, RowWriteMode::Insert) && current_version.is_some() { return Err(row_concurrency_conflict( - &mutation.schema, + mutation.schema, &mutation.key, 0, current_version.unwrap_or_default(), @@ -492,22 +487,20 @@ where match current_version { Some(expected_version) => { - let new_version = next_row_version(&mutation.schema, &mutation.key, current_version)?; let rows_affected = update_relational_row_values_in_tx( tx, - &mutation.schema, + mutation.schema, &mutation.key, &mutation.values, - expected_version, - new_version, + Some(expected_version), ) .await?; if rows_affected == 0 { - let actual = row_version_in_tx(tx, &mutation.schema, &mutation.key) + let actual = row_version_in_tx(tx, mutation.schema, &mutation.key) .await? .unwrap_or(expected_version); return Err(row_concurrency_conflict( - &mutation.schema, + mutation.schema, &mutation.key, expected_version, actual, @@ -517,7 +510,7 @@ where None => { insert_relational_row_in_tx( tx, - &mutation.schema, + mutation.schema, &mutation.values, initial_row_version(), ) @@ -530,8 +523,8 @@ where pub(crate) async fn patch_relational_row_in_tx( tx: &mut Transaction<'_, DB>, - mutation: PatchRowMutation, -) -> Result<(), ReadModelError> + mutation: PatchTableRowMutation, +) -> Result<(), TableStoreError> where DB: SqlxReadModelBackend, for<'c> &'c mut ::Connection: Executor<'c, Database = DB>, @@ -539,51 +532,67 @@ where for<'q> i64: Encode<'q, DB> + Type + sqlx::Decode<'q, DB>, for<'r> &'r str: sqlx::ColumnIndex<::Row>, { - validate_key(&mutation.schema, &mutation.key)?; + validate_key(mutation.schema, &mutation.key)?; + + // `NotExists` is the only shape that has to observe the row before writing; + // `Any`/`Exact` run the UPDATE directly and only re-read on a miss to tell + // "not found" apart from a version conflict. + if matches!(mutation.expected_version, ExpectedVersion::NotExists) { + let current_version = row_version_in_tx(tx, mutation.schema, &mutation.key).await?; + validate_row_expected_version( + mutation.schema, + &mutation.key, + &mutation.expected_version, + current_version, + )?; + if !matches!(mutation.mode, PatchMode::InsertMissing) { + return Err(TableStoreError::NotFound { + collection: mutation.schema.table_name.clone(), + id: key_fingerprint(&mutation.key), + }); + } + let values = row_values_from_key_and_patch(mutation.schema, &mutation.key, mutation.patch)?; + return insert_relational_row_in_tx(tx, mutation.schema, &values, initial_row_version()) + .await; + } - let current_version = row_version_in_tx(tx, &mutation.schema, &mutation.key).await?; - validate_row_expected_version( - &mutation.schema, + let expected_version = match mutation.expected_version { + ExpectedVersion::Exact(expected) => Some(expected), + _ => None, + }; + let patch_values = + patch_values_preserving_key(mutation.schema, &mutation.key, &mutation.patch)?; + let rows_affected = update_relational_columns_in_tx( + tx, + mutation.schema, &mutation.key, - &mutation.expected_version, - current_version, - )?; - - match current_version { - Some(expected_version) => { - let patch_values = - patch_values_preserving_key(&mutation.schema, &mutation.key, mutation.patch)?; - let new_version = next_row_version(&mutation.schema, &mutation.key, current_version)?; - let rows_affected = update_relational_patch_in_tx( - tx, - &mutation.schema, - &mutation.key, - patch_values, - expected_version, - new_version, - ) - .await?; - if rows_affected == 0 { - let actual = row_version_in_tx(tx, &mutation.schema, &mutation.key) - .await? - .unwrap_or(expected_version); - return Err(row_concurrency_conflict( - &mutation.schema, + patch_values, + expected_version, + ) + .await?; + if rows_affected == 0 { + if let Some(expected_version) = expected_version { + return match row_version_in_tx(tx, mutation.schema, &mutation.key).await? { + Some(actual) => Err(row_concurrency_conflict( + mutation.schema, &mutation.key, expected_version, actual, - )); - } + )), + None => Err(TableStoreError::NotFound { + collection: mutation.schema.table_name.clone(), + id: key_fingerprint(&mutation.key), + }), + }; } - None if matches!(mutation.mode, PatchMode::InsertMissing) => { + if matches!(mutation.mode, PatchMode::InsertMissing) { let values = - row_values_from_key_and_patch(&mutation.schema, &mutation.key, mutation.patch)?; - insert_relational_row_in_tx(tx, &mutation.schema, &values, initial_row_version()) + row_values_from_key_and_patch(mutation.schema, &mutation.key, mutation.patch)?; + insert_relational_row_in_tx(tx, mutation.schema, &values, initial_row_version()) .await?; - } - None => { - return Err(ReadModelError::NotFound { - collection: mutation.schema.table_name, + } else { + return Err(TableStoreError::NotFound { + collection: mutation.schema.table_name.clone(), id: key_fingerprint(&mutation.key), }); } @@ -594,8 +603,8 @@ where pub(crate) async fn delete_relational_row_in_tx( tx: &mut Transaction<'_, DB>, - mutation: DeleteRowMutation, -) -> Result<(), ReadModelError> + mutation: DeleteTableRowMutation, +) -> Result<(), TableStoreError> where DB: SqlxReadModelBackend, for<'c> &'c mut ::Connection: Executor<'c, Database = DB>, @@ -603,44 +612,60 @@ where for<'q> i64: Encode<'q, DB> + Type + sqlx::Decode<'q, DB>, for<'r> &'r str: sqlx::ColumnIndex<::Row>, { - validate_key(&mutation.schema, &mutation.key)?; - let current_version = row_version_in_tx(tx, &mutation.schema, &mutation.key).await?; - validate_row_expected_version( - &mutation.schema, - &mutation.key, - &mutation.expected_version, - current_version, - )?; - - let rows_affected = delete_relational_row_where_current_in_tx( - tx, - &mutation.schema, - &mutation.key, - current_version, - ) - .await?; - if rows_affected == 0 { - if let Some(expected_version) = current_version { - let actual = row_version_in_tx(tx, &mutation.schema, &mutation.key) - .await? - .unwrap_or(expected_version); - return Err(row_concurrency_conflict( - &mutation.schema, + validate_key(mutation.schema, &mutation.key)?; + + match mutation.expected_version { + // The row must not exist: nothing to delete, but surface a conflict if it does. + ExpectedVersion::NotExists => { + let current_version = row_version_in_tx(tx, mutation.schema, &mutation.key).await?; + validate_row_expected_version( + mutation.schema, &mutation.key, - expected_version, - actual, - )); + &mutation.expected_version, + current_version, + )?; + Ok(()) + } + // No version check: one DELETE; deleting a missing row is a no-op. + ExpectedVersion::Any => { + delete_relational_row_where_version_in_tx(tx, mutation.schema, &mutation.key, None) + .await?; + Ok(()) + } + // Version-checked delete: only re-read on a miss to tell "not found" + // apart from a version conflict. + ExpectedVersion::Exact(expected_version) => { + let rows_affected = delete_relational_row_where_version_in_tx( + tx, + mutation.schema, + &mutation.key, + Some(expected_version), + ) + .await?; + if rows_affected == 0 { + return match row_version_in_tx(tx, mutation.schema, &mutation.key).await? { + Some(actual) => Err(row_concurrency_conflict( + mutation.schema, + &mutation.key, + expected_version, + actual, + )), + None => Err(TableStoreError::NotFound { + collection: mutation.schema.table_name.clone(), + id: key_fingerprint(&mutation.key), + }), + }; + } + Ok(()) } } - - Ok(()) } pub(crate) async fn row_version_in_tx( tx: &mut Transaction<'_, DB>, - schema: &ReadModelSchema, + schema: &TableSchema, key: &RowKey, -) -> Result, ReadModelError> +) -> Result, TableStoreError> where DB: SqlxReadModelBackend, for<'c> &'c mut ::Connection: Executor<'c, Database = DB>, @@ -675,10 +700,10 @@ where pub(crate) async fn insert_relational_row_in_tx( tx: &mut Transaction<'_, DB>, - schema: &ReadModelSchema, + schema: &TableSchema, values: &RowValues, version: u64, -) -> Result<(), ReadModelError> +) -> Result<(), TableStoreError> where DB: SqlxReadModelBackend, for<'c> &'c mut ::Connection: Executor<'c, Database = DB>, @@ -729,14 +754,18 @@ where Ok(()) } -pub(crate) async fn update_relational_row_values_in_tx( +/// Upsert one row in a single statement: `INSERT ... ON CONFLICT (pk) DO UPDATE +/// SET = excluded., = + 1`. +/// +/// Both Postgres and SQLite (≥ 3.35) support `ON CONFLICT` with an explicit +/// column-list target and the `excluded` pseudo-table. New rows start at +/// version 1; conflicting rows bump their version in-database (an increment +/// past `i64::MAX` fails as a storage error). +pub(crate) async fn upsert_relational_row_on_conflict_in_tx( tx: &mut Transaction<'_, DB>, - schema: &ReadModelSchema, - key: &RowKey, + schema: &TableSchema, values: &RowValues, - expected_version: u64, - version: u64, -) -> Result +) -> Result<(), TableStoreError> where DB: SqlxReadModelBackend, for<'c> &'c mut ::Connection: Executor<'c, Database = DB>, @@ -744,19 +773,69 @@ where for<'q> i64: Encode<'q, DB> + Type + sqlx::Decode<'q, DB>, for<'r> &'r str: sqlx::ColumnIndex<::Row>, { - let mut write_values = row_write_values(schema, values)?; - write_values.retain(|(column, _)| !column.primary_key); - update_relational_columns_in_tx(tx, schema, key, write_values, expected_version, version).await + let version_column = version_column(schema)?; + let write_values = row_write_values(schema, values)?; + let mut builder = QueryBuilder::::new("INSERT INTO "); + builder.push(quote_identifier(&schema.table_name)); + builder.push(" ("); + for (column, _) in &write_values { + builder.push(quote_identifier(&column.column_name)); + builder.push(", "); + } + builder.push(quote_identifier(version_column)); + builder.push(") VALUES ("); + for (column, value) in write_values.iter().cloned() { + DB::push_row_value_bind(&mut builder, value, column)?; + builder.push(", "); + } + builder.push_bind(read_model_i64_from_u64( + DB::BACKEND, + initial_row_version(), + version_column, + DB::INTEGER_STORAGE, + )?); + builder.push(") ON CONFLICT ("); + for (index, column_name) in schema.primary_key.columns.iter().enumerate() { + if index > 0 { + builder.push(", "); + } + builder.push(quote_identifier(column_name)); + } + builder.push(") DO UPDATE SET "); + for (column, _) in write_values + .iter() + .filter(|(column, _)| !column.primary_key) + { + builder.push(quote_identifier(&column.column_name)); + builder.push(" = excluded."); + builder.push(quote_identifier(&column.column_name)); + builder.push(", "); + } + // Qualify the existing-row reference with the table name: inside `DO UPDATE` + // an unqualified column is ambiguous with `excluded` on Postgres. + builder.push(quote_identifier(version_column)); + builder.push(" = "); + builder.push(quote_identifier(&schema.table_name)); + builder.push("."); + builder.push(quote_identifier(version_column)); + builder.push(" + 1"); + + builder + .build() + .execute(&mut **tx) + .await + .map_err(|err| read_model_storage_error(DB::BACKEND, "upsert relational row", err))?; + + Ok(()) } -pub(crate) async fn update_relational_patch_in_tx( +pub(crate) async fn update_relational_row_values_in_tx( tx: &mut Transaction<'_, DB>, - schema: &ReadModelSchema, + schema: &TableSchema, key: &RowKey, - write_values: Vec<(&ColumnDef, RowValue)>, - expected_version: u64, - version: u64, -) -> Result + values: &RowValues, + expected_version: Option, +) -> Result where DB: SqlxReadModelBackend, for<'c> &'c mut ::Connection: Executor<'c, Database = DB>, @@ -764,17 +843,21 @@ where for<'q> i64: Encode<'q, DB> + Type + sqlx::Decode<'q, DB>, for<'r> &'r str: sqlx::ColumnIndex<::Row>, { - update_relational_columns_in_tx(tx, schema, key, write_values, expected_version, version).await + let mut write_values = row_write_values(schema, values)?; + write_values.retain(|(column, _)| !column.primary_key); + update_relational_columns_in_tx(tx, schema, key, write_values, expected_version).await } +/// `UPDATE SET , = + 1 WHERE [AND +/// = ]`, returning the affected-row count. The version bump +/// happens in-database; an increment past `i64::MAX` fails as a storage error. pub(crate) async fn update_relational_columns_in_tx( tx: &mut Transaction<'_, DB>, - schema: &ReadModelSchema, + schema: &TableSchema, key: &RowKey, - write_values: Vec<(&ColumnDef, RowValue)>, - expected_version: u64, - version: u64, -) -> Result + write_values: Vec<(&TableColumn, RowValue)>, + expected_version: Option, +) -> Result where DB: SqlxReadModelBackend, for<'c> &'c mut ::Connection: Executor<'c, Database = DB>, @@ -786,37 +869,28 @@ where let mut builder = QueryBuilder::::new("UPDATE "); builder.push(quote_identifier(&schema.table_name)); builder.push(" SET "); - let mut wrote_set = false; for (column, value) in write_values { - if wrote_set { - builder.push(", "); - } builder.push(quote_identifier(&column.column_name)); builder.push(" = "); DB::push_row_value_bind(&mut builder, value, column)?; - wrote_set = true; - } - if wrote_set { builder.push(", "); } builder.push(quote_identifier(version_column)); builder.push(" = "); - builder.push_bind(read_model_i64_from_u64( - DB::BACKEND, - version, - version_column, - DB::INTEGER_STORAGE, - )?); - push_key_predicates(&mut builder, schema, key)?; - builder.push(" AND "); builder.push(quote_identifier(version_column)); - builder.push(" = "); - builder.push_bind(read_model_i64_from_u64( - DB::BACKEND, - expected_version, - "expected version", - DB::INTEGER_STORAGE, - )?); + builder.push(" + 1"); + push_key_predicates(&mut builder, schema, key)?; + if let Some(expected_version) = expected_version { + builder.push(" AND "); + builder.push(quote_identifier(version_column)); + builder.push(" = "); + builder.push_bind(read_model_i64_from_u64( + DB::BACKEND, + expected_version, + "expected version", + DB::INTEGER_STORAGE, + )?); + } let result = builder .build() @@ -826,12 +900,12 @@ where Ok(DB::rows_affected(&result)) } -pub(crate) async fn delete_relational_row_where_current_in_tx( +pub(crate) async fn delete_relational_row_where_version_in_tx( tx: &mut Transaction<'_, DB>, - schema: &ReadModelSchema, + schema: &TableSchema, key: &RowKey, - current_version: Option, -) -> Result + expected_version: Option, +) -> Result where DB: SqlxReadModelBackend, for<'c> &'c mut ::Connection: Executor<'c, Database = DB>, @@ -842,7 +916,7 @@ where let mut builder = QueryBuilder::::new("DELETE FROM "); builder.push(quote_identifier(&schema.table_name)); push_key_predicates(&mut builder, schema, key)?; - if let Some(version) = current_version { + if let Some(version) = expected_version { let version_column = version_column(schema)?; builder.push(" AND "); builder.push(quote_identifier(version_column)); @@ -865,9 +939,9 @@ where pub(crate) fn push_key_predicates( builder: &mut QueryBuilder, - schema: &ReadModelSchema, + schema: &TableSchema, key: &RowKey, -) -> Result<(), ReadModelError> { +) -> Result<(), TableStoreError> { builder.push(" WHERE "); for (index, column_name) in schema.primary_key.columns.iter().enumerate() { if index > 0 { @@ -875,7 +949,7 @@ pub(crate) fn push_key_predicates( } let column = column_by_name(schema, column_name)?; let value = key.get(column_name).cloned().ok_or_else(|| { - ReadModelError::Metadata(format!( + TableStoreError::Metadata(format!( "read model `{}` row key is missing primary-key column `{}`", schema.model_name, column_name )) @@ -890,8 +964,8 @@ pub(crate) fn push_key_predicates( /// Build the shared `SELECT , FROM
` prefix used by every /// relational read. Per-column rendering is dialect-specific (`push_select_column`). pub(crate) fn relational_row_select( - schema: &ReadModelSchema, -) -> Result, ReadModelError> { + schema: &TableSchema, +) -> Result, TableStoreError> { let version_column = version_column(schema)?; let mut builder = QueryBuilder::::new("SELECT "); for (index, column) in schema.columns.iter().enumerate() { @@ -911,7 +985,7 @@ pub(crate) fn relational_row_select( pub(crate) fn push_order_by_primary_key( builder: &mut QueryBuilder, - schema: &ReadModelSchema, + schema: &TableSchema, ) { if schema.primary_key.columns.is_empty() { return; @@ -926,9 +1000,9 @@ pub(crate) fn push_order_by_primary_key( } pub(crate) fn row_to_versioned_values( - schema: &ReadModelSchema, + schema: &TableSchema, row: &DB::Row, -) -> Result, ReadModelError> +) -> Result, TableStoreError> where DB: SqlxReadModelBackend, for<'q> i64: Encode<'q, DB> + Type + sqlx::Decode<'q, DB>, @@ -954,9 +1028,9 @@ where pub(crate) async fn load_relational_row_by_key( pool: &sqlx::Pool, - schema: &ReadModelSchema, + schema: &TableSchema, key: &RowKey, -) -> Result>, ReadModelError> +) -> Result>, TableStoreError> where DB: SqlxReadModelBackend, for<'c> &'c sqlx::Pool: Executor<'c, Database = DB>, @@ -978,10 +1052,10 @@ where pub(crate) async fn load_relationship_rows( pool: &sqlx::Pool, - root_schema: &ReadModelSchema, + root_schema: &TableSchema, root_row: &RowValues, spec: &IncludeSpec, -) -> Result>, ReadModelError> +) -> Result>, TableStoreError> where DB: SqlxReadModelBackend, for<'c> &'c sqlx::Pool: Executor<'c, Database = DB>, @@ -994,7 +1068,7 @@ where RelationshipKind::BelongsTo => { load_belongs_to_rows(pool, root_schema, root_row, spec).await } - RelationshipKind::ManyToMany => Err(ReadModelError::Metadata(format!( + RelationshipKind::ManyToMany => Err(TableStoreError::Metadata(format!( "many-to-many relationship `{}` includes are not supported yet", spec.relationship.field_name ))), @@ -1006,7 +1080,7 @@ pub(crate) async fn load_read_model_graph( schemas: &RwLock, request: ReadModelLoadRequest, capabilities: ReadModelQueryCapabilities, -) -> Result +) -> Result where DB: SqlxReadModelBackend, for<'c> &'c sqlx::Pool: Executor<'c, Database = DB>, @@ -1044,10 +1118,10 @@ where async fn load_has_many_rows( pool: &sqlx::Pool, - root_schema: &ReadModelSchema, + root_schema: &TableSchema, root_row: &RowValues, spec: &IncludeSpec, -) -> Result>, ReadModelError> +) -> Result>, TableStoreError> where DB: SqlxReadModelBackend, for<'c> &'c sqlx::Pool: Executor<'c, Database = DB>, @@ -1056,28 +1130,28 @@ where for<'r> &'r str: sqlx::ColumnIndex<::Row>, { let foreign_key = spec.relationship.foreign_key.as_deref().ok_or_else(|| { - ReadModelError::Metadata(format!( + TableStoreError::Metadata(format!( "relationship `{}` must declare a foreign key", spec.relationship.field_name )) })?; - let target_column = crate::read_model::column_name_for(&spec.target_schema, foreign_key) + let target_column = crate::table::column_name_for(&spec.target_schema, foreign_key) .ok_or_else(|| { - ReadModelError::Metadata(format!( + TableStoreError::Metadata(format!( "relationship `{}` foreign key `{}` is not a target column", spec.relationship.field_name, foreign_key )) })?; - let root_column = crate::read_model::column_name_for(root_schema, foreign_key) + let root_column = crate::table::column_name_for(root_schema, foreign_key) .or_else(|| root_schema.primary_key.columns.first().cloned()) .ok_or_else(|| { - ReadModelError::Metadata(format!( + TableStoreError::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!( + TableStoreError::Metadata(format!( "read model `{}` root row is missing relationship key `{}`", root_schema.model_name, root_column )) @@ -1088,10 +1162,10 @@ where async fn load_belongs_to_rows( pool: &sqlx::Pool, - root_schema: &ReadModelSchema, + root_schema: &TableSchema, root_row: &RowValues, spec: &IncludeSpec, -) -> Result>, ReadModelError> +) -> Result>, TableStoreError> where DB: SqlxReadModelBackend, for<'c> &'c sqlx::Pool: Executor<'c, Database = DB>, @@ -1100,21 +1174,21 @@ where for<'r> &'r str: sqlx::ColumnIndex<::Row>, { let foreign_key = spec.relationship.foreign_key.as_deref().ok_or_else(|| { - ReadModelError::Metadata(format!( + TableStoreError::Metadata(format!( "relationship `{}` must declare a foreign key", spec.relationship.field_name )) })?; let source_column = - crate::read_model::column_name_for(root_schema, foreign_key).ok_or_else(|| { - ReadModelError::Metadata(format!( + crate::table::column_name_for(root_schema, foreign_key).ok_or_else(|| { + TableStoreError::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!( + TableStoreError::Metadata(format!( "read model `{}` root row is missing relationship key `{}`", root_schema.model_name, source_column )) @@ -1125,10 +1199,10 @@ where async fn load_rows_matching_column( pool: &sqlx::Pool, - schema: &ReadModelSchema, + schema: &TableSchema, column_name: &str, value: &RowValue, -) -> Result>, ReadModelError> +) -> Result>, TableStoreError> where DB: SqlxReadModelBackend, for<'c> &'c sqlx::Pool: Executor<'c, Database = DB>, diff --git a/src/table/error.rs b/src/table/error.rs new file mode 100644 index 000000000..3bc3b5331 --- /dev/null +++ b/src/table/error.rs @@ -0,0 +1,58 @@ +//! Shared error type for table stores and the read models built on them. + +use std::fmt; + +/// Error type for table-store and read-model operations. +#[derive(Debug, Clone, PartialEq, Eq)] +#[non_exhaustive] +pub enum TableStoreError { + /// Optimistic concurrency conflict. + ConcurrencyConflict { + collection: String, + id: String, + expected: u64, + actual: u64, + }, + /// Serialization/deserialization error. + Serde(String), + /// Storage-level error. + Storage(String), + /// Row not found. + NotFound { collection: String, id: String }, + /// Lock error. + Lock(crate::lock::LockError), + /// Schema/metadata error. + Metadata(String), +} + +impl fmt::Display for TableStoreError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + TableStoreError::ConcurrencyConflict { + collection, + id, + expected, + actual, + } => write!( + f, + "concurrency conflict on {}:{} (expected version {}, actual {})", + collection, id, expected, actual + ), + TableStoreError::Serde(msg) => write!(f, "table store serialization error: {}", msg), + TableStoreError::Storage(msg) => write!(f, "table storage error: {}", msg), + TableStoreError::NotFound { collection, id } => { + write!(f, "table row not found: {}:{}", collection, id) + } + TableStoreError::Lock(err) => write!(f, "table store lock error: {}", err), + TableStoreError::Metadata(msg) => write!(f, "table metadata error: {}", msg), + } + } +} + +impl std::error::Error for TableStoreError {} + +impl From for TableStoreError { + fn from(err: crate::lock::LockError) -> Self { + TableStoreError::Lock(err) + } +} diff --git a/src/read_model/metadata.rs b/src/table/metadata.rs similarity index 73% rename from src/read_model/metadata.rs rename to src/table/metadata.rs index 1d2d6b139..e15c36bf3 100644 --- a/src/read_model/metadata.rs +++ b/src/table/metadata.rs @@ -2,11 +2,11 @@ use std::collections::{BTreeMap, BTreeSet}; use serde::{de::DeserializeOwned, Deserialize, Serialize}; -use super::ReadModelError; +use super::TableStoreError; -pub const DEFAULT_READ_MODEL_VERSION_COLUMN: &str = "_sourced_version"; +pub const DEFAULT_TABLE_VERSION_COLUMN: &str = "_sourced_version"; -/// Logical storage type for a relational read-model column. +/// Logical storage type for a relational table column. #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub enum ColumnType { Text, @@ -36,7 +36,7 @@ impl ForeignKey { } } -/// Primary-key metadata for a relational read-model table. +/// Primary-key metadata for a relational table. #[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] pub struct PrimaryKey { pub columns: Vec, @@ -50,7 +50,7 @@ impl PrimaryKey { } } -/// Runtime primary-key values for one read-model row. +/// Runtime primary-key values for one table row. #[derive(Clone, Debug, Default, PartialEq)] pub struct RowKey { pub values: BTreeMap, @@ -85,9 +85,9 @@ impl RowKey { } } -/// Column metadata for a relational read-model table. +/// Column metadata for a relational table. #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -pub struct ColumnDef { +pub struct TableColumn { pub field_name: String, pub column_name: String, pub column_type: ColumnType, @@ -101,7 +101,7 @@ pub struct ColumnDef { pub skipped: bool, } -impl ColumnDef { +impl TableColumn { pub fn new( field_name: impl Into, column_name: impl Into, @@ -123,15 +123,15 @@ impl ColumnDef { } } -/// Index or unique-constraint metadata for a relational read-model table. +/// Index or unique-constraint metadata for a relational table. #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -pub struct IndexDef { +pub struct TableIndex { pub name: Option, pub columns: Vec, pub unique: bool, } -impl IndexDef { +impl TableIndex { pub fn new(columns: impl IntoIterator>) -> Self { Self { name: None, @@ -149,7 +149,7 @@ pub enum RelationshipKind { ManyToMany, } -/// Relationship metadata for a relational read model. +/// Relationship metadata for a relational table model. #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct RelationshipDef { pub field_name: String, @@ -159,44 +159,44 @@ pub struct RelationshipDef { pub through: Option, } -/// Schema metadata for one relational read-model table. +/// Schema metadata for one relational table. #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -pub struct ReadModelSchema { +pub struct TableSchema { pub model_name: String, pub table_name: String, - pub columns: Vec, + pub columns: Vec, pub primary_key: PrimaryKey, pub version_column: Option, pub foreign_keys: Vec, - pub indexes: Vec, + pub indexes: Vec, pub relationships: Vec, } -impl ReadModelSchema { - pub fn validate(&self) -> Result<(), ReadModelError> { +impl TableSchema { + pub fn validate(&self) -> Result<(), TableStoreError> { if self.table_name.is_empty() { - return Err(ReadModelError::Metadata( - "read model schema must declare a table name".into(), + return Err(TableStoreError::Metadata( + "table 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", + return Err(TableStoreError::Metadata(format!( + "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 `{}`", + return Err(TableStoreError::Metadata(format!( + "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 `{}`", + return Err(TableStoreError::Metadata(format!( + "model `{}` field `{}` has unsupported field shape `{}`", self.model_name, column.field_name, type_name ))); } @@ -207,30 +207,30 @@ impl ReadModelSchema { 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", + return Err(TableStoreError::Metadata(format!( + "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", + return Err(TableStoreError::Metadata(format!( + "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", + return Err(TableStoreError::Metadata(format!( + "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 `{}`", + return Err(TableStoreError::Metadata(format!( + "model `{}` primary key references missing column `{}`", self.model_name, column ))); } @@ -244,22 +244,22 @@ impl ReadModelSchema { 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 `{}`", + return Err(TableStoreError::Metadata(format!( + "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", + return Err(TableStoreError::Metadata(format!( + "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 `{}`", + return Err(TableStoreError::Metadata(format!( + "model `{}` index references missing column `{}`", self.model_name, column ))); } @@ -268,8 +268,8 @@ impl ReadModelSchema { for relationship in &self.relationships { if relationship.target_model.is_empty() { - return Err(ReadModelError::Metadata(format!( - "read model `{}` relationship `{}` must declare a target model", + return Err(TableStoreError::Metadata(format!( + "model `{}` relationship `{}` must declare a target model", self.model_name, relationship.field_name ))); } @@ -278,8 +278,8 @@ impl ReadModelSchema { .as_deref() .is_none_or(str::is_empty) { - return Err(ReadModelError::Metadata(format!( - "read model `{}` relationship `{}` must declare a foreign key", + return Err(TableStoreError::Metadata(format!( + "model `{}` relationship `{}` must declare a foreign key", self.model_name, relationship.field_name ))); } @@ -289,16 +289,16 @@ impl ReadModelSchema { } } -fn validate_foreign_key(model_name: &str, foreign_key: &ForeignKey) -> Result<(), ReadModelError> { +fn validate_foreign_key(model_name: &str, foreign_key: &ForeignKey) -> Result<(), TableStoreError> { 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" + return Err(TableStoreError::Metadata(format!( + "model `{model_name}` has an invalid foreign-key declaration" ))); } Ok(()) } -/// A typed value in a relational read-model row. +/// A typed value in a relational table row. #[derive(Clone, Debug, PartialEq)] pub enum RowValue { Null, @@ -312,9 +312,9 @@ pub enum RowValue { } impl RowValue { - pub fn from_serde(value: &T) -> Result { + pub fn from_serde(value: &T) -> Result { let value = - serde_json::to_value(value).map_err(|err| ReadModelError::Serde(err.to_string()))?; + serde_json::to_value(value).map_err(|err| TableStoreError::Serde(err.to_string()))?; Ok(Self::from_json_value(value)) } @@ -354,7 +354,7 @@ impl RowValue { } } -/// Column-value map for one relational read-model row. +/// Column-value map for one relational table row. #[derive(Clone, Debug, Default, PartialEq)] pub struct RowValues { values: BTreeMap, @@ -373,7 +373,7 @@ impl RowValues { &mut self, column: impl Into, value: &T, - ) -> Result, ReadModelError> { + ) -> Result, TableStoreError> { let value = RowValue::from_serde(value)?; Ok(self.insert(column, value)) } @@ -394,12 +394,12 @@ impl RowValues { self.values.is_empty() } - pub fn get_serde(&self, column: &str) -> Result { + 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}`")) + TableStoreError::Metadata(format!("row is missing required column `{column}`")) })?; serde_json::from_value(value.clone().into_json()) - .map_err(|err| ReadModelError::Serde(err.to_string())) + .map_err(|err| TableStoreError::Serde(err.to_string())) } pub fn iter(&self) -> impl Iterator { @@ -418,49 +418,30 @@ impl IntoIterator for RowValues { } } -/// 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 { + fn valid_schema() -> TableSchema { + TableSchema { model_name: "PlayerWeapon".into(), table_name: "player_weapons".into(), columns: vec![ - ColumnDef { + TableColumn { 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) + ..TableColumn::new("player_id", "player_id", ColumnType::Text) }, - ColumnDef { + TableColumn { primary_key: true, - ..ColumnDef::new("weapon_id", "weapon_id", ColumnType::Text) + ..TableColumn::new("weapon_id", "weapon_id", ColumnType::Text) }, ], primary_key: PrimaryKey::new(["player_id", "weapon_id"]), - version_column: Some(DEFAULT_READ_MODEL_VERSION_COLUMN.into()), + version_column: Some(DEFAULT_TABLE_VERSION_COLUMN.into()), foreign_keys: vec![ForeignKey::new("players", "player_id")], - indexes: vec![IndexDef::new(["player_id"])], + indexes: vec![TableIndex::new(["player_id"])], relationships: Vec::new(), } } @@ -480,14 +461,14 @@ mod tests { let err = schema.validate().unwrap_err(); assert!( - matches!(err, ReadModelError::Metadata(message) if message.contains("primary-key")) + matches!(err, TableStoreError::Metadata(message) if message.contains("primary-key")) ); } #[test] fn validate_rejects_unsupported_field_shapes() { let mut schema = valid_schema(); - schema.columns.push(ColumnDef::new( + schema.columns.push(TableColumn::new( "callback", "callback", ColumnType::Unsupported("fn()".into()), @@ -496,7 +477,7 @@ mod tests { let err = schema.validate().unwrap_err(); assert!( - matches!(err, ReadModelError::Metadata(message) if message.contains("unsupported field shape")) + matches!(err, TableStoreError::Metadata(message) if message.contains("unsupported field shape")) ); } @@ -514,7 +495,7 @@ mod tests { let err = schema.validate().unwrap_err(); assert!( - matches!(err, ReadModelError::Metadata(message) if message.contains("foreign key")) + matches!(err, TableStoreError::Metadata(message) if message.contains("foreign key")) ); } @@ -522,12 +503,12 @@ mod tests { fn validate_rejects_duplicate_explicit_index_names() { let mut schema = valid_schema(); schema.indexes = vec![ - IndexDef { + TableIndex { name: Some("idx_player_weapons_player_id".into()), columns: vec!["player_id".into()], unique: false, }, - IndexDef { + TableIndex { name: Some("idx_player_weapons_player_id".into()), columns: vec!["weapon_id".into()], unique: false, @@ -536,7 +517,7 @@ mod tests { let err = schema.validate().unwrap_err(); - assert!(matches!(err, ReadModelError::Metadata(message) + assert!(matches!(err, TableStoreError::Metadata(message) if message.contains("duplicate index name `idx_player_weapons_player_id`"))); } diff --git a/src/table/mod.rs b/src/table/mod.rs index bc5732a2c..2c0ee5c1b 100644 --- a/src/table/mod.rs +++ b/src/table/mod.rs @@ -1,26 +1,37 @@ //! Neutral table/row primitives shared by read models and operational tables. //! //! The read-model ORM introduced these structures first, but they are not -//! inherently read-model concepts. Outbox storage, inbox/checkpoint tables, and -//! future operational tables can use the same schema and row-write vocabulary. +//! inherently read-model concepts: outbox storage, inbox/checkpoint tables, and +//! future operational tables use the same schema, mutation, and row-write +//! vocabulary. This module owns the canonical types; `read_model` builds its +//! typed staging/load surface on top of them. +mod error; +mod metadata; +mod mutation; +mod plan; +mod registry; mod sql; -pub use crate::read_model::{ - ColumnDef as TableColumn, ColumnType, DeleteRowMutation as DeleteTableRowMutation, - ExpectedVersion, ForeignKey, IndexDef as TableIndex, PatchMode, - PatchRowMutation as PatchTableRowMutation, PrimaryKey, - ReadModelAdapterCapabilities as TableAdapterCapabilities, - ReadModelCommitOutcome as TableCommitOutcome, ReadModelError as TableStoreError, - ReadModelMigrationArtifact as TableMigrationArtifact, ReadModelMutation as TableMutation, - ReadModelSchema as TableSchema, ReadModelSchemaAdapter as TableSchemaAdapter, - ReadModelSchemaAdapterCapabilities as TableSchemaAdapterCapabilities, - ReadModelSchemaBootstrap as TableSchemaBootstrap, ReadModelSchemaIssue as TableSchemaIssue, - ReadModelSchemaIssueKind as TableSchemaIssueKind, - ReadModelSchemaRegistry as TableSchemaRegistry, - ReadModelSchemaVerification as TableSchemaVerification, ReadModelWritePlan as TableWritePlan, - RelationshipDef, RelationshipKind, RowKey, RowMutation as TableRowMutation, RowPatch, RowValue, - RowValues, RowWriteMode, DEFAULT_READ_MODEL_VERSION_COLUMN as DEFAULT_TABLE_VERSION_COLUMN, +pub use error::TableStoreError; +pub use metadata::{ + ColumnType, ForeignKey, PrimaryKey, RelationshipDef, RelationshipKind, RowKey, RowValue, + RowValues, TableColumn, TableIndex, TableSchema, DEFAULT_TABLE_VERSION_COLUMN, +}; +pub(crate) use mutation::{ + column_name_for, key_fingerprint, key_from_row, validate_delete_mutation, + validate_expected_version, validate_key, validate_patch_mutation, validate_row_mutation, + validate_row_values, +}; +pub use mutation::{ + DeleteTableRowMutation, ExpectedVersion, PatchMode, PatchTableRowMutation, RowPatch, + RowWriteMode, TableMutation, TableRowMutation, +}; +pub use plan::{TableAdapterCapabilities, TableCommitOutcome, TableWritePlan}; +pub use registry::{ + TableMigrationArtifact, TableSchemaAdapter, TableSchemaAdapterCapabilities, + TableSchemaBootstrap, TableSchemaIssue, TableSchemaIssueKind, TableSchemaRegistry, + TableSchemaVerification, }; pub use sql::{ bootstrap_result as table_schema_bootstrap_result, generate_table_migration_artifacts, @@ -29,7 +40,7 @@ pub use sql::{ /// Opt-in trait for non-read-model types that map to a relational table row. pub trait TableModel: Clone + Send + Sync + Sized { - fn table_schema() -> TableSchema; + fn table_schema() -> &'static TableSchema; fn table_key(&self) -> Result; fn to_table_row(&self) -> Result; } @@ -46,6 +57,6 @@ impl TableSchemaRegistryExt for TableSchemaRegistry { where M: TableModel, { - self.register_schema(M::table_schema()) + self.register_schema(M::table_schema().clone()) } } diff --git a/src/table/mutation.rs b/src/table/mutation.rs new file mode 100644 index 000000000..3a6007f18 --- /dev/null +++ b/src/table/mutation.rs @@ -0,0 +1,420 @@ +//! Staged relational row mutations and the row/key validation helpers shared +//! by the table write-plan builder, the workspace, and storage adapters. + +use std::cmp::Ordering; + +use serde::Serialize; + +use super::{RowKey, RowValue, RowValues, TableSchema, TableStoreError}; + +/// Expected optimistic version carried by a staged table 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, +} + +/// 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 + } +} + +/// Full relational row insert/upsert mutation. +#[derive(Clone, Debug, PartialEq)] +pub struct TableRowMutation { + pub schema: &'static TableSchema, + pub key: RowKey, + pub values: RowValues, + pub expected_version: ExpectedVersion, + pub mode: RowWriteMode, +} + +/// Sparse relational row patch mutation. +#[derive(Clone, Debug, PartialEq)] +pub struct PatchTableRowMutation { + pub schema: &'static TableSchema, + pub key: RowKey, + pub patch: RowPatch, + pub expected_version: ExpectedVersion, + pub mode: PatchMode, +} + +/// Relational row delete mutation. +#[derive(Clone, Debug, PartialEq)] +pub struct DeleteTableRowMutation { + pub schema: &'static TableSchema, + pub key: RowKey, + pub expected_version: ExpectedVersion, +} + +/// First-pass table write-plan mutation surface. +#[derive(Clone, Debug, PartialEq)] +pub enum TableMutation { + UpsertRow(TableRowMutation), + PatchRow(PatchTableRowMutation), + DeleteRow(DeleteTableRowMutation), +} + +impl TableMutation { + pub fn table_name(&self) -> &str { + self.schema().table_name.as_str() + } + + pub fn lock_key(&self) -> String { + format!("{}:{}", self.table_name(), key_fingerprint(self.key())) + } + + fn key(&self) -> &RowKey { + match self { + TableMutation::UpsertRow(mutation) => &mutation.key, + TableMutation::PatchRow(mutation) => &mutation.key, + TableMutation::DeleteRow(mutation) => &mutation.key, + } + } + + pub(crate) fn operation_rank(&self) -> u8 { + match self { + TableMutation::UpsertRow(_) => 1, + TableMutation::PatchRow(_) => 2, + TableMutation::DeleteRow(_) => 3, + } + } + + fn schema(&self) -> &'static TableSchema { + match self { + TableMutation::UpsertRow(mutation) => mutation.schema, + TableMutation::PatchRow(mutation) => mutation.schema, + TableMutation::DeleteRow(mutation) => mutation.schema, + } + } + + fn depends_on_table(&self, table_name: &str) -> bool { + let schema = self.schema(); + schema + .foreign_keys + .iter() + .any(|foreign_key| foreign_key.table == table_name) + || schema.columns.iter().any(|column| { + column + .foreign_key + .as_ref() + .is_some_and(|foreign_key| foreign_key.table == table_name) + }) + } + + pub(crate) fn dependency_order(&self, other: &Self) -> Option { + let self_depends_on_other = self.depends_on_table(other.table_name()); + let other_depends_on_self = other.depends_on_table(self.table_name()); + + match (self_depends_on_other, other_depends_on_self) { + (true, false) if self.operation_rank() == 3 && other.operation_rank() == 3 => { + Some(Ordering::Less) + } + (true, false) => Some(Ordering::Greater), + (false, true) if self.operation_rank() == 3 && other.operation_rank() == 3 => { + Some(Ordering::Greater) + } + (false, true) => Some(Ordering::Less), + _ => None, + } + } + + pub(crate) fn sort_key(&self) -> String { + format!( + "{}|{}|{}", + self.operation_rank(), + self.table_name(), + key_fingerprint(self.key()) + ) + } +} + +pub(crate) fn validate_row_mutation(mutation: &TableRowMutation) -> Result<(), TableStoreError> { + 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) +} + +pub(crate) fn validate_patch_mutation( + mutation: &PatchTableRowMutation, +) -> Result<(), TableStoreError> { + mutation.schema.validate()?; + validate_key(mutation.schema, &mutation.key)?; + validate_expected_version(&mutation.expected_version, mutation.schema)?; + if mutation.patch.is_empty() { + return Err(TableStoreError::Metadata(format!( + "model `{}` patch must set at least one column", + mutation.schema.model_name + ))); + } + validate_row_values(mutation.schema, &mutation.patch.values, false) +} + +pub(crate) fn validate_delete_mutation( + mutation: &DeleteTableRowMutation, +) -> Result<(), TableStoreError> { + mutation.schema.validate()?; + validate_key(mutation.schema, &mutation.key)?; + validate_expected_version(&mutation.expected_version, mutation.schema) +} + +pub(crate) fn validate_expected_version( + expected_version: &ExpectedVersion, + schema: &TableSchema, +) -> Result<(), TableStoreError> { + if matches!(expected_version, ExpectedVersion::Exact(0)) { + return Err(TableStoreError::Metadata(format!( + "model `{}` expected version must be greater than zero", + schema.model_name + ))); + } + Ok(()) +} + +pub(crate) fn validate_key(schema: &TableSchema, key: &RowKey) -> Result<(), TableStoreError> { + if key.is_empty() { + return Err(TableStoreError::Metadata(format!( + "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(TableStoreError::Metadata(format!( + "model `{}` primary-key column `{}` cannot be null", + schema.model_name, column + ))); + } + Some(_) => {} + None => { + return Err(TableStoreError::Metadata(format!( + "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(TableStoreError::Metadata(format!( + "model `{}` row key includes non-primary-key column `{}`", + schema.model_name, column + ))); + } + } + + Ok(()) +} + +pub(crate) fn validate_row_values( + schema: &TableSchema, + values: &RowValues, + full_row: bool, +) -> Result<(), TableStoreError> { + for (column_name, value) in values.iter() { + let column = schema + .columns + .iter() + .find(|column| column.column_name == column_name) + .ok_or_else(|| { + TableStoreError::Metadata(format!( + "model `{}` write references missing column `{}`", + schema.model_name, column_name + )) + })?; + + if matches!(value, RowValue::Null) { + if column.primary_key { + return Err(TableStoreError::Metadata(format!( + "model `{}` primary-key column `{}` cannot be null", + schema.model_name, column.column_name + ))); + } + if !column.nullable && !column.has_default { + return Err(TableStoreError::Metadata(format!( + "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(TableStoreError::Metadata(format!( + "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(TableStoreError::Metadata(format!( + "model `{}` delegated column `{}` must be populated before write", + schema.model_name, column.column_name + ))); + } + Some(_) => {} + } + } + } + + Ok(()) +} + +pub(crate) fn key_from_row( + schema: &TableSchema, + row: &RowValues, +) -> Result { + let mut key = RowKey::default(); + for column in &schema.primary_key.columns { + let value = row.get(column).cloned().ok_or_else(|| { + TableStoreError::Metadata(format!( + "model `{}` row is missing primary-key column `{}`", + schema.model_name, column + )) + })?; + key.insert(column.clone(), value); + } + validate_key(schema, &key)?; + Ok(key) +} + +pub(crate) fn column_name_for(schema: &TableSchema, 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 +} + +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)); + } +} diff --git a/src/table/plan.rs b/src/table/plan.rs new file mode 100644 index 000000000..febcbfe2d --- /dev/null +++ b/src/table/plan.rs @@ -0,0 +1,106 @@ +//! Write plans: the deterministic mutation batches table stores apply. + +use super::mutation::{validate_delete_mutation, validate_patch_mutation, validate_row_mutation}; +use super::{TableMutation, TableStoreError}; + +/// Adapter capabilities used to validate a write plan before any storage write. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct TableAdapterCapabilities { + pub relational_rows: bool, + pub sparse_patches: bool, + pub deletes: bool, +} + +impl Default for TableAdapterCapabilities { + fn default() -> Self { + Self { + relational_rows: true, + sparse_patches: true, + deletes: true, + } + } +} + +/// Result of applying a standalone table write plan. +/// +/// This is intentionally a stub: it carries no skipped/replay state and +/// [`was_applied`](Self::was_applied) is always `true`. The earlier +/// `read_model_processed_messages` dedupe table and `skipped_duplicate` outcome +/// were **deliberately removed** (see `specs/consumer-inbox-design.md`, decision +/// 2026-05-28) because coupling delivery-level dedupe to the read-model +/// projection contract was the wrong boundary. Replay safety is now a projection +/// convention — handlers make their writes idempotent so a redelivered event +/// re-converges (plus per-row `ExpectedVersion` optimistic concurrency). A +/// first-class replay barrier returns with the consumer inbox (an operational +/// `consumer_inbox` table committed as a `CommitBatch` participant), tracked +/// under `tasks/build-transport-bus-facade`; the variant set will grow then. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct TableCommitOutcome; + +impl TableCommitOutcome { + /// The write plan was applied. Currently the only outcome (see the type docs). + pub fn applied() -> Self { + Self + } + + /// Always `true` today — see the type docs for why there is no skipped variant. + pub fn was_applied(&self) -> bool { + true + } +} + +/// Deterministic unit-of-work output for relational table-store adapters. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct TableWritePlan { + pub mutations: Vec, +} + +impl TableWritePlan { + pub fn new(mutations: Vec) -> Self { + Self { mutations } + } + + pub fn is_empty(&self) -> bool { + self.mutations.is_empty() + } + + pub fn validate(&self) -> Result<(), TableStoreError> { + self.validate_for(&TableAdapterCapabilities::default()) + } + + pub fn validate_for( + &self, + capabilities: &TableAdapterCapabilities, + ) -> Result<(), TableStoreError> { + for mutation in &self.mutations { + match mutation { + TableMutation::UpsertRow(mutation) => { + if !capabilities.relational_rows { + return Err(TableStoreError::Metadata( + "read-model adapter does not support relational row writes".into(), + )); + } + validate_row_mutation(mutation)?; + } + TableMutation::PatchRow(mutation) => { + if !capabilities.relational_rows || !capabilities.sparse_patches { + return Err(TableStoreError::Metadata( + "read-model adapter does not support sparse row patches".into(), + )); + } + validate_patch_mutation(mutation)?; + } + TableMutation::DeleteRow(mutation) => { + if !capabilities.relational_rows || !capabilities.deletes { + return Err(TableStoreError::Metadata( + "read-model adapter does not support row deletes".into(), + )); + } + validate_delete_mutation(mutation)?; + } + } + } + + Ok(()) + } +} diff --git a/src/read_model/schema.rs b/src/table/registry.rs similarity index 69% rename from src/read_model/schema.rs rename to src/table/registry.rs index 746ce20ed..b8828a03d 100644 --- a/src/read_model/schema.rs +++ b/src/table/registry.rs @@ -1,41 +1,42 @@ +//! Registry and schema-management adapter surface for table schemas. + use std::collections::{BTreeMap, BTreeSet}; -use super::{ReadModelError, ReadModelSchema, RelationalReadModel, RelationshipKind}; +use crate::read_model::RelationalReadModel; + +use super::{RelationshipKind, TableSchema, TableStoreError}; -/// Registry of table-mapped read-model schemas an adapter should manage. +/// Registry of table schemas an adapter should manage. #[derive(Clone, Debug, Default, PartialEq, Eq)] -pub struct ReadModelSchemaRegistry { - schemas_by_table: BTreeMap, +pub struct TableSchemaRegistry { + schemas_by_table: BTreeMap, tables_by_model: BTreeMap, } -impl ReadModelSchemaRegistry { +impl TableSchemaRegistry { pub fn new() -> Self { Self::default() } - pub fn register(&mut self) -> Result<&mut Self, ReadModelError> + pub fn register(&mut self) -> Result<&mut Self, TableStoreError> where M: RelationalReadModel, { - self.register_schema(M::schema()) + self.register_schema(M::schema().clone()) } - pub fn register_schema( - &mut self, - schema: ReadModelSchema, - ) -> Result<&mut Self, ReadModelError> { + pub fn register_schema(&mut self, schema: TableSchema) -> Result<&mut Self, TableStoreError> { schema.validate()?; if self.schemas_by_table.contains_key(&schema.table_name) { - return Err(ReadModelError::Metadata(format!( - "read-model schema registry already contains table `{}`", + return Err(TableStoreError::Metadata(format!( + "table 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 `{}`", + return Err(TableStoreError::Metadata(format!( + "table schema registry already contains model `{}`", schema.model_name ))); } @@ -55,7 +56,7 @@ impl ReadModelSchemaRegistry { self.schemas_by_table.is_empty() } - pub fn schemas(&self) -> impl Iterator { + pub fn schemas(&self) -> impl Iterator { self.schemas_by_table.values() } @@ -63,17 +64,17 @@ impl ReadModelSchemaRegistry { self.schemas_by_table.keys().map(String::as_str) } - pub fn schema_for_table(&self, table_name: &str) -> Option<&ReadModelSchema> { + pub fn schema_for_table(&self, table_name: &str) -> Option<&TableSchema> { self.schemas_by_table.get(table_name) } - pub fn schema_for_model(&self, model_name: &str) -> Option<&ReadModelSchema> { + pub fn schema_for_model(&self, model_name: &str) -> Option<&TableSchema> { self.tables_by_model .get(model_name) .and_then(|table_name| self.schema_for_table(table_name)) } - pub fn validate(&self) -> Result<(), ReadModelError> { + pub fn validate(&self) -> Result<(), TableStoreError> { let table_names = self .schemas_by_table .keys() @@ -91,9 +92,9 @@ impl ReadModelSchemaRegistry { fn validate_column_foreign_keys( &self, - schema: &ReadModelSchema, + schema: &TableSchema, table_names: &BTreeSet, - ) -> Result<(), ReadModelError> { + ) -> Result<(), TableStoreError> { for column in &schema.columns { let Some(foreign_key) = &column.foreign_key else { continue; @@ -112,9 +113,9 @@ impl ReadModelSchemaRegistry { fn validate_schema_foreign_keys( &self, - schema: &ReadModelSchema, + schema: &TableSchema, table_names: &BTreeSet, - ) -> Result<(), ReadModelError> { + ) -> Result<(), TableStoreError> { for foreign_key in &schema.foreign_keys { self.validate_foreign_key_target( &schema.model_name, @@ -130,23 +131,23 @@ impl ReadModelSchemaRegistry { fn validate_relationships( &self, - schema: &ReadModelSchema, + schema: &TableSchema, table_names: &BTreeSet, - ) -> Result<(), ReadModelError> { + ) -> Result<(), TableStoreError> { 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 `{}`", + TableStoreError::Metadata(format!( + "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 `{}`", + return Err(TableStoreError::Metadata(format!( + "model `{}` relationship `{}` references unregistered join table `{}`", schema.model_name, relationship.field_name, through ))); } @@ -156,8 +157,8 @@ impl ReadModelSchemaRegistry { 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 `{}`", + return Err(TableStoreError::Metadata(format!( + "model `{}` relationship `{}` foreign key `{}` is not a column on target model `{}`", schema.model_name, relationship.field_name, foreign_key, @@ -167,8 +168,8 @@ impl ReadModelSchemaRegistry { } 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 `{}`", + return Err(TableStoreError::Metadata(format!( + "model `{}` relationship `{}` foreign key `{}` is not a column on source model `{}`", schema.model_name, relationship.field_name, foreign_key, @@ -179,14 +180,14 @@ impl ReadModelSchemaRegistry { 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 `{}`", + TableStoreError::Metadata(format!( + "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 `{}`", + return Err(TableStoreError::Metadata(format!( + "model `{}` relationship `{}` foreign key `{}` is not a column on join table `{}`", schema.model_name, relationship.field_name, foreign_key, @@ -208,16 +209,16 @@ impl ReadModelSchemaRegistry { target_table: &str, target_column: &str, table_names: &BTreeSet, - ) -> Result<(), ReadModelError> { + ) -> Result<(), TableStoreError> { 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}`" + return Err(TableStoreError::Metadata(format!( + "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}`" + TableStoreError::Metadata(format!( + "model `{model_name}` references unavailable foreign-key table `{target_table}`" )) })?; if !target_schema @@ -230,8 +231,8 @@ impl ReadModelSchemaRegistry { } 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}`" + return Err(TableStoreError::Metadata(format!( + "model `{model_name}` {local_column} references missing foreign-key column `{target_table}.{target_column}`" ))); } @@ -239,7 +240,7 @@ impl ReadModelSchemaRegistry { } } -fn schema_has_column_or_field(schema: &ReadModelSchema, name: &str) -> bool { +fn schema_has_column_or_field(schema: &TableSchema, name: &str) -> bool { schema .columns .iter() @@ -248,13 +249,13 @@ fn schema_has_column_or_field(schema: &ReadModelSchema, name: &str) -> bool { /// Schema lifecycle operations an adapter can support. #[derive(Clone, Debug, Default, PartialEq, Eq)] -pub struct ReadModelSchemaAdapterCapabilities { +pub struct TableSchemaAdapterCapabilities { pub migration_artifacts: bool, pub schema_verification: bool, pub dev_bootstrap: bool, } -impl ReadModelSchemaAdapterCapabilities { +impl TableSchemaAdapterCapabilities { pub fn all() -> Self { Self { migration_artifacts: true, @@ -266,12 +267,12 @@ impl ReadModelSchemaAdapterCapabilities { /// Generated or user-consumable migration artifact for registered schemas. #[derive(Clone, Debug, PartialEq, Eq)] -pub struct ReadModelMigrationArtifact { +pub struct TableMigrationArtifact { pub name: String, pub statements: Vec, } -impl ReadModelMigrationArtifact { +impl TableMigrationArtifact { pub fn new(name: impl Into, statements: impl IntoIterator) -> Self { Self { name: name.into(), @@ -282,11 +283,11 @@ impl ReadModelMigrationArtifact { /// Result of verifying registered metadata against an adapter-owned schema. #[derive(Clone, Debug, Default, PartialEq, Eq)] -pub struct ReadModelSchemaVerification { - pub issues: Vec, +pub struct TableSchemaVerification { + pub issues: Vec, } -impl ReadModelSchemaVerification { +impl TableSchemaVerification { pub fn verified() -> Self { Self::default() } @@ -298,18 +299,18 @@ impl ReadModelSchemaVerification { /// Adapter-facing schema verification issue. #[derive(Clone, Debug, PartialEq, Eq)] -pub struct ReadModelSchemaIssue { +pub struct TableSchemaIssue { pub table_name: String, pub column_name: Option, - pub kind: ReadModelSchemaIssueKind, + pub kind: TableSchemaIssueKind, pub message: String, } -impl ReadModelSchemaIssue { +impl TableSchemaIssue { pub fn new( table_name: impl Into, column_name: Option>, - kind: ReadModelSchemaIssueKind, + kind: TableSchemaIssueKind, message: impl Into, ) -> Self { Self { @@ -322,7 +323,7 @@ impl ReadModelSchemaIssue { } #[derive(Clone, Debug, PartialEq, Eq)] -pub enum ReadModelSchemaIssueKind { +pub enum TableSchemaIssueKind { MissingTable, MissingColumn, TypeMismatch, @@ -337,11 +338,11 @@ pub enum ReadModelSchemaIssueKind { /// Result of an explicit dev/test schema bootstrap operation. #[derive(Clone, Debug, Default, PartialEq, Eq)] -pub struct ReadModelSchemaBootstrap { +pub struct TableSchemaBootstrap { pub bootstrapped_tables: Vec, } -impl ReadModelSchemaBootstrap { +impl TableSchemaBootstrap { pub fn new(bootstrapped_tables: impl IntoIterator) -> Self { Self { bootstrapped_tables: bootstrapped_tables.into_iter().collect(), @@ -350,32 +351,32 @@ impl ReadModelSchemaBootstrap { } /// Adapter contract for schema generation, verification, and dev/test bootstrap. -pub trait ReadModelSchemaAdapter { - fn schema_capabilities(&self) -> ReadModelSchemaAdapterCapabilities; +pub trait TableSchemaAdapter { + fn schema_capabilities(&self) -> TableSchemaAdapterCapabilities; fn generate_migration_artifacts( &self, - _registry: &ReadModelSchemaRegistry, - ) -> Result, ReadModelError> { - Err(ReadModelError::Metadata( + _registry: &TableSchemaRegistry, + ) -> Result, TableStoreError> { + Err(TableStoreError::Metadata( "read-model schema adapter does not support migration artifact generation".into(), )) } fn verify_schema( &self, - _registry: &ReadModelSchemaRegistry, - ) -> Result { - Err(ReadModelError::Metadata( + _registry: &TableSchemaRegistry, + ) -> Result { + Err(TableStoreError::Metadata( "read-model schema adapter does not support startup schema verification".into(), )) } fn bootstrap_schema_for_dev( &self, - _registry: &ReadModelSchemaRegistry, - ) -> Result { - Err(ReadModelError::Metadata( + _registry: &TableSchemaRegistry, + ) -> Result { + Err(TableStoreError::Metadata( "read-model schema adapter does not support explicit dev/test bootstrap".into(), )) } diff --git a/src/table/sql.rs b/src/table/sql.rs index 59edc8d5c..82e494fa3 100644 --- a/src/table/sql.rs +++ b/src/table/sql.rs @@ -1,4 +1,4 @@ -use std::collections::{BTreeMap, BTreeSet}; +use std::collections::BTreeMap; use crate::table::{ ColumnType, TableMigrationArtifact, TableSchema, TableSchemaAdapter, @@ -83,40 +83,33 @@ pub fn table_schema_statements( fn table_schemas_in_dependency_order( registry: &TableSchemaRegistry, ) -> Result, TableStoreError> { - let schemas_by_table = registry + let mut remaining = registry .schemas() .map(|schema| (schema.table_name.as_str(), schema)) .collect::>(); - let mut remaining = schemas_by_table.keys().copied().collect::>(); let mut ordered = Vec::with_capacity(remaining.len()); while !remaining.is_empty() { let ready = remaining .iter() - .copied() - .filter(|table_name| { - let schema = schemas_by_table - .get(table_name) - .expect("remaining table should have schema"); - schema_dependency_tables(schema) - .all(|dependency| dependency == *table_name || !remaining.contains(dependency)) + .filter(|(table_name, schema)| { + schema_dependency_tables(schema).all(|dependency| { + dependency == **table_name || !remaining.contains_key(dependency) + }) }) + .map(|(table_name, schema)| (*table_name, *schema)) .collect::>(); if ready.is_empty() { - let cycle = remaining.into_iter().collect::>().join(", "); + let cycle = remaining.into_keys().collect::>().join(", "); return Err(TableStoreError::Metadata(format!( "table schema foreign-key cycle cannot be bootstrapped inline: {cycle}" ))); } - for table_name in ready { + for (table_name, schema) in ready { remaining.remove(table_name); - ordered.push( - *schemas_by_table - .get(table_name) - .expect("ready table should have schema"), - ); + ordered.push(schema); } } @@ -392,7 +385,7 @@ mod tests { fn renders_outbox_table_schema_for_sqlite() { let mut registry = TableSchemaRegistry::new(); registry - .register_schema(outbox_message_schema()) + .register_schema(outbox_message_schema().clone()) .expect("schema should register"); let artifact = generate_table_migration_artifacts(®istry, TableSqlDialect::Sqlite) @@ -421,7 +414,7 @@ mod tests { fn renders_outbox_table_schema_for_postgres_with_timestamp_columns() { let mut registry = TableSchemaRegistry::new(); registry - .register_schema(outbox_message_schema()) + .register_schema(outbox_message_schema().clone()) .expect("schema should register"); let artifact = generate_table_migration_artifacts(®istry, TableSqlDialect::Postgres) @@ -565,7 +558,7 @@ mod tests { fn bootstrap_result_lists_registered_tables() { let mut registry = TableSchemaRegistry::new(); registry - .register_schema(outbox_message_schema()) + .register_schema(outbox_message_schema().clone()) .expect("schema should register"); let result = bootstrap_result(®istry); diff --git a/tests/distributed_read_model/main.rs b/tests/distributed_read_model/main.rs index d7d108cad..3390318cf 100644 --- a/tests/distributed_read_model/main.rs +++ b/tests/distributed_read_model/main.rs @@ -26,9 +26,10 @@ use distributed::microsvc::{Context, Routes, Service, Session}; #[cfg(feature = "sqlite")] use distributed::SqliteRepository; use distributed::{ - AggregateBuilder, CommitBuilderExt, GetStream, OutboxMessage, OutboxStore, ReadModelError, + AggregateBuilder, CommitBuilderExt, GetStream, OutboxMessage, OutboxStore, ReadModelWritePlanBuilder, ReadModelWritePlanStore, RelationalReadModel, - RelationalReadModelIncludes, RelationalReadModelQueryStore, TransactionalCommit, + RelationalReadModelIncludes, RelationalReadModelQueryStore, TableStoreError, + TransactionalCommit, }; use distributed::{HashMapRepository, InMemoryReadModelStore, Queueable}; use projection_service::service as projection_service; @@ -382,7 +383,7 @@ where async fn load_checkout_screen( repo: &R, checkout_id: &str, -) -> Result, ReadModelError> +) -> Result, TableStoreError> where R: RelationalReadModelQueryStore + Send + Sync, { @@ -407,7 +408,7 @@ where Ok(Some(checkout)) } -async fn load_seat(repo: &R, seat_id: &str) -> Result, ReadModelError> +async fn load_seat(repo: &R, seat_id: &str) -> Result, TableStoreError> where R: RelationalReadModelQueryStore + Send + Sync, { diff --git a/tests/distributed_read_model/projection_service/handlers/mod.rs b/tests/distributed_read_model/projection_service/handlers/mod.rs index 541bb58e1..08ed47a63 100644 --- a/tests/distributed_read_model/projection_service/handlers/mod.rs +++ b/tests/distributed_read_model/projection_service/handlers/mod.rs @@ -5,8 +5,8 @@ pub mod checkout; pub mod seat; use distributed::microsvc::HandlerError; -use distributed::ReadModelError; +use distributed::TableStoreError; -pub fn read_model_error(err: ReadModelError) -> HandlerError { +pub fn read_model_error(err: TableStoreError) -> HandlerError { HandlerError::Repository(err.into()) } diff --git a/tests/distributed_read_model/query_service/mod.rs b/tests/distributed_read_model/query_service/mod.rs index a9d2a5511..a9a678fac 100644 --- a/tests/distributed_read_model/query_service/mod.rs +++ b/tests/distributed_read_model/query_service/mod.rs @@ -2,7 +2,7 @@ //! projected relational tables through primary-key loads plus explicit //! relationship includes. -use distributed::{InMemoryReadModelStore, ReadModelError, ReadModelWorkspaceExt}; +use distributed::{InMemoryReadModelStore, ReadModelWorkspaceExt, TableStoreError}; use crate::read_models::{checkout_key, seat_key, CheckoutView, SeatView}; @@ -20,7 +20,7 @@ impl CheckoutQueryService { pub async fn checkout_screen( &self, checkout_id: &str, - ) -> Result, ReadModelError> { + ) -> Result, TableStoreError> { let mut session = self.store.workspace(); Ok(session .load::(checkout_key(checkout_id)) @@ -31,7 +31,7 @@ impl CheckoutQueryService { .map(|view| view.data)) } - pub async fn seat(&self, seat_id: &str) -> Result, ReadModelError> { + pub async fn seat(&self, seat_id: &str) -> Result, TableStoreError> { let mut session = self.store.workspace(); Ok(session .load::(seat_key(seat_id)) diff --git a/tests/distributed_read_model/read_models/mod.rs b/tests/distributed_read_model/read_models/mod.rs index af1576c0f..6230b3d61 100644 --- a/tests/distributed_read_model/read_models/mod.rs +++ b/tests/distributed_read_model/read_models/mod.rs @@ -12,9 +12,9 @@ pub use seat_view::SeatView; #[cfg(any(feature = "postgres", feature = "sqlite"))] use distributed::table::TableSchemaRegistry; -use distributed::{InMemoryReadModelStore, ReadModelError, RowKey, RowValue}; +use distributed::{InMemoryReadModelStore, RowKey, RowValue, TableStoreError}; -pub fn register_schemas(store: &InMemoryReadModelStore) -> Result<(), ReadModelError> { +pub fn register_schemas(store: &InMemoryReadModelStore) -> Result<(), TableStoreError> { store.register_schema::()?; store.register_schema::()?; store.register_schema::()?; @@ -22,7 +22,7 @@ pub fn register_schemas(store: &InMemoryReadModelStore) -> Result<(), ReadModelE } #[cfg(any(feature = "postgres", feature = "sqlite"))] -pub fn table_schema_registry() -> Result { +pub fn table_schema_registry() -> Result { let mut registry = TableSchemaRegistry::new(); registry.register::()?; registry.register::()?; diff --git a/tests/distributed_read_model_board/projections_service/mod.rs b/tests/distributed_read_model_board/projections_service/mod.rs index 08c50c444..28b3f6f1b 100644 --- a/tests/distributed_read_model_board/projections_service/mod.rs +++ b/tests/distributed_read_model_board/projections_service/mod.rs @@ -6,7 +6,7 @@ mod handlers; use std::sync::Arc; use distributed::microsvc::{HandlerError, Routes, Service}; -use distributed::{InMemoryReadModelStore, ReadModelError, ReadModelWorkspaceExt}; +use distributed::{InMemoryReadModelStore, ReadModelWorkspaceExt, TableStoreError}; use crate::read_models::{board_key, BoardView}; @@ -19,7 +19,7 @@ pub fn service(store: InMemoryReadModelStore) -> Arc { ))) } -fn read_model_error(err: ReadModelError) -> HandlerError { +fn read_model_error(err: TableStoreError) -> HandlerError { HandlerError::Repository(err.into()) } diff --git a/tests/distributed_read_model_board/query_service/mod.rs b/tests/distributed_read_model_board/query_service/mod.rs index 7f48f34bc..52a822c4d 100644 --- a/tests/distributed_read_model_board/query_service/mod.rs +++ b/tests/distributed_read_model_board/query_service/mod.rs @@ -1,7 +1,7 @@ //! Read-only query service for the board read model. Primary-key loads plus //! `has_many` / `belongs_to` relationship includes. -use distributed::{InMemoryReadModelStore, ReadModelError, ReadModelWorkspaceExt}; +use distributed::{InMemoryReadModelStore, ReadModelWorkspaceExt, TableStoreError}; use crate::read_models::{board_key, card_key, BoardView, CardView}; @@ -19,7 +19,7 @@ impl BoardQueryService { pub async fn board_with_cards( &self, board_id: &str, - ) -> Result, ReadModelError> { + ) -> Result, TableStoreError> { let mut session = self.store.workspace(); Ok(session .load::(board_key(board_id)) @@ -34,7 +34,7 @@ impl BoardQueryService { &self, board_id: &str, card_id: &str, - ) -> Result, ReadModelError> { + ) -> Result, TableStoreError> { let mut session = self.store.workspace(); Ok(session .load::(card_key(board_id, card_id)) diff --git a/tests/distributed_read_model_board/read_models/mod.rs b/tests/distributed_read_model_board/read_models/mod.rs index de3002717..863d8ca3a 100644 --- a/tests/distributed_read_model_board/read_models/mod.rs +++ b/tests/distributed_read_model_board/read_models/mod.rs @@ -8,9 +8,9 @@ mod card_view; pub use board_view::BoardView; pub use card_view::{CardPayload, CardView}; -use distributed::{InMemoryReadModelStore, ReadModelError, RowKey, RowValue}; +use distributed::{InMemoryReadModelStore, RowKey, RowValue, TableStoreError}; -pub fn register_schemas(store: &InMemoryReadModelStore) -> Result<(), ReadModelError> { +pub fn register_schemas(store: &InMemoryReadModelStore) -> Result<(), TableStoreError> { store.register_schema::()?; store.register_schema::()?; Ok(()) diff --git a/tests/read_model_metadata/main.rs b/tests/read_model_metadata/main.rs index 97f71495a..8a5150dee 100644 --- a/tests/read_model_metadata/main.rs +++ b/tests/read_model_metadata/main.rs @@ -1,8 +1,8 @@ use std::collections::HashMap; use distributed::{ - ColumnType, ReadModel, ReadModelError, RelationalReadModel, RelationshipKind, RowValue, - DEFAULT_READ_MODEL_VERSION_COLUMN, + ColumnType, ReadModel, RelationalReadModel, RelationshipKind, RowValue, TableStoreError, + DEFAULT_TABLE_VERSION_COLUMN, }; use serde::{Deserialize, Serialize}; @@ -100,7 +100,7 @@ fn derive_describes_columns_indexes_nullability_and_jsonb() { assert_eq!(schema.primary_key.columns, vec!["account_id"]); assert_eq!( schema.version_column.as_deref(), - Some(DEFAULT_READ_MODEL_VERSION_COLUMN) + Some(DEFAULT_TABLE_VERSION_COLUMN) ); let owner = schema @@ -227,7 +227,7 @@ fn metadata_validation_reports_missing_keys_before_storage_writes() { let err = MissingKeyModel::schema().validate().unwrap_err(); - assert!(matches!(err, ReadModelError::Metadata(message) if message.contains("primary-key"))); + assert!(matches!(err, TableStoreError::Metadata(message) if message.contains("primary-key"))); } #[test] diff --git a/tests/read_model_relationship_includes/main.rs b/tests/read_model_relationship_includes/main.rs index 934969f4f..88fcde379 100644 --- a/tests/read_model_relationship_includes/main.rs +++ b/tests/read_model_relationship_includes/main.rs @@ -1,10 +1,10 @@ use std::future::Future; use distributed::{ - InMemoryReadModelStore, ReadModel, ReadModelAdapterCapabilities, ReadModelCommitOutcome, - ReadModelError, ReadModelLoadGraph, ReadModelLoadRequest, ReadModelQueryCapabilities, - ReadModelWorkspaceExt, ReadModelWritePlan, ReadModelWritePlanStore, - RelationalReadModelQueryStore, RowKey, RowValue, + InMemoryReadModelStore, ReadModel, ReadModelLoadGraph, ReadModelLoadRequest, + ReadModelQueryCapabilities, ReadModelWorkspaceExt, ReadModelWritePlanStore, + RelationalReadModelQueryStore, RowKey, RowValue, TableAdapterCapabilities, TableCommitOutcome, + TableStoreError, TableWritePlan, }; use serde::{Deserialize, Serialize}; @@ -99,14 +99,14 @@ impl NoIncludeStore { } impl ReadModelWritePlanStore for NoIncludeStore { - fn read_model_capabilities(&self) -> ReadModelAdapterCapabilities { + fn read_model_capabilities(&self) -> TableAdapterCapabilities { self.inner.read_model_capabilities() } fn commit_write_plan( &self, - plan: ReadModelWritePlan, - ) -> impl Future> + Send + '_ { + plan: TableWritePlan, + ) -> impl Future> + Send + '_ { self.inner.commit_write_plan(plan) } } @@ -119,7 +119,7 @@ impl RelationalReadModelQueryStore for NoIncludeStore { async fn load_graph( &self, request: ReadModelLoadRequest, - ) -> Result { + ) -> Result { request.validate_for_query_capabilities(&self.read_model_query_capabilities())?; self.inner.load_graph(request).await } @@ -417,7 +417,7 @@ fn unregistered_relationship_target_fails_before_loading() { .unwrap_err(); assert!( - matches!(err, ReadModelError::Metadata(message) if message.contains("unregistered model `PlayerWeapon`")) + matches!(err, TableStoreError::Metadata(message) if message.contains("unregistered model `PlayerWeapon`")) ); } @@ -451,7 +451,7 @@ fn adapter_without_include_capability_rejects_includes() { .unwrap_err(); assert!( - matches!(err, ReadModelError::Metadata(message) if message.contains("relationship includes")) + matches!(err, TableStoreError::Metadata(message) if message.contains("relationship includes")) ); } @@ -469,7 +469,7 @@ fn nested_query_style_include_paths_are_not_a_public_query_dsl() { .unwrap_err(); assert!( - matches!(err, ReadModelError::Metadata(message) if message.contains("has no relationship")) + matches!(err, TableStoreError::Metadata(message) if message.contains("has no relationship")) ); } @@ -488,7 +488,7 @@ fn many_to_many_include_fails_until_join_metadata_is_rich_enough() { .unwrap_err(); assert!( - matches!(err, ReadModelError::Metadata(message) if message.contains("many-to-many relationship")) + matches!(err, TableStoreError::Metadata(message) if message.contains("many-to-many relationship")) ); } @@ -523,7 +523,7 @@ fn belongs_to_include_rejects_composite_target_primary_key() { .unwrap_err(); assert!( - matches!(err, ReadModelError::Metadata(message) if message.contains("CompositeWeaponLabel") + matches!(err, TableStoreError::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 index ded152517..63b16c973 100644 --- a/tests/read_model_schema_bootstrap/main.rs +++ b/tests/read_model_schema_bootstrap/main.rs @@ -1,10 +1,10 @@ use std::collections::{BTreeSet, HashMap}; use distributed::{ - ColumnType, ReadModel, ReadModelError, ReadModelMigrationArtifact, ReadModelSchema, - ReadModelSchemaAdapter, ReadModelSchemaAdapterCapabilities, ReadModelSchemaBootstrap, - ReadModelSchemaIssue, ReadModelSchemaIssueKind, ReadModelSchemaRegistry, - ReadModelSchemaVerification, RelationalReadModel, DEFAULT_READ_MODEL_VERSION_COLUMN, + ColumnType, ReadModel, RelationalReadModel, TableMigrationArtifact, TableSchema, + TableSchemaAdapter, TableSchemaAdapterCapabilities, TableSchemaBootstrap, TableSchemaIssue, + TableSchemaIssueKind, TableSchemaRegistry, TableSchemaVerification, TableStoreError, + DEFAULT_TABLE_VERSION_COLUMN, }; use serde::{Deserialize, Serialize}; @@ -45,9 +45,9 @@ struct PlayerWeapon { struct UnsupportedSchemaAdapter; -impl ReadModelSchemaAdapter for UnsupportedSchemaAdapter { - fn schema_capabilities(&self) -> ReadModelSchemaAdapterCapabilities { - ReadModelSchemaAdapterCapabilities::default() +impl TableSchemaAdapter for UnsupportedSchemaAdapter { + fn schema_capabilities(&self) -> TableSchemaAdapterCapabilities { + TableSchemaAdapterCapabilities::default() } } @@ -63,17 +63,17 @@ impl FakeSqlSchemaAdapter { } } -impl ReadModelSchemaAdapter for FakeSqlSchemaAdapter { - fn schema_capabilities(&self) -> ReadModelSchemaAdapterCapabilities { - ReadModelSchemaAdapterCapabilities::all() +impl TableSchemaAdapter for FakeSqlSchemaAdapter { + fn schema_capabilities(&self) -> TableSchemaAdapterCapabilities { + TableSchemaAdapterCapabilities::all() } fn generate_migration_artifacts( &self, - registry: &ReadModelSchemaRegistry, - ) -> Result, ReadModelError> { + registry: &TableSchemaRegistry, + ) -> Result, TableStoreError> { registry.validate()?; - Ok(vec![ReadModelMigrationArtifact::new( + Ok(vec![TableMigrationArtifact::new( "read-models", registry.schemas().map(create_table_statement), )]) @@ -81,36 +81,36 @@ impl ReadModelSchemaAdapter for FakeSqlSchemaAdapter { fn verify_schema( &self, - registry: &ReadModelSchemaRegistry, - ) -> Result { + registry: &TableSchemaRegistry, + ) -> 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( + issues.push(TableSchemaIssue::new( schema.table_name.clone(), None::, - ReadModelSchemaIssueKind::MissingTable, + TableSchemaIssueKind::MissingTable, format!("missing table `{}`", schema.table_name), )); } } - Ok(ReadModelSchemaVerification { issues }) + Ok(TableSchemaVerification { issues }) } fn bootstrap_schema_for_dev( &self, - registry: &ReadModelSchemaRegistry, - ) -> Result { + registry: &TableSchemaRegistry, + ) -> Result { registry.validate()?; - Ok(ReadModelSchemaBootstrap::new( + Ok(TableSchemaBootstrap::new( registry.table_names().map(str::to_string), )) } } -fn registry() -> ReadModelSchemaRegistry { - let mut registry = ReadModelSchemaRegistry::new(); +fn registry() -> TableSchemaRegistry { + let mut registry = TableSchemaRegistry::new(); registry .register::() .unwrap() @@ -121,7 +121,7 @@ fn registry() -> ReadModelSchemaRegistry { registry } -fn create_table_statement(schema: &ReadModelSchema) -> String { +fn create_table_statement(schema: &TableSchema) -> String { let columns = schema .columns .iter() @@ -182,7 +182,7 @@ fn registry_registers_relational_models_and_exposes_schema_metadata() { assert_eq!(summary.table_name, "account_summaries"); assert_eq!( summary.version_column.as_deref(), - Some(DEFAULT_READ_MODEL_VERSION_COLUMN) + Some(DEFAULT_TABLE_VERSION_COLUMN) ); assert!(summary .columns @@ -207,32 +207,32 @@ fn registry_registers_relational_models_and_exposes_schema_metadata() { #[test] fn registry_rejects_duplicate_tables_and_invalid_foreign_key_targets() { - let mut duplicate_registry = ReadModelSchemaRegistry::new(); + let mut duplicate_registry = TableSchemaRegistry::new(); duplicate_registry.register::().unwrap(); let err = duplicate_registry.register::().unwrap_err(); assert!( - matches!(err, ReadModelError::Metadata(message) if message.contains("already contains table")) + matches!(err, TableStoreError::Metadata(message) if message.contains("already contains table")) ); - let mut invalid_registry = ReadModelSchemaRegistry::new(); + let mut invalid_registry = TableSchemaRegistry::new(); invalid_registry - .register_schema(PlayerWeapon::schema()) + .register_schema(PlayerWeapon::schema().clone()) .unwrap(); let err = invalid_registry.validate().unwrap_err(); assert!( - matches!(err, ReadModelError::Metadata(message) if message.contains("unregistered foreign-key table")) + matches!(err, TableStoreError::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(); + let mut player_schema = Player::schema().clone(); player_schema.relationships[0].foreign_key = Some("missing_player_id".into()); - let mut registry = ReadModelSchemaRegistry::new(); + let mut registry = TableSchemaRegistry::new(); registry .register::() .unwrap() @@ -243,7 +243,7 @@ fn registry_rejects_relationship_foreign_keys_missing_from_target_model() { let err = registry.validate().unwrap_err(); - assert!(matches!(err, ReadModelError::Metadata(message) + assert!(matches!(err, TableStoreError::Metadata(message) if message.contains("foreign key `missing_player_id`") && message.contains("target model `PlayerWeapon`"))); } @@ -258,7 +258,7 @@ fn adapters_can_generate_migration_artifacts_or_report_unsupported() { .unwrap_err(); assert!( - matches!(err, ReadModelError::Metadata(message) if message.contains("migration artifact generation")) + matches!(err, TableStoreError::Metadata(message) if message.contains("migration artifact generation")) ); let adapter = FakeSqlSchemaAdapter::new(Vec::::new()); @@ -271,7 +271,7 @@ fn adapters_can_generate_migration_artifacts_or_report_unsupported() { .iter() .any(|statement| statement.contains("account_summaries") && statement.contains("jsonb") - && statement.contains(DEFAULT_READ_MODEL_VERSION_COLUMN))); + && statement.contains(DEFAULT_TABLE_VERSION_COLUMN))); assert!(artifacts[0] .statements .iter() @@ -290,7 +290,7 @@ fn adapters_can_verify_schema_and_explicitly_bootstrap_dev_schema() { verification .issues .iter() - .filter(|issue| issue.kind == ReadModelSchemaIssueKind::MissingTable) + .filter(|issue| issue.kind == TableSchemaIssueKind::MissingTable) .count(), 2 ); diff --git a/tests/read_model_session/main.rs b/tests/read_model_session/main.rs index 1e5f903d8..8d321268a 100644 --- a/tests/read_model_session/main.rs +++ b/tests/read_model_session/main.rs @@ -1,9 +1,9 @@ use std::collections::HashMap; use distributed::{ - ExpectedVersion, InMemoryReadModelStore, PatchMode, ReadModel, ReadModelAdapterCapabilities, - ReadModelError, ReadModelMutation, ReadModelWorkspaceExt, ReadModelWritePlanBuilder, RowKey, - RowPatch, RowValue, RowWriteMode, Versioned, + ExpectedVersion, InMemoryReadModelStore, PatchMode, ReadModel, ReadModelWorkspaceExt, + ReadModelWritePlanBuilder, RowKey, RowPatch, RowValue, RowWriteMode, TableAdapterCapabilities, + TableMutation, TableStoreError, Versioned, }; use serde::{Deserialize, Serialize}; @@ -114,8 +114,8 @@ fn write_plan_contains_relational_rows_only() { let plan = session.into_write_plan().unwrap(); - assert!(matches!(plan.mutations[0], ReadModelMutation::UpsertRow(_))); - assert!(matches!(plan.mutations[1], ReadModelMutation::DeleteRow(_))); + assert!(matches!(plan.mutations[0], TableMutation::UpsertRow(_))); + assert!(matches!(plan.mutations[1], TableMutation::DeleteRow(_))); } #[test] @@ -131,14 +131,14 @@ fn sparse_patches_and_full_replacements_are_distinct() { let plan = session.into_write_plan().unwrap(); - let ReadModelMutation::UpsertRow(full_row) = &plan.mutations[0] else { + let TableMutation::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 { + let TableMutation::PatchRow(patch_row) = &plan.mutations[1] else { panic!("expected patch-row mutation"); }; assert_eq!(patch_row.mode, PatchMode::UpdateExisting); @@ -159,13 +159,13 @@ fn insert_and_upsert_patch_carry_explicit_missing_row_behavior() { let plan = session.into_write_plan().unwrap(); - let ReadModelMutation::UpsertRow(insert_row) = &plan.mutations[0] else { + let TableMutation::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 { + let TableMutation::PatchRow(upsert_patch) = &plan.mutations[1] else { panic!("expected upsert patch mutation"); }; assert_eq!(upsert_patch.mode, PatchMode::InsertMissing); @@ -221,7 +221,7 @@ async fn insert_missing_patch_rejects_primary_key_mismatch() { let err = session.commit(&store).await.unwrap_err(); assert!( - matches!(err, ReadModelError::Metadata(message) if message.contains("primary-key column `account_id`")) + matches!(err, TableStoreError::Metadata(message) if message.contains("primary-key column `account_id`")) ); } @@ -238,7 +238,7 @@ async fn insert_missing_patch_rejects_partial_new_row() { let err = session.commit(&store).await.unwrap_err(); assert!( - matches!(err, ReadModelError::Metadata(message) if message.contains("missing required column `balance_cents`")) + matches!(err, TableStoreError::Metadata(message) if message.contains("missing required column `balance_cents`")) ); let mut read_models = store.workspace(); @@ -267,7 +267,7 @@ async fn existing_patch_rejects_primary_key_mismatch() { let err = session.commit(&store).await.unwrap_err(); assert!( - matches!(err, ReadModelError::Metadata(message) if message.contains("primary-key column `account_id`")) + matches!(err, TableStoreError::Metadata(message) if message.contains("primary-key column `account_id`")) ); } @@ -289,7 +289,7 @@ fn relationship_operation_populates_child_foreign_key_in_explicit_row_mutation() let plan = session.into_write_plan().unwrap(); - let ReadModelMutation::UpsertRow(child_row) = &plan.mutations[0] else { + let TableMutation::UpsertRow(child_row) = &plan.mutations[0] else { panic!("expected child row mutation"); }; assert_eq!(child_row.schema.table_name, "player_weapons"); @@ -321,7 +321,7 @@ fn expected_versions_are_carried_into_plan() { let plan = session.into_write_plan().unwrap(); - let ReadModelMutation::UpsertRow(row) = &plan.mutations[0] else { + let TableMutation::UpsertRow(row) = &plan.mutations[0] else { panic!("expected upsert row"); }; assert_eq!(row.expected_version, ExpectedVersion::Exact(7)); @@ -347,7 +347,7 @@ fn load_requests_validate_primary_keys_and_explicit_relationship_includes() { ["missing"], ) .unwrap_err(); - assert!(matches!(err, ReadModelError::Metadata(message) if message.contains("relationship"))); + assert!(matches!(err, TableStoreError::Metadata(message) if message.contains("relationship"))); } #[test] @@ -361,7 +361,7 @@ fn validation_failures_happen_before_storage_writes() { let err = session.into_write_plan().unwrap_err(); - assert!(matches!(err, ReadModelError::Metadata(message) if message.contains("not nullable"))); + assert!(matches!(err, TableStoreError::Metadata(message) if message.contains("not nullable"))); } #[test] @@ -372,14 +372,14 @@ fn write_plan_validation_reports_unsupported_adapter_capabilities() { .patch::(account_key("acct-1"), patch) .unwrap(); let plan = session.into_write_plan().unwrap(); - let capabilities = ReadModelAdapterCapabilities { + let capabilities = TableAdapterCapabilities { sparse_patches: false, - ..ReadModelAdapterCapabilities::default() + ..TableAdapterCapabilities::default() }; let err = plan.validate_for(&capabilities).unwrap_err(); assert!( - matches!(err, ReadModelError::Metadata(message) if message.contains("sparse row patches")) + matches!(err, TableStoreError::Metadata(message) if message.contains("sparse row patches")) ); } diff --git a/tests/sqlite_repository/main.rs b/tests/sqlite_repository/main.rs index 2f3dad5bb..c2900e910 100644 --- a/tests/sqlite_repository/main.rs +++ b/tests/sqlite_repository/main.rs @@ -97,7 +97,7 @@ async fn dev_bootstrap_applies_registered_table_schemas() { let repo = SqliteRepository::connect("sqlite::memory:").await.unwrap(); let mut registry = TableSchemaRegistry::new(); registry - .register_schema(distributed::outbox_message_schema()) + .register_schema(distributed::outbox_message_schema().clone()) .unwrap(); let artifacts = repo.generate_table_migration_artifacts(®istry).unwrap();