diff --git a/crates/loro-common/src/lib.rs b/crates/loro-common/src/lib.rs index a7b6ba4a3..f35868c51 100644 --- a/crates/loro-common/src/lib.rs +++ b/crates/loro-common/src/lib.rs @@ -73,6 +73,18 @@ pub fn check_root_container_name(name: &str) -> bool { !name.is_empty() && name.char_indices().all(|(_, x)| x != '/' && x != '\0') } +/// Return whether the given name indicates a mergeable container. +/// +/// Mergeable containers are special containers that use a Root Container ID format +/// but have a parent. They are identified by having a `/` in their name, which is +/// forbidden for user-created root containers. +/// +/// The format is: `parent_container_id/key` +#[inline] +pub fn is_mergeable_container_name(name: &str) -> bool { + name.contains('/') +} + impl CompactId { pub fn new(peer: PeerID, counter: Counter) -> Self { Self { @@ -584,6 +596,19 @@ mod container { pub fn is_unknown(&self) -> bool { matches!(self.container_type(), ContainerType::Unknown(_)) } + + /// Returns true if this is a mergeable container. + /// + /// Mergeable containers are special containers that use a Root Container ID format + /// but have a parent. They are identified by having a `/` in their name, which is + /// forbidden for user-created root containers. + #[inline] + pub fn is_mergeable(&self) -> bool { + match self { + ContainerID::Root { name, .. } => crate::is_mergeable_container_name(name), + ContainerID::Normal { .. } => false, + } + } } impl TryFrom<&str> for ContainerType { diff --git a/crates/loro-internal/src/arena.rs b/crates/loro-internal/src/arena.rs index b7ab1bf22..690e5aebc 100644 --- a/crates/loro-internal/src/arena.rs +++ b/crates/loro-internal/src/arena.rs @@ -330,7 +330,8 @@ impl SharedArena { } pub fn get_parent(&self, child: ContainerIdx) -> Option { - if self.get_container_id(child).unwrap().is_root() { + let id = self.get_container_id(child).unwrap(); + if id.is_root() && !id.is_mergeable() { // TODO: PERF: we can speed this up by use a special bit in ContainerIdx to indicate // whether the target is a root container return None; diff --git a/crates/loro-internal/src/encoding/value.rs b/crates/loro-internal/src/encoding/value.rs index 05c21dff3..b2f34a78b 100644 --- a/crates/loro-internal/src/encoding/value.rs +++ b/crates/loro-internal/src/encoding/value.rs @@ -70,6 +70,8 @@ pub enum LoroValueKind { List, Map, ContainerType, + /// Root container type - used for mergeable containers + RootContainerType, } impl LoroValueKind { fn from_u8(kind: u8) -> Self { @@ -84,6 +86,7 @@ impl LoroValueKind { 7 => LoroValueKind::List, 8 => LoroValueKind::Map, 9 => LoroValueKind::ContainerType, + 10 => LoroValueKind::RootContainerType, _ => unreachable!(), } } @@ -100,6 +103,7 @@ impl LoroValueKind { LoroValueKind::List => 7, LoroValueKind::Map => 8, LoroValueKind::ContainerType => 9, + LoroValueKind::RootContainerType => 10, } } } @@ -665,6 +669,23 @@ impl<'a> ValueReader<'a> { LoroValue::Container(container_id) } + LoroValueKind::RootContainerType => { + // Read container type + let type_u8 = self.read_u8()?; + let container_type = + ContainerType::try_from_u8(type_u8).unwrap_or(ContainerType::Unknown(type_u8)); + // Read the root container name from the keys arena + let key_idx = self.read_usize()?; + let name = keys + .get(key_idx) + .ok_or(LoroError::DecodeDataCorruptionError)? + .clone(); + let container_id = ContainerID::Root { + name, + container_type, + }; + LoroValue::Container(container_id) + } }) } @@ -777,6 +798,23 @@ impl<'a> ValueReader<'a> { LoroValue::Container(container_id) } + LoroValueKind::RootContainerType => { + // Read container type + let type_u8 = self.read_u8()?; + let container_type = ContainerType::try_from_u8(type_u8) + .unwrap_or(ContainerType::Unknown(type_u8)); + // Read the root container name from the keys arena + let name_key_idx = self.read_usize()?; + let name = keys + .get(name_key_idx) + .ok_or(LoroError::DecodeDataCorruptionError)? + .clone(); + let container_id = ContainerID::Root { + name, + container_type, + }; + LoroValue::Container(container_id) + } }; task = match task { @@ -1035,10 +1073,23 @@ impl ValueWriter { (LoroValueKind::Map, len) } LoroValue::Binary(value) => (LoroValueKind::Binary, self.write_binary(value)), - LoroValue::Container(c) => ( - LoroValueKind::ContainerType, - self.write_u8(c.container_type().to_u8()), - ), + LoroValue::Container(c) => match c { + ContainerID::Normal { container_type, .. } => ( + LoroValueKind::ContainerType, + self.write_u8(container_type.to_u8()), + ), + ContainerID::Root { + name, + container_type, + } => { + // For Root containers, we need to encode: + // 1. The container type + // 2. The name (via key register) + let key_idx = registers.key_mut().register(name); + let len = self.write_u8(container_type.to_u8()) + self.write_usize(key_idx); + (LoroValueKind::RootContainerType, len) + } + }, } } @@ -1145,6 +1196,9 @@ fn get_loro_value_kind(value: &LoroValue) -> LoroValueKind { LoroValue::List(_) => LoroValueKind::List, LoroValue::Map(_) => LoroValueKind::Map, LoroValue::Binary(_) => LoroValueKind::Binary, - LoroValue::Container(_) => LoroValueKind::ContainerType, + LoroValue::Container(c) => match c { + ContainerID::Normal { .. } => LoroValueKind::ContainerType, + ContainerID::Root { .. } => LoroValueKind::RootContainerType, + }, } } diff --git a/crates/loro-internal/src/handler.rs b/crates/loro-internal/src/handler.rs index 63205c279..40573beb2 100644 --- a/crates/loro-internal/src/handler.rs +++ b/crates/loro-internal/src/handler.rs @@ -4151,6 +4151,141 @@ impl MapHandler { }), } } + + pub fn get_mergeable_list(&self, key: &str) -> LoroResult { + self.get_or_create_mergeable_container( + key, + Handler::new_unattached(ContainerType::List) + .into_list() + .unwrap(), + ) + } + + pub fn get_mergeable_map(&self, key: &str) -> LoroResult { + self.get_or_create_mergeable_container( + key, + Handler::new_unattached(ContainerType::Map) + .into_map() + .unwrap(), + ) + } + + pub fn get_mergeable_movable_list(&self, key: &str) -> LoroResult { + self.get_or_create_mergeable_container( + key, + Handler::new_unattached(ContainerType::MovableList) + .into_movable_list() + .unwrap(), + ) + } + + pub fn get_mergeable_text(&self, key: &str) -> LoroResult { + self.get_or_create_mergeable_container( + key, + Handler::new_unattached(ContainerType::Text) + .into_text() + .unwrap(), + ) + } + + pub fn get_mergeable_tree(&self, key: &str) -> LoroResult { + self.get_or_create_mergeable_container( + key, + Handler::new_unattached(ContainerType::Tree) + .into_tree() + .unwrap(), + ) + } + + #[cfg(feature = "counter")] + pub fn get_mergeable_counter(&self, key: &str) -> LoroResult { + self.get_or_create_mergeable_container( + key, + Handler::new_unattached(ContainerType::Counter) + .into_counter() + .unwrap(), + ) + } + + pub fn get_or_create_mergeable_container( + &self, + key: &str, + child: C, + ) -> LoroResult { + // Extract just the name portion from the parent container ID. + // For Root containers, we use the name directly (without the "cid:root-" prefix). + // For Normal containers, we format as "counter@peer:type". + let parent_name = match self.id() { + ContainerID::Root { name, .. } => name.to_string(), + ContainerID::Normal { + peer, + counter, + container_type, + } => format!("{}@{}:{}", counter, peer, container_type), + }; + let name = format!("{}/{}", parent_name, key); + let expected_id = ContainerID::Root { + name: name.into(), + container_type: child.kind(), + }; + + // Check if exists + if let Some(ValueOrHandler::Handler(h)) = self.get_(key) { + if h.id() == expected_id { + if let Some(c) = C::from_handler(h) { + return Ok(c); + } else { + unreachable!("Container type mismatch for same ID"); + } + } + } + + // Create + match &self.inner { + MaybeDetached::Detached(_) => Err(LoroError::MisuseDetachedContainer { + method: "get_or_create_mergeable_container", + }), + MaybeDetached::Attached(a) => a.with_txn(|txn| { + self.insert_mergeable_container_with_txn(txn, key, child, expected_id) + }), + } + } + + pub fn insert_mergeable_container_with_txn( + &self, + txn: &mut Transaction, + key: &str, + child: H, + container_id: ContainerID, + ) -> LoroResult { + let inner = self.inner.try_attached_state()?; + + // Insert into Map + txn.apply_local_op( + inner.container_idx, + crate::op::RawOpContent::Map(crate::container::map::MapSet { + key: key.into(), + value: Some(LoroValue::Container(container_id.clone())), + }), + EventHint::Map { + key: key.into(), + value: Some(LoroValue::Container(container_id.clone())), + }, + &inner.doc, + )?; + + // Attach + let ans = child.attach(txn, inner, container_id)?; + + // Set Parent in Arena + let child_idx = ans.idx(); + inner + .doc + .arena + .set_parent(child_idx, Some(inner.container_idx)); + + Ok(ans) + } } fn with_txn(doc: &LoroDoc, f: impl FnOnce(&mut Transaction) -> LoroResult) -> LoroResult { diff --git a/crates/loro-internal/src/state.rs b/crates/loro-internal/src/state.rs index 1b9bacf0b..29d0652c1 100644 --- a/crates/loro-internal/src/state.rs +++ b/crates/loro-internal/src/state.rs @@ -876,7 +876,7 @@ impl DocState { let roots = self.arena.root_containers(flag); let ans: loro_common::LoroMapValue = roots .into_iter() - .map(|idx| { + .filter_map(|idx| { let id = self.arena.idx_to_id(idx).unwrap(); let ContainerID::Root { name, @@ -885,7 +885,11 @@ impl DocState { else { unreachable!() }; - (name.to_string(), LoroValue::Container(id)) + // Skip mergeable containers - they should not appear at the root level + if id.is_mergeable() { + return None; + } + Some((name.to_string(), LoroValue::Container(id))) }) .collect(); LoroValue::Map(ans) @@ -905,6 +909,10 @@ impl DocState { let id = self.arena.idx_to_id(root_idx).unwrap(); match &id { loro_common::ContainerID::Root { name, .. } => { + // Skip mergeable containers - they should not appear at the root level + if id.is_mergeable() { + continue; + } let v = self.get_container_deep_value(root_idx); if (should_hide_empty_root_container || deleted_root_container.contains(&id)) && v.is_empty_collection() @@ -931,6 +939,10 @@ impl DocState { let id = self.arena.idx_to_id(root_idx).unwrap(); match id.clone() { loro_common::ContainerID::Root { name, .. } => { + // Skip mergeable containers - they should not appear at the root level + if id.is_mergeable() { + continue; + } ans.insert( name.to_string(), self.get_container_deep_value_with_id(root_idx, Some(id)), diff --git a/crates/loro-internal/tests/mergeable_container.rs b/crates/loro-internal/tests/mergeable_container.rs new file mode 100644 index 000000000..9c9eb0fe1 --- /dev/null +++ b/crates/loro-internal/tests/mergeable_container.rs @@ -0,0 +1,300 @@ +use loro_internal::handler::HandlerTrait; +use loro_internal::loro::ExportMode; +use loro_internal::LoroDoc; +use loro_internal::ToJson; + +#[test] +fn test_mergeable_list() { + let doc = LoroDoc::new(); + let map = doc.get_map("map"); + let list = map.get_mergeable_list("list").unwrap(); + list.insert(0, 1).unwrap(); + + let list2 = map.get_mergeable_list("list").unwrap(); + assert_eq!(list.id(), list2.id()); + assert_eq!( + list.get_value().to_json_value(), + list2.get_value().to_json_value() + ); +} + +#[test] +fn test_concurrent_mergeable_list() { + let doc1 = LoroDoc::new(); + doc1.set_peer_id(1).unwrap(); + let map1 = doc1.get_map("map"); + let list1 = map1.get_mergeable_list("list").unwrap(); + list1.insert(0, 1).unwrap(); + + let doc2 = LoroDoc::new(); + doc2.set_peer_id(2).unwrap(); + let map2 = doc2.get_map("map"); + let list2 = map2.get_mergeable_list("list").unwrap(); + list2.insert(0, 2).unwrap(); + + doc1.import(&doc2.export(ExportMode::snapshot()).unwrap()) + .unwrap(); + doc2.import(&doc1.export(ExportMode::snapshot()).unwrap()) + .unwrap(); + + let list1 = map1.get_mergeable_list("list").unwrap(); + let list2 = map2.get_mergeable_list("list").unwrap(); + + assert_eq!(list1.id(), list2.id()); + assert_eq!(list1.len(), 2); + assert_eq!(list2.len(), 2); + + // Check that it is indeed a mergeable container (Root ID) + assert!(list1.id().is_mergeable()); +} + +#[test] +fn test_serialization_hides_mergeable() { + let doc = LoroDoc::new(); + let map = doc.get_map("map"); + let _list = map.get_mergeable_list("list").unwrap(); + + let json = doc.get_value(); + // The mergeable container should NOT appear at the root level + // It should only appear under "map" + + // doc.get_value() returns LoroValue::Map + let map_val = json.as_map().unwrap(); + // It should contain "map" + assert!(map_val.contains_key("map")); + + // It should NOT contain the mergeable list ID as a key (because it's a Root container) + // Root containers usually appear in `doc.get_value()` if they are top-level. + + let root_keys: Vec = map_val.keys().cloned().collect(); + assert!(root_keys.contains(&"map".to_string())); + + // The mergeable list has a name like "cid:root-map:Map/list". + // We want to ensure this name is NOT in root_keys. + for key in root_keys { + assert!(!key.contains('/')); + } +} + +#[test] +fn test_mergeable_map() { + let doc = LoroDoc::new(); + let map = doc.get_map("map"); + let sub_map = map.get_mergeable_map("sub_map").unwrap(); + sub_map.insert("key", "value").unwrap(); + + let sub_map2 = map.get_mergeable_map("sub_map").unwrap(); + assert_eq!(sub_map.id(), sub_map2.id()); + assert_eq!( + sub_map.get_value().to_json_value(), + sub_map2.get_value().to_json_value() + ); +} + +#[test] +fn test_mergeable_text() { + let doc = LoroDoc::new(); + let map = doc.get_map("map"); + let text = map.get_mergeable_text("text").unwrap(); + text.insert_utf8(0, "Hello").unwrap(); + + let text2 = map.get_mergeable_text("text").unwrap(); + assert_eq!(text.id(), text2.id()); + assert_eq!(text.to_string(), text2.to_string()); +} + +#[test] +fn test_mergeable_tree() { + let doc = LoroDoc::new(); + let map = doc.get_map("map"); + let tree = map.get_mergeable_tree("tree").unwrap(); + let root = tree.create(loro_internal::TreeParentId::Root).unwrap(); + + let tree2 = map.get_mergeable_tree("tree").unwrap(); + assert_eq!(tree.id(), tree2.id()); + assert!(tree2.contains(root)); +} + +#[test] +fn test_mergeable_movable_list() { + let doc = LoroDoc::new(); + let map = doc.get_map("map"); + let list = map.get_mergeable_movable_list("list").unwrap(); + list.insert(0, 1).unwrap(); + + let list2 = map.get_mergeable_movable_list("list").unwrap(); + assert_eq!(list.id(), list2.id()); + assert_eq!( + list.get_value().to_json_value(), + list2.get_value().to_json_value() + ); +} + +#[test] +fn test_nested_mergeable_containers() { + let doc = LoroDoc::new(); + let map = doc.get_map("map"); + + // Can we have a mergeable container inside a mergeable map? + let mergeable_map = map.get_mergeable_map("mergeable_map").unwrap(); + let nested_mergeable_list = mergeable_map.get_mergeable_list("nested_list").unwrap(); + nested_mergeable_list.insert(0, "nested").unwrap(); + + let mergeable_map2 = map.get_mergeable_map("mergeable_map").unwrap(); + let nested_mergeable_list2 = mergeable_map2.get_mergeable_list("nested_list").unwrap(); + + assert_eq!(nested_mergeable_list.id(), nested_mergeable_list2.id()); + assert_eq!( + nested_mergeable_list.get_value().to_json_value(), + nested_mergeable_list2.get_value().to_json_value() + ); +} + +#[test] +fn test_deep_nested_concurrent_merge() { + let doc1 = LoroDoc::new(); + doc1.set_peer_id(1).unwrap(); + let doc2 = LoroDoc::new(); + doc2.set_peer_id(2).unwrap(); + + // Peer 1 creates: Map("root") -> MergeableMap("level1") -> MergeableMap("level2") -> MergeableList("list") -> insert "A" + { + let root = doc1.get_map("root"); + let level1 = root.get_mergeable_map("level1").unwrap(); + let level2 = level1.get_mergeable_map("level2").unwrap(); + let list = level2.get_mergeable_list("list").unwrap(); + list.insert(0, "A").unwrap(); + } + + // Peer 2 creates: Map("root") -> MergeableMap("level1") -> MergeableMap("level2") -> MergeableList("list") -> insert "B" + { + let root = doc2.get_map("root"); + let level1 = root.get_mergeable_map("level1").unwrap(); + let level2 = level1.get_mergeable_map("level2").unwrap(); + let list = level2.get_mergeable_list("list").unwrap(); + list.insert(0, "B").unwrap(); + } + + // Merge + doc1.import(&doc2.export(ExportMode::snapshot()).unwrap()) + .unwrap(); + doc2.import(&doc1.export(ExportMode::snapshot()).unwrap()) + .unwrap(); + + // Verify + let root = doc1.get_map("root"); + let level1 = root.get_mergeable_map("level1").unwrap(); + let level2 = level1.get_mergeable_map("level2").unwrap(); + let list = level2.get_mergeable_list("list").unwrap(); + + assert_eq!(list.len(), 2); + // Order depends on Lamport/PeerID, but both should be there. + let val = list.get_value().to_json_value(); + let arr = val.as_array().unwrap(); + let items: Vec<&str> = arr.iter().map(|v| v.as_str().unwrap()).collect(); + assert!(items.contains(&"A")); + assert!(items.contains(&"B")); +} + +#[test] +fn test_mixed_nested_concurrent_merge() { + let doc1 = LoroDoc::new(); + doc1.set_peer_id(1).unwrap(); + let doc2 = LoroDoc::new(); + doc2.set_peer_id(2).unwrap(); + + // Path: root -> map -> movable_list -> insert text + // Peer 1 + { + let root = doc1.get_map("root"); + let map = root.get_mergeable_map("map").unwrap(); + let list = map.get_mergeable_movable_list("list").unwrap(); + list.insert(0, "A").unwrap(); + } + + // Peer 2 + { + let root = doc2.get_map("root"); + let map = root.get_mergeable_map("map").unwrap(); + let list = map.get_mergeable_movable_list("list").unwrap(); + list.insert(0, "B").unwrap(); + } + + // Merge + doc1.import(&doc2.export(ExportMode::snapshot()).unwrap()) + .unwrap(); + doc2.import(&doc1.export(ExportMode::snapshot()).unwrap()) + .unwrap(); + + // Verify + let root = doc1.get_map("root"); + let map = root.get_mergeable_map("map").unwrap(); + let list = map.get_mergeable_movable_list("list").unwrap(); + + assert_eq!(list.len(), 2); +} + +#[test] +#[cfg(feature = "counter")] +fn test_mergeable_counter() { + let doc = LoroDoc::new(); + let map = doc.get_map("map"); + let counter = map.get_mergeable_counter("counter").unwrap(); + counter.increment(1.0).unwrap(); + + let counter2 = map.get_mergeable_counter("counter").unwrap(); + assert_eq!(counter.id(), counter2.id()); + assert_eq!( + counter.get_value().to_json_value(), + counter2.get_value().to_json_value() + ); + assert_eq!(counter.get_value().to_json_value(), serde_json::json!(1.0)); +} + +#[test] +#[cfg(feature = "counter")] +fn test_nested_mergeable_counter_concurrent() { + let doc1 = LoroDoc::new(); + doc1.set_peer_id(1).unwrap(); + let doc2 = LoroDoc::new(); + doc2.set_peer_id(2).unwrap(); + + // Map -> MergeableMap -> MergeableCounter + { + let root = doc1.get_map("root"); + let map = root.get_mergeable_map("nested").unwrap(); + let counter = map.get_mergeable_counter("counter").unwrap(); + counter.increment(10.0).unwrap(); + } + + { + let root = doc2.get_map("root"); + let map = root.get_mergeable_map("nested").unwrap(); + let counter = map.get_mergeable_counter("counter").unwrap(); + counter.increment(20.0).unwrap(); + } + + doc1.import(&doc2.export(ExportMode::snapshot()).unwrap()) + .unwrap(); + doc2.import(&doc1.export(ExportMode::snapshot()).unwrap()) + .unwrap(); + + let root = doc1.get_map("root"); + let map = root.get_mergeable_map("nested").unwrap(); + let counter = map.get_mergeable_counter("counter").unwrap(); + + assert_eq!(counter.get_value().to_json_value(), serde_json::json!(30.0)); +} + +#[test] +fn test_mergeable_container_path() { + let doc = LoroDoc::new(); + let map = doc.get_map("map"); + let list = map.get_mergeable_list("list").unwrap(); + + let path = doc.get_path_to_container(&list.id()).unwrap(); + // Path should be [map, list] + assert_eq!(path.len(), 2); + assert_eq!(path[0].1, loro_internal::event::Index::Key("map".into())); + assert_eq!(path[1].1, loro_internal::event::Index::Key("list".into())); +} diff --git a/crates/loro-wasm/src/lib.rs b/crates/loro-wasm/src/lib.rs index c0421cc93..d2f558cdf 100644 --- a/crates/loro-wasm/src/lib.rs +++ b/crates/loro-wasm/src/lib.rs @@ -1519,7 +1519,8 @@ impl LoroDoc { let json = if IN_PRE_COMMIT_CALLBACK.with(|f| f.get()) { self.doc.export_json_in_id_span(id_span) } else { - self.doc.with_barrier(|| self.doc.export_json_in_id_span(id_span)) + self.doc + .with_barrier(|| self.doc.export_json_in_id_span(id_span)) }; let s = serde_wasm_bindgen::Serializer::new().serialize_maps_as_objects(true); let v = json @@ -3422,6 +3423,78 @@ impl LoroMap { let v: JsValue = self.handler.get_value().into(); v.into() } + + /// Get a mergeable LoroList by key. + /// + /// If the container does not exist, it will be created. + /// + /// Mergeable containers are special containers that can be merged with other containers + /// created with the same key by other peers. + #[wasm_bindgen(js_name = "getMergeableList", skip_typescript)] + pub fn get_mergeable_list(&self, key: &str) -> JsResult { + let list = self.handler.get_mergeable_list(key)?; + Ok(LoroList { handler: list }) + } + + /// Get a mergeable LoroMap by key. + /// + /// If the container does not exist, it will be created. + /// + /// Mergeable containers are special containers that can be merged with other containers + /// created with the same key by other peers. + #[wasm_bindgen(js_name = "getMergeableMap", skip_typescript)] + pub fn get_mergeable_map(&self, key: &str) -> JsResult { + let map = self.handler.get_mergeable_map(key)?; + Ok(LoroMap { handler: map }) + } + + /// Get a mergeable LoroMovableList by key. + /// + /// If the container does not exist, it will be created. + /// + /// Mergeable containers are special containers that can be merged with other containers + /// created with the same key by other peers. + #[wasm_bindgen(js_name = "getMergeableMovableList", skip_typescript)] + pub fn get_mergeable_movable_list(&self, key: &str) -> JsResult { + let list = self.handler.get_mergeable_movable_list(key)?; + Ok(LoroMovableList { handler: list }) + } + + /// Get a mergeable LoroText by key. + /// + /// If the container does not exist, it will be created. + /// + /// Mergeable containers are special containers that can be merged with other containers + /// created with the same key by other peers. + #[wasm_bindgen(js_name = "getMergeableText", skip_typescript)] + pub fn get_mergeable_text(&self, key: &str) -> JsResult { + let text = self.handler.get_mergeable_text(key)?; + Ok(LoroText { handler: text }) + } + + /// Get a mergeable LoroTree by key. + /// + /// If the container does not exist, it will be created. + /// + /// Mergeable containers are special containers that can be merged with other containers + /// created with the same key by other peers. + #[wasm_bindgen(js_name = "getMergeableTree", skip_typescript)] + pub fn get_mergeable_tree(&self, key: &str) -> JsResult { + let tree = self.handler.get_mergeable_tree(key)?; + Ok(LoroTree { handler: tree }) + } + + /// Get a mergeable LoroCounter by key. + /// + /// If the container does not exist, it will be created. + /// + /// Mergeable containers are special containers that can be merged with other containers + /// created with the same key by other peers. + #[wasm_bindgen(js_name = "getMergeableCounter", skip_typescript)] + pub fn get_mergeable_counter(&self, key: &str) -> JsResult { + let counter = self.handler.get_mergeable_counter(key)?; + Ok(LoroCounter { handler: counter }) + } } impl Default for LoroMap { @@ -6921,6 +6994,12 @@ interface LoroMap = Record> { set(key: Key, value: Exclude): void; delete(key: string): void; subscribe(listener: Listener): Subscription; + getMergeableList(key: string): LoroList; + getMergeableMap(key: string): LoroMap; + getMergeableMovableList(key: string): LoroMovableList; + getMergeableText(key: string): LoroText; + getMergeableTree(key: string): LoroTree; + getMergeableCounter(key: string): LoroCounter; } interface LoroText { new(): LoroText; diff --git a/crates/loro-wasm/tests/mergeable.test.ts b/crates/loro-wasm/tests/mergeable.test.ts new file mode 100644 index 000000000..517f3873c --- /dev/null +++ b/crates/loro-wasm/tests/mergeable.test.ts @@ -0,0 +1,145 @@ +import { describe, expect, it } from "vitest"; +import { LoroDoc } from "../"; + +describe("Mergeable Container", () => { + it("should merge concurrent lists", () => { + const doc1 = new LoroDoc(); + doc1.setPeerId(1); + const doc2 = new LoroDoc(); + doc2.setPeerId(2); + + const map1 = doc1.getMap("map"); + const list1 = map1.getMergeableList("list"); + list1.insert(0, 1); + + const map2 = doc2.getMap("map"); + const list2 = map2.getMergeableList("list"); + list2.insert(0, 2); + + doc1.import(doc2.export({ mode: "snapshot" })); + doc2.import(doc1.export({ mode: "snapshot" })); + + const list1Merged = map1.getMergeableList("list"); + const list2Merged = map2.getMergeableList("list"); + + expect(list1Merged.id).toBe(list2Merged.id); + expect(list1Merged.length).toBe(2); + expect(list2Merged.length).toBe(2); + expect(list1Merged.toJSON()).toEqual(expect.arrayContaining([1, 2])); + }); + + it("should support deep nesting", () => { + const doc1 = new LoroDoc(); + doc1.setPeerId(1); + const doc2 = new LoroDoc(); + doc2.setPeerId(2); + + // Map -> MergeableMap -> MergeableMap -> MergeableList + { + const root = doc1.getMap("root"); + const level1 = root.getMergeableMap("level1"); + const level2 = level1.getMergeableMap("level2"); + const list = level2.getMergeableList("list"); + list.insert(0, "A"); + } + + { + const root = doc2.getMap("root"); + const level1 = root.getMergeableMap("level1"); + const level2 = level1.getMergeableMap("level2"); + const list = level2.getMergeableList("list"); + list.insert(0, "B"); + } + + doc1.import(doc2.export({ mode: "snapshot" })); + doc2.import(doc1.export({ mode: "snapshot" })); + + const root = doc1.getMap("root"); + const level1 = root.getMergeableMap("level1"); + const level2 = level1.getMergeableMap("level2"); + const list = level2.getMergeableList("list"); + + expect(list.length).toBe(2); + expect(list.toJSON()).toEqual(expect.arrayContaining(["A", "B"])); + }); + + it("should hide mergeable containers from root", () => { + const doc = new LoroDoc(); + const map = doc.getMap("map"); + map.getMergeableList("list"); + + const json = doc.toJSON() as any; + // Should contain "map" + expect(json.map).toBeDefined(); + + // Should NOT contain the mergeable list ID as a key in the root map + // The mergeable list ID contains "/", so we can check if any key contains "/" + const keys = Object.keys(json); + for (const key of keys) { + expect(key).not.toContain("/"); + } + }); + + it("should support all types", () => { + const doc = new LoroDoc(); + const map = doc.getMap("map"); + + const mList = map.getMergeableList("list"); + mList.insert(0, 1); + + const mMap = map.getMergeableMap("map"); + mMap.set("key", "value"); + + const mText = map.getMergeableText("text"); + mText.insert(0, "hello"); + + const mTree = map.getMergeableTree("tree"); + mTree.createNode(); + + const mMovableList = map.getMergeableMovableList("movableList"); + mMovableList.insert(0, 1); + + const mCounter = map.getMergeableCounter("counter"); + mCounter.increment(10); + + const json = map.toJSON() as any; + expect(json.list).toEqual([1]); + expect(json.map).toEqual({ key: "value" }); + expect(json.text).toBe("hello"); + expect(json.tree).toHaveLength(1); + expect(json.movableList).toEqual([1]); + expect(json.counter).toBe(10); + }); + + it("should not have malformed container IDs with repeated cid:root- prefix", () => { + // This test verifies the fix for the bug where nested mergeable containers + // would have malformed IDs like "cid:root-cid:root-cid:root-game:Map/players:Map/alice:Map" + // instead of clean IDs like "cid:root-game/players/alice:Map" + const doc = new LoroDoc(); + + // Create a nested structure: root map -> mergeable map -> mergeable map + const game = doc.getMap("game"); + const players = game.getMergeableMap("players"); + const alice = players.getMergeableMap("alice"); + + // Get the container IDs + const gameId = game.id; + const playersId = players.id; + const aliceId = alice.id; + + // The game container should be a normal root container + expect(gameId).toBe("cid:root-game:Map"); + + // The players container should have a clean ID without repeated "cid:root-" prefix + // Expected: "cid:root-game/players:Map" + // Bug produces: "cid:root-cid:root-game:Map/players:Map" + expect(playersId).not.toContain("cid:root-cid:root-"); + expect(playersId).toBe("cid:root-game/players:Map"); + + // The alice container should also have a clean ID + // Expected: "cid:root-game/players/alice:Map" + // Bug produces: "cid:root-cid:root-cid:root-game:Map/players:Map/alice:Map" + expect(aliceId).not.toContain("cid:root-cid:root-"); + expect(aliceId).toBe("cid:root-game/players/alice:Map"); + }); +});