Skip to main content

distributed/read_model/
session.rs

1use std::cmp::Ordering;
2use std::collections::{BTreeMap, BTreeSet};
3use std::marker::PhantomData;
4
5use serde::Serialize;
6
7use crate::repository::{ReadModelWritePlanStore, RelationalReadModelQueryStore};
8
9use super::{
10    ReadModelError, ReadModelSchema, RelationalReadModel, RelationalReadModelIncludes,
11    RelationshipDef, RelationshipKind, RowKey, RowValue, RowValues, Versioned,
12};
13
14/// Expected optimistic version carried by a staged read-model write.
15#[derive(Clone, Debug, Default, PartialEq, Eq)]
16pub enum ExpectedVersion {
17    /// No optimistic version check is requested.
18    #[default]
19    Any,
20    /// The target row must currently have this version.
21    Exact(u64),
22    /// The target row must not exist yet.
23    NotExists,
24}
25
26/// Full-row write behavior for a relational row mutation.
27#[derive(Clone, Debug, PartialEq, Eq)]
28pub enum RowWriteMode {
29    Insert,
30    Upsert,
31}
32
33/// Sparse patch behavior for a relational row mutation.
34#[derive(Clone, Debug, PartialEq, Eq)]
35pub enum PatchMode {
36    UpdateExisting,
37    InsertMissing,
38}
39
40/// Adapter capabilities used to validate a write plan before any storage write.
41#[derive(Clone, Debug, PartialEq, Eq)]
42pub struct ReadModelAdapterCapabilities {
43    pub relational_rows: bool,
44    pub sparse_patches: bool,
45    pub deletes: bool,
46}
47
48impl Default for ReadModelAdapterCapabilities {
49    fn default() -> Self {
50        Self {
51            relational_rows: true,
52            sparse_patches: true,
53            deletes: true,
54        }
55    }
56}
57
58/// Result of applying a standalone read-model write plan.
59///
60/// This is intentionally a stub: it carries no skipped/replay state and
61/// [`was_applied`](Self::was_applied) is always `true`. The earlier
62/// `read_model_processed_messages` dedupe table and `skipped_duplicate` outcome
63/// were **deliberately removed** (see `specs/consumer-inbox-design.md`, decision
64/// 2026-05-28) because coupling delivery-level dedupe to the read-model
65/// projection contract was the wrong boundary. Replay safety is now a projection
66/// convention — handlers make their writes idempotent so a redelivered event
67/// re-converges (plus per-row `ExpectedVersion` optimistic concurrency). A
68/// first-class replay barrier returns with the consumer inbox (an operational
69/// `consumer_inbox` table committed as a `CommitBatch` participant), tracked
70/// under `tasks/build-transport-bus-facade`; the variant set will grow then.
71#[derive(Clone, Debug, Default, PartialEq, Eq)]
72pub struct ReadModelCommitOutcome;
73
74impl ReadModelCommitOutcome {
75    /// The write plan was applied. Currently the only outcome (see the type docs).
76    pub fn applied() -> Self {
77        Self
78    }
79
80    /// Always `true` today — see the type docs for why there is no skipped variant.
81    pub fn was_applied(&self) -> bool {
82        true
83    }
84}
85
86/// A request an adapter can satisfy with a primary-key read plus explicit includes.
87#[derive(Clone, Debug, PartialEq)]
88pub struct ReadModelLoadRequest {
89    pub schema: ReadModelSchema,
90    pub key: RowKey,
91    pub includes: Vec<String>,
92}
93
94impl ReadModelLoadRequest {
95    pub fn validate_for_query_capabilities(
96        &self,
97        capabilities: &ReadModelQueryCapabilities,
98    ) -> Result<(), ReadModelError> {
99        if !self.includes.is_empty() && !capabilities.relationship_includes {
100            return Err(ReadModelError::Metadata(
101                "read-model adapter does not support relationship includes".into(),
102            ));
103        }
104
105        Ok(())
106    }
107}
108
109/// Adapter capabilities for primary-key relational read-model loads.
110#[derive(Clone, Debug, Default, PartialEq, Eq)]
111pub struct ReadModelQueryCapabilities {
112    pub relationship_includes: bool,
113}
114
115impl ReadModelQueryCapabilities {
116    pub fn relationship_includes() -> Self {
117        Self {
118            relationship_includes: true,
119        }
120    }
121}
122
123/// Rows loaded for one requested relationship include.
124#[derive(Clone, Debug, PartialEq)]
125pub struct ReadModelIncludeRows {
126    pub relationship: RelationshipDef,
127    pub target_schema: ReadModelSchema,
128    pub rows: Vec<Versioned<RowValues>>,
129}
130
131/// Untyped graph loaded by a relational read-model adapter.
132#[derive(Clone, Debug, Default, PartialEq)]
133pub struct ReadModelLoadGraph {
134    pub root: Option<Versioned<RowValues>>,
135    pub includes: BTreeMap<String, ReadModelIncludeRows>,
136}
137
138/// Sparse column updates for a relational row.
139#[derive(Clone, Debug, Default, PartialEq)]
140pub struct RowPatch {
141    values: RowValues,
142}
143
144impl RowPatch {
145    pub fn new() -> Self {
146        Self::default()
147    }
148
149    pub fn set(mut self, column: impl Into<String>, value: RowValue) -> Self {
150        self.values.insert(column, value);
151        self
152    }
153
154    pub fn set_serde<T: Serialize + ?Sized>(
155        mut self,
156        column: impl Into<String>,
157        value: &T,
158    ) -> Result<Self, ReadModelError> {
159        self.values.insert_serde(column, value)?;
160        Ok(self)
161    }
162
163    pub fn get(&self, column: &str) -> Option<&RowValue> {
164        self.values.get(column)
165    }
166
167    pub fn iter(&self) -> impl Iterator<Item = (&str, &RowValue)> {
168        self.values.iter()
169    }
170
171    pub fn is_empty(&self) -> bool {
172        self.values.is_empty()
173    }
174
175    pub fn into_values(self) -> RowValues {
176        self.values
177    }
178}
179
180/// Full relational row insert/upsert mutation.
181#[derive(Clone, Debug, PartialEq)]
182pub struct RowMutation {
183    pub schema: ReadModelSchema,
184    pub key: RowKey,
185    pub values: RowValues,
186    pub expected_version: ExpectedVersion,
187    pub mode: RowWriteMode,
188}
189
190/// Sparse relational row patch mutation.
191#[derive(Clone, Debug, PartialEq)]
192pub struct PatchRowMutation {
193    pub schema: ReadModelSchema,
194    pub key: RowKey,
195    pub patch: RowPatch,
196    pub expected_version: ExpectedVersion,
197    pub mode: PatchMode,
198}
199
200/// Relational row delete mutation.
201#[derive(Clone, Debug, PartialEq)]
202pub struct DeleteRowMutation {
203    pub schema: ReadModelSchema,
204    pub key: RowKey,
205    pub expected_version: ExpectedVersion,
206}
207
208/// First-pass read-model write-plan mutation surface.
209#[derive(Clone, Debug, PartialEq)]
210pub enum ReadModelMutation {
211    UpsertRow(RowMutation),
212    PatchRow(PatchRowMutation),
213    DeleteRow(DeleteRowMutation),
214}
215
216impl ReadModelMutation {
217    pub fn table_name(&self) -> &str {
218        match self {
219            ReadModelMutation::UpsertRow(mutation) => mutation.schema.table_name.as_str(),
220            ReadModelMutation::PatchRow(mutation) => mutation.schema.table_name.as_str(),
221            ReadModelMutation::DeleteRow(mutation) => mutation.schema.table_name.as_str(),
222        }
223    }
224
225    pub fn lock_key(&self) -> String {
226        match self {
227            ReadModelMutation::UpsertRow(mutation) => format!(
228                "{}:{}",
229                mutation.schema.table_name,
230                key_fingerprint(&mutation.key)
231            ),
232            ReadModelMutation::PatchRow(mutation) => format!(
233                "{}:{}",
234                mutation.schema.table_name,
235                key_fingerprint(&mutation.key)
236            ),
237            ReadModelMutation::DeleteRow(mutation) => format!(
238                "{}:{}",
239                mutation.schema.table_name,
240                key_fingerprint(&mutation.key)
241            ),
242        }
243    }
244
245    fn operation_rank(&self) -> u8 {
246        match self {
247            ReadModelMutation::UpsertRow(_) => 1,
248            ReadModelMutation::PatchRow(_) => 2,
249            ReadModelMutation::DeleteRow(_) => 3,
250        }
251    }
252
253    fn schema(&self) -> &ReadModelSchema {
254        match self {
255            ReadModelMutation::UpsertRow(mutation) => &mutation.schema,
256            ReadModelMutation::PatchRow(mutation) => &mutation.schema,
257            ReadModelMutation::DeleteRow(mutation) => &mutation.schema,
258        }
259    }
260
261    fn depends_on_table(&self, table_name: &str) -> bool {
262        let schema = self.schema();
263        schema
264            .foreign_keys
265            .iter()
266            .any(|foreign_key| foreign_key.table == table_name)
267            || schema.columns.iter().any(|column| {
268                column
269                    .foreign_key
270                    .as_ref()
271                    .is_some_and(|foreign_key| foreign_key.table == table_name)
272            })
273    }
274
275    fn dependency_order(&self, other: &Self) -> Option<Ordering> {
276        let self_depends_on_other = self.depends_on_table(other.table_name());
277        let other_depends_on_self = other.depends_on_table(self.table_name());
278
279        match (self_depends_on_other, other_depends_on_self) {
280            (true, false) if self.operation_rank() == 3 && other.operation_rank() == 3 => {
281                Some(Ordering::Less)
282            }
283            (true, false) => Some(Ordering::Greater),
284            (false, true) if self.operation_rank() == 3 && other.operation_rank() == 3 => {
285                Some(Ordering::Greater)
286            }
287            (false, true) => Some(Ordering::Less),
288            _ => None,
289        }
290    }
291
292    fn sort_key(&self) -> String {
293        match self {
294            ReadModelMutation::UpsertRow(mutation) => format!(
295                "1|{}|{}",
296                mutation.schema.table_name,
297                key_fingerprint(&mutation.key)
298            ),
299            ReadModelMutation::PatchRow(mutation) => format!(
300                "2|{}|{}",
301                mutation.schema.table_name,
302                key_fingerprint(&mutation.key)
303            ),
304            ReadModelMutation::DeleteRow(mutation) => format!(
305                "3|{}|{}",
306                mutation.schema.table_name,
307                key_fingerprint(&mutation.key)
308            ),
309        }
310    }
311}
312
313/// Deterministic unit-of-work output for relational read-model adapters.
314#[derive(Clone, Debug, Default, PartialEq)]
315pub struct ReadModelWritePlan {
316    pub mutations: Vec<ReadModelMutation>,
317}
318
319impl ReadModelWritePlan {
320    pub fn new(mutations: Vec<ReadModelMutation>) -> Self {
321        Self { mutations }
322    }
323
324    pub fn is_empty(&self) -> bool {
325        self.mutations.is_empty()
326    }
327
328    pub fn validate(&self) -> Result<(), ReadModelError> {
329        self.validate_for(&ReadModelAdapterCapabilities::default())
330    }
331
332    pub fn validate_for(
333        &self,
334        capabilities: &ReadModelAdapterCapabilities,
335    ) -> Result<(), ReadModelError> {
336        for mutation in &self.mutations {
337            match mutation {
338                ReadModelMutation::UpsertRow(mutation) => {
339                    if !capabilities.relational_rows {
340                        return Err(ReadModelError::Metadata(
341                            "read-model adapter does not support relational row writes".into(),
342                        ));
343                    }
344                    validate_row_mutation(mutation)?;
345                }
346                ReadModelMutation::PatchRow(mutation) => {
347                    if !capabilities.relational_rows || !capabilities.sparse_patches {
348                        return Err(ReadModelError::Metadata(
349                            "read-model adapter does not support sparse row patches".into(),
350                        ));
351                    }
352                    validate_patch_mutation(mutation)?;
353                }
354                ReadModelMutation::DeleteRow(mutation) => {
355                    if !capabilities.relational_rows || !capabilities.deletes {
356                        return Err(ReadModelError::Metadata(
357                            "read-model adapter does not support row deletes".into(),
358                        ));
359                    }
360                    validate_delete_mutation(mutation)?;
361                }
362            }
363        }
364
365        Ok(())
366    }
367}
368
369#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
370struct RowIdentity {
371    table_name: String,
372    key: String,
373}
374
375#[derive(Clone, Debug)]
376struct StagedMutation {
377    sequence: u64,
378    mutation: ReadModelMutation,
379}
380
381/// Detached builder for read-model write plans that are applied at commit.
382#[derive(Clone, Debug, Default)]
383pub struct ReadModelWritePlanBuilder {
384    mutations: Vec<StagedMutation>,
385    expected_versions: BTreeMap<RowIdentity, u64>,
386    next_sequence: u64,
387}
388
389impl ReadModelWritePlanBuilder {
390    pub fn new() -> Self {
391        Self::default()
392    }
393
394    pub fn is_empty(&self) -> bool {
395        self.mutations.is_empty()
396    }
397
398    pub fn load<M>(&self, key: RowKey) -> Result<ReadModelLoadRequest, ReadModelError>
399    where
400        M: RelationalReadModel,
401    {
402        self.load_with::<M, Vec<String>, String>(key, Vec::new())
403    }
404
405    pub fn load_with<M, I, S>(
406        &self,
407        key: RowKey,
408        includes: I,
409    ) -> Result<ReadModelLoadRequest, ReadModelError>
410    where
411        M: RelationalReadModel,
412        I: IntoIterator<Item = S>,
413        S: Into<String>,
414    {
415        let schema = validated_schema::<M>()?;
416        validate_key(&schema, &key)?;
417        let includes: Vec<String> = includes.into_iter().map(Into::into).collect();
418        for include in &includes {
419            if !schema
420                .relationships
421                .iter()
422                .any(|relationship| relationship.field_name == *include)
423            {
424                return Err(ReadModelError::Metadata(format!(
425                    "read model `{}` has no relationship `{}`",
426                    schema.model_name, include
427                )));
428            }
429        }
430
431        Ok(ReadModelLoadRequest {
432            schema,
433            key,
434            includes,
435        })
436    }
437
438    pub fn track_loaded<M>(&mut self, versioned: &Versioned<M>) -> Result<&mut Self, ReadModelError>
439    where
440        M: RelationalReadModel,
441    {
442        self.expect_version::<M>(versioned.data.primary_key()?, versioned.version)
443    }
444
445    pub fn expect_version<M>(
446        &mut self,
447        key: RowKey,
448        expected_version: u64,
449    ) -> Result<&mut Self, ReadModelError>
450    where
451        M: RelationalReadModel,
452    {
453        let schema = validated_schema::<M>()?;
454        validate_key(&schema, &key)?;
455        validate_expected_version(&ExpectedVersion::Exact(expected_version), &schema)?;
456        self.expected_versions.insert(
457            RowIdentity {
458                table_name: schema.table_name,
459                key: key_fingerprint(&key),
460            },
461            expected_version,
462        );
463        Ok(self)
464    }
465
466    pub fn insert<M>(&mut self, model: &M) -> Result<&mut Self, ReadModelError>
467    where
468        M: RelationalReadModel,
469    {
470        self.stage_full_row(
471            model,
472            RowWriteMode::Insert,
473            Some(ExpectedVersion::NotExists),
474        )
475    }
476
477    pub fn upsert<M>(&mut self, model: &M) -> Result<&mut Self, ReadModelError>
478    where
479        M: RelationalReadModel,
480    {
481        self.stage_full_row(model, RowWriteMode::Upsert, None)
482    }
483
484    pub fn insert_related<P, C>(
485        &mut self,
486        parent: &P,
487        relationship_field: &str,
488        child: &C,
489    ) -> Result<&mut Self, ReadModelError>
490    where
491        P: RelationalReadModel,
492        C: RelationalReadModel,
493    {
494        self.stage_related_row(parent, relationship_field, child, RowWriteMode::Insert)
495    }
496
497    pub fn upsert_related<P, C>(
498        &mut self,
499        parent: &P,
500        relationship_field: &str,
501        child: &C,
502    ) -> Result<&mut Self, ReadModelError>
503    where
504        P: RelationalReadModel,
505        C: RelationalReadModel,
506    {
507        self.stage_related_row(parent, relationship_field, child, RowWriteMode::Upsert)
508    }
509
510    pub fn patch<M>(&mut self, key: RowKey, patch: RowPatch) -> Result<&mut Self, ReadModelError>
511    where
512        M: RelationalReadModel,
513    {
514        self.stage_patch::<M>(key, patch, PatchMode::UpdateExisting)
515    }
516
517    pub fn upsert_patch<M>(
518        &mut self,
519        key: RowKey,
520        patch: RowPatch,
521    ) -> Result<&mut Self, ReadModelError>
522    where
523        M: RelationalReadModel,
524    {
525        self.stage_patch::<M>(key, patch, PatchMode::InsertMissing)
526    }
527
528    pub fn delete<M>(&mut self, key: RowKey) -> Result<&mut Self, ReadModelError>
529    where
530        M: RelationalReadModel,
531    {
532        let schema = validated_schema::<M>()?;
533        validate_key(&schema, &key)?;
534        let expected_version = self.expected_for(&schema, &key);
535        let mutation = DeleteRowMutation {
536            schema,
537            key,
538            expected_version,
539        };
540        self.push(ReadModelMutation::DeleteRow(mutation));
541        Ok(self)
542    }
543
544    pub fn delete_model<M>(&mut self, model: &M) -> Result<&mut Self, ReadModelError>
545    where
546        M: RelationalReadModel,
547    {
548        self.delete::<M>(model.primary_key()?)
549    }
550
551    pub fn into_write_plan(self) -> Result<ReadModelWritePlan, ReadModelError> {
552        let mut mutations = self.mutations;
553        mutations.sort_by(|left, right| {
554            left.mutation
555                .operation_rank()
556                .cmp(&right.mutation.operation_rank())
557                .then_with(|| {
558                    left.mutation
559                        .dependency_order(&right.mutation)
560                        .unwrap_or(Ordering::Equal)
561                })
562                .then_with(|| left.mutation.sort_key().cmp(&right.mutation.sort_key()))
563                .then(left.sequence.cmp(&right.sequence))
564        });
565        let mutations = mutations
566            .into_iter()
567            .map(|staged| staged.mutation)
568            .collect::<Vec<_>>();
569        let plan = ReadModelWritePlan::new(mutations);
570        plan.validate()?;
571        Ok(plan)
572    }
573
574    pub async fn commit<S>(self, store: &S) -> Result<ReadModelCommitOutcome, ReadModelError>
575    where
576        S: ReadModelWritePlanStore + ?Sized,
577    {
578        store.commit_write_plan(self.into_write_plan()?).await
579    }
580
581    fn stage_full_row<M>(
582        &mut self,
583        model: &M,
584        mode: RowWriteMode,
585        expected_version: Option<ExpectedVersion>,
586    ) -> Result<&mut Self, ReadModelError>
587    where
588        M: RelationalReadModel,
589    {
590        let schema = validated_schema::<M>()?;
591        let key = model.primary_key()?;
592        let values = model.to_row()?;
593        validate_key(&schema, &key)?;
594        let expected_version = expected_version.unwrap_or_else(|| self.expected_for(&schema, &key));
595        let mutation = RowMutation {
596            schema,
597            key,
598            values,
599            expected_version,
600            mode,
601        };
602        self.push(ReadModelMutation::UpsertRow(mutation));
603        Ok(self)
604    }
605
606    fn stage_related_row<P, C>(
607        &mut self,
608        parent: &P,
609        relationship_field: &str,
610        child: &C,
611        mode: RowWriteMode,
612    ) -> Result<&mut Self, ReadModelError>
613    where
614        P: RelationalReadModel,
615        C: RelationalReadModel,
616    {
617        let parent_schema = validated_schema::<P>()?;
618        let child_schema = validated_schema::<C>()?;
619        let relationship = parent_schema
620            .relationships
621            .iter()
622            .find(|relationship| relationship.field_name == relationship_field)
623            .ok_or_else(|| {
624                ReadModelError::Metadata(format!(
625                    "read model `{}` has no relationship `{}`",
626                    parent_schema.model_name, relationship_field
627                ))
628            })?;
629
630        if relationship.target_model != child_schema.model_name {
631            return Err(ReadModelError::Metadata(format!(
632                "relationship `{}` targets `{}`, not `{}`",
633                relationship.field_name, relationship.target_model, child_schema.model_name
634            )));
635        }
636
637        let parent_row = parent.to_row()?;
638        let mut child_row = child.to_row()?;
639        populate_delegated_relationship_values(
640            &parent_schema,
641            &parent_row,
642            relationship,
643            &child_schema,
644            &mut child_row,
645        )?;
646        let key = key_from_row(&child_schema, &child_row)?;
647        let expected_version = match mode {
648            RowWriteMode::Insert => ExpectedVersion::NotExists,
649            RowWriteMode::Upsert => self.expected_for(&child_schema, &key),
650        };
651        let mutation = RowMutation {
652            schema: child_schema,
653            key,
654            values: child_row,
655            expected_version,
656            mode,
657        };
658        self.push(ReadModelMutation::UpsertRow(mutation));
659        Ok(self)
660    }
661
662    fn stage_patch<M>(
663        &mut self,
664        key: RowKey,
665        patch: RowPatch,
666        mode: PatchMode,
667    ) -> Result<&mut Self, ReadModelError>
668    where
669        M: RelationalReadModel,
670    {
671        let schema = validated_schema::<M>()?;
672        validate_key(&schema, &key)?;
673        let expected_version = self.expected_for(&schema, &key);
674        let mutation = PatchRowMutation {
675            schema,
676            key,
677            patch,
678            expected_version,
679            mode,
680        };
681        self.push(ReadModelMutation::PatchRow(mutation));
682        Ok(self)
683    }
684
685    fn push(&mut self, mutation: ReadModelMutation) {
686        let sequence = self.next_sequence;
687        self.next_sequence = self.next_sequence.saturating_add(1);
688        self.mutations.push(StagedMutation { sequence, mutation });
689    }
690
691    fn expected_for(&self, schema: &ReadModelSchema, key: &RowKey) -> ExpectedVersion {
692        self.expected_versions
693            .get(&RowIdentity {
694                table_name: schema.table_name.clone(),
695                key: key_fingerprint(key),
696            })
697            .copied()
698            .map(ExpectedVersion::Exact)
699            .unwrap_or(ExpectedVersion::Any)
700    }
701}
702
703#[derive(Clone, Debug)]
704struct TrackedRowBaseline {
705    key: RowKey,
706    row: RowValues,
707    version: u64,
708}
709
710#[derive(Clone, Debug)]
711struct TrackedIncludeBaseline {
712    relationship: RelationshipDef,
713    target_schema: ReadModelSchema,
714    rows: BTreeMap<String, TrackedRowBaseline>,
715}
716
717#[derive(Clone, Debug)]
718struct TrackedModelBaseline {
719    root_schema: ReadModelSchema,
720    root_key: RowKey,
721    root_row: RowValues,
722    root_version: u64,
723    includes: BTreeMap<String, TrackedIncludeBaseline>,
724}
725
726const INITIAL_TRACKED_ROW_VERSION: u64 = 1;
727
728/// Store-bound read-model workspace for load, mutate, sync, commit workflows.
729///
730/// The mutation/sync/diff surface is store-independent; `load`/`commit`
731/// are provided by the async-store impl block below.
732pub struct ReadModelWorkspace<'a, S> {
733    store: &'a S,
734    writes: ReadModelWritePlanBuilder,
735    baselines: Vec<TrackedModelBaseline>,
736}
737
738impl<'a, S> ReadModelWorkspace<'a, S> {
739    pub fn new(store: &'a S) -> Self {
740        Self {
741            store,
742            writes: ReadModelWritePlanBuilder::new(),
743            baselines: Vec::new(),
744        }
745    }
746
747    pub fn is_empty(&self) -> bool {
748        self.writes.is_empty()
749    }
750
751    pub fn sync<M>(&mut self, model: M) -> Result<&mut Self, ReadModelError>
752    where
753        M: RelationalReadModel + RelationalReadModelIncludes,
754    {
755        let schema = validated_schema::<M>()?;
756        let key = model.primary_key()?;
757        validate_key(&schema, &key)?;
758        let identity = RowIdentity {
759            table_name: schema.table_name.clone(),
760            key: key_fingerprint(&key),
761        };
762        let baseline_index = self
763            .baselines
764            .iter()
765            .position(|baseline| {
766                baseline.root_schema.table_name == identity.table_name
767                    && key_fingerprint(&baseline.root_key) == identity.key
768            })
769            .ok_or_else(|| {
770                ReadModelError::Metadata(format!(
771                    "read model `{}` has no tracked baseline for sync",
772                    schema.model_name
773                ))
774            })?;
775        let baseline = self.baselines[baseline_index].clone();
776        let current_row = model.to_row()?;
777
778        let root_version = self
779            .stage_row_diff(
780                schema.clone(),
781                key.clone(),
782                &baseline.root_row,
783                &current_row,
784                baseline.root_version,
785            )?
786            .unwrap_or(baseline.root_version);
787
788        let mut refreshed_includes = BTreeMap::new();
789        for (include_name, include) in &baseline.includes {
790            let current_rows = model.include_rows(include_name)?;
791            let refreshed_include =
792                self.stage_include_changes(&schema, &current_row, include, current_rows)?;
793            refreshed_includes.insert(include_name.clone(), refreshed_include);
794        }
795
796        self.writes.expected_versions.insert(identity, root_version);
797        self.baselines[baseline_index] = TrackedModelBaseline {
798            root_schema: schema,
799            root_key: key,
800            root_row: current_row,
801            root_version,
802            includes: refreshed_includes,
803        };
804
805        Ok(self)
806    }
807
808    pub fn upsert<M>(&mut self, model: &M) -> Result<&mut Self, ReadModelError>
809    where
810        M: RelationalReadModel,
811    {
812        self.writes.upsert(model)?;
813        Ok(self)
814    }
815
816    pub fn insert<M>(&mut self, model: &M) -> Result<&mut Self, ReadModelError>
817    where
818        M: RelationalReadModel,
819    {
820        self.writes.insert(model)?;
821        Ok(self)
822    }
823
824    pub fn upsert_related<P, C>(
825        &mut self,
826        parent: &P,
827        relationship_field: &str,
828        child: &C,
829    ) -> Result<&mut Self, ReadModelError>
830    where
831        P: RelationalReadModel,
832        C: RelationalReadModel,
833    {
834        self.writes
835            .upsert_related(parent, relationship_field, child)?;
836        Ok(self)
837    }
838
839    pub fn insert_related<P, C>(
840        &mut self,
841        parent: &P,
842        relationship_field: &str,
843        child: &C,
844    ) -> Result<&mut Self, ReadModelError>
845    where
846        P: RelationalReadModel,
847        C: RelationalReadModel,
848    {
849        self.writes
850            .insert_related(parent, relationship_field, child)?;
851        Ok(self)
852    }
853
854    pub fn patch<M>(&mut self, key: RowKey, patch: RowPatch) -> Result<&mut Self, ReadModelError>
855    where
856        M: RelationalReadModel,
857    {
858        self.writes.patch::<M>(key, patch)?;
859        Ok(self)
860    }
861
862    pub fn upsert_patch<M>(
863        &mut self,
864        key: RowKey,
865        patch: RowPatch,
866    ) -> Result<&mut Self, ReadModelError>
867    where
868        M: RelationalReadModel,
869    {
870        self.writes.upsert_patch::<M>(key, patch)?;
871        Ok(self)
872    }
873
874    pub fn delete<M>(&mut self, key: RowKey) -> Result<&mut Self, ReadModelError>
875    where
876        M: RelationalReadModel,
877    {
878        self.writes.delete::<M>(key)?;
879        Ok(self)
880    }
881
882    pub fn delete_model<M>(&mut self, model: &M) -> Result<&mut Self, ReadModelError>
883    where
884        M: RelationalReadModel,
885    {
886        self.writes.delete_model(model)?;
887        Ok(self)
888    }
889
890    pub fn into_write_plan(self) -> Result<ReadModelWritePlan, ReadModelError> {
891        self.writes.into_write_plan()
892    }
893
894    fn track_graph(
895        &mut self,
896        schema: ReadModelSchema,
897        root: Versioned<RowValues>,
898        includes: BTreeMap<String, ReadModelIncludeRows>,
899    ) -> Result<(), ReadModelError> {
900        let root_key = key_from_row(&schema, &root.data)?;
901        let root_identity = RowIdentity {
902            table_name: schema.table_name.clone(),
903            key: key_fingerprint(&root_key),
904        };
905        self.writes
906            .expected_versions
907            .insert(root_identity, root.version);
908
909        let mut tracked_includes = BTreeMap::new();
910        for (include_name, include_rows) in includes {
911            let mut rows = BTreeMap::new();
912            for row in include_rows.rows {
913                let key = key_from_row(&include_rows.target_schema, &row.data)?;
914                rows.insert(
915                    key_fingerprint(&key),
916                    TrackedRowBaseline {
917                        key,
918                        row: row.data,
919                        version: row.version,
920                    },
921                );
922            }
923            tracked_includes.insert(
924                include_name,
925                TrackedIncludeBaseline {
926                    relationship: include_rows.relationship,
927                    target_schema: include_rows.target_schema,
928                    rows,
929                },
930            );
931        }
932
933        let fingerprint = key_fingerprint(&root_key);
934        self.baselines.retain(|baseline| {
935            baseline.root_schema.table_name != schema.table_name
936                || key_fingerprint(&baseline.root_key) != fingerprint
937        });
938        self.baselines.push(TrackedModelBaseline {
939            root_schema: schema,
940            root_key,
941            root_row: root.data,
942            root_version: root.version,
943            includes: tracked_includes,
944        });
945        Ok(())
946    }
947
948    fn stage_include_changes(
949        &mut self,
950        root_schema: &ReadModelSchema,
951        root_row: &RowValues,
952        baseline: &TrackedIncludeBaseline,
953        current_rows: Vec<RowValues>,
954    ) -> Result<TrackedIncludeBaseline, ReadModelError> {
955        if matches!(baseline.relationship.kind, RelationshipKind::BelongsTo)
956            && current_rows.len() > 1
957        {
958            return Err(ReadModelError::Metadata(format!(
959                "belongs_to relationship `{}` can sync at most one related row",
960                baseline.relationship.field_name
961            )));
962        }
963
964        let mut current_fingerprints = BTreeSet::new();
965        let mut refreshed_rows = BTreeMap::new();
966        for mut current_row in current_rows {
967            match baseline.relationship.kind {
968                RelationshipKind::HasMany => populate_delegated_relationship_values(
969                    root_schema,
970                    root_row,
971                    &baseline.relationship,
972                    &baseline.target_schema,
973                    &mut current_row,
974                )?,
975                RelationshipKind::BelongsTo => {}
976                RelationshipKind::ManyToMany => {
977                    return Err(ReadModelError::Metadata(format!(
978                        "many-to-many relationship `{}` includes are not supported yet",
979                        baseline.relationship.field_name
980                    )));
981                }
982            }
983
984            let key = key_from_row(&baseline.target_schema, &current_row)?;
985            let fingerprint = key_fingerprint(&key);
986            current_fingerprints.insert(fingerprint.clone());
987            if let Some(loaded) = baseline.rows.get(&fingerprint) {
988                let version = self
989                    .stage_row_diff(
990                        baseline.target_schema.clone(),
991                        loaded.key.clone(),
992                        &loaded.row,
993                        &current_row,
994                        loaded.version,
995                    )?
996                    .unwrap_or(loaded.version);
997                refreshed_rows.insert(
998                    fingerprint,
999                    TrackedRowBaseline {
1000                        key,
1001                        row: current_row,
1002                        version,
1003                    },
1004                );
1005            } else {
1006                self.stage_upsert_row(
1007                    baseline.target_schema.clone(),
1008                    key.clone(),
1009                    current_row.clone(),
1010                )?;
1011                refreshed_rows.insert(
1012                    fingerprint,
1013                    TrackedRowBaseline {
1014                        key,
1015                        row: current_row,
1016                        version: INITIAL_TRACKED_ROW_VERSION,
1017                    },
1018                );
1019            }
1020        }
1021
1022        // `sync` makes storage match the struct: an owned `has_many` child
1023        // dropped from the loaded collection is deleted. `belongs_to` clears never
1024        // delete the target, which is the owner that other rows may reference.
1025        if matches!(baseline.relationship.kind, RelationshipKind::HasMany) {
1026            for (fingerprint, loaded) in &baseline.rows {
1027                if !current_fingerprints.contains(fingerprint) {
1028                    self.stage_delete_row(
1029                        baseline.target_schema.clone(),
1030                        loaded.key.clone(),
1031                        loaded.version,
1032                    )?;
1033                }
1034            }
1035        } else {
1036            for (fingerprint, loaded) in &baseline.rows {
1037                if !current_fingerprints.contains(fingerprint) {
1038                    refreshed_rows.insert(fingerprint.clone(), loaded.clone());
1039                }
1040            }
1041        }
1042
1043        Ok(TrackedIncludeBaseline {
1044            relationship: baseline.relationship.clone(),
1045            target_schema: baseline.target_schema.clone(),
1046            rows: refreshed_rows,
1047        })
1048    }
1049
1050    fn stage_row_diff(
1051        &mut self,
1052        schema: ReadModelSchema,
1053        key: RowKey,
1054        before: &RowValues,
1055        after: &RowValues,
1056        expected_version: u64,
1057    ) -> Result<Option<u64>, ReadModelError> {
1058        let patch = diff_rows(before, after);
1059        if patch.is_empty() {
1060            return Ok(None);
1061        }
1062        let next_version = next_tracked_version(&schema, &key, expected_version)?;
1063
1064        let mutation = PatchRowMutation {
1065            schema,
1066            key,
1067            patch,
1068            expected_version: ExpectedVersion::Exact(expected_version),
1069            mode: PatchMode::UpdateExisting,
1070        };
1071        validate_patch_mutation(&mutation)?;
1072        self.writes.push(ReadModelMutation::PatchRow(mutation));
1073        Ok(Some(next_version))
1074    }
1075
1076    fn stage_upsert_row(
1077        &mut self,
1078        schema: ReadModelSchema,
1079        key: RowKey,
1080        values: RowValues,
1081    ) -> Result<(), ReadModelError> {
1082        let mutation = RowMutation {
1083            schema,
1084            key,
1085            values,
1086            expected_version: ExpectedVersion::Any,
1087            mode: RowWriteMode::Upsert,
1088        };
1089        validate_row_mutation(&mutation)?;
1090        self.writes.push(ReadModelMutation::UpsertRow(mutation));
1091        Ok(())
1092    }
1093
1094    fn stage_delete_row(
1095        &mut self,
1096        schema: ReadModelSchema,
1097        key: RowKey,
1098        expected_version: u64,
1099    ) -> Result<(), ReadModelError> {
1100        let mutation = DeleteRowMutation {
1101            schema,
1102            key,
1103            expected_version: ExpectedVersion::Exact(expected_version),
1104        };
1105        validate_delete_mutation(&mutation)?;
1106        self.writes.push(ReadModelMutation::DeleteRow(mutation));
1107        Ok(())
1108    }
1109}
1110
1111impl<'a, S> ReadModelWorkspace<'a, S>
1112where
1113    S: ReadModelWritePlanStore + RelationalReadModelQueryStore,
1114{
1115    /// Begin a tracked load against the asynchronous store traits.
1116    pub fn load<M>(&mut self, key: RowKey) -> ReadModelLoadBuilder<'_, 'a, S, M>
1117    where
1118        M: RelationalReadModel + RelationalReadModelIncludes,
1119    {
1120        ReadModelLoadBuilder {
1121            unit: self,
1122            key,
1123            includes: Vec::new(),
1124            _marker: PhantomData,
1125        }
1126    }
1127
1128    /// Commit the staged write plan through the asynchronous store.
1129    pub async fn commit(self) -> Result<ReadModelCommitOutcome, ReadModelError> {
1130        self.writes.commit(self.store).await
1131    }
1132}
1133
1134/// Builder for one explicit primary-key read-model load over the async store traits.
1135pub struct ReadModelLoadBuilder<'workspace, 'store, S, M>
1136where
1137    S: ReadModelWritePlanStore + RelationalReadModelQueryStore,
1138{
1139    unit: &'workspace mut ReadModelWorkspace<'store, S>,
1140    key: RowKey,
1141    includes: Vec<String>,
1142    _marker: PhantomData<M>,
1143}
1144
1145impl<'workspace, 'store, S, M> ReadModelLoadBuilder<'workspace, 'store, S, M>
1146where
1147    S: ReadModelWritePlanStore + RelationalReadModelQueryStore,
1148    M: RelationalReadModel + RelationalReadModelIncludes,
1149{
1150    pub fn include(mut self, relationship: impl Into<String>) -> Self {
1151        self.includes.push(relationship.into());
1152        self
1153    }
1154
1155    pub async fn one(self) -> Result<Option<Versioned<M>>, ReadModelError> {
1156        let request = self
1157            .unit
1158            .writes
1159            .load_with::<M, _, _>(self.key, self.includes)?;
1160        let graph = self.unit.store.load_graph(request.clone()).await?;
1161        let Some(root) = graph.root else {
1162            return Ok(None);
1163        };
1164
1165        let mut model = M::from_row(root.data.clone())?;
1166        for (include_name, include_rows) in &graph.includes {
1167            let rows = include_rows
1168                .rows
1169                .iter()
1170                .map(|row| row.data.clone())
1171                .collect::<Vec<_>>();
1172            model.hydrate_include(include_name, rows)?;
1173        }
1174
1175        self.unit
1176            .track_graph(request.schema, root.clone(), graph.includes)?;
1177        Ok(Some(Versioned {
1178            data: model,
1179            version: root.version,
1180        }))
1181    }
1182}
1183
1184/// Extension trait that starts a tracked read-model workspace from an async store.
1185pub trait ReadModelWorkspaceExt:
1186    ReadModelWritePlanStore + RelationalReadModelQueryStore + Sized
1187{
1188    fn workspace(&self) -> ReadModelWorkspace<'_, Self> {
1189        ReadModelWorkspace::new(self)
1190    }
1191}
1192
1193impl<S> ReadModelWorkspaceExt for S where S: ReadModelWritePlanStore + RelationalReadModelQueryStore {}
1194
1195fn diff_rows(before: &RowValues, after: &RowValues) -> RowPatch {
1196    let mut patch = RowPatch::new();
1197    for (column, value) in after.iter() {
1198        if before.get(column) != Some(value) {
1199            patch = patch.set(column.to_string(), value.clone());
1200        }
1201    }
1202    patch
1203}
1204
1205fn next_tracked_version(
1206    schema: &ReadModelSchema,
1207    key: &RowKey,
1208    current_version: u64,
1209) -> Result<u64, ReadModelError> {
1210    current_version.checked_add(1).ok_or_else(|| {
1211        ReadModelError::Storage(format!(
1212            "read model version overflow for {}:{}",
1213            schema.table_name,
1214            key_fingerprint(key)
1215        ))
1216    })
1217}
1218
1219fn validated_schema<M>() -> Result<ReadModelSchema, ReadModelError>
1220where
1221    M: RelationalReadModel,
1222{
1223    let schema = M::schema();
1224    schema.validate()?;
1225    Ok(schema)
1226}
1227
1228fn validate_row_mutation(mutation: &RowMutation) -> Result<(), ReadModelError> {
1229    mutation.schema.validate()?;
1230    validate_key(&mutation.schema, &mutation.key)?;
1231    validate_expected_version(&mutation.expected_version, &mutation.schema)?;
1232    validate_row_values(&mutation.schema, &mutation.values, true)
1233}
1234
1235fn validate_patch_mutation(mutation: &PatchRowMutation) -> Result<(), ReadModelError> {
1236    mutation.schema.validate()?;
1237    validate_key(&mutation.schema, &mutation.key)?;
1238    validate_expected_version(&mutation.expected_version, &mutation.schema)?;
1239    if mutation.patch.is_empty() {
1240        return Err(ReadModelError::Metadata(format!(
1241            "read model `{}` patch must set at least one column",
1242            mutation.schema.model_name
1243        )));
1244    }
1245    validate_row_values(&mutation.schema, &mutation.patch.values, false)
1246}
1247
1248fn validate_delete_mutation(mutation: &DeleteRowMutation) -> Result<(), ReadModelError> {
1249    mutation.schema.validate()?;
1250    validate_key(&mutation.schema, &mutation.key)?;
1251    validate_expected_version(&mutation.expected_version, &mutation.schema)
1252}
1253
1254fn validate_expected_version(
1255    expected_version: &ExpectedVersion,
1256    schema: &ReadModelSchema,
1257) -> Result<(), ReadModelError> {
1258    if matches!(expected_version, ExpectedVersion::Exact(0)) {
1259        return Err(ReadModelError::Metadata(format!(
1260            "read model `{}` expected version must be greater than zero",
1261            schema.model_name
1262        )));
1263    }
1264    Ok(())
1265}
1266
1267pub(crate) fn validate_key(schema: &ReadModelSchema, key: &RowKey) -> Result<(), ReadModelError> {
1268    if key.is_empty() {
1269        return Err(ReadModelError::Metadata(format!(
1270            "read model `{}` row key cannot be empty",
1271            schema.model_name
1272        )));
1273    }
1274
1275    for column in &schema.primary_key.columns {
1276        match key.get(column) {
1277            Some(RowValue::Null) => {
1278                return Err(ReadModelError::Metadata(format!(
1279                    "read model `{}` primary-key column `{}` cannot be null",
1280                    schema.model_name, column
1281                )));
1282            }
1283            Some(_) => {}
1284            None => {
1285                return Err(ReadModelError::Metadata(format!(
1286                    "read model `{}` row key is missing primary-key column `{}`",
1287                    schema.model_name, column
1288                )));
1289            }
1290        }
1291    }
1292
1293    for (column, _) in key.iter() {
1294        if !schema.primary_key.columns.iter().any(|key| key == column) {
1295            return Err(ReadModelError::Metadata(format!(
1296                "read model `{}` row key includes non-primary-key column `{}`",
1297                schema.model_name, column
1298            )));
1299        }
1300    }
1301
1302    Ok(())
1303}
1304
1305pub(crate) fn validate_row_values(
1306    schema: &ReadModelSchema,
1307    values: &RowValues,
1308    full_row: bool,
1309) -> Result<(), ReadModelError> {
1310    for (column_name, value) in values.iter() {
1311        let column = schema
1312            .columns
1313            .iter()
1314            .find(|column| column.column_name == column_name)
1315            .ok_or_else(|| {
1316                ReadModelError::Metadata(format!(
1317                    "read model `{}` write references missing column `{}`",
1318                    schema.model_name, column_name
1319                ))
1320            })?;
1321
1322        if matches!(value, RowValue::Null) {
1323            if column.primary_key {
1324                return Err(ReadModelError::Metadata(format!(
1325                    "read model `{}` primary-key column `{}` cannot be null",
1326                    schema.model_name, column.column_name
1327                )));
1328            }
1329            if !column.nullable && !column.has_default {
1330                return Err(ReadModelError::Metadata(format!(
1331                    "read model `{}` column `{}` is not nullable",
1332                    schema.model_name, column.column_name
1333                )));
1334            }
1335        }
1336    }
1337
1338    if full_row {
1339        for column in &schema.columns {
1340            if column.skipped || column.nullable || column.has_default {
1341                continue;
1342            }
1343            if !values.contains_key(&column.column_name) {
1344                return Err(ReadModelError::Metadata(format!(
1345                    "read model `{}` row is missing required column `{}`",
1346                    schema.model_name, column.column_name
1347                )));
1348            }
1349        }
1350
1351        for column in schema
1352            .columns
1353            .iter()
1354            .filter(|column| column.delegated_from.is_some())
1355        {
1356            match values.get(&column.column_name) {
1357                Some(RowValue::Null) | None => {
1358                    return Err(ReadModelError::Metadata(format!(
1359                        "read model `{}` delegated column `{}` must be populated before write",
1360                        schema.model_name, column.column_name
1361                    )));
1362                }
1363                Some(_) => {}
1364            }
1365        }
1366    }
1367
1368    Ok(())
1369}
1370
1371pub(crate) fn key_from_row(
1372    schema: &ReadModelSchema,
1373    row: &RowValues,
1374) -> Result<RowKey, ReadModelError> {
1375    let mut key = RowKey::default();
1376    for column in &schema.primary_key.columns {
1377        let value = row.get(column).cloned().ok_or_else(|| {
1378            ReadModelError::Metadata(format!(
1379                "read model `{}` row is missing primary-key column `{}`",
1380                schema.model_name, column
1381            ))
1382        })?;
1383        key.insert(column.clone(), value);
1384    }
1385    validate_key(schema, &key)?;
1386    Ok(key)
1387}
1388
1389fn populate_delegated_relationship_values(
1390    parent_schema: &ReadModelSchema,
1391    parent_row: &RowValues,
1392    relationship: &RelationshipDef,
1393    child_schema: &ReadModelSchema,
1394    child_row: &mut RowValues,
1395) -> Result<(), ReadModelError> {
1396    let mut populated = 0;
1397    for column in child_schema
1398        .columns
1399        .iter()
1400        .filter(|column| column.delegated_from.is_some())
1401    {
1402        let delegated_from = column.delegated_from.as_deref().unwrap_or_default();
1403        let Some((model_name, source_name)) = delegated_from.split_once('.') else {
1404            return Err(ReadModelError::Metadata(format!(
1405                "read model `{}` delegated column `{}` has invalid source `{}`",
1406                child_schema.model_name, column.column_name, delegated_from
1407            )));
1408        };
1409
1410        if model_name != parent_schema.model_name {
1411            continue;
1412        }
1413
1414        let source_column = column_name_for(parent_schema, source_name).ok_or_else(|| {
1415            ReadModelError::Metadata(format!(
1416                "read model `{}` delegated source `{}` is not a parent column",
1417                child_schema.model_name, delegated_from
1418            ))
1419        })?;
1420        let value = parent_row.get(&source_column).cloned().ok_or_else(|| {
1421            ReadModelError::Metadata(format!(
1422                "read model `{}` parent row is missing delegated source column `{}`",
1423                parent_schema.model_name, source_column
1424            ))
1425        })?;
1426        child_row.insert(column.column_name.clone(), value);
1427        populated += 1;
1428    }
1429
1430    if populated == 0 {
1431        let foreign_key = relationship.foreign_key.as_deref().ok_or_else(|| {
1432            ReadModelError::Metadata(format!(
1433                "read model `{}` relationship `{}` must declare a foreign key",
1434                parent_schema.model_name, relationship.field_name
1435            ))
1436        })?;
1437        let child_column = column_name_for(child_schema, foreign_key).ok_or_else(|| {
1438            ReadModelError::Metadata(format!(
1439                "relationship `{}` foreign key `{}` is not a child column",
1440                relationship.field_name, foreign_key
1441            ))
1442        })?;
1443        let parent_column = column_name_for(parent_schema, foreign_key)
1444            .or_else(|| parent_schema.primary_key.columns.first().cloned())
1445            .ok_or_else(|| {
1446                ReadModelError::Metadata(format!(
1447                    "relationship `{}` has no parent key to delegate",
1448                    relationship.field_name
1449                ))
1450            })?;
1451        let value = parent_row.get(&parent_column).cloned().ok_or_else(|| {
1452            ReadModelError::Metadata(format!(
1453                "read model `{}` parent row is missing relationship key `{}`",
1454                parent_schema.model_name, parent_column
1455            ))
1456        })?;
1457        child_row.insert(child_column, value);
1458    }
1459
1460    Ok(())
1461}
1462
1463pub(crate) fn column_name_for(schema: &ReadModelSchema, field_or_column: &str) -> Option<String> {
1464    schema
1465        .columns
1466        .iter()
1467        .find(|column| {
1468            column.field_name == field_or_column || column.column_name == field_or_column
1469        })
1470        .map(|column| column.column_name.clone())
1471}
1472
1473pub(crate) fn key_fingerprint(key: &RowKey) -> String {
1474    let mut fingerprint = String::new();
1475    for (column, value) in key.iter() {
1476        push_fingerprint_part(&mut fingerprint, column);
1477        push_fingerprint_part(&mut fingerprint, &value_fingerprint(value));
1478    }
1479    fingerprint
1480}
1481
1482fn push_fingerprint_part(fingerprint: &mut String, part: &str) {
1483    fingerprint.push_str(&part.len().to_string());
1484    fingerprint.push(':');
1485    fingerprint.push_str(part);
1486    fingerprint.push(';');
1487}
1488
1489fn value_fingerprint(value: &RowValue) -> String {
1490    match value {
1491        RowValue::Null => "null".into(),
1492        RowValue::Bool(value) => format!("bool:{value}"),
1493        RowValue::I64(value) => format!("i64:{value}"),
1494        RowValue::U64(value) => format!("u64:{value}"),
1495        RowValue::F64(value) => format!("f64:{value:?}"),
1496        RowValue::String(value) => format!("string:{value}"),
1497        RowValue::Bytes(value) => format!("bytes:{value:?}"),
1498        RowValue::Json(value) => format!(
1499            "json:{}",
1500            serde_json::to_string(value).unwrap_or_else(|_| value.to_string())
1501        ),
1502    }
1503}
1504
1505#[cfg(test)]
1506mod tests {
1507    use super::*;
1508
1509    #[test]
1510    fn key_fingerprint_distinguishes_delimiter_collisions() {
1511        let left = RowKey::new([
1512            ("a", RowValue::String("x,b=y".into())),
1513            ("b", RowValue::String("z".into())),
1514        ]);
1515        let right = RowKey::new([
1516            ("a", RowValue::String("x".into())),
1517            ("b", RowValue::String("y,b=z".into())),
1518        ]);
1519
1520        assert_ne!(key_fingerprint(&left), key_fingerprint(&right));
1521    }
1522
1523    #[test]
1524    fn key_fingerprint_distinguishes_row_value_types() {
1525        let integer = RowKey::new([("id", RowValue::I64(1))]);
1526        let string = RowKey::new([("id", RowValue::String("1".into()))]);
1527
1528        assert_ne!(key_fingerprint(&integer), key_fingerprint(&string));
1529    }
1530}