diff --git a/.changeset/wasm-container-tree.md b/.changeset/wasm-container-tree.md new file mode 100644 index 000000000..6a1c4139a --- /dev/null +++ b/.changeset/wasm-container-tree.md @@ -0,0 +1,9 @@ +--- +"loro-crdt": minor +--- + +Add `toContainerTree()` to documents and attached containers. It returns independent recursive `{type, cid, value}` nodes and opaque ordinary `Value` nodes. Documents can select visible roots without creating missing roots. Text formatting applies to all descendants and is inferred in TypeScript. List and MovableList expose `toContainerTreeSlice(start, end)` with explicit `start`, `totalLength`, and `items`. Fixed JS construction, per-read key/peer reuse, and owned binary buffers avoid a public transport protocol. + +Optional text configurations retain the default plain-text possibility in TypeScript. Literal root selections preserve their names as optional result properties. + +Compile Node snippets to CommonJS so the package does not require `require(esm)` support. diff --git a/AGENTS.md b/AGENTS.md index 58f657493..cb00f0dd4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -97,3 +97,5 @@ Use narrow checks first. Ask before broad fuzzing or long browser matrices. History uses short imperative commits, often prefixed by `fix:`, `test:`, `chore:`, or `refactor:`. PRs should include summary, rationale, validation, and linked issues or traces when relevant. +- Structured WASM reads, node identity, ranges and performance responsibilities: + [context/wasm-container-tree.md](context/wasm-container-tree.md). diff --git a/context/wasm-container-tree.md b/context/wasm-container-tree.md new file mode 100644 index 000000000..fd58a2dc8 --- /dev/null +++ b/context/wasm-container-tree.md @@ -0,0 +1,111 @@ +# Container tree API and the performance stack + +Verified against code 2026-09-06. + +## Responsibility + +- #1093 bounds retained decoded container state; it benefits existing handle reads. +- #1085 caches wrapper kind; #1086 supplies legacy subtree/range reads with IDs. +- #1087 adds `LoroDoc.toContainerTree` to construct a consumer-ready JS snapshot directly. +- #1090/#1091 concern shallow snapshot causal boundaries and constructing their + root state. They optimize history import/export, independently of this read API. + +## Contract + +```ts +const roots = doc.toContainerTree(); +const subtree = map.toContainerTree(); +const window = list.toContainerTreeSlice(20, 40); +const formatted = text.toContainerTree({text: "delta"}); +``` + +Containers are `{type, cid, value}`. Map values and List/MovableList items are +nodes. An ordinary value is `{type: "Value", value}`: its contents are opaque, +including plain objects shaped exactly like container nodes. Text contains a +string by default, or its formatting delta when requested. Tree contains the +existing nested tree layout, but each `meta` is an ID-bearing Map node; its +fields follow the same node contract. Counter contains a number. + +Identity is emitted only at real CRDT edges, including mergeable Map markers +and Tree metadata edges. No keys, scalar values, paths, traversal positions, or +user object shapes are used to guess identity. Full-document reads obey root +visibility. An attached root handle can read its empty root. Detached handles and unknown container types return errors. Document roots selection uses visible root names; missing names are omitted without creating roots, duplicates are ignored, and an empty selection returns {}. Reads do not +commit; caller mutations of returned objects/buffers do not mutate the document. + +List/MovableList.toContainerTreeSlice requires nonnegative u32 integer bounds; +end is exclusive, bounds clamp, inverted ranges are empty. It returns {cid, start, totalLength, items}, not a full container node; metadata and items are read under one state lock. Only selected child +subtrees are traversed, though obtaining the parent shallow list is still O(N). +Read traversal rejects nesting above 256 levels. JS number conversion follows +existing reads (including i64 rounding, NaN and infinity); Binary is Uint8Array. +Own `__proto__` and integer keys are ordinary data properties and do not invoke +inherited setters. Consumers must preserve this when projecting nodes themselves. + +## Implementation + +`state/container_tree.rs` emits a traversal to a sink without constructing a deep +whole-document LoroValue tree. Plain-mode shallow values are read ephemerally; values fetched to determine +root visibility are reused. The delta option materializes and retains Text states +through `get_or_create_mut`, matching the existing `toDelta()` path. `loro-wasm/src/container_tree.rs` +owns the JS construction stack and uses fixed imported functions for stable +wrapper shapes, IDs and own-property writes. Keys and peer decimal strings are +cached only for one read; complete CIDs are constructed in JS. Binary buffers +are copied once into an owned JS Uint8Array. There is no public transport format, +codec, path index, JS callback, or document-lifetime cache. + +Inductive correctness: each ordinary edge emits one opaque Value node preserving +its value; each container edge emits its actual ID and the recursively transformed +children; the document enumerates exactly its visible roots. Thus projecting +wrappers away preserves visible values and walking only node children enumerates +container occurrences without confusing embedded Map/List data. The proof relies +on the existing shallow-value/mergeable-edge semantics. Tests separately check +Tree metadata, deltas, binary ownership, special keys, IDs, ranges, errors and +root visibility; this is a design argument, not a machine-checked proof. + +## Mirror integration + +Mirror can consume the node tree in its existing schema/registry walk. On a Value +node it decodes the opaque value; on a container node it registers `cid` and +recurses by `type`. This removes shallow reads previously needed to distinguish +legacy `{cid,value}` nodes from user objects. Schema decoding, Ignore fields, +lazy hydration, Tree normalization, and incremental events remain Mirror's work. +A bulk API should not force eager traversal of lazy history: use subtree/range +reads where the schema allows it. Legacy `getDeepValueWithID` remains unchanged. + +## Measured end-to-end cost + +Final Node 22.23.1 and Chromium 152 comparisons used the same newly built Loro +package and Mirror adapter, an imported local 61.85 MiB snapshot, 10,921 reachable +containers (10,925 after schema-created roots), and the actual application schema. +Each fresh process/page measured its first constructor separately, then two warmups +and five samples; cases ran forward and backward. Import was outside the timed +constructor. Full state and sorted registered IDs agreed across methods. Private +snapshots/transcripts are not fixtures and are not committed. + +| Full Mirror initialization | Node warm median | Chromium warm median | +| --- | --- | --- | +| Per-container handles | 64.5–66.3 ms | 58.8–59.4 ms | +| Legacy bulk + shallow disambiguation | 79.4–80.5 ms | 73.0–74.2 ms | +| Fixed constructors + explicit nodes | 51.9–53.1 ms | 47.2–47.3 ms | + +First constructor: new path 80–83 ms in Node, 71–73 ms in Chromium; handle path +89–91 ms and 85–88 ms respectively. JS retained heap was similar (about 37 MiB); +reuse of already allocated WASM memory must not be described as zero memory cost. +A synthetic 70,051-container sample measured about 256 ms versus 321 ms for legacy +bulk; such small-string workloads do not predict the ranking of string transports +on the large-text application document. + +Moving the entire builder stack to JS did not consistently win and was rejected. +Pre-creating dense array slots likewise gave no material improvement. The shipped +builder retains fixed wrapper constructors, per-read peer/key reuse, and safe own +property writes. These timings describe the tested machines/workload, not a general +speed guarantee. Mirror's Tree normalization still uses its existing handle path. + +Document toContainerTree({roots}) filters before reading root values; the optional text format applies recursively. TypeScript infers both the receiver kind and Text value format. Mirror selects schema roots and, when preserving unknown roots, includes them while excluding explicit Ignore roots. + +TypeScript conditional types distinguish a required `text` from an optional format: only +`{ text: "delta" }` guarantees delta arrays. Optional delta options (or omitted +options) retain `"plain"` in the result type. Explicit required option types also +require the argument at the call site. +Required literal root selections return `Partial>`, +because selected roots may be missing or hidden; optional roots can select all +roots when omitted and therefore do not restrict the result's key names. diff --git a/crates/loro-internal/src/lib.rs b/crates/loro-internal/src/lib.rs index d802af3a1..83d354239 100644 --- a/crates/loro-internal/src/lib.rs +++ b/crates/loro-internal/src/lib.rs @@ -14,6 +14,8 @@ pub mod diff; pub mod diff_calc; pub mod handler; pub mod sync; +pub use state::container_tree; + use crate::sync::{AtomicBool, AtomicUsize}; use std::sync::Arc; mod change_meta; diff --git a/crates/loro-internal/src/state.rs b/crates/loro-internal/src/state.rs index e0b04ba3f..afdd5e225 100644 --- a/crates/loro-internal/src/state.rs +++ b/crates/loro-internal/src/state.rs @@ -1,3 +1,5 @@ +pub mod container_tree; + use crate::sync::{AtomicU64, Mutex, RwLock}; #[cfg(test)] use std::cell::Cell; diff --git a/crates/loro-internal/src/state/AGENTS.md b/crates/loro-internal/src/state/AGENTS.md index 74c8f6680..60e6c9a28 100644 --- a/crates/loro-internal/src/state/AGENTS.md +++ b/crates/loro-internal/src/state/AGENTS.md @@ -44,3 +44,7 @@ before changing mergeable child behavior. - `cargo test -p loro-internal --test mergeable_cid_encoding` - `cargo test -p loro-internal --test mergeable_container` - `cargo test -p loro-internal import_atomicity` if import or rollback is involved. + +`container_tree.rs` traverses ephemeral shallow values into a sink. Container identity +comes from CRDT edges, including mergeable markers and Tree metadata; ordinary +values remain opaque. See [bulk reads](../../../../context/wasm-container-tree.md). diff --git a/crates/loro-internal/src/state/container_tree.rs b/crates/loro-internal/src/state/container_tree.rs new file mode 100644 index 000000000..2c771dc48 --- /dev/null +++ b/crates/loro-internal/src/state/container_tree.rs @@ -0,0 +1,269 @@ +//! Structured state traversal. Container identity comes from CRDT edges, never value shape. +//! A sink constructs the target representation without a whole-document intermediate tree. +use super::{deleted_root_container_value_is_cleared, visible_container_value_is_empty, DocState}; +use crate::{container::idx::ContainerIdx, ContainerType, LoroValue}; +use loro_common::{ContainerID, LoroError, LoroResult}; +use std::sync::atomic::Ordering; +#[derive(Debug)] +pub enum Event<'a> { + Object(usize), + Array(usize), + End, + Key(&'a str), + String(&'a str), + Number(f64), + Bool(bool), + Null, + Binary(&'a [u8]), +} +pub trait Sink { + fn emit(&mut self, e: Event<'_>) -> LoroResult<()>; + fn container(&mut self, id: &ContainerID) -> LoroResult<()>; + fn container_end(&mut self) -> LoroResult<()> { + self.emit(Event::End) + } + fn value_start(&mut self) -> LoroResult<()>; + fn value_end(&mut self) -> LoroResult<()>; +} +impl DocState { + pub fn read_container_tree( + &mut self, + s: &mut S, + cid: Option<&ContainerID>, + rich: bool, + selected_roots: Option<&[String]>, + ) -> LoroResult<()> { + if let Some(id) = cid { + let idx = self.arena.register_container(id); + return self.read_container_node(s, idx, rich, None, None, 0); + } + let roots = self.preferred_root_containers(); + let mut visible = Vec::new(); + let selected: Option> = + selected_roots.map(|names| names.iter().map(String::as_str).collect()); + for idx in roots { + if let Some(names) = &selected { + let name = self + .root_container_name(idx) + .ok_or_else(|| err("Missing root name"))?; + if !names.contains(name.as_str()) { + continue; + } + } + if matches!(idx.get_type(), ContainerType::Unknown(_)) { + return Err(err("Unsupported container type")); + } + let id = self + .arena + .idx_to_id(idx) + .ok_or_else(|| err("Missing container ID"))?; + let hidden = self + .config + .hide_empty_root_containers + .load(Ordering::Relaxed); + let deleted = self.config.deleted_root_containers.lock().contains(&id); + let v = if hidden || deleted { + self.store + .try_get_value_ephemeral(idx)? + .or_else(|| Some(idx.get_type().default_value())) + } else { + None + }; + if v.as_ref().is_some_and(|v| { + (hidden && visible_container_value_is_empty(idx.get_type(), v)) + || (deleted && deleted_root_container_value_is_cleared(idx.get_type(), v)) + }) { + continue; + } + visible.push(( + self.root_container_name(idx) + .ok_or_else(|| err("Missing root name"))?, + idx, + v, + )); + } + s.emit(Event::Object(visible.len()))?; + for (key, idx, value) in visible { + s.emit(Event::Key(&key))?; + self.read_container_node(s, idx, rich, None, value, 0)?; + } + s.emit(Event::End) + } + /// Read a list window and its coordinates under the same state lock. + pub fn read_container_tree_slice( + &mut self, + sink: &mut S, + cid: &ContainerID, + rich: bool, + start: usize, + end: usize, + ) -> LoroResult<(usize, usize)> { + let idx = self.arena.register_container(cid); + let kind = idx.get_type(); + if !matches!(kind, ContainerType::List | ContainerType::MovableList) { + return Err(err("Expected list container")); + } + let value = self + .store + .try_get_value_ephemeral(idx)? + .unwrap_or_else(|| kind.default_value()); + let total = value + .as_list() + .ok_or_else(|| err("Expected list value"))? + .len(); + let start = start.min(total); + self.read_container_node(sink, idx, rich, Some((start, end)), Some(value), 0)?; + Ok((start, total)) + } + fn read_container_node( + &mut self, + s: &mut S, + idx: ContainerIdx, + rich: bool, + range: Option<(usize, usize)>, + value: Option, + depth: usize, + ) -> LoroResult<()> { + check_depth(depth)?; + let id = self + .arena + .idx_to_id(idx) + .ok_or_else(|| err("Missing container ID"))?; + let kind = idx.get_type(); + if matches!(kind, ContainerType::Unknown(_)) { + return Err(err("Unsupported container type")); + } + let v = if rich && kind == ContainerType::Text { + self.store + .get_or_create_mut(idx) + .as_richtext_state_mut() + .ok_or_else(|| err("Missing text state"))? + .get_richtext_value() + } else { + match value { + Some(value) => value, + None => self + .store + .try_get_value_ephemeral(idx)? + .unwrap_or_else(|| kind.default_value()), + } + }; + s.container(&id)?; + + match (&v, kind) { + (LoroValue::Map(m), ContainerType::Map) => { + s.emit(Event::Object(m.len()))?; + for (k, v) in m.iter() { + s.emit(Event::Key(k))?; + let merge = loro_common::parse_mergeable_marker(&id, k, v) + .map(|t| ContainerID::new_mergeable(&id, k, t)); + if let Some(c) = merge { + let i = self.arena.register_container(&c); + self.read_container_node(s, i, rich, None, None, depth + 1)?; + } else { + self.read_container_edge(s, v, rich, depth + 1)?; + } + } + s.emit(Event::End)?; + } + (LoroValue::List(l), ContainerType::List | ContainerType::MovableList) => { + let (start, end) = range.unwrap_or((0, l.len())); + let start = start.min(l.len()); + let end = end.min(l.len()).max(start); + s.emit(Event::Array(end - start))?; + for v in &l[start..end] { + self.read_container_edge(s, v, rich, depth + 1)?; + } + s.emit(Event::End)?; + } + (_, ContainerType::Tree) => self.read_tree_nodes(s, &v, rich, depth + 1)?, + _ => raw(s, &v, depth + 1)?, + }; + s.container_end() + } + fn read_container_edge( + &mut self, + s: &mut S, + v: &LoroValue, + rich: bool, + depth: usize, + ) -> LoroResult<()> { + if let LoroValue::Container(cid) = v { + let idx = self.arena.register_container(cid); + return self.read_container_node(s, idx, rich, None, None, depth); + } + s.value_start()?; + raw(s, v, depth + 1)?; + s.value_end() + } + fn read_tree_nodes( + &mut self, + s: &mut S, + v: &LoroValue, + rich: bool, + depth: usize, + ) -> LoroResult<()> { + check_depth(depth)?; + match v { + LoroValue::List(l) => { + s.emit(Event::Array(l.len()))?; + for node in l.iter() { + let m = node.as_map().ok_or_else(|| err("Invalid tree node"))?; + s.emit(Event::Object(m.len()))?; + for (k, v) in m.iter() { + s.emit(Event::Key(k))?; + if k == "meta" { + self.read_container_edge(s, v, rich, depth + 1)?; + } else if k == "children" { + self.read_tree_nodes(s, v, rich, depth + 1)?; + } else { + raw(s, v, depth + 1)?; + } + } + s.emit(Event::End)?; + } + s.emit(Event::End) + } + _ => Err(err("Invalid tree value")), + } + } +} +pub fn err(s: &str) -> LoroError { + LoroError::JsError(s.to_string().into_boxed_str()) +} +fn raw(s: &mut S, v: &LoroValue, depth: usize) -> LoroResult<()> { + check_depth(depth)?; + match v { + LoroValue::Null => s.emit(Event::Null), + LoroValue::Bool(b) => s.emit(Event::Bool(*b)), + // Match the existing JavaScript value conversion, including i64 -> number. + LoroValue::I64(n) => s.emit(Event::Number(*n as f64)), + LoroValue::Double(n) => s.emit(Event::Number(*n)), + LoroValue::Binary(v) => s.emit(Event::Binary(v)), + LoroValue::Container(id) => s.emit(Event::String(&id.to_string())), + LoroValue::String(v) => s.emit(Event::String(v)), + LoroValue::List(l) => { + s.emit(Event::Array(l.len()))?; + for v in l.iter() { + raw(s, v, depth + 1)?; + } + s.emit(Event::End) + } + LoroValue::Map(m) => { + s.emit(Event::Object(m.len()))?; + for (k, v) in m.iter() { + s.emit(Event::Key(k))?; + raw(s, v, depth + 1)?; + } + s.emit(Event::End) + } + } +} + +fn check_depth(depth: usize) -> LoroResult<()> { + if depth > 256 { + Err(err("toContainerTree nesting exceeds 256 levels")) + } else { + Ok(()) + } +} diff --git a/crates/loro-wasm/AGENTS.md b/crates/loro-wasm/AGENTS.md index 425ba127f..6816756f4 100644 --- a/crates/loro-wasm/AGENTS.md +++ b/crates/loro-wasm/AGENTS.md @@ -87,3 +87,20 @@ are documented in and Rollup need either the `base64` entry or an explicit asset copy. Keep the bundler smoke tests aligned with these expectations. - If package output or published behavior changes, add a changeset. + +`LoroDoc.toContainerTree` constructs typed nested snapshots with fixed JS helpers. See +[context/wasm-container-tree.md](../../context/wasm-container-tree.md) for identity, ownership, +range and Mirror integration contracts. + +`toContainerTree` on attached containers recursively applies its text format. +Document `roots` filters before reading values; missing roots are omitted. List +`toContainerTreeSlice` returns coordinates and items under one state lock, never +a partial ContainerNode. Keep receiver and text-format inference in TypeScript. + +Only a required `text` option can exclude the default plain format from the +return type. Optional options/text must retain plain, including explicit generic +arguments. Required root selections preserve literal keys as optional properties. + +The nodejs target must load without Node's `require(esm)` support. Convert +wasm-bindgen snippets to CommonJS during the build and keep the package test's +`--no-experimental-require-module` smoke check. Other targets retain ESM snippets. diff --git a/crates/loro-wasm/package.json b/crates/loro-wasm/package.json index efce2f324..e491f9133 100644 --- a/crates/loro-wasm/package.json +++ b/crates/loro-wasm/package.json @@ -77,7 +77,7 @@ "scripts": { "build-dev": "deno run -A ./scripts/build.ts dev && rollup -c && deno run -A ./scripts/post-rollup.ts && npm run test", "build-release": "deno run -A ./scripts/build.ts release && rollup -c && deno run -A ./scripts/post-rollup.ts && npm run test", - "test": "node --expose-gc ./node_modules/vitest/vitest.mjs run && npx tsc --noEmit && cd ./deno_tests && deno test -A && cd ../bun_tests && bun test", + "test": "node --no-experimental-require-module ./scripts/test-commonjs.cjs && node --expose-gc ./node_modules/vitest/vitest.mjs run && npx tsc --noEmit && cd ./deno_tests && deno test -A && cd ../bun_tests && bun test", "test:snapshot-memory": "node --expose-gc ./scripts/measure-snapshot-round-memory.cjs" }, "homepage": "https://loro.dev", diff --git a/crates/loro-wasm/scripts/build.ts b/crates/loro-wasm/scripts/build.ts index d95c2c2ff..15c7f491e 100644 --- a/crates/loro-wasm/scripts/build.ts +++ b/crates/loro-wasm/scripts/build.ts @@ -237,6 +237,19 @@ async function buildTarget(target: string) { if (target === "nodejs") { console.log("🔨 Patching nodejs target"); + const snippets = await new Deno.Command("node", { + args: [ + path.resolve(__dirname, "nodejs-snippets.cjs"), + path.resolve(targetDirPath, "snippets"), + ], + cwd: LoroWasmDir, + }).output(); + if (!snippets.success) { + throw new Error( + `CommonJS snippet conversion failed: ${textDecoder.decode(snippets.stderr)}`, + ); + } + const patch = await Deno.readTextFile( path.resolve(__dirname, "./nodejs_patch.js"), ); diff --git a/crates/loro-wasm/scripts/nodejs-snippets.cjs b/crates/loro-wasm/scripts/nodejs-snippets.cjs new file mode 100644 index 000000000..ad6d77d7b --- /dev/null +++ b/crates/loro-wasm/scripts/nodejs-snippets.cjs @@ -0,0 +1,25 @@ +// wasm-bindgen emits ES modules for snippets even with --target nodejs. +// Compile them to CommonJS so loading the package never relies on require(esm). +const fs = require("node:fs"); +const path = require("node:path"); +const { transformSync } = require("esbuild"); + +function convert(directory) { + for (const entry of fs.readdirSync(directory, { withFileTypes: true })) { + const file = path.join(directory, entry.name); + if (entry.isDirectory()) { + convert(file); + } else if (entry.isFile() && entry.name.endsWith(".js")) { + const { code } = transformSync(fs.readFileSync(file, "utf8"), { + format: "cjs", + target: "node18", + sourcefile: file, + }); + fs.writeFileSync(file, code); + } + } +} + +const directory = process.argv[2]; +if (!directory) throw new Error("Expected the nodejs snippets directory"); +if (fs.existsSync(directory)) convert(directory); diff --git a/crates/loro-wasm/scripts/test-commonjs.cjs b/crates/loro-wasm/scripts/test-commonjs.cjs new file mode 100644 index 000000000..dcdcec9d2 --- /dev/null +++ b/crates/loro-wasm/scripts/test-commonjs.cjs @@ -0,0 +1,22 @@ +// Run with --no-experimental-require-module to catch ESM leaking into CJS. +const assert = require("node:assert/strict"); +const { LoroDoc, LoroText } = require("../nodejs"); +const doc = new LoroDoc(); +const map = doc.getMap("root"); +const text = map.setContainer("text", new LoroText()); +text.insert(0, "hello"); +text.mark({ start: 0, end: 5 }, "bold", true); +map.set("bytes", new Uint8Array([0, 255])); +assert.deepEqual(doc.toJSON().root.text, "hello"); +const result = doc.toContainerTree({ text: "delta" }).root; +assert.equal(result.cid, map.id); +assert.deepEqual(result.value.text, { + type: "Text", + cid: text.id, + value: text.toDelta(), +}); +assert.deepEqual(result.value.bytes.value, new Uint8Array([0, 255])); +doc.free(); +console.log( + "CommonJS loading and container tree smoke passed without require(esm)", +); diff --git a/crates/loro-wasm/src/container_tree.rs b/crates/loro-wasm/src/container_tree.rs new file mode 100644 index 000000000..aab27c99b --- /dev/null +++ b/crates/loro-wasm/src/container_tree.rs @@ -0,0 +1,466 @@ +//! Build JS state with fixed constructors; no callbacks or document-wide intermediary. +use super::*; +use loro_internal::container_tree::{err, Event, Sink}; +use std::collections::HashMap; +#[wasm_bindgen(inline_js = " +const kinds = ['Map', 'List', 'MovableList', 'Text', 'Tree', 'Counter']; +export function stateOptions(options, document) { + if (typeof options !== 'object' || options === null || Array.isArray(options)) throw new Error('Invalid toContainerTree options'); + for (const key of Object.keys(options)) { + if (key !== 'text' && !(document && key === 'roots')) throw new Error('Unknown toContainerTree option: ' + key); + } + if (document && options.roots !== undefined && !Array.isArray(options.roots)) throw new Error('roots must be an array'); + return options; +} +export function stateSlice(node, start, totalLength) { return {cid:node.cid, start, totalLength, items:node.value}; } +export function stateContainer(kind, cid, value) { return {type: kinds[kind], cid, value}; } +export function stateCid(peer,counter,kind) { return 'cid:'+counter+'@'+peer+':'+kinds[kind]; } +export function stateRootCid(name,kind) { return 'cid:root-'+name+':'+kinds[kind]; } +export function stateValue(value) { return {type: 'Value', value}; } +export function stateBinary(bytes) { return bytes.slice(); } +export function stateIndex(array, index, value) { Object.defineProperty(array, index, {value, enumerable: true, writable: true, configurable: true}); } +export function stateSet(object, key, value) { Object.defineProperty(object, key, {value, enumerable: true, writable: true, configurable: true}); } +")] +extern "C" { + #[wasm_bindgen(catch)] + fn stateOptions(options: JsValue, document: bool) -> Result; + fn stateSlice(node: JsValue, start: u32, total_length: u32) -> JsValue; + fn stateContainer(kind: u8, cid: &JsValue, value: JsValue) -> JsValue; + fn stateValue(value: JsValue) -> JsValue; + fn stateBinary(bytes: &[u8]) -> JsValue; + #[wasm_bindgen(catch)] + fn stateIndex(array: &JsValue, index: u32, value: &JsValue) -> Result<(), JsValue>; + fn stateCid(peer: &JsValue, counter: i32, kind: u8) -> JsValue; + fn stateRootCid(name: &str, kind: u8) -> JsValue; + #[wasm_bindgen(catch)] + fn stateSet(object: &JsValue, key: &JsValue, value: &JsValue) -> Result<(), JsValue>; +} +struct Frame { + wrapper: u8, + cid: JsValue, + v: JsValue, + array: bool, + next: u32, + key: JsValue, +} +#[derive(Default)] +struct JsSink { + stack: Vec, + root: JsValue, + keys: HashMap, +} +impl JsSink { + fn add(&mut self, v: JsValue) -> LoroResult<()> { + if let Some(f) = self.stack.last_mut() { + if f.wrapper != 0 { + f.v = v; + return Ok(()); + } + if f.array { + stateIndex(&f.v, f.next, &v).map_err(|_| err("JS array property failed"))?; + f.next += 1; + } else { + stateSet(&f.v, &f.key, &v).map_err(|_| err("JS define property failed"))?; + } + } else { + self.root = v; + } + Ok(()) + } +} +impl JsSink { + fn emit(&mut self, e: Event<'_>) -> LoroResult<()> { + match e { + Event::Key(k) => { + let key = if let Some(v) = self.keys.get(k) { + v.clone() + } else { + let v: JsValue = k.into(); + self.keys.insert(k.to_owned(), v.clone()); + v + }; + let f = self + .stack + .last_mut() + .ok_or_else(|| err("Missing object frame"))?; + f.key = key; + } + Event::Object(_) => self.stack.push(Frame { + wrapper: 0, + cid: JsValue::NULL, + v: Object::new().into(), + array: false, + next: 0, + key: JsValue::NULL, + }), + Event::Array(n) => self.stack.push(Frame { + wrapper: 0, + cid: JsValue::NULL, + v: Array::new_with_length(n as u32).into(), + array: true, + next: 0, + key: JsValue::NULL, + }), + Event::End => { + let f = self.stack.pop().ok_or_else(|| err("Missing state frame"))?; + let v = if f.wrapper == 16 { + stateValue(f.v) + } else if f.wrapper != 0 { + stateContainer(f.wrapper - 10, &f.cid, f.v) + } else { + f.v + }; + self.add(v)?; + } + Event::String(s) => self.add(s.into())?, + Event::Number(n) => self.add(n.into())?, + Event::Bool(b) => self.add(b.into())?, + Event::Binary(bytes) => self.add(stateBinary(bytes))?, + Event::Null => self.add(JsValue::NULL)?, + }; + Ok(()) + } +} +#[derive(Default)] +struct FixedSink(JsSink, HashMap); +impl Sink for FixedSink { + fn container(&mut self, id: &ContainerID) -> LoroResult<()> { + let kind = match id.container_type() { + ContainerType::Map => 0, + ContainerType::List => 1, + ContainerType::MovableList => 2, + ContainerType::Text => 3, + ContainerType::Tree => 4, + ContainerType::Counter => 5, + _ => return Err(err("Unsupported container type")), + }; + let cid = match id { + ContainerID::Root { name, .. } => stateRootCid(name, kind), + ContainerID::Normal { peer, counter, .. } => { + let p = self + .1 + .entry(*peer) + .or_insert_with(|| JsValue::from_str(&peer.to_string())); + stateCid(p, *counter, kind) + } + }; + self.0.stack.push(Frame { + wrapper: kind + 10, + cid, + v: JsValue::NULL, + array: false, + next: 0, + key: JsValue::NULL, + }); + Ok(()) + } + + fn emit(&mut self, e: Event<'_>) -> LoroResult<()> { + self.0.emit(e) + } + fn container_end(&mut self) -> LoroResult<()> { + self.emit(Event::End) + } + fn value_start(&mut self) -> LoroResult<()> { + self.0.stack.push(Frame { + wrapper: 16, + cid: JsValue::NULL, + v: JsValue::NULL, + array: false, + next: 0, + key: JsValue::NULL, + }); + Ok(()) + } + fn value_end(&mut self) -> LoroResult<()> { + self.emit(Event::End) + } +} + +#[derive(Default, serde::Deserialize)] +#[serde(deny_unknown_fields)] +struct Options { + #[serde(default)] + text: TextMode, +} +#[derive(Default, serde::Deserialize)] +#[serde(deny_unknown_fields)] +struct DocumentOptions { + #[serde(default)] + text: TextMode, + roots: Option>, +} +#[derive(Default, serde::Deserialize)] +#[serde(rename_all = "lowercase")] +enum TextMode { + #[default] + Plain, + Delta, +} +fn options( + value: Option, + document: bool, +) -> JsResult { + match value { + None => Ok(T::default()), + Some(value) => serde_wasm_bindgen::from_value(stateOptions(value, document)?) + .map_err(|e| JsValue::from_str(&format!("Invalid toContainerTree options: {e}"))), + } +} +fn container_tree( + handler: &H, + opts: Option, +) -> JsResult { + let opts: Options = options(opts, false)?; + let doc = handler + .doc() + .ok_or_else(|| JsValue::from_str("toContainerTree requires an attached container"))?; + let mut sink = FixedSink::default(); + doc.app_state().lock().read_container_tree( + &mut sink, + Some(&handler.id()), + matches!(opts.text, TextMode::Delta), + None, + )?; + Ok(sink.0.root.unchecked_into()) +} +fn list_slice( + handler: &H, + start: f64, + end: f64, + opts: Option, +) -> JsResult { + if [start, end] + .iter() + .any(|n| !n.is_finite() || *n < 0.0 || *n > u32::MAX as f64 || n.fract() != 0.0) + { + return Err(JsValue::from_str( + "Slice bounds must be nonnegative u32 integers", + )); + } + let opts: Options = options(opts, false)?; + let doc = handler + .doc() + .ok_or_else(|| JsValue::from_str("toContainerTreeSlice requires an attached container"))?; + let mut sink = FixedSink::default(); + let (start, total) = doc.app_state().lock().read_container_tree_slice( + &mut sink, + &handler.id(), + matches!(opts.text, TextMode::Delta), + start as usize, + end as usize, + )?; + Ok(stateSlice(sink.0.root, start as u32, total as u32).unchecked_into()) +} +#[wasm_bindgen] +impl LoroDoc { + /// Convert visible roots to independent container trees without committing. + /// The text format applies recursively, including Tree metadata. Unknown or + /// hidden roots are omitted; roots: [] returns an empty object. Reads never + /// create roots. Binary values are owned Uint8Arrays. Not a CRDT export. + #[wasm_bindgen(js_name = toContainerTree, skip_typescript)] + pub fn to_container_tree( + &self, + opts: Option, + ) -> JsResult { + let opts: DocumentOptions = options(opts.map(Into::into), true)?; + let mut sink = FixedSink::default(); + self.doc.app_state().lock().read_container_tree( + &mut sink, + None, + matches!(opts.text, TextMode::Delta), + opts.roots.as_deref(), + )?; + Ok(sink.0.root.unchecked_into()) + } +} +#[wasm_bindgen] +impl LoroMap { + /// Convert this attached container and all descendants to an independent tree. + /// Text format applies recursively. Does not commit; throws for detached + /// containers, unsupported types, invalid options, or nesting above 256. + #[wasm_bindgen(js_name = toContainerTree, skip_typescript)] + pub fn to_container_tree( + &self, + opts: Option, + ) -> JsResult { + container_tree(&self.handler, opts.map(Into::into)) + } +} +#[wasm_bindgen] +impl LoroList { + /// Convert this attached container and all descendants to an independent tree. + /// Text format applies recursively. Does not commit; throws for detached + /// containers, unsupported types, invalid options, or nesting above 256. + #[wasm_bindgen(js_name = toContainerTree, skip_typescript)] + pub fn to_container_tree( + &self, + opts: Option, + ) -> JsResult { + container_tree(&self.handler, opts.map(Into::into)) + } +} +#[wasm_bindgen] +impl LoroList { + /// Read [start,end), clamped to list length. Inverted bounds return no items. + /// Returns actual start, totalLength and items; this is not a complete container. + /// Only selected descendants are traversed; parent shallow list access is O(N). + #[wasm_bindgen(js_name = toContainerTreeSlice, skip_typescript)] + pub fn to_container_tree_slice( + &self, + start: f64, + end: f64, + opts: Option, + ) -> JsResult { + list_slice(&self.handler, start, end, opts.map(Into::into)) + } +} +#[wasm_bindgen] +impl LoroMovableList { + /// Convert this attached container and all descendants to an independent tree. + /// Text format applies recursively. Does not commit; throws for detached + /// containers, unsupported types, invalid options, or nesting above 256. + #[wasm_bindgen(js_name = toContainerTree, skip_typescript)] + pub fn to_container_tree( + &self, + opts: Option, + ) -> JsResult { + container_tree(&self.handler, opts.map(Into::into)) + } +} +#[wasm_bindgen] +impl LoroMovableList { + /// Read [start,end), clamped to list length. Inverted bounds return no items. + /// Returns actual start, totalLength and items; this is not a complete container. + /// Only selected descendants are traversed; parent shallow list access is O(N). + #[wasm_bindgen(js_name = toContainerTreeSlice, skip_typescript)] + pub fn to_container_tree_slice( + &self, + start: f64, + end: f64, + opts: Option, + ) -> JsResult { + list_slice(&self.handler, start, end, opts.map(Into::into)) + } +} +#[wasm_bindgen] +impl LoroText { + /// Convert this attached container and all descendants to an independent tree. + /// Text format applies recursively. Does not commit; throws for detached + /// containers, unsupported types, invalid options, or nesting above 256. + #[wasm_bindgen(js_name = toContainerTree, skip_typescript)] + pub fn to_container_tree( + &self, + opts: Option, + ) -> JsResult { + container_tree(&self.handler, opts.map(Into::into)) + } +} +#[wasm_bindgen] +impl LoroTree { + /// Convert this attached container and all descendants to an independent tree. + /// Text format applies recursively. Does not commit; throws for detached + /// containers, unsupported types, invalid options, or nesting above 256. + #[wasm_bindgen(js_name = toContainerTree, skip_typescript)] + pub fn to_container_tree( + &self, + opts: Option, + ) -> JsResult { + container_tree(&self.handler, opts.map(Into::into)) + } +} +#[wasm_bindgen] +impl LoroCounter { + /// Convert this attached container and all descendants to an independent tree. + /// Text format applies recursively. Does not commit; throws for detached + /// containers, unsupported types, invalid options, or nesting above 256. + #[wasm_bindgen(js_name = toContainerTree, skip_typescript)] + pub fn to_container_tree( + &self, + opts: Option, + ) -> JsResult { + container_tree(&self.handler, opts.map(Into::into)) + } +} +#[wasm_bindgen] +extern "C" { + #[wasm_bindgen(typescript_type = "ContainerTreeOptions")] + pub type JsContainerTreeOptions; + #[wasm_bindgen(typescript_type = "DocumentContainerTreeOptions")] + pub type JsDocumentContainerTreeOptions; + #[wasm_bindgen(typescript_type = "ContainerNode")] + pub type JsContainerNode; + #[wasm_bindgen(typescript_type = "Record")] + pub type JsDocumentContainerTree; + #[wasm_bindgen(typescript_type = "ContainerTreeSlice")] + pub type JsContainerTreeSlice; +} +#[wasm_bindgen(typescript_custom_section)] +const TYPES: &str = r#" +export type ContainerTreeTextFormat = "plain" | "delta"; +/** Text format applies to every descendant Text, including Tree metadata. */ +export interface ContainerTreeOptions { text?: T } +export interface DocumentContainerTreeOptions extends ContainerTreeOptions { + /** Select visible roots by name. Missing roots are omitted, never created. [] selects none. */ + roots?: readonly string[]; +} +/** Opaque ordinary data: never interpret objects inside value as container nodes. */ +export type ValueNode = { type: "Value"; value: Value }; +export type ContainerTreeNode = ContainerNode | ValueNode; +export type ContainerNode = + | { type: "Map"; cid: ContainerID; value: Record> } + | { type: "List"; cid: ContainerID; value: ContainerTreeNode[] } + | { type: "MovableList"; cid: ContainerID; value: ContainerTreeNode[] } + | { type: "Text"; cid: ContainerID; value: T extends "delta" ? Delta[] : string } + | { type: "Tree"; cid: ContainerID; value: TreeNodeSnapshot[] } + | { type: "Counter"; cid: ContainerID; value: number }; +export interface TreeNodeSnapshot { + id: TreeID; parent: TreeID | null; index: number; fractional_index: string; + meta: Extract, {type:"Map"}>; + children: TreeNodeSnapshot[]; +} +/** A partial list, not a complete ContainerNode. start is clamped to totalLength. */ +export interface ContainerTreeSlice { + cid: ContainerID; start: number; totalLength: number; items: ContainerTreeNode[]; +} +/** Resolve runtime defaults, distributing over optional/union configurations. */ +export type ContainerTreeText = O extends { text: infer T extends ContainerTreeTextFormat } + ? T : O extends { text?: infer T } ? Extract | "plain" : "plain"; +export type DocumentContainerTree = O extends { roots: readonly (infer R extends string)[] } + ? Partial>>> + : Record>>; +interface LoroDoc { + /** Convert visible roots to independent container trees. No commit, live handles or CRDT history. + * Text defaults to plain strings. Throws for invalid options, unsupported types or nesting >256. + */ + toContainerTree(...args: A): DocumentContainerTree; +} +interface LoroMap { + /** Read this attached container and descendants; text format applies recursively. Throws if detached. */ + toContainerTree(...args: A): Extract>, {type:"Map"}>; +} +interface LoroList { + /** Read this attached container and descendants; text format applies recursively. Throws if detached. */ + toContainerTree(...args: A): Extract>, {type:"List"}>; + /** Read [start,end), clamped, with source coordinates; parent shallow list access remains O(N). */ + toContainerTreeSlice(start:number,end:number,...args:A): ContainerTreeSlice>; +} +interface LoroMovableList { + /** Read this attached container and descendants; text format applies recursively. Throws if detached. */ + toContainerTree(...args: A): Extract>, {type:"MovableList"}>; + /** Read [start,end), clamped, with source coordinates; parent shallow list access remains O(N). */ + toContainerTreeSlice(start:number,end:number,...args:A): ContainerTreeSlice>; +} +interface LoroText { + /** Read this attached container and descendants; text format applies recursively. Throws if detached. */ + toContainerTree(...args: A): Extract>, {type:"Text"}>; +} +interface LoroTree { + /** Read this attached container and descendants; text format applies recursively. Throws if detached. */ + toContainerTree(...args: A): Extract>, {type:"Tree"}>; +} +interface LoroCounter { + /** Read this attached container and descendants; text format applies recursively. Throws if detached. */ + toContainerTree(...args: A): Extract>, {type:"Counter"}>; +} +"#; diff --git a/crates/loro-wasm/src/lib.rs b/crates/loro-wasm/src/lib.rs index 65adddbc8..7bd4e822b 100644 --- a/crates/loro-wasm/src/lib.rs +++ b/crates/loro-wasm/src/lib.rs @@ -5,6 +5,8 @@ #![allow(clippy::doc_lazy_continuation)] // #![warn(missing_docs)] +mod container_tree; + use convert::{ import_blob_metadata_to_js, import_status_to_js_value, js_diff_to_inner_diff, js_json_schema_to_loro_json_schema, js_to_id_span, js_to_version_vector, diff --git a/crates/loro-wasm/tests/container_tree.test.ts b/crates/loro-wasm/tests/container_tree.test.ts new file mode 100644 index 000000000..804fbc854 --- /dev/null +++ b/crates/loro-wasm/tests/container_tree.test.ts @@ -0,0 +1,417 @@ +import { describe, expect, it } from "vitest"; +import { + LoroDoc, + LoroMap, + LoroText, + LoroList, + LoroMovableList, + LoroTree, + LoroCounter, + type ContainerNode, + type ContainerTreeNode, +} from "../bundler/index"; + +function project(node: ContainerTreeNode): unknown { + switch (node.type) { + case "Value": + return node.value; + case "Map": + return Object.fromEntries( + Object.entries(node.value).map(([k, v]) => [k, project(v)]), + ); + case "List": + case "MovableList": + return node.value.map(project); + case "Tree": { + const visit = (nodes: typeof node.value): unknown[] => + nodes.map((n) => ({ + ...n, + meta: project(n.meta), + children: visit(n.children), + })); + return visit(node.value); + } + default: + return node.value; + } +} +function ids(node: ContainerTreeNode): string[] { + if (node.type === "Value") return []; + if (node.type === "Map") + return [node.cid, ...Object.values(node.value).flatMap(ids)]; + if (node.type === "List" || node.type === "MovableList") + return [node.cid, ...node.value.flatMap(ids)]; + return [node.cid]; +} + +describe("toContainerTree", () => { + it("distinguishes containers from opaque values and preserves exact IDs", () => { + const d = new LoroDoc(); + d.setPeerId("18446744073709551614"); + const m = d.getMap("root"); + const fake = { + type: "Map", + cid: "cid:root-fake:Map", + value: { nested: [null, true, "🙂"] }, + }; + m.set("fake", fake); + m.set("__proto__", { safe: true }); + m.set("bytes", new Uint8Array([0, 255, 128])); + m.set("number", Infinity); + m.set("nan", NaN); + const l = m.setContainer("list", new LoroMovableList()); + const t = l.pushContainer(new LoroText()); + t.insert(0, "\uFEFFhello中🙂"); + const merged = m.ensureMergeableMap("merged"); + merged.set("ok", true); + const read = d.toContainerTree(); + expect(project(read.root)).toStrictEqual({ + ...m.toJSON(), + ["__proto__"]: { safe: true }, + }); + expect(ids(read.root).sort()).toEqual([m.id, l.id, t.id, merged.id].sort()); + const root = read.root as Extract; + expect(root.value.fake).toEqual({ type: "Value", value: fake }); + expect(Object.prototype.hasOwnProperty.call(root.value, "__proto__")).toBe( + true, + ); + expect(Object.getPrototypeOf(root.value)).toBe(Object.prototype); + expect(m.toContainerTree()).toStrictEqual(root); + const bytes = root.value.bytes as { type: "Value"; value: Uint8Array }; + bytes.value[0] = 42; + expect(m.get("bytes")).toEqual(new Uint8Array([0, 255, 128])); + d.free(); + expect(bytes.value[1]).toBe(255); + }); + + it("includes Tree metadata IDs and rich text formatting", () => { + const d = new LoroDoc(); + const tree = d.getTree("tree"); + const node = tree.createNode(); + const text = node.data.setContainer("body", new LoroText()); + text.insert(0, "hello"); + text.mark({ start: 0, end: 5 }, "bold", true); + node.createNode().data.set("title", "child"); + const plain = d.toContainerTree().tree; + expect(project(plain)).toEqual(d.toJSON().tree); + const rich = tree.toContainerTree({ text: "delta" }); + if (rich.type !== "Tree") throw new Error("expected Tree"); + expect(rich.value[0].meta.cid).toBe(node.data.id); + expect(rich.value[0].meta.value.body).toEqual({ + type: "Text", + cid: text.id, + value: text.toDelta(), + }); + }); + + it("reads only the selected list subtrees and clamps ranges", () => { + for (const list of [new LoroList(), new LoroMovableList()]) { + const d = new LoroDoc(); + const l = d.getMap("root").setContainer("list", list); + l.push(1); + l.pushContainer(new LoroMap()).set("x", 2); + l.push("end"); + const full = l.toContainerTree(); + if (full.type !== "List" && full.type !== "MovableList") + throw new Error("expected list"); + expect(l.toContainerTreeSlice(1, 2)).toEqual({ + cid: l.id, + start: 1, + totalLength: 3, + items: full.value.slice(1, 2), + }); + for (const range of [ + { start: 3, end: 99 }, + { start: 2, end: 1 }, + { start: 99, end: 100 }, + ]) { + expect(l.toContainerTreeSlice(range.start, range.end)).toEqual({ + cid: l.id, + start: Math.min(range.start, 3), + totalLength: 3, + items: [], + }); + } + } + }); + + it("rejects invalid inputs and remains usable", () => { + const d = new LoroDoc(); + d.getMap("root").set("ok", true); + const read = d.toContainerTree.bind(d) as (o: unknown) => unknown; + for (const options of [ + { container: "bad" }, + { container: "cid:99@88:Map" }, + { text: "html" }, + { range: { start: 0, end: 1 } }, + { container: "cid:root-root:Map", range: { start: 0, end: 1 } }, + { container: "cid:root-l:List", range: { start: -1, end: 1 } }, + ]) { + expect(() => read(options)).toThrow(); + expect(project(d.toContainerTree().root)).toEqual({ ok: true }); + } + }); + + it("does not invoke inherited setters or commit pending changes", () => { + const d = new LoroDoc(); + const m = d.getMap("root"); + m.set("__container_tree_probe", "data"); + m.set("array", [1, 2]); + let calls = 0; + const vv = d.version().encode(); + Object.defineProperty(Object.prototype, "__container_tree_probe", { + configurable: true, + set() { + calls++; + }, + }); + let result: ReturnType; + try { + result = d.toContainerTree(); + } finally { + delete (Object.prototype as Record) + .__container_tree_probe; + } + expect(calls).toBe(0); + expect(d.version().encode()).toEqual(vv); + expect(project((result as Record).root)).toEqual( + m.toJSON(), + ); + }); + + it("bounds recursive traversal and leaves the document usable after an error", () => { + const d = new LoroDoc(); + let m = d.getMap("deep"); + for (let i = 0; i < 270; i++) m = m.setContainer("child", new LoroMap()); + expect(() => d.toContainerTree()).toThrow("nesting"); + expect(m.toContainerTree()).toEqual({ + type: "Map", + cid: m.id, + value: {}, + }); + }); + + it("constructs dense arrays without invoking inherited index setters", () => { + const d = new LoroDoc(); + d.getList("list").push([1, 2]); + let calls = 0; + let result: unknown; + Object.defineProperty(Array.prototype, "0", { + configurable: true, + set() { + calls++; + }, + }); + try { + result = d.toContainerTree(); + } finally { + delete (Array.prototype as unknown as Record)["0"]; + } + expect(calls).toBe(0); + expect(result).toEqual({ + list: { + type: "List", + cid: "cid:root-list:List", + value: [{ type: "Value", value: [1, 2] }], + }, + }); + }); + + it("honors root visibility and returns empty implicit roots", () => { + const d = new LoroDoc(); + expect(d.getMap("empty").toContainerTree()).toEqual({ + type: "Map", + cid: "cid:root-empty:Map", + value: {}, + }); + expect( + Object.fromEntries( + Object.entries(d.toContainerTree()).map(([k, v]) => [k, project(v)]), + ), + ).toEqual(d.toJSON()); + d.getMap("empty"); + d.setHideEmptyRootContainers(true); + expect(d.toContainerTree()).toEqual({}); + d.getCounter("counter").increment(3); + expect(d.toContainerTree().counter).toEqual({ + type: "Counter", + cid: "cid:root-counter:Counter", + value: 3, + }); + d.deleteRootContainer("cid:root-counter:Counter"); + expect(d.toContainerTree()).toEqual({}); + }); +}); + +it("selects roots without traversing excluded subtrees or creating missing roots", () => { + const d = new LoroDoc(); + d.getMap("selected").set("ok", true); + let deep = d.getMap("excluded"); + for (let i = 0; i < 270; i++) + deep = deep.setContainer("child", new LoroMap()); + const vv = d.version().encode(); + const shallow = d.getShallowValue(); + expect( + d.toContainerTree({ roots: ["selected", "missing", "selected"] }), + ).toEqual({ selected: d.getMap("selected").toContainerTree() }); + expect(d.toContainerTree({ roots: [] })).toEqual({}); + expect(d.getShallowValue()).toEqual(shallow); + expect(d.version().encode()).toEqual(vv); + expect(() => d.toContainerTree()).toThrow("nesting"); +}); +it("propagates text format through maps, lists and Tree metadata", () => { + const d = new LoroDoc(); + const map = d.getMap("root"); + const list = map.setContainer("list", new LoroList()); + const text = list + .pushContainer(new LoroMap()) + .setContainer("text", new LoroText()); + text.insert(0, "hello"); + text.mark({ start: 0, end: 5 }, "bold", true); + const tree = d.getTree("tree"); + const node = tree.createNode(); + node.data.setContainer("text", new LoroText()).insert(0, "child"); + const roots = d.toContainerTree({ text: "delta" }); + expect(roots.root).toEqual(map.toContainerTree({ text: "delta" })); + expect(roots.tree).toEqual(tree.toContainerTree({ text: "delta" })); + const nested = map.toContainerTree({ text: "delta" }).value.list; + if (nested.type !== "List") throw Error("list"); + const child = nested.value[0]; + if (child.type !== "Map") throw Error("map"); + expect(child.value.text).toEqual({ + type: "Text", + cid: text.id, + value: text.toDelta(), + }); + expect(list.toContainerTreeSlice(0, 1, { text: "delta" }).items).toEqual( + nested.value, + ); +}); +it("rejects detached containers and invalid slice bounds", () => { + for (const container of [ + new LoroMap(), + new LoroList(), + new LoroMovableList(), + new LoroText(), + new LoroTree(), + new LoroCounter(), + ]) { + expect(() => container.toContainerTree()).toThrow("attached"); + } + const d = new LoroDoc(); + const list = d.getList("list"); + list.push("ok"); + for (const n of [-1, 0.5, NaN, Infinity, 2 ** 32]) { + expect(() => list.toContainerTreeSlice(n, 1)).toThrow("bounds"); + expect(() => list.toContainerTreeSlice(0, n)).toThrow("bounds"); + } + expect(list.toContainerTreeSlice(0, 99)).toEqual({ + cid: list.id, + start: 0, + totalLength: 1, + items: [{ type: "Value", value: "ok" }], + }); + expect(d.getCounter("counter").toContainerTree()).toEqual({ + type: "Counter", + cid: "cid:root-counter:Counter", + value: 0, + }); +}); +// Compile-only API contracts. This function is deliberately never called. +function checkContainerTreeTypes( + d: LoroDoc, + map: LoroMap, + list: LoroList, + text: LoroText, +) { + const plain: string = text.toContainerTree().value; + const delta: ReturnType = text.toContainerTree({ + text: "delta", + }).value; + const mapKind: "Map" = map.toContainerTree().type; + // @ts-expect-error Document selection is by roots, not a container ID. + d.toContainerTree({ container: map.id }); + // @ts-expect-error Complete tree reads never accept a range. + list.toContainerTree({ range: { start: 0, end: 1 } }); + // @ts-expect-error Containers do not select document roots. + map.toContainerTree({ roots: ["root"] }); + // @ts-expect-error Plain text is not a delta. + const invalid: typeof delta = plain; + return [plain, delta, mapKind, invalid]; +} +void checkContainerTreeTypes; + +// Optional options must not promise delta when runtime defaults to plain text. +function checkOptionalTreeTypes( + text: LoroText, + doc: LoroDoc, + map: LoroMap, + tree: LoroTree, + list: LoroList, + movable: LoroMovableList, + optional: import("../bundler/index").ContainerTreeOptions<"delta">, + maybe: { text: "delta" } | undefined, + mode: "plain" | "delta", + listUnion: LoroList | LoroMovableList, +) { + type Delta = ReturnType; + const unionList = listUnion.toContainerTree({ text: "delta" }); + const unionSlice = listUnion.toContainerTreeSlice(0, 1, { text: "delta" }); + void [unionList, unionSlice]; + const union: string | Delta = text.toContainerTree(optional).value; + const dynamic: string | Delta = text.toContainerTree({ text: mode }).value; + // @ts-expect-error Optional text can default to a string. + const a: Delta = text.toContainerTree(optional).value; + // @ts-expect-error Missing options default to a string. + const b: Delta = text.toContainerTree(maybe).value; + // @ts-expect-error Explicit type arguments cannot override runtime defaults. + const c: Delta = text.toContainerTree<[{ text: "delta" }]>().value; + const plain: string = text.toContainerTree({}).value; + const delta: Delta = text.toContainerTree({ text: "delta" }).value; + const selected = doc.toContainerTree({ + roots: ["settings"] as const, + text: "delta", + }); + // @ts-expect-error Unselected root names are not in the result type. + selected.other; + // @ts-expect-error A selected root can be missing or hidden. + const required: ContainerNode<"delta"> = selected.settings; + const selectedNode: ContainerNode<"delta"> | undefined = selected.settings; + const absent = doc.toContainerTree({ roots: [] as const }); + // @ts-expect-error Empty selection has no keys. + absent.settings; + const child = map.toContainerTree(optional).value.text; + if (child.type === "Text") { + // @ts-expect-error Optional text applies recursively. + const d: Delta = child.value; + void d; + } + const meta = tree.toContainerTree(optional).value[0].meta.value.text; + if (meta.type === "Text") { + // @ts-expect-error Tree metadata also retains the default plain possibility. + const e: Delta = meta.value; + void e; + } + for (const items of [ + list.toContainerTreeSlice(0, 1, maybe).items, + movable.toContainerTreeSlice(0, 1, maybe).items, + ]) { + const item = items[0]; + if (item.type === "Text") { + // @ts-expect-error Optional slice options can produce strings. + const f: Delta = item.value; + void f; + } + } + return [union, dynamic, a, b, c, plain, delta, required, selectedNode]; +} +void checkOptionalTreeTypes; + +it("defaults optional delta configurations to plain text when omitted", () => { + const text = new LoroDoc().getText("text"); + text.insert(0, "hello"); + const options: import("../bundler/index").ContainerTreeOptions<"delta"> = {}; + expect(text.toContainerTree(options).value).toBe("hello"); + expect(text.toContainerTree(undefined).value).toBe("hello"); + expect(text.toContainerTree({ text: "delta" }).value).toEqual(text.toDelta()); +});