From 8410d15a5fb24ff2b3e56d47ac99583db02a6c1a Mon Sep 17 00:00:00 2001 From: Zixuan Chen Date: Fri, 4 Sep 2026 00:38:23 +0800 Subject: [PATCH] feat(wasm): per-container and range deep reads with container ids - Fix getDeepValueWithID() cid field: emit the bare ContainerID string (the same as container.id) instead of the Debug-style 'idx:N, id:cid:...' composite. Potentially breaking for consumers that parsed the old form; restores parity with the pure-TS runtime. - Add getDeepValueWithID() on LoroMap/LoroList/LoroMovableList/LoroTree ({ cid, value } node) and LoroText ({ cid, value: string }). - Add getRangeDeepValueWithID(start, end) and getRangeValue(start, end) on LoroList/LoroMovableList for one-call deep reads of a slice; bounds are clamped, empty or inverted ranges return []. - Detached containers return a readable error instead of trapping. MovableListHandler::get_deep_value_with_id now returns LoroResult instead of unwrapping on detached handlers. Wasm size (dev build, with debug info): +158 KB (+0.16%). --- .changeset/wasm-container-bulk-read.md | 7 + crates/loro-internal/src/handler.rs | 236 +++++++++++++++- crates/loro-internal/src/handler/tree.rs | 10 + crates/loro-internal/src/state.rs | 53 +++- crates/loro-wasm/src/lib.rs | 322 ++++++++++++++++++++++ crates/loro-wasm/tests/deep_value.test.ts | 200 ++++++++++++++ 6 files changed, 820 insertions(+), 8 deletions(-) create mode 100644 .changeset/wasm-container-bulk-read.md create mode 100644 crates/loro-wasm/tests/deep_value.test.ts diff --git a/.changeset/wasm-container-bulk-read.md b/.changeset/wasm-container-bulk-read.md new file mode 100644 index 000000000..7323ec563 --- /dev/null +++ b/.changeset/wasm-container-bulk-read.md @@ -0,0 +1,7 @@ +--- +"loro-crdt": minor +--- + +Add bulk deep-read APIs on containers. `LoroMap`/`LoroList`/`LoroMovableList`/`LoroTree`/`LoroText` now expose `getDeepValueWithID()` returning the same `{ cid, value }` node shape as `LoroDoc.getDeepValueWithID()`. `LoroList` and `LoroMovableList` also expose `getRangeDeepValueWithID(start, end)` and `getRangeValue(start, end)` for reading a slice of a list in one WASM call; bounds are clamped (negatives to 0, overflows to the length) and empty or inverted ranges return `[]`. Detached containers throw a readable error instead of trapping. + +Potentially breaking: the `cid` field in `getDeepValueWithID()` results is now the bare container id string (e.g. `cid:92@2311024965712536503:Map`, `cid:root-map:Map`) — exactly what the container's `id` property returns. Previously it was a Debug-style composite like `idx:85, id:cid:92@2311024965712536503:Map`. Update any consumer that parsed or matched the old `idx:N, id:...` shape. diff --git a/crates/loro-internal/src/handler.rs b/crates/loro-internal/src/handler.rs index f8590b116..402b3d366 100644 --- a/crates/loro-internal/src/handler.rs +++ b/crates/loro-internal/src/handler.rs @@ -2689,6 +2689,15 @@ impl TextHandler { } } + /// Get the deep value of the text with its container id, as a + /// `{ cid, value }` map where `value` is the text content. + pub fn get_deep_value_with_id(&self) -> LoroResult { + let inner = self.inner.try_attached_state()?; + Ok(inner.with_doc_state(|state| { + state.get_container_deep_value_with_id(inner.container_idx, None) + })) + } + pub fn get_cursor(&self, event_index: usize, side: Side) -> Option { self.get_cursor_internal(event_index, side, true) } @@ -3359,6 +3368,30 @@ impl ListHandler { })) } + /// Get the deep value of the elements in the range `[start, end)`. + /// + /// Child containers in the range are recursively resolved to `{ cid, value }` + /// nodes. Out-of-range bounds are clamped to the list length; an empty or + /// inverted range returns an empty list. + pub fn get_slice_deep_value_with_id(&self, start: usize, end: usize) -> LoroResult { + let inner = self.inner.try_attached_state()?; + Ok(inner.with_doc_state(|state| { + state.get_list_range_deep_value(inner.container_idx, start, end, true) + })) + } + + /// Get the deep value of the elements in the range `[start, end)`. + /// + /// Child containers in the range are recursively resolved to their deep value. + /// Out-of-range bounds are clamped to the list length; an empty or inverted + /// range returns an empty list. + pub fn get_slice_deep_value(&self, start: usize, end: usize) -> LoroResult { + let inner = self.inner.try_attached_state()?; + Ok(inner.with_doc_state(|state| { + state.get_list_range_deep_value(inner.container_idx, start, end, false) + })) + } + pub fn get(&self, index: usize) -> Option { match &self.inner { MaybeDetached::Detached(l) => l.lock().value.get(index).map(|x| x.to_value()), @@ -4039,13 +4072,35 @@ impl MovableListHandler { self.len() == 0 } - pub fn get_deep_value_with_id(&self) -> LoroValue { - let inner = self.inner.try_attached_state().unwrap(); - inner - .doc - .state - .lock() - .get_container_deep_value_with_id(inner.container_idx, None) + pub fn get_deep_value_with_id(&self) -> LoroResult { + let inner = self.inner.try_attached_state()?; + Ok(inner.with_doc_state(|state| { + state.get_container_deep_value_with_id(inner.container_idx, None) + })) + } + + /// Get the deep value of the elements in the range `[start, end)`. + /// + /// Child containers in the range are recursively resolved to `{ cid, value }` + /// nodes. Out-of-range bounds are clamped to the list length; an empty or + /// inverted range returns an empty list. + pub fn get_slice_deep_value_with_id(&self, start: usize, end: usize) -> LoroResult { + let inner = self.inner.try_attached_state()?; + Ok(inner.with_doc_state(|state| { + state.get_list_range_deep_value(inner.container_idx, start, end, true) + })) + } + + /// Get the deep value of the elements in the range `[start, end)`. + /// + /// Child containers in the range are recursively resolved to their deep value. + /// Out-of-range bounds are clamped to the list length; an empty or inverted + /// range returns an empty list. + pub fn get_slice_deep_value(&self, start: usize, end: usize) -> LoroResult { + let inner = self.inner.try_attached_state()?; + Ok(inner.with_doc_state(|state| { + state.get_list_range_deep_value(inner.container_idx, start, end, false) + })) } pub fn get(&self, index: usize) -> Option { @@ -5733,4 +5788,171 @@ mod test { assert!(unknown.get_attached().is_some()); assert!(super::UnknownHandler::from_handler(handler).is_some()); } + + #[test] + fn deep_value_with_id_cid_matches_container_id_string() { + let loro = LoroDoc::new_auto_commit(); + let map = loro.get_map("map"); + map.insert("key", "value").unwrap(); + let child = map + .insert_container("child", ListHandler::new_detached()) + .unwrap(); + child.push("item").unwrap(); + + let value = loro.get_deep_value_with_id(); + assert_eq!( + value["map"]["cid"].to_json_value(), + json!(map.id().to_string()) + ); + assert_eq!( + value["map"]["cid"].to_json_value(), + json!("cid:root-map:Map") + ); + let child_cid = value["map"]["value"]["child"]["cid"].to_json_value(); + assert_eq!(child_cid, json!(child.id().to_string())); + let child_cid = child_cid.as_str().unwrap(); + assert!(child_cid.starts_with("cid:"), "unexpected cid: {child_cid}"); + assert!(!child_cid.contains("idx:"), "unexpected cid: {child_cid}"); + + // The per-container handler API emits the same node shape + let map_value = map.get_deep_value_with_id().unwrap(); + assert_eq!( + map_value["cid"].to_json_value(), + json!(map.id().to_string()) + ); + assert_eq!( + map_value["value"]["child"]["cid"].to_json_value(), + json!(child.id().to_string()) + ); + assert_eq!( + map_value["value"]["child"]["value"][0].to_json_value(), + json!("item") + ); + } + + #[test] + fn text_and_tree_deep_value_with_id() { + let loro = LoroDoc::new_auto_commit(); + let text = loro.get_text("text"); + text.insert(0, "hello", PosType::Unicode).unwrap(); + let text_value = text.get_deep_value_with_id().unwrap(); + assert_eq!( + text_value.to_json_value(), + json!({"cid": text.id().to_string(), "value": "hello"}) + ); + + let tree = loro.get_tree("tree"); + let node = tree.create(TreeParentId::Root).unwrap(); + tree.get_meta(node).unwrap().insert("name", "root").unwrap(); + let tree_value = tree.get_deep_value_with_id().unwrap(); + assert_eq!( + tree_value["cid"].to_json_value(), + json!(tree.id().to_string()) + ); + assert_eq!( + tree_value["value"][0]["meta"]["name"].to_json_value(), + json!("root") + ); + + // Detached containers must error rather than panic + let detached_text = TextHandler::new_detached(); + assert!(matches!( + detached_text.get_deep_value_with_id(), + Err(LoroError::MisuseDetachedContainer { .. }) + )); + let detached_tree = crate::handler::TreeHandler::new_detached(); + assert!(matches!( + detached_tree.get_deep_value_with_id(), + Err(LoroError::MisuseDetachedContainer { .. }) + )); + } + + #[test] + fn list_slice_deep_value() { + let loro = LoroDoc::new_auto_commit(); + let list = loro.get_list("list"); + list.push("a").unwrap(); + list.push("b").unwrap(); + let child = list.push_container(MapHandler::new_detached()).unwrap(); + child.insert("k", "v").unwrap(); + list.push("d").unwrap(); + + // Full range with ids: containers become { cid, value } nodes + let all = list.get_slice_deep_value_with_id(0, 4).unwrap(); + assert_eq!(all[0].to_json_value(), json!("a")); + assert_eq!( + all[2].to_json_value(), + json!({"cid": child.id().to_string(), "value": {"k": "v"}}) + ); + + // Sub-range without ids: containers become their plain deep value + let mid = list.get_slice_deep_value(1, 3).unwrap(); + assert_eq!(mid.to_json_value(), json!(["b", {"k": "v"}])); + + // Bounds are clamped to the list length + let clamped = list.get_slice_deep_value_with_id(2, 100).unwrap(); + assert_eq!(clamped.to_json_value().as_array().unwrap().len(), 2); + let clamped_start = list.get_slice_deep_value_with_id(100, 200).unwrap(); + assert_eq!(clamped_start.to_json_value(), json!([])); + + // Empty and inverted ranges return an empty list + assert_eq!( + list.get_slice_deep_value(2, 2).unwrap().to_json_value(), + json!([]) + ); + assert_eq!( + list.get_slice_deep_value(3, 1).unwrap().to_json_value(), + json!([]) + ); + + // Detached lists must error rather than panic + let detached = ListHandler::new_detached(); + assert!(matches!( + detached.get_slice_deep_value_with_id(0, 1), + Err(LoroError::MisuseDetachedContainer { .. }) + )); + assert!(matches!( + detached.get_slice_deep_value(0, 1), + Err(LoroError::MisuseDetachedContainer { .. }) + )); + } + + #[test] + fn movable_list_slice_deep_value() { + let loro = LoroDoc::new_auto_commit(); + let list = loro.get_movable_list("list"); + list.push("a".into()).unwrap(); + let child = list.push_container(ListHandler::new_detached()).unwrap(); + child.push("x").unwrap(); + list.push("b".into()).unwrap(); + + let all = list.get_slice_deep_value_with_id(0, 3).unwrap(); + assert_eq!( + all[1].to_json_value(), + json!({"cid": child.id().to_string(), "value": ["x"]}) + ); + + let plain = list.get_slice_deep_value(0, 2).unwrap(); + assert_eq!(plain.to_json_value(), json!(["a", ["x"]])); + + // Whole-container deep value with id errors on detached containers + let attached = list.get_deep_value_with_id().unwrap(); + assert_eq!( + attached["cid"].to_json_value(), + json!(list.id().to_string()) + ); + let detached = MovableListHandler::new_detached(); + assert!(matches!( + detached.get_deep_value_with_id(), + Err(LoroError::MisuseDetachedContainer { .. }) + )); + assert!(matches!( + detached.get_slice_deep_value_with_id(0, 1), + Err(LoroError::MisuseDetachedContainer { .. }) + )); + assert!(matches!( + detached.get_slice_deep_value(0, 1), + Err(LoroError::MisuseDetachedContainer { .. }) + )); + } } diff --git a/crates/loro-internal/src/handler/tree.rs b/crates/loro-internal/src/handler/tree.rs index ca2853bf6..52fad6a31 100644 --- a/crates/loro-internal/src/handler/tree.rs +++ b/crates/loro-internal/src/handler/tree.rs @@ -353,6 +353,16 @@ impl TreeHandler { } } + /// Get the deep value of the tree with its container id, as a + /// `{ cid, value }` map. Each node in `value` carries the deep value of its + /// associated meta map under the `meta` field. + pub fn get_deep_value_with_id(&self) -> LoroResult { + let inner = self.inner.try_attached_state()?; + Ok(inner.with_doc_state(|state| { + state.get_container_deep_value_with_id(inner.container_idx, None) + })) + } + pub fn delete(&self, target: TreeID) -> LoroResult<()> { match &self.inner { MaybeDetached::Detached(t) => { diff --git a/crates/loro-internal/src/state.rs b/crates/loro-internal/src/state.rs index ae5f5616e..e0b04ba3f 100644 --- a/crates/loro-internal/src/state.rs +++ b/crates/loro-internal/src/state.rs @@ -1469,7 +1469,10 @@ impl DocState { let Some(value) = self.store.get_value_ephemeral(container) else { return container.get_type().default_value(); }; - let cid_str = LoroValue::String(format!("idx:{}, id:{}", container.to_index(), id).into()); + // The `cid` is the container id's Display string, the same form that the + // JS `container.id` getter returns (e.g. `cid:root-map:Map`, + // `cid:92@2311024965712536503:Map`). + let cid_str = LoroValue::String(id.to_string().into()); match value { LoroValue::Container(_) => unreachable!(), LoroValue::List(mut list) => { @@ -1621,6 +1624,54 @@ impl DocState { } } + /// Get the deep value of a list/movable-list container's elements in the range + /// `[start, end)`. + /// + /// `start`/`end` are clamped to the list length; an empty or inverted range + /// returns an empty list. Container items inside the range are replaced by + /// their deep value: when `with_id` is true they become `{ cid, value }` nodes + /// (via [`Self::get_container_deep_value_with_id`]), otherwise their plain deep + /// value (via [`Self::get_container_deep_value`]). + pub(crate) fn get_list_range_deep_value( + &mut self, + container: ContainerIdx, + start: usize, + end: usize, + with_id: bool, + ) -> LoroValue { + let Some(value) = self.store.get_value_ephemeral(container) else { + return container.get_type().default_value(); + }; + let LoroValue::List(list) = value else { + return container.get_type().default_value(); + }; + + let len = list.len(); + let start = start.min(len); + let end = end.min(len); + if start >= end { + return LoroValue::List(Default::default()); + } + + let mut ans = Vec::with_capacity(end - start); + for item in list.iter().skip(start).take(end - start) { + if item.is_container() { + let cid = item.as_container().unwrap(); + let container_idx = self.arena.register_container(cid); + let value = if with_id { + self.get_container_deep_value_with_id(container_idx, Some(cid.clone())) + } else { + self.get_container_deep_value(container_idx) + }; + ans.push(value); + } else { + ans.push(item.clone()); + } + } + + LoroValue::List(ans.into()) + } + pub(crate) fn get_all_alive_containers(&mut self) -> LoroResult> { Ok(self .get_all_alive_container_indices()? diff --git a/crates/loro-wasm/src/lib.rs b/crates/loro-wasm/src/lib.rs index 2bd9edca5..65adddbc8 100644 --- a/crates/loro-wasm/src/lib.rs +++ b/crates/loro-wasm/src/lib.rs @@ -3232,6 +3232,27 @@ impl LoroText { pub fn to_json(&self) -> JsValue { self.handler.get_value().into() } + + /// Get the deep value of the text with its container id. + /// + /// The result is a `{ cid, value }` object, where `cid` is the container id + /// string (the same as `text.id`) and `value` is the text content. This is + /// the same node shape that `LoroDoc.getDeepValueWithID()` emits for every + /// container. + /// + /// @example + /// ```ts + /// import { LoroDoc } from "loro-crdt"; + /// + /// const doc = new LoroDoc(); + /// const text = doc.getText("text"); + /// text.insert(0, "Hello"); + /// console.log(text.getDeepValueWithID()); // { cid: "cid:root-text:Text", value: "Hello" } + /// ``` + #[wasm_bindgen(js_name = "getDeepValueWithID", skip_typescript)] + pub fn get_deep_value_with_id(&self) -> JsResult { + Ok(self.handler.get_deep_value_with_id()?.into()) + } } impl Default for LoroText { @@ -3495,6 +3516,32 @@ impl LoroMap { self.handler.get_deep_value().into() } + /// Get the deep value of the map with its container id. + /// + /// The result is a `{ cid, value }` object, where `cid` is the container id + /// string (the same as `map.id`) and `value` is the deep value of the map. + /// Child containers inside `value` are recursively replaced by their own + /// `{ cid, value }` nodes. This is the same node shape that + /// `LoroDoc.getDeepValueWithID()` emits for every container. + /// + /// @example + /// ```ts + /// import { LoroDoc, LoroText } from "loro-crdt"; + /// + /// const doc = new LoroDoc(); + /// const map = doc.getMap("map"); + /// map.set("foo", "bar"); + /// const text = map.setContainer("text", new LoroText()); + /// text.insert(0, "Hello"); + /// console.log(map.getDeepValueWithID()); + /// // { cid: "cid:root-map:Map", + /// // value: { foo: "bar", text: { cid: "cid:0@1:Text", value: "Hello" } } } + /// ``` + #[wasm_bindgen(js_name = "getDeepValueWithID", skip_typescript)] + pub fn get_deep_value_with_id(&self) -> JsResult { + Ok(self.handler.get_deep_value_with_id()?.into()) + } + /// Set the key with a regular child container. /// /// The inserted child receives a regular op-created container id. Use this @@ -3900,6 +3947,85 @@ impl LoroList { value.into() } + /// Get the deep value of the list with its container id. + /// + /// The result is a `{ cid, value }` object, where `cid` is the container id + /// string (the same as `list.id`) and `value` is the deep value of the + /// list. Child containers inside `value` are recursively replaced by their + /// own `{ cid, value }` nodes. This is the same node shape that + /// `LoroDoc.getDeepValueWithID()` emits for every container. + /// + /// @example + /// ```ts + /// import { LoroDoc, LoroText } from "loro-crdt"; + /// + /// const doc = new LoroDoc(); + /// const list = doc.getList("list"); + /// list.insert(0, 100); + /// const text = list.insertContainer(1, new LoroText()); + /// text.insert(0, "Hello"); + /// console.log(list.getDeepValueWithID()); + /// // { cid: "cid:root-list:List", + /// // value: [100, { cid: "cid:0@1:Text", value: "Hello" }] } + /// ``` + #[wasm_bindgen(js_name = "getDeepValueWithID", skip_typescript)] + pub fn get_deep_value_with_id(&self) -> JsResult { + Ok(self.handler.get_deep_value_with_id()?.into()) + } + + /// Get the deep value of the elements in the range `[start, end)`, with + /// container ids. + /// + /// Each child container in the range is recursively replaced by its own + /// `{ cid, value }` node. Negative bounds are clamped to 0 and + /// out-of-range bounds are clamped to the list length; an empty or + /// inverted range returns `[]`. + /// + /// @example + /// ```ts + /// import { LoroDoc, LoroText } from "loro-crdt"; + /// + /// const doc = new LoroDoc(); + /// const list = doc.getList("list"); + /// list.insert(0, 100); + /// const text = list.insertContainer(1, new LoroText()); + /// text.insert(0, "Hello"); + /// console.log(list.getRangeDeepValueWithID(0, 2)); + /// // [100, { cid: "cid:0@1:Text", value: "Hello" }] + /// ``` + #[wasm_bindgen(js_name = "getRangeDeepValueWithID", skip_typescript)] + pub fn get_range_deep_value_with_id(&self, start: isize, end: isize) -> JsResult { + let value = self + .handler + .get_slice_deep_value_with_id(start.max(0) as usize, end.max(0) as usize)?; + Ok(value.into()) + } + + /// Get the deep value of the elements in the range `[start, end)`. + /// + /// Each child container in the range is recursively resolved to its deep + /// value. Negative bounds are clamped to 0 and out-of-range bounds are + /// clamped to the list length; an empty or inverted range returns `[]`. + /// + /// @example + /// ```ts + /// import { LoroDoc, LoroText } from "loro-crdt"; + /// + /// const doc = new LoroDoc(); + /// const list = doc.getList("list"); + /// list.insert(0, 100); + /// const text = list.insertContainer(1, new LoroText()); + /// text.insert(0, "Hello"); + /// console.log(list.getRangeValue(0, 2)); // [100, "Hello"] + /// ``` + #[wasm_bindgen(js_name = "getRangeValue", skip_typescript)] + pub fn get_range_value(&self, start: isize, end: isize) -> JsResult { + let value = self + .handler + .get_slice_deep_value(start.max(0) as usize, end.max(0) as usize)?; + Ok(value.into()) + } + /// Insert a container at the index. /// /// @example @@ -4263,6 +4389,85 @@ impl LoroMovableList { value.into() } + /// Get the deep value of the movable list with its container id. + /// + /// The result is a `{ cid, value }` object, where `cid` is the container id + /// string (the same as `list.id`) and `value` is the deep value of the + /// list. Child containers inside `value` are recursively replaced by their + /// own `{ cid, value }` nodes. This is the same node shape that + /// `LoroDoc.getDeepValueWithID()` emits for every container. + /// + /// @example + /// ```ts + /// import { LoroDoc, LoroText } from "loro-crdt"; + /// + /// const doc = new LoroDoc(); + /// const list = doc.getMovableList("list"); + /// list.insert(0, 100); + /// const text = list.insertContainer(1, new LoroText()); + /// text.insert(0, "Hello"); + /// console.log(list.getDeepValueWithID()); + /// // { cid: "cid:root-list:MovableList", + /// // value: [100, { cid: "cid:0@1:Text", value: "Hello" }] } + /// ``` + #[wasm_bindgen(js_name = "getDeepValueWithID", skip_typescript)] + pub fn get_deep_value_with_id(&self) -> JsResult { + Ok(self.handler.get_deep_value_with_id()?.into()) + } + + /// Get the deep value of the elements in the range `[start, end)`, with + /// container ids. + /// + /// Each child container in the range is recursively replaced by its own + /// `{ cid, value }` node. Negative bounds are clamped to 0 and + /// out-of-range bounds are clamped to the list length; an empty or + /// inverted range returns `[]`. + /// + /// @example + /// ```ts + /// import { LoroDoc, LoroText } from "loro-crdt"; + /// + /// const doc = new LoroDoc(); + /// const list = doc.getMovableList("list"); + /// list.insert(0, 100); + /// const text = list.insertContainer(1, new LoroText()); + /// text.insert(0, "Hello"); + /// console.log(list.getRangeDeepValueWithID(0, 2)); + /// // [100, { cid: "cid:0@1:Text", value: "Hello" }] + /// ``` + #[wasm_bindgen(js_name = "getRangeDeepValueWithID", skip_typescript)] + pub fn get_range_deep_value_with_id(&self, start: isize, end: isize) -> JsResult { + let value = self + .handler + .get_slice_deep_value_with_id(start.max(0) as usize, end.max(0) as usize)?; + Ok(value.into()) + } + + /// Get the deep value of the elements in the range `[start, end)`. + /// + /// Each child container in the range is recursively resolved to its deep + /// value. Negative bounds are clamped to 0 and out-of-range bounds are + /// clamped to the list length; an empty or inverted range returns `[]`. + /// + /// @example + /// ```ts + /// import { LoroDoc, LoroText } from "loro-crdt"; + /// + /// const doc = new LoroDoc(); + /// const list = doc.getMovableList("list"); + /// list.insert(0, 100); + /// const text = list.insertContainer(1, new LoroText()); + /// text.insert(0, "Hello"); + /// console.log(list.getRangeValue(0, 2)); // [100, "Hello"] + /// ``` + #[wasm_bindgen(js_name = "getRangeValue", skip_typescript)] + pub fn get_range_value(&self, start: isize, end: isize) -> JsResult { + let value = self + .handler + .get_slice_deep_value(start.max(0) as usize, end.max(0) as usize)?; + Ok(value.into()) + } + /// Insert a container at the index. /// /// @example @@ -5008,6 +5213,31 @@ impl LoroTree { self.handler.get_deep_value().into() } + /// Get the deep value of the tree with its container id. + /// + /// The result is a `{ cid, value }` object, where `cid` is the container id + /// string (the same as `tree.id`) and `value` is the deep value of the + /// tree (each node's `meta` is the deep value of its associated metadata + /// map). This is the same node shape that `LoroDoc.getDeepValueWithID()` + /// emits for every container. + /// + /// @example + /// ```ts + /// import { LoroDoc } from "loro-crdt"; + /// + /// const doc = new LoroDoc(); + /// const tree = doc.getTree("tree"); + /// const root = tree.createNode(); + /// root.data.set("color", "red"); + /// console.log(tree.getDeepValueWithID()); + /// // { cid: "cid:root-tree:Tree", + /// // value: [ { id: '0@1', parent: null, ..., meta: { color: 'red' }, children: [] } ] } + /// ``` + #[wasm_bindgen(js_name = "getDeepValueWithID", skip_typescript)] + pub fn get_deep_value_with_id(&self) -> JsResult { + Ok(self.handler.get_deep_value_with_id()?.into()) + } + /// Get all tree nodes of the forest, including deleted nodes. /// /// @example @@ -6393,6 +6623,19 @@ export type Value = | Value[] | undefined; +/** + * A container node inside a deep value returned by `getDeepValueWithID()` or + * `getRangeDeepValueWithID()`. + * + * `cid` is the container id string (the same as the container's `id` + * property). `value` is the deep value of the container; child containers + * inside it are recursively represented as `ValueWithContainerID` nodes. + */ +export type ValueWithContainerID = { + cid: ContainerID, + value: Value, +} + export type IdSpan = { peer: PeerID, counter: number, @@ -7024,6 +7267,33 @@ interface LoroDoc = Record { new(): LoroList; + /** + * Get the deep value of the list with its container id. + * + * The result is a `{ cid, value }` object, where `cid` is the container id + * string (the same as `list.id`) and `value` is the deep value of the + * list. Child containers inside `value` are recursively replaced by their + * own `{ cid, value }` nodes. + */ + getDeepValueWithID(): ValueWithContainerID; + /** + * Get the deep value of the elements in the range `[start, end)`, with + * container ids. + * + * Each child container in the range is recursively replaced by its own + * `{ cid, value }` node. Negative bounds are clamped to 0 and + * out-of-range bounds are clamped to the list length; an empty or + * inverted range returns `[]`. + */ + getRangeDeepValueWithID(start: number, end: number): (ValueWithContainerID | Value)[]; + /** + * Get the deep value of the elements in the range `[start, end)`. + * + * Each child container in the range is recursively resolved to its deep + * value. Negative bounds are clamped to 0 and out-of-range bounds are + * clamped to the list length; an empty or inverted range returns `[]`. + */ + getRangeValue(start: number, end: number): Value[]; /** * Get elements of the list. If the value is a child container, the corresponding * `Container` will be returned. @@ -7100,6 +7370,33 @@ interface LoroList { } interface LoroMovableList { new(): LoroMovableList; + /** + * Get the deep value of the movable list with its container id. + * + * The result is a `{ cid, value }` object, where `cid` is the container id + * string (the same as `list.id`) and `value` is the deep value of the + * list. Child containers inside `value` are recursively replaced by their + * own `{ cid, value }` nodes. + */ + getDeepValueWithID(): ValueWithContainerID; + /** + * Get the deep value of the elements in the range `[start, end)`, with + * container ids. + * + * Each child container in the range is recursively replaced by its own + * `{ cid, value }` node. Negative bounds are clamped to 0 and + * out-of-range bounds are clamped to the list length; an empty or + * inverted range returns `[]`. + */ + getRangeDeepValueWithID(start: number, end: number): (ValueWithContainerID | Value)[]; + /** + * Get the deep value of the elements in the range `[start, end)`. + * + * Each child container in the range is recursively resolved to its deep + * value. Negative bounds are clamped to 0 and out-of-range bounds are + * clamped to the list length; an empty or inverted range returns `[]`. + */ + getRangeValue(start: number, end: number): Value[]; /** * Get elements of the list. If the value is a child container, the corresponding * `Container` will be returned. @@ -7242,6 +7539,15 @@ interface LoroMovableList { */ interface LoroMap = Record> { new(): LoroMap; + /** + * Get the deep value of the map with its container id. + * + * The result is a `{ cid, value }` object, where `cid` is the container id + * string (the same as `map.id`) and `value` is the deep value of the map. + * Child containers inside `value` are recursively replaced by their own + * `{ cid, value }` nodes. + */ + getDeepValueWithID(): ValueWithContainerID; /** * Get or create a regular child container at the given key. * @@ -7361,6 +7667,13 @@ interface LoroMap = Record> { } interface LoroText { new(): LoroText; + /** + * Get the deep value of the text with its container id. + * + * The result is a `{ cid, value }` object, where `cid` is the container id + * string (the same as `text.id`) and `value` is the text content. + */ + getDeepValueWithID(): { cid: ContainerID, value: string }; insert(pos: number, text: string): void; delete(pos: number, len: number): void; subscribe(listener: Listener): Subscription; @@ -7397,6 +7710,15 @@ interface LoroText { } interface LoroTree = Record> { new(): LoroTree; + /** + * Get the deep value of the tree with its container id. + * + * The result is a `{ cid, value }` object, where `cid` is the container id + * string (the same as `tree.id`) and `value` is the deep value of the + * tree. This is the same node shape that `LoroDoc.getDeepValueWithID()` + * emits for every container. + */ + getDeepValueWithID(): ValueWithContainerID; /** * Create a new tree node as the child of parent and return a `LoroTreeNode` instance. * If the parent is undefined, the tree node will be a root node. diff --git a/crates/loro-wasm/tests/deep_value.test.ts b/crates/loro-wasm/tests/deep_value.test.ts new file mode 100644 index 000000000..4fa55ee32 --- /dev/null +++ b/crates/loro-wasm/tests/deep_value.test.ts @@ -0,0 +1,200 @@ +import { describe, expect, it } from "vitest"; +import { + LoroDoc, + LoroList, + LoroMap, + LoroMovableList, + LoroText, + LoroTree, + ValueWithContainerID, +} from "../bundler/index"; + +describe("getDeepValueWithID", () => { + it("doc-level cid equals the container id string", () => { + const doc = new LoroDoc(); + const map = doc.getMap("map"); + map.set("key", "value"); + const child = map.setContainer("child", new LoroList()); + child.insert(0, "item"); + doc.commit(); + + const value = doc.getDeepValueWithID() as Record< + string, + ValueWithContainerID + >; + expect(value.map.cid).toBe(map.id); + expect(value.map.cid).toBe("cid:root-map:Map"); + const childNode = (value.map.value as Record) + .child; + expect(childNode.cid).toBe(child.id); + expect(childNode.cid.startsWith("cid:")).toBe(true); + expect(childNode.cid.includes("idx:")).toBe(false); + expect(childNode.value).toStrictEqual(["item"]); + }); + + it("per-container nodes match the doc-level node shape", () => { + const doc = new LoroDoc(); + const map = doc.getMap("map"); + map.set("foo", "bar"); + const text = map.setContainer("text", new LoroText()); + text.insert(0, "Hello"); + doc.commit(); + + expect(map.getDeepValueWithID()).toStrictEqual({ + cid: map.id, + value: { + foo: "bar", + text: { cid: text.id, value: "Hello" }, + }, + }); + expect(text.getDeepValueWithID()).toStrictEqual({ + cid: text.id, + value: "Hello", + }); + }); + + it("list and movable list nodes", () => { + const doc = new LoroDoc(); + const list = doc.getList("list"); + list.insert(0, 100); + const text = list.insertContainer(1, new LoroText()); + text.insert(0, "Hello"); + + const movable = doc.getMovableList("movable"); + movable.insert(0, "a"); + const sub = movable.insertContainer(1, new LoroList()); + sub.insert(0, "x"); + doc.commit(); + + expect(list.getDeepValueWithID()).toStrictEqual({ + cid: list.id, + value: [100, { cid: text.id, value: "Hello" }], + }); + expect(movable.getDeepValueWithID()).toStrictEqual({ + cid: movable.id, + value: ["a", { cid: sub.id, value: ["x"] }], + }); + }); + + it("tree node", () => { + const doc = new LoroDoc(); + const tree = doc.getTree("tree"); + const root = tree.createNode(); + root.data.set("name", "root"); + doc.commit(); + + const node = tree.getDeepValueWithID(); + expect(node.cid).toBe(tree.id); + const nodes = node.value as { + id: string; + meta: Record; + children: unknown[]; + }[]; + expect(nodes).toHaveLength(1); + expect(nodes[0].id).toBe(root.id); + expect(nodes[0].meta).toStrictEqual({ name: "root" }); + expect(nodes[0].children).toStrictEqual([]); + }); + + it("throws on detached containers instead of trapping", () => { + expect(() => new LoroMap().getDeepValueWithID()).toThrow(); + expect(() => new LoroList().getDeepValueWithID()).toThrow(); + expect(() => new LoroMovableList().getDeepValueWithID()).toThrow(); + expect(() => new LoroTree().getDeepValueWithID()).toThrow(); + expect(() => new LoroText().getDeepValueWithID()).toThrow(); + }); +}); + +describe("range deep reads", () => { + const setup = () => { + const doc = new LoroDoc(); + const list = doc.getList("list"); + list.insert(0, "a"); + list.insert(1, "b"); + const child = list.insertContainer(2, new LoroMap()); + child.set("k", "v"); + list.insert(3, "d"); + doc.commit(); + return { doc, list, child }; + }; + + it("getRangeDeepValueWithID resolves containers to { cid, value } nodes", () => { + const { list, child } = setup(); + expect(list.getRangeDeepValueWithID(0, 4)).toStrictEqual([ + "a", + "b", + { cid: child.id, value: { k: "v" } }, + "d", + ]); + expect(list.getRangeDeepValueWithID(2, 3)).toStrictEqual([ + { cid: child.id, value: { k: "v" } }, + ]); + }); + + it("getRangeValue resolves containers to plain deep values", () => { + const { list } = setup(); + expect(list.getRangeValue(1, 3)).toStrictEqual(["b", { k: "v" }]); + expect(list.getRangeValue(0, 4)).toStrictEqual([ + "a", + "b", + { k: "v" }, + "d", + ]); + }); + + it("clamps out-of-range bounds and returns [] for empty ranges", () => { + const { list } = setup(); + // end > len clamps to len + expect(list.getRangeValue(3, 100)).toStrictEqual(["d"]); + // negative start clamps to 0 + expect(list.getRangeValue(-1, 1)).toStrictEqual(["a"]); + expect(list.getRangeDeepValueWithID(-5, 2)).toStrictEqual(["a", "b"]); + // start >= len, empty, and inverted ranges are empty + expect(list.getRangeValue(100, 200)).toStrictEqual([]); + expect(list.getRangeValue(2, 2)).toStrictEqual([]); + expect(list.getRangeValue(3, 1)).toStrictEqual([]); + }); + + it("nested containers inside the range carry their own { cid, value }", () => { + const doc = new LoroDoc(); + const list = doc.getList("list"); + const outer = list.insertContainer(0, new LoroList()); + const inner = outer.insertContainer(0, new LoroText()); + inner.insert(0, "deep"); + doc.commit(); + + expect(list.getRangeDeepValueWithID(0, 1)).toStrictEqual([ + { + cid: outer.id, + value: [{ cid: inner.id, value: "deep" }], + }, + ]); + }); + + it("works on movable lists", () => { + const doc = new LoroDoc(); + const list = doc.getMovableList("list"); + list.insert(0, "a"); + const child = list.insertContainer(1, new LoroText()); + child.insert(0, "hi"); + list.insert(2, "b"); + doc.commit(); + + expect(list.getRangeDeepValueWithID(0, 3)).toStrictEqual([ + "a", + { cid: child.id, value: "hi" }, + "b", + ]); + expect(list.getRangeValue(1, 2)).toStrictEqual(["hi"]); + expect(list.getRangeValue(0, 0)).toStrictEqual([]); + }); + + it("throws on detached lists", () => { + expect(() => new LoroList().getRangeValue(0, 1)).toThrow(); + expect(() => new LoroList().getRangeDeepValueWithID(0, 1)).toThrow(); + expect(() => new LoroMovableList().getRangeValue(0, 1)).toThrow(); + expect(() => + new LoroMovableList().getRangeDeepValueWithID(0, 1), + ).toThrow(); + }); +});