diff --git a/apps/state-schema-conformance/src/lib.rs b/apps/state-schema-conformance/src/lib.rs index 0e6d17151..2fd6c7941 100644 --- a/apps/state-schema-conformance/src/lib.rs +++ b/apps/state-schema-conformance/src/lib.rs @@ -111,20 +111,20 @@ impl StateSchemaConformance { #[app::init] pub fn init() -> StateSchemaConformance { StateSchemaConformance { - string_map: UnorderedMap::new(), - int_map: UnorderedMap::new(), - record_map: UnorderedMap::new(), - nested_map: UnorderedMap::new(), - counter_list: Vector::new(), - register_list: Vector::new(), - record_list: Vector::new(), - nested_list: Vector::new(), - map_of_counters: UnorderedMap::new(), - map_of_lists: UnorderedMap::new(), - list_of_maps: Vector::new(), - string_set: UnorderedSet::new(), - visit_counter: Counter::new(), - profile_map: UnorderedMap::new(), + string_map: UnorderedMap::new_with_field_name("string_map"), + int_map: UnorderedMap::new_with_field_name("int_map"), + record_map: UnorderedMap::new_with_field_name("record_map"), + nested_map: UnorderedMap::new_with_field_name("nested_map"), + counter_list: Vector::new_with_field_name("counter_list"), + register_list: Vector::new_with_field_name("register_list"), + record_list: Vector::new_with_field_name("record_list"), + nested_list: Vector::new_with_field_name("nested_list"), + map_of_counters: UnorderedMap::new_with_field_name("map_of_counters"), + map_of_lists: UnorderedMap::new_with_field_name("map_of_lists"), + list_of_maps: Vector::new_with_field_name("list_of_maps"), + string_set: UnorderedSet::new_with_field_name("string_set"), + visit_counter: Counter::new_with_field_name("visit_counter"), + profile_map: UnorderedMap::new_with_field_name("profile_map"), status: LwwRegister::new(Status::Inactive), user_id: LwwRegister::new(UserId32([0; 32])), counter: LwwRegister::new(0), diff --git a/crates/sdk/macros/src/logic/method.rs b/crates/sdk/macros/src/logic/method.rs index 57b508b16..3554f8095 100644 --- a/crates/sdk/macros/src/logic/method.rs +++ b/crates/sdk/macros/src/logic/method.rs @@ -159,8 +159,15 @@ impl ToTokens for PublicLogicMethod<'_> { // todo! when generics are present, strip them let init_impl = if init_method { + // Wrap init call to assign deterministic IDs after creation call = quote_spanned! {name.span()=> - ::calimero_storage::collections::Root::new(|| #call) + ::calimero_storage::collections::Root::new(|| { + let mut state = #call; + // Assign deterministic IDs to all collection fields based on field names + // This ensures CIP Invariant I9: all nodes generate identical entity IDs + state.__assign_deterministic_ids(); + state + }) }; quote_spanned! {name.span()=> diff --git a/crates/sdk/macros/src/state.rs b/crates/sdk/macros/src/state.rs index 27294f4a2..e8e9a2d04 100644 --- a/crates/sdk/macros/src/state.rs +++ b/crates/sdk/macros/src/state.rs @@ -50,6 +50,9 @@ impl ToTokens for StateImpl<'_> { // Generate registration hook let registration_hook = generate_registration_hook(ident, &ty_generics); + // Generate deterministic ID assignment method + let assign_ids_impl = generate_assign_deterministic_ids_impl(ident, generics, orig); + quote! { #orig @@ -68,6 +71,9 @@ impl ToTokens for StateImpl<'_> { // Auto-generated registration hook #registration_hook + + // Auto-generated deterministic ID assignment + #assign_ids_impl } .to_tokens(tokens); } @@ -337,8 +343,8 @@ fn generate_mergeable_impl( // Only merge fields that are known CRDT types let merge_calls: Vec<_> = fields .iter() - .filter_map(|field| { - let field_name = field.ident.as_ref()?; + .enumerate() + .filter_map(|(idx, field)| { let field_type = &field.ty; // Check if this is a known CRDT type by examining the type path @@ -360,21 +366,41 @@ fn generate_mergeable_impl( return None; } - // Generate merge call for CRDT fields - Some(quote! { - ::calimero_storage::collections::Mergeable::merge( - &mut self.#field_name, - &other.#field_name - ).map_err(|e| { - ::calimero_storage::collections::crdt_meta::MergeError::StorageError( - format!( - "Failed to merge field '{}': {:?}", - stringify!(#field_name), - e + // Handle both named fields and tuple struct fields + if let Some(field_name) = &field.ident { + // Named field + Some(quote! { + ::calimero_storage::collections::Mergeable::merge( + &mut self.#field_name, + &other.#field_name + ).map_err(|e| { + ::calimero_storage::collections::crdt_meta::MergeError::StorageError( + format!( + "Failed to merge field '{}': {:?}", + stringify!(#field_name), + e + ) + ) + })?; + }) + } else { + // Tuple struct field + let field_index = syn::Index::from(idx); + Some(quote! { + ::calimero_storage::collections::Mergeable::merge( + &mut self.#field_index, + &other.#field_index + ).map_err(|e| { + ::calimero_storage::collections::crdt_meta::MergeError::StorageError( + format!( + "Failed to merge field {}: {:?}", + #idx, + e + ) ) - ) - })?; - }) + })?; + }) + } }) .collect(); @@ -436,3 +462,93 @@ fn generate_registration_hook(ident: &Ident, ty_generics: &syn::TypeGenerics<'_> } } } + +/// Generate method to assign deterministic IDs to all collection fields. +/// +/// This method is called by the init wrapper to ensure all top-level collections +/// have deterministic IDs based on their field names, regardless of how they were +/// created in the user's init() function. +fn generate_assign_deterministic_ids_impl( + ident: &Ident, + generics: &Generics, + orig: &StructOrEnumItem, +) -> TokenStream { + let (impl_generics, ty_generics, where_clause) = generics.split_for_impl(); + + // Extract fields from the struct + let fields = match orig { + StructOrEnumItem::Struct(s) => &s.fields, + StructOrEnumItem::Enum(_) => { + // Enums don't have fields + return quote! {}; + } + }; + + // Helper function to check if a type is a collection that needs ID assignment + fn is_collection_type(type_str: &str) -> bool { + type_str.contains("UnorderedMap") + || type_str.contains("Vector") + || type_str.contains("UnorderedSet") + || type_str.contains("Counter") + || type_str.contains("ReplicatedGrowableArray") + || type_str.contains("UserStorage") + || type_str.contains("FrozenStorage") + } + + // Generate reassign calls for each collection field + let reassign_calls: Vec<_> = fields + .iter() + .enumerate() + .filter_map(|(idx, field)| { + let field_type = &field.ty; + let type_str = quote! { #field_type }.to_string(); + + if !is_collection_type(&type_str) { + return None; + } + + // Handle both named fields and tuple struct fields + if let Some(field_name) = &field.ident { + // Named field: use field name for both access and ID + let field_name_str = field_name.to_string(); + Some(quote! { + self.#field_name.reassign_deterministic_id(#field_name_str); + }) + } else { + // Tuple struct field: use index for access, index string for ID + let field_index = syn::Index::from(idx); + let field_name_str = idx.to_string(); + Some(quote! { + self.#field_index.reassign_deterministic_id(#field_name_str); + }) + } + }) + .collect(); + + quote! { + // ============================================================================ + // AUTO-GENERATED Deterministic ID Assignment + // ============================================================================ + // + // This method is called after init() to ensure all top-level collections have + // deterministic IDs. This allows users to use `UnorderedMap::new()` in init() + // while still getting deterministic IDs for proper sync behavior. + // + // CIP Invariant I9: Deterministic Entity IDs + // > Given the same application code and field names, all nodes MUST generate + // > identical entity IDs for the same logical entities. + // + // Note: This method is always generated (even if empty) because the init wrapper + // unconditionally calls it. For apps without CRDT collections, this is a no-op. + // + impl #impl_generics #ident #ty_generics #where_clause { + /// Assigns deterministic IDs to all collection fields based on their field names. + /// + /// This is called automatically by the init wrapper. Users should not call this directly. + #[doc(hidden)] + pub fn __assign_deterministic_ids(&mut self) { + #(#reassign_calls)* + } + } + } +} diff --git a/crates/storage/src/collections.rs b/crates/storage/src/collections.rs index 9ccfba40e..f307c8d7f 100644 --- a/crates/storage/src/collections.rs +++ b/crates/storage/src/collections.rs @@ -50,18 +50,44 @@ pub use frozen_value::FrozenValue; use crate as calimero_storage; use crate::address::Id; use crate::entities::{ChildInfo, Data, Element, StorageType}; +use crate::index::Index; use crate::interface::{Interface, StorageError}; -use crate::store::{MainStorage, StorageAdaptor}; +use crate::store::{Key, MainStorage, StorageAdaptor}; use crate::{AtomicUnit, Collection}; -/// Compute the ID for a key. +/// Domain separator for map entry IDs to prevent collision with collection IDs. +/// This ensures that a map entry with key "X" never collides with a nested collection +/// with field name "X" in the same parent. +const DOMAIN_SEPARATOR_ENTRY: &[u8] = b"__calimero_entry__"; + +/// Domain separator for collection IDs to prevent collision with map entry IDs. +/// This ensures that a nested collection with field name "X" never collides with a +/// map entry with key "X" in the same parent. +const DOMAIN_SEPARATOR_COLLECTION: &[u8] = b"__calimero_collection__"; + +/// Compute the ID for a key in a map. +/// Uses domain separation to prevent collision with collection IDs. fn compute_id(parent: Id, key: &[u8]) -> Id { let mut hasher = Sha256::new(); hasher.update(parent.as_bytes()); + hasher.update(DOMAIN_SEPARATOR_ENTRY); hasher.update(key); Id::new(hasher.finalize().into()) } +/// Compute a deterministic collection ID from parent ID and field name. +/// This ensures the same collection gets the same ID across all nodes. +/// Uses domain separation to prevent collision with map entry IDs. +pub(crate) fn compute_collection_id(parent_id: Option, field_name: &str) -> Id { + let mut hasher = Sha256::new(); + if let Some(parent) = parent_id { + hasher.update(parent.as_bytes()); + } + hasher.update(DOMAIN_SEPARATOR_COLLECTION); + hasher.update(field_name.as_bytes()); + Id::new(hasher.finalize().into()) +} + #[derive(BorshSerialize, BorshDeserialize)] struct Collection { storage: Element, @@ -131,6 +157,139 @@ impl Collection { this } + /// Creates a new collection with a deterministic ID derived from parent ID and field name. + /// This ensures collections get the same ID across all nodes when created with the same + /// parent and field name. + /// + /// # Arguments + /// * `parent_id` - The ID of the parent collection (None for root-level collections) + /// * `field_name` - The name of the field containing this collection + #[expect(clippy::expect_used, reason = "fatal error if it happens")] + pub(crate) fn new_with_field_name(parent_id: Option, field_name: &str) -> Self { + let id = compute_collection_id(parent_id, field_name); + + let mut this = Self { + children_ids: RefCell::new(None), + storage: Element::new_with_field_name(Some(id), Some(field_name.to_string())), + _priv: PhantomData, + }; + + if id.is_root() { + let _ignored = >::save(&mut this).expect("save"); + } else { + let _ = >::add_child_to(*ROOT_ID, &mut this).expect("add child"); + } + + this + } + + /// Creates a new collection with deterministic ID, field name, and CRDT type. + /// + /// # Arguments + /// * `parent_id` - The ID of the parent collection (None for root-level collections) + /// * `field_name` - The name of the field containing this collection + /// * `crdt_type` - The CRDT type for merge dispatch + #[expect(clippy::expect_used, reason = "fatal error if it happens")] + pub(crate) fn new_with_field_name_and_crdt_type( + parent_id: Option, + field_name: &str, + crdt_type: CrdtType, + ) -> Self { + let id = compute_collection_id(parent_id, field_name); + + let mut this = Self { + children_ids: RefCell::new(None), + storage: Element::new_with_field_name_and_crdt_type( + Some(id), + Some(field_name.to_string()), + crdt_type, + ), + _priv: PhantomData, + }; + + if id.is_root() { + let _ignored = >::save(&mut this).expect("save"); + } else { + let _ = >::add_child_to(*ROOT_ID, &mut this).expect("add child"); + } + + this + } + + /// Reassigns the collection's ID to a deterministic ID based on field name. + /// + /// This is called by the `#[app::state]` macro after `init()` returns to ensure + /// all top-level collections have deterministic IDs regardless of how they were + /// created in `init()`. + /// + /// This method cleans up the old storage entry and parent-child references + /// when moving from a random ID to a deterministic one. + /// + /// # Arguments + /// * `field_name` - The name of the struct field containing this collection + #[expect(clippy::expect_used, reason = "fatal error if cleanup fails")] + pub(crate) fn reassign_deterministic_id(&mut self, field_name: &str) { + let new_id = compute_collection_id(None, field_name); + let old_id = self.storage.id(); + + // If already has the correct ID, nothing to do + if old_id == new_id { + return; + } + + // Clean up old storage entry and index + let _ignored = S::storage_remove(Key::Entry(old_id)); + let _ignored = S::storage_remove(Key::Index(old_id)); + + // Remove old child reference from ROOT (without creating tombstone) + let _ = >::remove_child_reference_only(*ROOT_ID, old_id); + + // Update in-memory ID and field name + self.storage.reassign_id_and_field_name(new_id, field_name); + + // Add the collection with new ID to ROOT + let _ = >::add_child_to(*ROOT_ID, self) + .expect("failed to add collection with new ID"); + } + + /// Reassigns the collection's ID with a specific CRDT type. + /// + /// This method cleans up the old storage entry and parent-child references + /// when moving from a random ID to a deterministic one. + /// + /// # Arguments + /// * `field_name` - The name of the struct field containing this collection + /// * `crdt_type` - The CRDT type for merge dispatch + #[expect(clippy::expect_used, reason = "fatal error if cleanup fails")] + pub(crate) fn reassign_deterministic_id_with_crdt_type( + &mut self, + field_name: &str, + crdt_type: CrdtType, + ) { + let new_id = compute_collection_id(None, field_name); + let old_id = self.storage.id(); + + // If already has the correct ID, nothing to do + if old_id == new_id { + return; + } + + // Clean up old storage entry and index + let _ignored = S::storage_remove(Key::Entry(old_id)); + let _ignored = S::storage_remove(Key::Index(old_id)); + + // Remove old child reference from ROOT (without creating tombstone) + let _ = >::remove_child_reference_only(*ROOT_ID, old_id); + + // Update in-memory ID, field name, and CRDT type + self.storage.reassign_id_and_field_name(new_id, field_name); + self.storage.metadata.crdt_type = Some(crdt_type); + + // Add the collection with new ID to ROOT + let _ = >::add_child_to(*ROOT_ID, self) + .expect("failed to add collection with new ID"); + } + /// Inserts an item into the collection. fn insert(&mut self, id: Option, item: T) -> StoreResult { self.insert_with_storage_type(id, item, StorageType::Public) diff --git a/crates/storage/src/collections/counter.rs b/crates/storage/src/collections/counter.rs index c0683e246..053850ddb 100644 --- a/crates/storage/src/collections/counter.rs +++ b/crates/storage/src/collections/counter.rs @@ -9,7 +9,7 @@ use borsh::io::{ErrorKind, Read, Result as BorshResult, Write}; use borsh::{BorshDeserialize, BorshSerialize}; -use super::{StorageAdaptor, UnorderedMap}; +use super::{CrdtType, StorageAdaptor, UnorderedMap}; use crate::collections::error::StoreError; use crate::interface::StorageError; use crate::store::MainStorage; @@ -179,11 +179,34 @@ pub type GCounter = Counter; pub type PNCounter = Counter; impl Counter { - /// Creates a new counter + /// Creates a new counter with a random ID. + /// + /// Use this for nested collections stored as values in other maps. + /// Merge happens by the parent map's key, so the nested collection's ID + /// doesn't affect sync semantics. + /// + /// For top-level state fields, use `new_with_field_name` instead. #[must_use] pub fn new() -> Self { Self::new_internal() } + + /// Creates a new counter with a deterministic ID. + /// + /// The `field_name` is used to generate a deterministic collection ID, + /// ensuring the same code produces the same ID across all nodes. + /// + /// Use this for top-level state fields (the `#[app::state]` macro does this + /// automatically). + /// + /// # Example + /// ```ignore + /// let visits = Counter::new_with_field_name("visit_count"); + /// ``` + #[must_use] + pub fn new_with_field_name(field_name: &str) -> Self { + Self::new_with_field_name_internal(None, field_name) + } } impl Counter { @@ -195,6 +218,53 @@ impl Counter } } + /// Creates a new counter with deterministic IDs (internal) + pub(super) fn new_with_field_name_internal( + parent_id: Option, + field_name: &str, + ) -> Self { + // For Counter, we need to create deterministic IDs for both positive and negative maps + // Use a reserved internal prefix to prevent collisions with user-created collections. + // The prefix "__counter_internal_" is reserved for Counter's internal maps and ensures + // that a Counter with field name "X" won't collide with a user-created collection + // named "X_positive" or "X_negative". + // + // The positive map gets CrdtType::Counter as it represents the main counter entity. + // The negative map is internal and doesn't need special CRDT type. + Self { + positive: UnorderedMap::new_with_field_name_and_crdt_type( + parent_id, + &format!("__counter_internal_{field_name}_positive"), + CrdtType::Counter, + ), + negative: UnorderedMap::new_with_field_name_internal( + parent_id, + &format!("__counter_internal_{field_name}_negative"), + ), + } + } + + /// Reassigns the counter's ID to a deterministic ID based on field name. + /// + /// This is called by the `#[app::state]` macro after `init()` returns to ensure + /// all top-level collections have deterministic IDs regardless of how they were + /// created in `init()`. + /// + /// # Arguments + /// * `field_name` - The name of the struct field containing this counter + pub fn reassign_deterministic_id(&mut self, field_name: &str) { + // Counter has two internal maps - both need deterministic IDs + self.positive + .inner + .reassign_deterministic_id_with_crdt_type( + &format!("__counter_internal_{field_name}_positive"), + CrdtType::Counter, + ); + self.negative + .inner + .reassign_deterministic_id(&format!("__counter_internal_{field_name}_negative")); + } + /// Increment the counter for the current executor /// /// # Errors @@ -754,4 +824,90 @@ mod tests { err_str ); } + + #[test] + fn test_deterministic_counter_ids() { + crate::env::reset_for_testing(); + + // Create two counters with the same field name - they should have the same IDs + let counter1 = GCounter::new_with_field_name("visit_count"); + let counter2 = GCounter::new_with_field_name("visit_count"); + + assert_eq!( + as crate::entities::Data>::id(&counter1.positive), + as crate::entities::Data>::id(&counter2.positive), + "Counters with same field name should have same positive map ID" + ); + assert_eq!( + as crate::entities::Data>::id(&counter1.negative), + as crate::entities::Data>::id(&counter2.negative), + "Counters with same field name should have same negative map ID" + ); + + // Different field names should produce different IDs + let counter3 = GCounter::new_with_field_name("click_count"); + assert_ne!( + as crate::entities::Data>::id(&counter1.positive), + as crate::entities::Data>::id(&counter3.positive), + "Counters with different field names should have different IDs" + ); + } + + #[test] + fn test_random_vs_deterministic_counter_ids() { + crate::env::reset_for_testing(); + + // Random IDs (new()) should be different each time + let counter1 = GCounter::new(); + let counter2 = GCounter::new(); + + assert_ne!( + as crate::entities::Data>::id(&counter1.positive), + as crate::entities::Data>::id(&counter2.positive), + "Counters with new() should have different random IDs" + ); + + // Deterministic IDs (new_with_field_name) should be the same + let counter3 = GCounter::new_with_field_name("visits"); + let counter4 = GCounter::new_with_field_name("visits"); + assert_eq!( + as crate::entities::Data>::id(&counter3.positive), + as crate::entities::Data>::id(&counter4.positive), + "Counters with same field name should have same ID" + ); + } + + #[test] + fn test_counter_internal_maps_no_collision() { + crate::env::reset_for_testing(); + + // Verify that Counter's internal maps don't collide with user-created collections + // A Counter with field name "visits" should NOT collide with a user-created + // UnorderedMap with field name "visits_positive" or "visits_negative" + let counter = GCounter::new_with_field_name("visits"); + let user_map_positive = UnorderedMap::::new_with_field_name("visits_positive"); + let user_map_negative = UnorderedMap::::new_with_field_name("visits_negative"); + + // Counter's internal maps should have different IDs than user-created maps + assert_ne!( + as crate::entities::Data>::id(&counter.positive), + as crate::entities::Data>::id(&user_map_positive), + "Counter's internal positive map should not collide with user-created 'visits_positive' map" + ); + assert_ne!( + as crate::entities::Data>::id(&counter.negative), + as crate::entities::Data>::id(&user_map_negative), + "Counter's internal negative map should not collide with user-created 'visits_negative' map" + ); + + // Also verify that Counter's internal maps use the reserved prefix + // by checking that a map with the actual internal name matches + let internal_positive = + UnorderedMap::::new_with_field_name("__counter_internal_visits_positive"); + assert_eq!( + as crate::entities::Data>::id(&counter.positive), + as crate::entities::Data>::id(&internal_positive), + "Counter's internal positive map should use the reserved prefix" + ); + } } diff --git a/crates/storage/src/collections/crdt_meta.rs b/crates/storage/src/collections/crdt_meta.rs index 9ade23682..309828c74 100644 --- a/crates/storage/src/collections/crdt_meta.rs +++ b/crates/storage/src/collections/crdt_meta.rs @@ -12,22 +12,91 @@ use borsh::{BorshDeserialize, BorshSerialize}; -/// Identifies the specific CRDT type -#[derive(Debug, Clone, PartialEq, Eq)] +/// Identifies the specific CRDT type for merge dispatch during state synchronization. +/// +/// # ID Assignment +/// +/// Collections get deterministic IDs via the `#[app::state]` macro which calls +/// `reassign_deterministic_id(field_name)` after `init()` returns. This ensures: +/// +/// - **CIP Invariant I9**: Given the same application code and field names, all nodes +/// generate identical entity IDs for the same logical entities. +/// +/// # Merge Behavior +/// +/// During synchronization, the storage layer uses `crdt_type` to dispatch merges: +/// +/// - **Built-in types** (Counter, Map, Vector, etc.) - merged in the storage layer +/// - **Custom types** - dispatched to WASM for app-defined merge logic +/// - **None/Unknown** - falls back to Last-Write-Wins (LWW) semantics +/// +/// # Nested Collections +/// +/// For nested collections like `Map`, the parent map stores +/// entries by key. The nested Counter's ID doesn't affect sync - merging happens +/// by the parent map's key. This is why nested collections can use `new()` with +/// random IDs while top-level fields need deterministic IDs. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, BorshSerialize, BorshDeserialize)] pub enum CrdtType { - /// Last-Write-Wins Register + /// Last-Write-Wins Register - wraps primitive types with timestamp-based conflict resolution. + /// + /// Merge: Higher timestamp wins, with node ID as tie-breaker. LwwRegister, - /// Grow-only Counter + + /// PN-Counter - supports both increment and decrement operations. + /// + /// Internally uses two maps: positive and negative counts per executor. + /// Merge: Union of positive maps, union of negative maps, then compute difference. Counter, - /// Replicated Growable Array (text CRDT) + + /// Replicated Growable Array - CRDT for collaborative text editing. + /// + /// Supports concurrent insertions and deletions with causal ordering. + /// Merge: Interleave characters by (timestamp, node_id) ordering. Rga, - /// Unordered Map (add-wins set semantics for keys) + + /// Unordered Map - key-value store with add-wins semantics for keys. + /// + /// Keys are never lost once added (tombstoned but retained). + /// Values are merged recursively if they implement Mergeable. + /// Merge: Union of keys, recursive merge of values. UnorderedMap, - /// Unordered Set (add-wins semantics) + + /// Unordered Set - collection of unique values with add-wins semantics. + /// + /// Elements are never lost once added. + /// Merge: Union of all elements from both sets. UnorderedSet, - /// Vector (ordered list with operational transformation) + + /// Vector - ordered list with append operations. + /// + /// Elements are identified by index + timestamp for ordering. + /// Merge: Interleave by timestamp, preserving causal order. Vector, - /// Custom user-defined CRDT (with #[derive(CrdtState)]) + + /// User Storage - per-user data storage with signature-based access control. + /// + /// Only the owning user (identified by executor ID) can modify their data. + /// Merge: Latest update per user based on nonce/timestamp. + UserStorage, + + /// Frozen Storage - write-once storage for immutable data. + /// + /// Data can be written once and never modified or deleted. + /// Merge: First-write-wins (subsequent writes are no-ops). + FrozenStorage, + + /// Record - a composite struct that merges field-by-field. + /// + /// Used for the root application state (annotated with `#[app::state]`). + /// Each field is merged according to its own CRDT type. + /// Merge: Recursively merge each field using the auto-generated `Mergeable` impl. + Record, + + /// Custom user-defined CRDT type. + /// + /// For types annotated with `#[derive(CrdtState)]` that define custom merge logic. + /// Merge: Dispatched to WASM runtime to call the app's merge function. Custom(String), } diff --git a/crates/storage/src/collections/frozen.rs b/crates/storage/src/collections/frozen.rs index 3d4fbf1be..5a713e5aa 100644 --- a/crates/storage/src/collections/frozen.rs +++ b/crates/storage/src/collections/frozen.rs @@ -32,13 +32,52 @@ impl FrozenStorage where T: BorshSerialize + BorshDeserialize, { - /// Creates a new, empty FrozenStorage. + /// Creates a new, empty FrozenStorage with a random ID. + /// + /// Use this for nested collections stored as values in other maps. + /// Merge happens by the parent map's key, so the nested collection's ID + /// doesn't affect sync semantics. + /// + /// For top-level state fields, use `new_with_field_name` instead. pub fn new() -> Self { Self { inner: UnorderedMap::new(), storage: Element::new(None), } } + + /// Creates a new, empty FrozenStorage with a deterministic ID. + /// + /// The `field_name` is used to generate a deterministic collection ID, + /// ensuring the same code produces the same ID across all nodes. + /// + /// Use this for top-level state fields (the `#[app::state]` macro does this + /// automatically). + pub fn new_with_field_name(field_name: &str) -> Self { + let mut storage = Element::new_with_field_name(None, Some(field_name.to_string())); + storage.metadata.crdt_type = Some(CrdtType::FrozenStorage); + Self { + inner: UnorderedMap::new_with_field_name(&format!("__frozen_storage_{field_name}")), + storage, + } + } + + /// Reassigns the FrozenStorage's ID to a deterministic ID based on field name. + /// + /// This is called by the `#[app::state]` macro after `init()` returns to ensure + /// all top-level collections have deterministic IDs regardless of how they were + /// created in `init()`. + /// + /// # Arguments + /// * `field_name` - The name of the struct field containing this FrozenStorage + pub fn reassign_deterministic_id(&mut self, field_name: &str) { + use super::compute_collection_id; + let new_id = compute_collection_id(None, field_name); + self.storage.reassign_id_and_field_name(new_id, field_name); + self.storage.metadata.crdt_type = Some(CrdtType::FrozenStorage); + self.inner + .reassign_deterministic_id(&format!("__frozen_storage_{field_name}")); + } } impl Default for FrozenStorage @@ -159,7 +198,7 @@ where S: StorageAdaptor, { fn crdt_type() -> CrdtType { - CrdtType::Custom("FrozenStorage".to_owned()) + CrdtType::FrozenStorage } fn storage_strategy() -> StorageStrategy { StorageStrategy::Structured diff --git a/crates/storage/src/collections/rga.rs b/crates/storage/src/collections/rga.rs index 5455da2a1..6aa25e577 100644 --- a/crates/storage/src/collections/rga.rs +++ b/crates/storage/src/collections/rga.rs @@ -26,7 +26,7 @@ use borsh::{BorshDeserialize, BorshSerialize}; -use super::UnorderedMap; +use super::{CrdtType, UnorderedMap}; use crate::collections::error::StoreError; use crate::env; use crate::store::{MainStorage, StorageAdaptor}; @@ -141,11 +141,34 @@ pub struct ReplicatedGrowableArray { } impl ReplicatedGrowableArray { - /// Create a new empty RGA + /// Create a new empty RGA with a random ID. + /// + /// Use this for nested collections stored as values in other maps. + /// Merge happens by the parent map's key, so the nested collection's ID + /// doesn't affect sync semantics. + /// + /// For top-level state fields, use `new_with_field_name` instead. #[must_use] pub fn new() -> Self { Self::new_internal() } + + /// Create a new RGA with a deterministic ID. + /// + /// The `field_name` is used to generate a deterministic collection ID, + /// ensuring the same code produces the same ID across all nodes. + /// + /// Use this for top-level state fields (the `#[app::state]` macro does this + /// automatically). + /// + /// # Example + /// ```ignore + /// let document = ReplicatedGrowableArray::new_with_field_name("document"); + /// ``` + #[must_use] + pub fn new_with_field_name(field_name: &str) -> Self { + Self::new_with_field_name_internal(None, field_name) + } } impl Default for ReplicatedGrowableArray { @@ -161,6 +184,34 @@ impl ReplicatedGrowableArray { } } + /// Create a new RGA with deterministic ID (internal) + pub(super) fn new_with_field_name_internal( + parent_id: Option, + field_name: &str, + ) -> Self { + Self { + chars: UnorderedMap::new_with_field_name_and_crdt_type( + parent_id, + field_name, + CrdtType::Rga, + ), + } + } + + /// Reassigns the RGA's ID to a deterministic ID based on field name. + /// + /// This is called by the `#[app::state]` macro after `init()` returns to ensure + /// all top-level collections have deterministic IDs regardless of how they were + /// created in `init()`. + /// + /// # Arguments + /// * `field_name` - The name of the struct field containing this RGA + pub fn reassign_deterministic_id(&mut self, field_name: &str) { + self.chars + .inner + .reassign_deterministic_id_with_crdt_type(field_name, CrdtType::Rga); + } + /// Insert a character at the given visible position /// /// # Errors diff --git a/crates/storage/src/collections/unordered_map.rs b/crates/storage/src/collections/unordered_map.rs index db757ad50..f21f31274 100644 --- a/crates/storage/src/collections/unordered_map.rs +++ b/crates/storage/src/collections/unordered_map.rs @@ -9,7 +9,7 @@ use borsh::{BorshDeserialize, BorshSerialize}; use serde::ser::SerializeMap; use serde::Serialize; -use super::{compute_id, Collection, EntryMut, StorageAdaptor}; +use super::{compute_id, Collection, CrdtType, EntryMut, StorageAdaptor}; use crate::address::Id; use crate::collections::error::StoreError; use crate::entities::{ChildInfo, Data, Element, StorageType}; @@ -29,10 +29,32 @@ where K: BorshSerialize + BorshDeserialize, V: BorshSerialize + BorshDeserialize, { - /// Create a new map collection. + /// Create a new map collection with a random ID. + /// + /// Use this for nested collections stored as values in other maps. + /// Merge happens by the parent map's key, so the nested collection's ID + /// doesn't affect sync semantics. + /// + /// For top-level state fields, use `new_with_field_name` instead. pub fn new() -> Self { Self::new_internal() } + + /// Create a new map collection with a deterministic ID. + /// + /// The `field_name` is used to generate a deterministic collection ID, + /// ensuring the same code produces the same ID across all nodes. + /// + /// Use this for top-level state fields (the `#[app::state]` macro does this + /// automatically). + /// + /// # Example + /// ```ignore + /// let items = UnorderedMap::::new_with_field_name("items"); + /// ``` + pub fn new_with_field_name(field_name: &str) -> Self { + Self::new_with_field_name_internal(None, field_name) + } } impl UnorderedMap @@ -48,6 +70,44 @@ where } } + /// Create a new map collection with deterministic ID (internal) + pub(super) fn new_with_field_name_internal( + parent_id: Option, + field_name: &str, + ) -> Self { + Self { + inner: Collection::new_with_field_name_and_crdt_type( + parent_id, + field_name, + CrdtType::UnorderedMap, + ), + } + } + + /// Create a new map with deterministic ID and explicit CRDT type (for Counter's internal maps) + pub(super) fn new_with_field_name_and_crdt_type( + parent_id: Option, + field_name: &str, + crdt_type: CrdtType, + ) -> Self { + Self { + inner: Collection::new_with_field_name_and_crdt_type(parent_id, field_name, crdt_type), + } + } + + /// Reassigns the map's ID to a deterministic ID based on field name. + /// + /// This is called by the `#[app::state]` macro after `init()` returns to ensure + /// all top-level collections have deterministic IDs regardless of how they were + /// created in `init()`. + /// + /// # Arguments + /// * `field_name` - The name of the struct field containing this map + pub fn reassign_deterministic_id(&mut self, field_name: &str) { + self.inner + .reassign_deterministic_id_with_crdt_type(field_name, CrdtType::UnorderedMap); + } + /// Insert a key-value pair into the map. /// /// # Errors @@ -946,4 +1006,82 @@ mod tests { assert_eq!(map.get("key3").unwrap(), None); // Key should be gone assert_eq!(map.len().unwrap(), 2); // Length should decrease } + + #[test] + fn test_deterministic_map_ids() { + crate::env::reset_for_testing(); + + // Create two maps with the same field name - they should have the same IDs + let map1_val: UnorderedMap = UnorderedMap::new_with_field_name("items"); + let map2_val: UnorderedMap = UnorderedMap::new_with_field_name("items"); + + assert_eq!( + as crate::entities::Data>::id(&map1_val), + as crate::entities::Data>::id(&map2_val), + "Maps with same field name should have same ID" + ); + + // Different field names should produce different IDs + let map3_val: UnorderedMap = UnorderedMap::new_with_field_name("products"); + assert_ne!( + as crate::entities::Data>::id(&map1_val), + as crate::entities::Data>::id(&map3_val), + "Maps with different field names should have different IDs" + ); + } + + #[test] + fn test_random_vs_deterministic_map_ids() { + crate::env::reset_for_testing(); + + // Random IDs (new()) should be different each time + let map1: UnorderedMap = UnorderedMap::new(); + let map2: UnorderedMap = UnorderedMap::new(); + + assert_ne!( + as crate::entities::Data>::id(&map1), + as crate::entities::Data>::id(&map2), + "Maps with new() should have different random IDs" + ); + + // Deterministic IDs (new_with_field_name) should be the same + let map3: UnorderedMap = UnorderedMap::new_with_field_name("items"); + let map4: UnorderedMap = UnorderedMap::new_with_field_name("items"); + assert_eq!( + as crate::entities::Data>::id(&map3), + as crate::entities::Data>::id(&map4), + "Maps with same field name should have same ID" + ); + } + + #[test] + fn test_nested_map_uses_random_ids() { + crate::env::reset_for_testing(); + + // For nested maps (values in other maps), we use new() which generates random IDs. + // This is fine because merge happens by the parent map's key, not the nested map's ID. + let mut parent_map = Root::new(|| UnorderedMap::::new()); + + // Insert an entry + parent_map + .insert("key".to_string(), "value".to_string()) + .unwrap(); + + // Verify the entry exists + assert!( + parent_map.contains("key").unwrap(), + "Map entry should exist" + ); + + // Nested maps created with new() have random IDs - this is intentional + // because merge happens by key, not by the nested map's ID + let nested1: UnorderedMap = UnorderedMap::new(); + let nested2: UnorderedMap = UnorderedMap::new(); + + assert_ne!( + as crate::entities::Data>::id(&nested1), + as crate::entities::Data>::id(&nested2), + "Nested maps with new() should have different IDs (random)" + ); + } } diff --git a/crates/storage/src/collections/unordered_set.rs b/crates/storage/src/collections/unordered_set.rs index 5a032a729..13dc88323 100644 --- a/crates/storage/src/collections/unordered_set.rs +++ b/crates/storage/src/collections/unordered_set.rs @@ -7,7 +7,7 @@ use borsh::{BorshDeserialize, BorshSerialize}; use serde::ser::SerializeSeq; use serde::Serialize; -use super::{compute_id, Collection}; +use super::{compute_id, Collection, CrdtType}; use crate::collections::error::StoreError; use crate::entities::Data; use crate::store::{MainStorage, StorageAdaptor}; @@ -23,10 +23,32 @@ impl UnorderedSet where V: BorshSerialize + BorshDeserialize, { - /// Create a new set collection. + /// Create a new set collection with a random ID. + /// + /// Use this for nested collections stored as values in other maps. + /// Merge happens by the parent map's key, so the nested collection's ID + /// doesn't affect sync semantics. + /// + /// For top-level state fields, use `new_with_field_name` instead. pub fn new() -> Self { Self::new_internal() } + + /// Create a new set collection with a deterministic ID. + /// + /// The `field_name` is used to generate a deterministic collection ID, + /// ensuring the same code produces the same ID across all nodes. + /// + /// Use this for top-level state fields (the `#[app::state]` macro does this + /// automatically). + /// + /// # Example + /// ```ignore + /// let tags = UnorderedSet::::new_with_field_name("tags"); + /// ``` + pub fn new_with_field_name(field_name: &str) -> Self { + Self::new_with_field_name_internal(None, field_name) + } } impl UnorderedSet @@ -41,6 +63,33 @@ where } } + /// Create a new set collection with deterministic ID (internal) + pub(super) fn new_with_field_name_internal( + parent_id: Option, + field_name: &str, + ) -> Self { + Self { + inner: Collection::new_with_field_name_and_crdt_type( + parent_id, + field_name, + CrdtType::UnorderedSet, + ), + } + } + + /// Reassigns the set's ID to a deterministic ID based on field name. + /// + /// This is called by the `#[app::state]` macro after `init()` returns to ensure + /// all top-level collections have deterministic IDs regardless of how they were + /// created in `init()`. + /// + /// # Arguments + /// * `field_name` - The name of the struct field containing this set + pub fn reassign_deterministic_id(&mut self, field_name: &str) { + self.inner + .reassign_deterministic_id_with_crdt_type(field_name, CrdtType::UnorderedSet); + } + /// Insert a value pair into the set collection if the element does not already exist. /// /// # Errors diff --git a/crates/storage/src/collections/user.rs b/crates/storage/src/collections/user.rs index 5d3506793..dac849798 100644 --- a/crates/storage/src/collections/user.rs +++ b/crates/storage/src/collections/user.rs @@ -30,13 +30,52 @@ impl UserStorage where T: BorshSerialize + BorshDeserialize, { - /// Creates a new, empty UserStorage. + /// Creates a new, empty UserStorage with a random ID. + /// + /// Use this for nested collections stored as values in other maps. + /// Merge happens by the parent map's key, so the nested collection's ID + /// doesn't affect sync semantics. + /// + /// For top-level state fields, use `new_with_field_name` instead. pub fn new() -> Self { Self { inner: UnorderedMap::new(), storage: Element::new(None), } } + + /// Creates a new, empty UserStorage with a deterministic ID. + /// + /// The `field_name` is used to generate a deterministic collection ID, + /// ensuring the same code produces the same ID across all nodes. + /// + /// Use this for top-level state fields (the `#[app::state]` macro does this + /// automatically). + pub fn new_with_field_name(field_name: &str) -> Self { + let mut storage = Element::new_with_field_name(None, Some(field_name.to_string())); + storage.metadata.crdt_type = Some(CrdtType::UserStorage); + Self { + inner: UnorderedMap::new_with_field_name(&format!("__user_storage_{field_name}")), + storage, + } + } + + /// Reassigns the UserStorage's ID to a deterministic ID based on field name. + /// + /// This is called by the `#[app::state]` macro after `init()` returns to ensure + /// all top-level collections have deterministic IDs regardless of how they were + /// created in `init()`. + /// + /// # Arguments + /// * `field_name` - The name of the struct field containing this UserStorage + pub fn reassign_deterministic_id(&mut self, field_name: &str) { + use super::compute_collection_id; + let new_id = compute_collection_id(None, field_name); + self.storage.reassign_id_and_field_name(new_id, field_name); + self.storage.metadata.crdt_type = Some(CrdtType::UserStorage); + self.inner + .reassign_deterministic_id(&format!("__user_storage_{field_name}")); + } } impl Default for UserStorage @@ -170,7 +209,7 @@ where S: StorageAdaptor, { fn crdt_type() -> CrdtType { - CrdtType::Custom("UserStorage".to_owned()) + CrdtType::UserStorage } fn storage_strategy() -> StorageStrategy { StorageStrategy::Structured diff --git a/crates/storage/src/collections/vector.rs b/crates/storage/src/collections/vector.rs index f2315d2f3..c1ce165af 100644 --- a/crates/storage/src/collections/vector.rs +++ b/crates/storage/src/collections/vector.rs @@ -8,7 +8,7 @@ use borsh::{BorshDeserialize, BorshSerialize}; use serde::ser::SerializeSeq; use serde::Serialize; -use super::Collection; +use super::{Collection, CrdtType}; use crate::collections::error::StoreError; use crate::store::{MainStorage, StorageAdaptor}; @@ -42,10 +42,32 @@ impl Vector where V: BorshSerialize + BorshDeserialize, { - /// Create a new vector collection. + /// Create a new vector collection with a random ID. + /// + /// Use this for nested collections stored as values in other maps. + /// Merge happens by the parent map's key, so the nested collection's ID + /// doesn't affect sync semantics. + /// + /// For top-level state fields, use `new_with_field_name` instead. pub fn new() -> Self { Self::new_internal() } + + /// Create a new vector collection with a deterministic ID. + /// + /// The `field_name` is used to generate a deterministic collection ID, + /// ensuring the same code produces the same ID across all nodes. + /// + /// Use this for top-level state fields (the `#[app::state]` macro does this + /// automatically). + /// + /// # Example + /// ```ignore + /// let items = Vector::::new_with_field_name("items"); + /// ``` + pub fn new_with_field_name(field_name: &str) -> Self { + Self::new_with_field_name_internal(None, field_name) + } } impl Vector @@ -60,6 +82,33 @@ where } } + /// Create a new vector collection with deterministic ID (internal) + pub(super) fn new_with_field_name_internal( + parent_id: Option, + field_name: &str, + ) -> Self { + Self { + inner: Collection::new_with_field_name_and_crdt_type( + parent_id, + field_name, + CrdtType::Vector, + ), + } + } + + /// Reassigns the vector's ID to a deterministic ID based on field name. + /// + /// This is called by the `#[app::state]` macro after `init()` returns to ensure + /// all top-level collections have deterministic IDs regardless of how they were + /// created in `init()`. + /// + /// # Arguments + /// * `field_name` - The name of the struct field containing this vector + pub fn reassign_deterministic_id(&mut self, field_name: &str) { + self.inner + .reassign_deterministic_id_with_crdt_type(field_name, CrdtType::Vector); + } + /// Add a value to the end of the vector. /// /// # Errors diff --git a/crates/storage/src/entities.rs b/crates/storage/src/entities.rs index 3f6b19165..59befb63e 100644 --- a/crates/storage/src/entities.rs +++ b/crates/storage/src/entities.rs @@ -21,6 +21,7 @@ use std::ops::{Deref, DerefMut}; use borsh::{BorshDeserialize, BorshSerialize}; use crate::address::Id; +use crate::collections::CrdtType; use crate::env::time_now; /// Marker trait for atomic, persistable entities. @@ -189,6 +190,50 @@ impl Element { created_at: timestamp, updated_at: timestamp.into(), storage_type: StorageType::Public, + crdt_type: None, + field_name: None, + }, + merkle_hash: [0; 32], + } + } + + /// Creates a new element with field name for schema inference. + #[must_use] + pub fn new_with_field_name(id: Option, field_name: Option) -> Self { + let timestamp = time_now(); + let element_id = id.unwrap_or_else(Id::random); + Self { + id: element_id, + is_dirty: true, + metadata: Metadata { + created_at: timestamp, + updated_at: timestamp.into(), + storage_type: StorageType::Public, + crdt_type: None, + field_name, + }, + merkle_hash: [0; 32], + } + } + + /// Creates a new element with field name and CRDT type. + #[must_use] + pub fn new_with_field_name_and_crdt_type( + id: Option, + field_name: Option, + crdt_type: CrdtType, + ) -> Self { + let timestamp = time_now(); + let element_id = id.unwrap_or_else(Id::random); + Self { + id: element_id, + is_dirty: true, + metadata: Metadata { + created_at: timestamp, + updated_at: timestamp.into(), + storage_type: StorageType::Public, + crdt_type: Some(crdt_type), + field_name, }, merkle_hash: [0; 32], } @@ -205,6 +250,8 @@ impl Element { created_at: timestamp, updated_at: timestamp.into(), storage_type: StorageType::Public, + crdt_type: None, + field_name: None, }, merkle_hash: [0; 32], } @@ -282,6 +329,20 @@ impl Element { self.metadata.storage_type = StorageType::Frozen; self.update(); // Mark as dirty } + + /// Reassigns the element's ID and field name for deterministic ID generation. + /// + /// This is called by the `#[app::state]` macro after `init()` returns to ensure + /// all collections have deterministic IDs based on their field names. + /// + /// # Arguments + /// * `new_id` - The new deterministic ID + /// * `field_name` - The field name for metadata + pub fn reassign_id_and_field_name(&mut self, new_id: Id, field_name: &str) { + self.id = new_id; + self.metadata.field_name = Some(field_name.to_string()); + self.is_dirty = true; + } } #[cfg(test)] @@ -333,7 +394,7 @@ impl Default for StorageType { /// System metadata (timestamps in u64 nanoseconds). #[derive( - BorshDeserialize, BorshSerialize, Clone, Debug, Default, Eq, Ord, PartialEq, PartialOrd, + BorshSerialize, BorshDeserialize, Clone, Debug, Default, Eq, Ord, PartialEq, PartialOrd, )] #[non_exhaustive] pub struct Metadata { @@ -346,6 +407,20 @@ pub struct Metadata { /// different characteristics of handling in the node. /// See `StorageType`. pub storage_type: StorageType, + + /// CRDT type for merge dispatch during state synchronization. + /// + /// - Built-in types (Counter, Map, etc.) merge in storage layer + /// - Custom types dispatch to WASM for app-defined merge + /// - None indicates legacy data (falls back to LWW) + pub crdt_type: Option, + + /// Field name for schema inference. + /// + /// - Stored when entity is created via `new_with_field_name()` + /// - Enables schema inference from database without external schema file + /// - None for legacy data or entities created without field name + pub field_name: Option, } impl Metadata { @@ -356,6 +431,49 @@ impl Metadata { created_at, updated_at: updated_at.into(), storage_type: StorageType::default(), + crdt_type: None, + field_name: None, + } + } + + /// Creates metadata with CRDT type. + #[must_use] + pub fn with_crdt_type(created_at: u64, updated_at: u64, crdt_type: CrdtType) -> Self { + Self { + created_at, + updated_at: updated_at.into(), + storage_type: StorageType::default(), + crdt_type: Some(crdt_type), + field_name: None, + } + } + + /// Creates metadata with field name. + #[must_use] + pub fn with_field_name(created_at: u64, updated_at: u64, field_name: String) -> Self { + Self { + created_at, + updated_at: updated_at.into(), + storage_type: StorageType::default(), + crdt_type: None, + field_name: Some(field_name), + } + } + + /// Creates metadata with CRDT type and field name. + #[must_use] + pub fn with_crdt_type_and_field_name( + created_at: u64, + updated_at: u64, + crdt_type: CrdtType, + field_name: String, + ) -> Self { + Self { + created_at, + updated_at: updated_at.into(), + storage_type: StorageType::default(), + crdt_type: Some(crdt_type), + field_name: Some(field_name), } } @@ -377,6 +495,10 @@ impl Metadata { } } +// Metadata uses standard Borsh serialization via derive. +// Breaking change: Old data without crdt_type/field_name fields will fail to deserialize. +// This is intentional - we require fresh nodes with the new data format. + /// Update timestamp (PartialEq always true for CRDT semantics). #[derive(BorshDeserialize, BorshSerialize, Copy, Clone, Debug, Default, Eq, Ord, PartialOrd)] pub struct UpdatedAt(u64); diff --git a/crates/storage/src/index.rs b/crates/storage/src/index.rs index acb7815fc..5a0f39789 100644 --- a/crates/storage/src/index.rs +++ b/crates/storage/src/index.rs @@ -309,6 +309,20 @@ impl Index { Self::mark_deleted(id, time_now()) } + /// Removes a child reference from a parent without creating a tombstone. + /// + /// Used when reassigning collection IDs - we need to remove the old child + /// reference from the parent but don't want to create a tombstone since + /// the collection is being moved, not deleted. + pub(crate) fn remove_child_reference_only( + parent_id: Id, + child_id: Id, + ) -> Result<(), StorageError> { + Self::update_parent_after_child_removal(parent_id, child_id)?; + Self::recalculate_ancestor_hashes_for(parent_id)?; + Ok(()) + } + /// Updates parent's children list and hash after child removal. /// /// Step 2 of deletion: Remove child from parent's index and recalculate hash. diff --git a/crates/storage/src/tests/common.rs b/crates/storage/src/tests/common.rs index 9da990709..fe47ce0e6 100644 --- a/crates/storage/src/tests/common.rs +++ b/crates/storage/src/tests/common.rs @@ -187,6 +187,8 @@ pub fn create_signed_user_add_action( nonce, }), }, + crdt_type: None, + field_name: None, }; // Create action for signing @@ -237,6 +239,8 @@ pub fn create_signed_user_update_action( nonce, }), }, + crdt_type: None, + field_name: None, }; let mut action = Action::Update { diff --git a/crates/storage/src/tests/index.rs b/crates/storage/src/tests/index.rs index 46ebaeff8..d2e2b085d 100644 --- a/crates/storage/src/tests/index.rs +++ b/crates/storage/src/tests/index.rs @@ -20,6 +20,8 @@ mod index__public_methods { created_at: 1, updated_at: 1.into(), storage_type: StorageType::Public, + crdt_type: None, + field_name: None, }, }; @@ -33,12 +35,16 @@ mod index__public_methods { created_at: 43, updated_at: 22.into(), storage_type: StorageType::Public, + crdt_type: None, + field_name: None, }, )], metadata: Metadata { created_at: 1, updated_at: 1.into(), storage_type: StorageType::Public, + crdt_type: None, + field_name: None, }, }; diff --git a/crates/storage/src/tests/interface.rs b/crates/storage/src/tests/interface.rs index 2622d6a66..28e4e2124 100644 --- a/crates/storage/src/tests/interface.rs +++ b/crates/storage/src/tests/interface.rs @@ -779,6 +779,8 @@ mod user_storage_signature_verification { owner, signature_data: None, // No signature! }, + crdt_type: None, + field_name: None, }, }; @@ -1121,6 +1123,8 @@ mod frozen_storage_verification { created_at: timestamp, updated_at: timestamp.into(), storage_type: StorageType::Frozen, + crdt_type: None, + field_name: None, }, }; @@ -1149,6 +1153,8 @@ mod frozen_storage_verification { created_at: timestamp, updated_at: timestamp.into(), storage_type: StorageType::Frozen, + crdt_type: None, + field_name: None, }, }; @@ -1185,6 +1191,8 @@ mod frozen_storage_verification { created_at: timestamp, updated_at: timestamp.into(), storage_type: StorageType::Frozen, + crdt_type: None, + field_name: None, }, }; assert!(MainInterface::apply_action(add_action).is_ok()); @@ -1204,6 +1212,8 @@ mod frozen_storage_verification { created_at: timestamp, updated_at: new_timestamp.into(), storage_type: StorageType::Frozen, + crdt_type: None, + field_name: None, }, }; @@ -1240,6 +1250,8 @@ mod frozen_storage_verification { created_at: timestamp, updated_at: timestamp.into(), storage_type: StorageType::Frozen, + crdt_type: None, + field_name: None, }, }; assert!(MainInterface::apply_action(add_action).is_ok()); @@ -1286,6 +1298,8 @@ mod frozen_storage_verification { created_at: timestamp, updated_at: timestamp.into(), storage_type: StorageType::Frozen, + crdt_type: None, + field_name: None, }, }; @@ -1326,6 +1340,8 @@ mod frozen_storage_verification { created_at: timestamp, updated_at: timestamp.into(), storage_type: StorageType::Frozen, + crdt_type: None, + field_name: None, }, }; @@ -1362,6 +1378,8 @@ mod timestamp_drift_protection { created_at: future_timestamp, updated_at: future_timestamp.into(), storage_type: StorageType::Public, + crdt_type: None, + field_name: None, }, }; @@ -1394,6 +1412,8 @@ mod timestamp_drift_protection { created_at: future_timestamp, updated_at: future_timestamp.into(), storage_type: StorageType::Public, + crdt_type: None, + field_name: None, }, }; @@ -1420,6 +1440,8 @@ mod timestamp_drift_protection { created_at: past_timestamp, updated_at: past_timestamp.into(), storage_type: StorageType::Public, + crdt_type: None, + field_name: None, }, }; @@ -1490,6 +1512,8 @@ mod storage_type_edge_cases { nonce, }), }, + crdt_type: None, + field_name: None, }; let mut action = Action::DeleteRef { @@ -1654,6 +1678,8 @@ mod storage_type_edge_cases { owner, signature_data: None, // No signature! }, + crdt_type: None, + field_name: None, }, }; @@ -1742,6 +1768,8 @@ mod storage_type_edge_cases { created_at: page.element().created_at(), updated_at: timestamp.into(), storage_type: StorageType::Public, // Changed to Public! + crdt_type: None, + field_name: None, }, };