Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/wasm-container-bulk-read.md
Original file line number Diff line number Diff line change
@@ -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.
236 changes: 229 additions & 7 deletions crates/loro-internal/src/handler.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<LoroValue> {
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<Cursor> {
self.get_cursor_internal(event_index, side, true)
}
Expand Down Expand Up @@ -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<LoroValue> {
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<LoroValue> {
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<LoroValue> {
match &self.inner {
MaybeDetached::Detached(l) => l.lock().value.get(index).map(|x| x.to_value()),
Expand Down Expand Up @@ -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<LoroValue> {
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<LoroValue> {
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<LoroValue> {
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<LoroValue> {
Expand Down Expand Up @@ -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 { .. })
));
}
}
10 changes: 10 additions & 0 deletions crates/loro-internal/src/handler/tree.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<LoroValue> {
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) => {
Expand Down
53 changes: 52 additions & 1 deletion crates/loro-internal/src/state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down Expand Up @@ -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<FxHashSet<ContainerID>> {
Ok(self
.get_all_alive_container_indices()?
Expand Down
Loading
Loading