diff --git a/.changeset/loro-js-diff-reattached-child.md b/.changeset/loro-js-diff-reattached-child.md new file mode 100644 index 000000000..fdc46d9ba --- /dev/null +++ b/.changeset/loro-js-diff-reattached-child.md @@ -0,0 +1,11 @@ +--- +"loro.js": patch +--- + +`diff()` and `revertTo()` in `loro.js` now restore containers that the range makes reachable again: a map key set back to an older child, a revived list element, or a revived tree node with its whole subtree and metadata, including a node moved out of a deleted ancestor. Their whole state is included, together with nested children, in parent-before-child order, as Rust does. Containers that stay reachable, such as a moved movable-list child, keep only their own changes. `diff()` leaves out containers that are unreachable at the target version. + +Mergeable children follow Rust main (loro-dev/loro#1134). `diff()` reports a re-activated mergeable child with its full state. `applyDiff` applies it incrementally by default, and `applyDiff(diff, { fullState: true })` aligns the receiver's existing child with it: maps per key, counters by difference, text and lists by common prefix and suffix, and trees by node ID. `revertTo` and undo apply only the actual change, so the child keeps its identity. A local re-ensure emits only the parent marker. Import and checkout events now carry the full state of a re-activated child, as in loro-crdt. **An event mirror (subscribe โ†’ `applyDiff`) should therefore pass `{ fullState: true }` for `import` and `checkout` batches.** With the default, the full state is applied on top of the mirror's hidden copy. + +Before writing anything, `applyDiff` checks every list delta against the list lengths the batch produces, including a hidden mergeable child's own length. It also dry-runs every tree item, so an out-of-range list delta or a tree item that cannot apply rejects the whole batch. Other malformed entries (text ranges, counter values, a diff whose type does not match its container) can still leave earlier entries applied, as before and as in Rust. Tree items apply in order, as in Rust: a create of a node that is alive moves it, a move of a missing or deleted node creates a new one, and a delete of a deleted node does nothing. Map children are always set as new containers. Tree diffs list moves first (parent first), then the topmost deletes, then revived subtrees, with each index counted in the tree the earlier items leave. A node that is hidden at the target version is reported as deleted. On a movable list, `applyDiff` moves an existing child container instead of creating an empty copy. Diffs, imports and checkouts of deep trees take linear time. + +Undo can revert movable-list moves, including several moves of the same element. The element returns next to its old physical neighbour when that neighbour has not moved since. The undo manager keeps only the tracked ranges its stacks can still use, at a commit cost independent of the stack length. Undoing a move writes a move op, and loro.js still places a remote move by its index rather than its causal position, so a move concurrent with an insert can diverge from Rust (the MovableList model rework, loro-dev/loro#1132). diff --git a/AGENTS.md b/AGENTS.md index 024ee3ff6..523a73b39 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -34,6 +34,8 @@ Loro is a Rust CRDT workspace with JS/WASM packaging and a MoonBit codec. and delete `start_id`s: [context/richtext-insert-positions.md](context/richtext-insert-positions.md). - Tree diff cache transitions and why their lamport window comes from the two versions, not the replay base: [context/tree-checkout-window.md](context/tree-checkout-window.md). +- loro.js tree diff item order, index counting, apply resolution, and batch dry-run: + [context/loro-js-tree-diff.md](context/loro-js-tree-diff.md). - User-facing Loro usage, sync, editor integration, and performance guidance: [skills/loro/SKILL.md](skills/loro/SKILL.md). - Pure TypeScript runtime indexes, complexity contracts, benchmarks, and remaining gaps: diff --git a/context/loro-js-tree-diff.md b/context/loro-js-tree-diff.md new file mode 100644 index 000000000..05c74ef03 --- /dev/null +++ b/context/loro-js-tree-diff.md @@ -0,0 +1,75 @@ +# loro.js Tree Diffs: Item Order, Indexes, and Apply + +Verified against code 2026-09-29. + +How `loro.js` builds and applies tree diff items, and why. The Rust side is in +[tree-checkout-window.md](tree-checkout-window.md) and `crates/loro-internal/src/handler.rs` +(`Handler::apply_diff`, `Self::Tree`). + +## Items apply in order + +A tree diff is a sequence. Each item's `index`/`oldIndex` refers to the tree that the +earlier items leave, as in Rust. `LoroDoc.#applyTreeDiff` (`loro-js/src/runtime/document.ts`) +applies items in order. It defers an item only while its parent does not exist yet. + +Resolution follows Rust's `apply_diff`: + +- A `create` of a node that is alive here (the node and all its ancestors are not + deleted) moves it. +- A `move` of a node that is missing or deleted here creates a new node and remaps its + meta map. +- A `delete` of a node that is already deleted does nothing. + +Placement differs from Rust on purpose. Rust places a node at its `fractionalIndex` and +ignores `index`; loro.js uses `index`. Equal positions (concurrent creates) tie on the new +op's writer, so position placement reorders them. In a random oracle (300 concurrent +histories), position placement lost 30 of 1,200 applies and 47 of 1,200 reverts that +index placement gets right. Consequence: a receiver whose *other* siblings differ from the +source (for example a doc with a different hidden mergeable tree, applied without +`fullState`) orders new nodes differently from Rust. + +## How `diff()` orders items + +`#completeTreeRevivals` rewrites every tree diff that `diff()` and import/checkout events +produce (via `#completeDiffEntries`): + +1. **Moves of live nodes, parent first by depth at `to`.** A node leaves a subtree before + that subtree is deleted, and no move makes a cycle. +2. **Deletes, topmost only.** These are the diff's own deletes, plus the old copies of + moved-in nodes that a revived subtree recreates. A node that is hidden at `to` but was + alive at `from` is emitted as a delete, whatever its recorded item was. A node alive at + neither version is dropped. +3. **Revived subtrees as creates, parent first.** A revived node's whole subtree is + recreated, because `diff()` has full-state semantics. + +`#sequenceTreeItems` counts each index against the tree the earlier items leave. Only +parents that exist at `from` need counting; a recreated parent gets its children in +order. Under such a parent, the children at any moment are: + +- the unchanged ones, counted with the tree's rank query (`LoroTree._childRank`, O(log n)) + minus the changed nodes currently there; +- the present *slots* of changed nodes: a slot at `from` for each changed node that was + there, and a slot at `to` for each moved or revived node placed there. These are kept in + a Fenwick tree. + +The cost is O(changed ยท log n), independent of the sibling count. A single move among +100k siblings must stay about 1 ms. Enumerating siblings instead cost 50โ€“75 ms per diff or +event. + +## Validation before writing + +`applyDiff` dry-runs every tree item with `validateTreeItems`, using the same order, +deferral and resolution as `#applyTreeDiff`. It throws what applying would throw: a +missing parent, a move below itself, or an index outside the parent's children. A +failing batch therefore writes nothing (`loro-js/tests/tree-diff-order.test.ts`). Keep +the two functions in step when changing either one. + +## Pitfalls + +- Hidden checks walk to the root. Pass a memo (`#isTreeRecordHidden(tree, record, + cache)`, `#treeNodeAliveAt(..., cache)`), or deep chains go quadratic in `diff()` and in + import/checkout events with subscribers. +- Build fractional-index hex strings flat (`fractionalIndexHex`). Appending per byte + leaves rope strings that a 100k-node diff keeps alive, and GC dominates. +- Rust applies a TS diff by fractional index (see above). Diffs with equal positions can + therefore come out ordered differently in Rust than in loro.js. diff --git a/context/mergeable-containers.md b/context/mergeable-containers.md index 0f2d2b3cb..b1c2e66c6 100644 --- a/context/mergeable-containers.md +++ b/context/mergeable-containers.md @@ -1,6 +1,6 @@ # Mergeable Container Context -Verified against code 2026-09-28. +Verified against code 2026-09-29. Mergeable containers let two peers independently create the same child container under a map key and converge to one deterministic container id. The source of @@ -123,6 +123,14 @@ full-state diff. So the mode is explicit: difference is written, keeping char/element/`TreeID` identity. - **UndoManager** composes remote events with `ContainerDiff::change()`, so a peer can still undo its edit after a remote undo re-activates the child. +- **loro.js** mirrors this in `loro-js/src/runtime/document.ts`. `#completeDiffEntries` + adds full state for a re-activated child, both in `diff()` and in import/checkout events + (`#emit`). `revertTo` sets `#changesOnlyDiff`, which skips full state for mergeable + children. `applyDiff(diff, { fullState: true })` aligns through `#fullStateTargets` and + `#alignFullState`. `#validateDiffBatch` checks a hidden mergeable list's deltas against + its hidden length (re-ensuring resurfaces it). An event mirror passes `{ fullState: true }` + for `import`/`checkout` batches, as with loro-crdt (`ApplyDiffOptions.fullState`). Tests: + `loro-js/tests/mergeable-diff-alignment.test.ts`, with values taken from the Rust WASM build. Which batches may carry the flag: diff --git a/loro-js/src/codec/bytes.ts b/loro-js/src/codec/bytes.ts index 46f75e99e..dd652e6d7 100644 --- a/loro-js/src/codec/bytes.ts +++ b/loro-js/src/codec/bytes.ts @@ -252,11 +252,14 @@ export function compareBytes(a: Uint8Array, b: Uint8Array): number { return a.length - b.length; } +const HEX_BYTES = Array.from({ length: 256 }, (_, byte) => + byte.toString(16).padStart(2, "0"), +); + export function bytesToHex(bytes: Uint8Array): string { let output = ""; - for (const byte of bytes) { - output += byte.toString(16).padStart(2, "0"); - } + for (let index = 0; index < bytes.length; index += 1) + output += HEX_BYTES[bytes[index]!]; return output; } diff --git a/loro-js/src/runtime/containers.ts b/loro-js/src/runtime/containers.ts index e94b407e6..d7e9c46df 100644 --- a/loro-js/src/runtime/containers.ts +++ b/loro-js/src/runtime/containers.ts @@ -70,6 +70,15 @@ export interface SequenceMoveMeta { readonly beforeNext: CodecId | undefined; readonly afterPrevious: CodecId | undefined; readonly afterNext: CodecId | undefined; + /** + * The element physically after the moved one before the move, deleted or + * not; `null` when it was last. Undo puts the element back there, so undone + * moves and restored deletions keep their relative order. Undefined when + * unknown (e.g. metadata rebuilt from a snapshot). + */ + readonly beforePhysicalNext?: CodecId | null | undefined; + /** The element physically before it, deleted or not; `null` when it was first. */ + readonly beforePhysicalPrevious?: CodecId | null | undefined; } export type CausalVersion = ReadonlyMap; @@ -2660,6 +2669,11 @@ export class LoroMovableList extends LoroList { return this._sequence.atVisible(pos)?.id.peer.toString(); } + /** Physical placement for the next `_applyMove` of `element` (used by undo). */ + _physicalMoveHint: + | { readonly element: SequenceElement; readonly before: SequenceElement | undefined } + | undefined; + _applyMove( from: number, to: number, @@ -2670,12 +2684,31 @@ export class LoroMovableList extends LoroList { if (element === undefined) return; const beforePrevious = this._sequence.previousVisible(element)?.id; const beforeNext = this._sequence.nextVisible(element)?.id; - this._sequence.moveVisible(from, to); + const physical = this._sequence.physicalIndexOf(element); + const beforePhysicalNext = + physical === undefined + ? undefined + : (this._sequence.atPhysical(physical + 1)?.id ?? null); + const beforePhysicalPrevious = + physical === undefined + ? undefined + : physical === 0 + ? null + : (this._sequence.atPhysical(physical - 1)?.id ?? null); + const hint = this._physicalMoveHint; + this._physicalMoveHint = undefined; + if (hint !== undefined && hint.element === element) { + this._sequence.moveBefore(element, hint.before); + } else { + this._sequence.moveVisible(from, to); + } if (operation === undefined) return; const meta: SequenceMoveMeta = { ...operation, beforePrevious, beforeNext, + beforePhysicalNext, + beforePhysicalPrevious, afterPrevious: this._sequence.previousVisible(element)?.id, afterNext: this._sequence.nextVisible(element)?.id, }; @@ -2945,6 +2978,25 @@ export class LoroTree< ); } + /** How many current children of `parent` sort before `key`, in O(log n). */ + _childRank( + parent: CodecId | undefined, + key: Pick, + ): number { + this._ensureHydrated(); + return ( + this._children + .get(treeParentKey(parent)) + ?._lowerBoundBy((record) => compareTreeRecords(record, key as TreeNodeRecord)) ?? + 0 + ); + } + + _childCount(parent: CodecId | undefined): number { + this._ensureHydrated(); + return this._children.get(treeParentKey(parent))?.size ?? 0; + } + _childrenOf(parent: CodecId | undefined): TreeNodeRecord[] { this._ensureHydrated(); return this._children.get(treeParentKey(parent))?.values() ?? []; diff --git a/loro-js/src/runtime/document.ts b/loro-js/src/runtime/document.ts index c30fe62e1..81937856f 100644 --- a/loro-js/src/runtime/document.ts +++ b/loro-js/src/runtime/document.ts @@ -114,6 +114,7 @@ import type { JsonChange, JsonContainerID, JsonDiff, + ApplyDiffOptions, JsonIdLp, JsonOp, JsonOpContent, @@ -329,6 +330,12 @@ export class LoroDoc = Record | undefined; + // Set while revertTo computes its diff: a re-activated mergeable child then + // contributes its actual change instead of its full state. + #changesOnlyDiff = false; // Root store entries by container key, built by the import merge or on first // lookup. #shallowRootEntries: Map | undefined; @@ -1242,10 +1249,15 @@ export class LoroDoc = Record>(); + const entries = [...changed] .flatMap((id) => { const container = this.#containers.get(id); - return container === undefined ? [] : [container]; + // As in Rust, a container that is not reachable at `to` (deleted, + // under a deleted tree node, or a hidden mergeable child) has no entry. + return container === undefined || !this.#isReachable(container, hiddenNodes) + ? [] + : [container]; }) .sort((left, right) => containerDepth(left) - containerDepth(right)) .flatMap((container) => { @@ -1255,14 +1267,17 @@ export class LoroDoc = Record + [id, forJson ? diffForJson(diff) : diff] as [ContainerID, Diff | JsonDiff], + ); } catch (error) { failed = true; throw error; @@ -1319,24 +1334,1128 @@ export class LoroDoc = Record[], + containerRemap: Map, + ): boolean { + // Children of a lazily imported list only exist once it is decoded. + list._ensureHydrated(); + // Deleted ranges in original (pre-delta) indices, in ascending order. + const deletedRanges: { readonly start: number; readonly length: number }[] = []; + const insertedChildren: ContainerID[] = []; + let index = 0; + for (const operation of delta) { + if ("retain" in operation) { + index += operation.retain; + } else if ("delete" in operation) { + const last = deletedRanges.at(-1); + if (last !== undefined && last.start + last.length === index) { + deletedRanges[deletedRanges.length - 1] = { + start: last.start, + length: last.length + operation.delete, + }; + } else if (operation.delete > 0) { + deletedRanges.push({ start: index, length: operation.delete }); + } + index += operation.delete; + } else { + for (const value of operation.insert) { + const id = diffContainerId(value); + if (id !== undefined) insertedChildren.push(id); + } + } + } + if (insertedChildren.length === 0 || deletedRanges.length === 0) return false; + + const inDeletedRange = (position: number): boolean => { + let low = 0; + let high = deletedRanges.length; + while (low < high) { + const middle = (low + high) >>> 1; + if (deletedRanges[middle]!.start <= position) low = middle + 1; + else high = middle; + } + const range = deletedRanges[low - 1]; + return range !== undefined && position < range.start + range.length; + }; + // Inserted children that currently occupy a deleted slot of this list. + const moved = new Map(); + for (const sourceId of insertedChildren) { + let id: ContainerID | undefined = sourceId; + let child = this.#containers.get(id); + for (let hops = 0; child === undefined && hops < 64; hops += 1) { + id = containerRemap.get(id!)?.id; + if (id === undefined) break; + child = this.#containers.get(id); + } + if (child === undefined || child.parent() !== list) continue; + const binding = child._parentLink?.binding ?? recoverParentBinding(child, list); + if (binding?.kind !== "sequence" || binding.element.value !== child) continue; + if (binding.element.deleted) continue; + const position = list._sequence.visibleIndexOf(binding.element); + if (position === undefined || !inDeletedRange(position)) continue; + if (!moved.has(sourceId)) + moved.set(sourceId, { element: binding.element, index: position }); + } + if (moved.size === 0) return false; + + // Walk the delta once more: each inserted item is placed right after its + // predecessor in the final order (a retained element or an earlier item). + type Item = { + readonly value: unknown; + readonly movedElement: SequenceElement | undefined; + readonly after: Predecessor | undefined; + placed: SequenceElement | undefined; + }; + type Predecessor = + | { readonly kind: "retained"; readonly element: SequenceElement } + | { readonly kind: "item"; readonly item: Item }; + const items: Item[] = []; + const used = new Set(); + const movedElements = new Set(); + let predecessor: Predecessor | undefined; + index = 0; + for (const operation of delta) { + if ("retain" in operation) { + if (operation.retain > 0) { + index += operation.retain; + predecessor = { + kind: "retained", + element: list._sequence.atVisible(index - 1)!, + }; + } + } else if ("delete" in operation) { + index += operation.delete; + } else { + for (const value of operation.insert) { + const id = diffContainerId(value); + const entry = id === undefined || used.has(id) ? undefined : moved.get(id); + if (entry !== undefined) { + used.add(id!); + movedElements.add(entry.element); + } + const item: Item = { + value, + movedElement: entry?.element, + after: predecessor, + placed: undefined, + }; + items.push(item); + predecessor = { kind: "item", item }; + } + } + } + + // Delete the deleted ranges back to front, keeping the moved elements. + // Only the gaps between kept positions are deleted, so the work follows + // the number of moved children, not the length of the deleted ranges. + const kept = [...moved.values()] + .filter(({ element }) => movedElements.has(element)) + .map(({ index: position }) => position) + .sort((left, right) => right - left); + let keptIndex = 0; + for (let rangeIndex = deletedRanges.length - 1; rangeIndex >= 0; rangeIndex -= 1) { + const range = deletedRanges[rangeIndex]!; + let end = range.start + range.length; + while (keptIndex < kept.length && kept[keptIndex]! >= range.start) { + const position = kept[keptIndex]!; + keptIndex += 1; + if (position >= end) continue; + if (end > position + 1) list.delete(position + 1, end - position - 1); + end = position; + } + if (end > range.start) list.delete(range.start, end - range.start); + } + + for (const item of items) { + const after = + item.after === undefined + ? undefined + : item.after.kind === "retained" + ? item.after.element + : item.after.item.placed; + let target = after === undefined ? 0 : list._sequence.visibleIndexOf(after)! + 1; + if (item.movedElement !== undefined) { + const from = list._sequence.visibleIndexOf(item.movedElement)!; + if (from < target) target -= 1; + if (from !== target) list.move(from, target); + item.placed = item.movedElement; + continue; + } + const sourceChildId = diffContainerId(item.value); + if (sourceChildId === undefined) { + list.insert(target, item.value); + } else { + const parsed = parseContainerId(sourceChildId); + if (parsed.kind === "root") { + throw new TypeError("a root container cannot be inserted as a child"); + } + const child = createContainer( + codecTypeToPublic(parsed.containerType), + ) as Container; + containerRemap.set(sourceChildId, list.insertContainer(target, child)); + } + item.placed = list._sequence.atVisible(target); + } + return true; + } + + /** + * A tree node that is alive at `to` but not at `from` (it or an ancestor + * was deleted) is created again together with its whole subtree, parent + * first, as Rust reports it. A recorded tree diff only lists the nodes whose + * own records changed: reviving a parent omits its children, and moving a + * node out of a deleted ancestor shows up as a plain move. + */ + #completeTreeRevivals( + tree: LoroTree, + items: readonly TreeDiffItem[], + from: VersionVector, + ): TreeDiffItem[] { + if (items.length === 0) return []; + const aliveCache = new Map(); + const aliveAtFrom = (node: CodecId): boolean => + this.#treeNodeAliveAt(tree, node, from, aliveCache); + const hiddenCache = new Map(); + const hiddenNow = (record: TreeNodeRecord): boolean => + this.#isTreeRecordHidden(tree, record, hiddenCache); + // A node that is hidden now (it or an ancestor is deleted) and was alive + // at `from` is deleted, whatever its item: a move into a deleted subtree + // or a parent the receiver lacks shows as its removal. A node alive at + // neither version changes nothing visible and is left out. + const deleted = new Set(); + const revived = new Set(); + for (const item of items) { + if (item.action === "delete") { + deleted.add(item.target); + continue; + } + const record = tree._nodes.get(item.target); + if (record === undefined) continue; + if (hiddenNow(record)) { + if (aliveAtFrom(record.id)) deleted.add(item.target); + continue; + } + if (item.action === "create" || !aliveAtFrom(record.id)) revived.add(item.target); + } + // Whether an ancestor is revived, memoized along each walked path. + const insideCache = new Map(); + const insideRevived = (target: TreeID): boolean => { + const path: string[] = []; + let result = false; + for (let parent = tree._nodes.get(target)?.parent; parent !== undefined; ) { + const key = formatTreeId(parent); + const known = insideCache.get(key); + if (known !== undefined) { + result = known; + break; + } + if (revived.has(key)) { + result = true; + break; + } + path.push(key); + parent = tree._nodes.get(key)?.parent; + } + for (const key of path) insideCache.set(key, result); + return result; + }; + + // Items apply in order, as in Rust, so each needs the tree its earlier + // items leave: first the moves of live nodes, parent first, so a node + // leaves a subtree before the subtree is deleted; then the deletes (the + // diff's own, and the old copies of moved-in nodes that a revived subtree + // recreates); then the revived subtrees, parent first. Only the topmost + // deleted nodes are listed: a delete removes the node's subtree. + const deleteTargets: CodecId[] = []; + for (const target of deleted) { + const node = parseTreeId(target); + const parent = this.#treeNodePlacementAt(tree, node, from)?.parent; + if (parent === undefined || !deleted.has(formatTreeId(parent))) + deleteTargets.push(node); + } + const moves: TreeNodeRecord[] = []; + const revivedTrees: { records: TreeNodeRecord[]; indices: number[] }[] = []; + for (const item of items) { + if (item.action === "delete" || deleted.has(item.target)) continue; + if (!revived.has(item.target)) { + const record = tree._nodes.get(item.target); + if ( + record !== undefined && + !hiddenNow(record) && + item.action === "move" && + !insideRevived(item.target) + ) { + moves.push(record); + } + continue; + } + if (insideRevived(item.target)) continue; + // Iterative preorder: a wide subtree must not be spread into call + // arguments, and each node is visited once. + const records: TreeNodeRecord[] = []; + const indices: number[] = []; + const top = tree._nodes.get(item.target)!; + records.push(top); + indices.push(tree._indexOf(top)); + const stack = [{ children: tree._childrenOf(top.id), next: 0 }]; + while (stack.length > 0) { + const frame = stack.at(-1)!; + if (frame.next === frame.children.length) { + stack.pop(); + continue; + } + const index = frame.next; + frame.next += 1; + const record = frame.children[index]!; + records.push(record); + indices.push(index); + stack.push({ children: tree._childrenOf(record.id), next: 0 }); + } + // A node of the subtree that was already alive at `from` (moved in, not + // revived) is recreated with the rest, so its old copy is deleted, as + // Rust does. Deleting the topmost such nodes removes the others. + const alive = new Set(); + for (let position = 1; position < records.length; position += 1) { + if (aliveAtFrom(records[position]!.id)) alive.add(idKey(records[position]!.id)); + } + for (let position = 1; position < records.length; position += 1) { + const record = records[position]!; + if (!alive.has(idKey(record.id))) continue; + const parent = this.#treeNodePlacementAt(tree, record.id, from)?.parent; + if (parent !== undefined && alive.has(idKey(parent))) continue; + deleteTargets.push(record.id); + } + revivedTrees.push({ records, indices }); + } + // Parent first by depth at `to`: a moved node's new ancestors are then in + // place, so no move makes a cycle. + const depths = new Map(); + const depthOf = (record: TreeNodeRecord): number => { + const path: TreeNodeRecord[] = []; + let depth = 0; + for (let current: TreeNodeRecord | undefined = record; current !== undefined; ) { + const known = depths.get(idKey(current.id)); + if (known !== undefined) { + depth = known; + break; + } + path.push(current); + current = + current.parent === undefined + ? undefined + : tree._nodes.get(formatTreeId(current.parent)); + } + for (let index = path.length - 1; index >= 0; index -= 1) { + depth += 1; + depths.set(idKey(path[index]!.id), depth); + } + return depths.get(idKey(record.id))!; + }; + const movesByDepth = moves + .map((record) => ({ record, depth: depthOf(record) })) + .sort((left, right) => left.depth - right.depth) + .map(({ record }) => record); + const changed = new Set(items.map((item) => item.target)); + return this.#sequenceTreeItems( + tree, + movesByDepth, + deleteTargets, + revivedTrees, + changed, + from, + aliveAtFrom, + ); + } + + /** + * Emits `moves`, then deletes of `deleteTargets`, then creates of + * `revivedTrees`, each index counted in the tree that the earlier items + * leave, starting from the tree at `from`. Only parents that exist at + * `from` need counting: a recreated parent gets its children in order. + * Under such a parent the children at any moment are the unchanged ones + * plus the present slots of changed nodes: a slot at `from` for each + * changed node that was there, and a slot at `to` for each moved or revived + * node placed there. All are in sibling order, so the index of a slot is the + * number of unchanged children before it (the current rank less the changed + * nodes before it now) plus the present changed slots before it, kept in a + * Fenwick tree. The cost is O(changed log n), whatever the sibling count. + */ + #sequenceTreeItems( + tree: LoroTree, + moves: readonly TreeNodeRecord[], + deleteTargets: readonly CodecId[], + revivedTrees: readonly { records: TreeNodeRecord[]; indices: number[] }[], + changed: ReadonlySet, + from: VersionVector, + aliveAtFrom: (node: CodecId) => boolean, + ): TreeDiffItem[] { + const placements = new Map(); + const placementOf = (node: CodecId): TreePlacement | undefined => { + const key = idKey(node); + if (!placements.has(key)) + placements.set(key, this.#treeNodePlacementAt(tree, node, from)); + return placements.get(key); + }; + const parentKey = (parent: CodecId | undefined): string => + parent === undefined ? "" : idKey(parent); + const oldParentOf = (node: CodecId): { parent: CodecId | undefined } | undefined => { + if (!aliveAtFrom(node)) return undefined; + const placement = placementOf(node); + return placement === undefined ? undefined : { parent: placement.parent }; + }; + + type Slot = { key: TreeSiblingKey; node: string; isNew: boolean }; + interface ParentOrder { + readonly parent: CodecId | undefined; + readonly slots: Slot[]; + // Keys of the changed nodes under the parent now, sorted. + readonly current: TreeSiblingKey[]; + readonly old: Map; + readonly new: Map; + counts: Fenwick; + } + const parents = new Map(); + const parentEntry = (parent: CodecId | undefined): ParentOrder => { + const key = parentKey(parent); + let entry = parents.get(key); + if (entry === undefined) { + entry = { + parent, + slots: [], + current: [], + old: new Map(), + new: new Map(), + counts: new Fenwick(0), + }; + parents.set(key, entry); + } + return entry; + }; + for (const record of moves) { + const old = oldParentOf(record.id); + if (old !== undefined) parentEntry(old.parent); + parentEntry(record.parent); + } + for (const target of deleteTargets) { + const old = oldParentOf(target); + if (old !== undefined) parentEntry(old.parent); + } + for (const { records } of revivedTrees) parentEntry(records[0]!.parent); + for (const target of changed) { + const node = parseTreeId(target); + // A slot at `from`, for a changed node that was under the parent then. + const old = oldParentOf(node); + const fromEntry = + old === undefined ? undefined : parents.get(parentKey(old.parent)); + if (fromEntry !== undefined) { + fromEntry.slots.push({ + key: siblingKey(node, placementOf(node)!), + node: target, + isNew: false, + }); + } + // A changed node under the parent now is not one of its unchanged children. + const record = tree._nodes.get(target); + const nowEntry = + record === undefined || record.deleted + ? undefined + : parents.get(parentKey(record.parent)); + if (nowEntry !== undefined) nowEntry.current.push(siblingKey(record!.id, record!)); + } + // Slots at `to`: moved nodes and the tops of revived subtrees. + for (const record of [...moves, ...revivedTrees.map(({ records }) => records[0]!)]) { + parentEntry(record.parent).slots.push({ + key: siblingKey(record.id, record), + node: formatTreeId(record.id), + isNew: true, + }); + } + for (const entry of parents.values()) { + const { slots } = entry; + slots.sort( + (left, right) => + compareSiblingKeys(left.key, right.key) || + Number(left.isNew) - Number(right.isNew), + ); + slots.forEach((slot, index) => + (slot.isNew ? entry.new : entry.old).set(slot.node, index), + ); + entry.counts = new Fenwick(slots.length, (index) => !slots[index]!.isNew); + entry.current.sort(compareSiblingKeys); + } + // Unchanged children of `entry` that sort before `key`. + const unchangedBefore = (entry: ParentOrder, key: TreeSiblingKey): number => { + let low = 0; + let high = entry.current.length; + while (low < high) { + const middle = (low + high) >>> 1; + if (compareSiblingKeys(entry.current[middle]!, key) < 0) low = middle + 1; + else high = middle; + } + return tree._childRank(entry.parent, key) - low; + }; + const indexOf = (entry: ParentOrder, slot: number): number => + unchangedBefore(entry, entry.slots[slot]!.key) + entry.counts.prefix(slot); + const removeOld = ( + node: CodecId, + ): { parent: CodecId | undefined; index: number } | undefined => { + const old = oldParentOf(node); + const entry = old === undefined ? undefined : parents.get(parentKey(old.parent)); + const slot = entry?.old.get(formatTreeId(node)); + if (old === undefined || entry === undefined || slot === undefined) + return undefined; + const index = indexOf(entry, slot); + entry.counts.remove(slot); + return { parent: old.parent, index }; + }; + const addNew = (record: TreeNodeRecord): number => { + const entry = parents.get(parentKey(record.parent))!; + const slot = entry.new.get(formatTreeId(record.id))!; + entry.counts.add(slot); + return indexOf(entry, slot); + }; + const fractionalIndex = (record: TreeNodeRecord): string => + fractionalIndexHex(record.position); + const parentId = (parent: CodecId | undefined): TreeID | undefined => + parent === undefined ? undefined : formatTreeId(parent); + + const result: TreeDiffItem[] = []; + for (const record of moves) { + const old = removeOld(record.id); + const index = addNew(record); + result.push({ + target: formatTreeId(record.id), + action: "move", + parent: parentId(record.parent), + index, + fractionalIndex: fractionalIndex(record), + oldParent: parentId(old?.parent), + oldIndex: old?.index ?? 0, + }); + } + for (const target of deleteTargets) { + const old = removeOld(target); + if (old === undefined) continue; + result.push({ + target: formatTreeId(target), + action: "delete", + oldParent: parentId(old.parent), + oldIndex: old.index, + }); + } + for (const { records, indices } of revivedTrees) { + for (let position = 0; position < records.length; position += 1) { + const record = records[position]!; + result.push({ + target: formatTreeId(record.id), + action: "create", + parent: parentId(record.parent), + index: position === 0 ? addNew(record) : indices[position]!, + fractionalIndex: fractionalIndex(record), + }); + } + } + return result; + } + + /** Whether `container` is reachable from a root container in the current state. */ + /** + * `hiddenNodes` memoizes, per tree, which nodes are hidden, so the nodes of + * a deep tree resolve in linear time over one call site's containers. + */ + #isReachable( + container: LoroContainer, + hiddenNodes: Map> = new Map(), + ): boolean { + const hiddenCacheFor = (tree: LoroTree): Map => { + let cache = hiddenNodes.get(tree); + if (cache === undefined) hiddenNodes.set(tree, (cache = new Map())); + return cache; + }; + for (let current: LoroContainer | undefined = container; current !== undefined; ) { + const id = current._codecId; + if (id?.kind === "root" && !isMergeableContainerId(id)) + return this.#roots.has(id.name); + if (this._isContainerDeleted(current)) return false; + const parent = current.parent(); + const binding = + current._parentLink?.binding ?? + (parent === undefined ? undefined : recoverParentBinding(current, parent)); + if ( + binding?.kind === "tree" && + parent instanceof LoroTree && + this.#isTreeRecordHidden(parent, binding.record, hiddenCacheFor(parent)) + ) { + return false; + } + current = parent; + } + return false; + } + + /** + * Whether `record` or an ancestor is deleted. `cache` memoizes every node on + * the walked path, so the nodes of a deep chain resolve in linear time. + */ + #isTreeRecordHidden( + tree: LoroTree, + record: TreeNodeRecord, + cache?: Map, + ): boolean { + const path: string[] = []; + let result = false; + for (let current: TreeNodeRecord | undefined = record; current !== undefined; ) { + const key = cache === undefined ? "" : idKey(current.id); + const known = cache?.get(key); + if (known !== undefined) { + result = known; + break; + } + if (current.deleted) { + result = true; + break; + } + if (cache !== undefined) path.push(key); + current = + current.parent === undefined + ? undefined + : tree._nodes.get(formatTreeId(current.parent)); + } + for (const key of path) cache!.set(key, result); + return result; + } + + /** `node`'s own placement at `version`, or undefined if it did not exist yet. */ + #treeNodePlacementAt( + tree: LoroTree, + node: CodecId, + version: VersionVector, + ): TreePlacement | undefined { + const key = idKey(node); + const operations = this.#treeOperationHistory.get(tree.id)?.get(key); + if (operations === undefined) { + // No retained op (e.g. a shallow root node): its placement is unchanged. + const record = tree._nodes.get(formatTreeId(node)); + return record === undefined + ? undefined + : { + deleted: record.deleted, + parent: record.parent, + position: record.position, + writer: record.writer, + }; + } + const winner = latestIncludedOperation(operations, version); + if (winner === undefined) { + // A shallow history trims the ops before its root; until the first + // retained op, the node keeps its placement in the root state. + const rootNode = this.#shallowRootTreeNode(tree, formatTreeId(node)); + return rootNode === undefined + ? undefined + : { + deleted: rootNode.deleted, + parent: rootNode.parent, + position: rootNode.position, + writer: rootNode.writer, + }; + } + const content = winner.operation.content; + if (content.type !== "tree-create" && content.type !== "tree-move") { + return { + deleted: true, + parent: undefined, + position: EMPTY_BYTES, + writer: winner.writer, + }; + } + return { + deleted: false, + parent: content.parent, + position: content.position, + writer: winner.writer, + }; + } + + /** + * Whether `node` and all of its ancestors are alive at `version`. `cache` + * memoizes every node on the walked path, so a whole subtree resolves in + * time linear in its size. + */ + #treeNodeAliveAt( + tree: LoroTree, + node: CodecId, + version: VersionVector, + cache: Map = new Map(), + ): boolean { + const path: string[] = []; + const onPath = new Set(); + let result: boolean | undefined; + for (let current: CodecId | undefined = node; ; ) { + if (current === undefined) { + result = true; + break; + } + const key = idKey(current); + const cached = cache.get(key); + if (cached !== undefined) { + result = cached; + break; + } + if (onPath.has(key)) { + result = false; + break; + } + path.push(key); + onPath.add(key); + const placement = this.#treeNodePlacementAt(tree, current, version); + if (placement === undefined || placement.deleted) { + result = false; + break; + } + current = placement.parent; + } + for (const key of path) cache.set(key, result); + return result; + } + + /** + * Turns the full state `diff` of a re-activated mergeable child (or of a + * container it keeps) into an edit of what this document holds for it, as + * Rust's `handler/full_state.rs` does. Children kept by ID become targets + * too. Identity is kept for shared entries, list prefixes and suffixes, + * moved movable-list children, and tree nodes by TreeID. + */ + #alignFullState( + container: LoroContainer, + diff: Diff | JsonDiff, + remap: Map, + treeRemap: Map, + ): void { + const targets = this.#fullStateTargets!; + const keep = (sourceId: ContainerID, child: unknown): void => { + if (child instanceof LoroContainer) { + remap.set(sourceId, child as Container); + targets.add(child); + } + }; + if (diff.type === "map" && container instanceof LoroMap) { + const wanted = new Map( + Object.entries(diff.updated).filter(([, value]) => value !== undefined), + ); + for (const key of container.keys()) { + if (!wanted.has(key)) container.delete(key); + } + for (const [key, value] of wanted) { + const sourceId = diffContainerId(value); + const current = container.get(key); + if (sourceId === undefined) { + if (!sameDiffValue(current, value)) container.set(key, value); + continue; + } + if ( + isContainer(current) && + current.id === (remap.get(sourceId)?.id ?? sourceId) + ) { + keep(sourceId, current); + continue; + } + this.#applyMapChildDiff(container, key, sourceId, remap); + } + return; + } + if (diff.type === "counter" && container instanceof LoroCounter) { + const change = diff.increment - container.value; + if (change !== 0) container.increment(change); + return; + } + if (diff.type === "text" && container instanceof LoroText) { + alignText(container, diff.diff); + return; + } + if (diff.type === "list" && container instanceof LoroList) { + const wanted: unknown[] = []; + for (const item of diff.diff) if ("insert" in item) wanted.push(...item.insert); + const current = container.toArray(); + const same = (index: number, wantedIndex: number): boolean => { + const value = wanted[wantedIndex]; + const sourceId = diffContainerId(value); + const existing = container.get(index); + return sourceId === undefined + ? !isContainer(existing) && sameDiffValue(existing, value) + : isContainer(existing) && + existing.id === (remap.get(sourceId)?.id ?? sourceId); + }; + let prefix = 0; + while (prefix < current.length && prefix < wanted.length && same(prefix, prefix)) { + prefix += 1; + } + let suffix = 0; + while ( + suffix < current.length - prefix && + suffix < wanted.length - prefix && + same(current.length - 1 - suffix, wanted.length - 1 - suffix) + ) { + suffix += 1; + } + const middle = wanted.slice(prefix, wanted.length - suffix); + const delta: Delta[] = []; + if (prefix > 0) delta.push({ retain: prefix }); + const deleted = current.length - prefix - suffix; + if (deleted > 0) delta.push({ delete: deleted }); + if (middle.length > 0) delta.push({ insert: middle }); + if (delta.length > 0) { + this.#applyContainerDiff( + container as Container, + { type: "list", diff: delta }, + remap, + treeRemap, + ); + } + // Children present in both (kept prefix/suffix, moved middle children + // of a movable list) keep their ID and are aligned too. + for (let index = 0; index < wanted.length; index += 1) { + const sourceId = diffContainerId(wanted[index]); + if (sourceId === undefined || remap.get(sourceId) !== undefined) continue; + const existing = container.get(index); + if (isContainer(existing) && existing.id === sourceId) keep(sourceId, existing); + } + return; + } + if (diff.type === "tree" && container instanceof LoroTree) { + const wanted = diff.diff.filter( + (item): item is Extract => + item.action === "create", + ); + const wantedIds = new Set(wanted.map((item) => item.target)); + const items: TreeDiffItem[] = []; + // Delete the topmost unwanted nodes; their subtrees go with them. + for (const node of container.getNodes()) { + if (wantedIds.has(node.id) || node.isDeleted()) continue; + const parent = node.parent()?.id; + if (parent === undefined || wantedIds.has(parent)) { + items.push({ + target: node.id, + action: "delete", + oldParent: parent, + oldIndex: 0, + }); + } + } + const kept: TreeID[] = []; + for (const item of wanted) { + const alive = container.has(item.target) && !container.isNodeDeleted(item.target); + if (!alive) { + items.push(item); + continue; + } + kept.push(item.target); + items.push({ + target: item.target, + action: "move", + parent: item.parent, + index: item.index, + fractionalIndex: item.fractionalIndex, + oldParent: undefined, + oldIndex: 0, + }); + } + this.#applyTreeDiff(container, items, remap, treeRemap); + for (const node of kept) { + const data = container.getNodeByID(node)?.data; + if (data !== undefined) keep(data.id, data); + } + return; + } + throw diffKindMismatch(container as Container, diff.type); + } + + /** Empties a container (a full-state target the batch has no entry for). */ + #clearContainer(container: LoroContainer): void { + if (container instanceof LoroMap) { + for (const key of container.keys()) container.delete(key); + } else if (container instanceof LoroList) { + if (container.length > 0) container.delete(0, container.length); + } else if (container instanceof LoroText) { + if (container.length > 0) container.delete(0, container.length); + } else if (container instanceof LoroCounter) { + if (container.value !== 0) container.increment(-container.value); + } else if (container instanceof LoroTree) { + for (const root of container.roots()) container.delete(root.id); + } + } + + /** + * Completes the per-container diffs of a range from `from` to the current + * state, in parent-first order: tree revivals become creates of the whole + * subtree, and a container that is unreachable at `from` and reachable now + * (a map key set back to an older child, a revived list element or tree + * node, anything under such a container, or a re-activated mergeable child) + * carries its whole state, as in Rust. With `changesOnly` (revertTo), a + * re-activated mergeable child keeps only its actual change. + */ + #completeDiffEntries( + entries: readonly (readonly [ContainerID, LoroEvent["diff"]])[], + fromVersion: VersionVector, + changesOnly: boolean, + ): [ContainerID, LoroEvent["diff"]][] { + const diffs = new Map(entries); + for (const [id, diff] of diffs) { + const tree = this.#containers.get(id); + if (diff.type === "tree" && tree instanceof LoroTree) { + diffs.set(id, { + type: "tree", + diff: this.#completeTreeRevivals(tree, diff.diff, fromVersion), + }); + } + } + // Most diffs attach no child container; skip the reachability pass and + // keep the caller's depth order. + const attachesChildren = [...diffs].some( + ([id, diff]) => this.#attachedChildren(this.#containers.get(id)!, diff).length > 0, + ); + if (attachesChildren) { + const created = new Set(); + const byDepth = new Map(); + const schedule = (container: LoroContainer): void => { + const depth = containerDepth(container); + const bucket = byDepth.get(depth); + if (bucket === undefined) byDepth.set(depth, [container]); + else bucket.push(container); + }; + for (const [id] of entries) schedule(this.#containers.get(id)!); + for (let depth = 0; byDepth.size > 0; depth += 1) { + const bucket = byDepth.get(depth); + if (bucket === undefined) continue; + byDepth.delete(depth); + for (const parent of bucket) { + const diff = diffs.get(parent.id); + if (diff === undefined) continue; + const parentCreated = created.has(parent.id); + for (const child of this.#attachedChildren(parent, diff)) { + if (created.has(child.id)) continue; + // A re-activated mergeable child keeps its id and hidden state. + // Public diffs report its full state, as Rust's `LoroDoc::diff` + // does (a full-state batch); revertTo applies only its actual + // change (Rust's `KeptChange`) so identity is kept. + if ( + !parentCreated && + ((changesOnly && isMergeableContainerId(child._codecId!)) || + this.#reachableThroughParentAt(child, parent, fromVersion)) + ) { + continue; + } + created.add(child.id); + if (!diffs.has(child.id)) schedule(child); + diffs.set(child.id, containerDiff(child, undefined)); + } + } + } + const ordered = [...diffs] + .filter(([, diff]) => !isEmptyContainerDiff(diff)) + .map(([id, diff]) => ({ + id, + diff, + depth: containerDepth(this.#containers.get(id)!), + })) + .sort((left, right) => left.depth - right.depth); + return ordered.map(({ id, diff }) => [id, diff]); + } + return [...diffs]; + } + + /** Child containers that a map, list, or tree diff of `parent` attaches. */ + #attachedChildren(parent: LoroContainer, diff: LoroEvent["diff"]): LoroContainer[] { + const children: LoroContainer[] = []; + const add = (id: ContainerID | undefined): void => { + const child = id === undefined ? undefined : this.#containers.get(id); + if (child !== undefined) children.push(child); + }; + if (diff.type === "map") { + for (const value of Object.values(diff.updated)) add(diffContainerId(value)); + } else if (diff.type === "list") { + for (const delta of diff.diff) { + if (!("insert" in delta)) continue; + for (const value of delta.insert) add(diffContainerId(value)); + } + } else if (diff.type === "tree" && parent instanceof LoroTree) { + for (const item of diff.diff) { + if (item.action !== "create") continue; + const record = parent._nodes.get(item.target); + if (record !== undefined) children.push(record.data); + } + } + return children; + } + + /** + * Whether `child` already occupied its slot in `parent` at `version`. Only + * a sequence can attach a child that was reachable before: a movable-list + * move reports the moved child as an insert. A map attach always replaces + * the key's value, and a tree `create` always revives a node. + */ + #reachableThroughParentAt( + child: LoroContainer, + parent: LoroContainer, + version: VersionVector, + ): boolean { + if (!(parent instanceof LoroList)) return false; + const binding = child._parentLink?.binding ?? recoverParentBinding(child, parent); + if (binding?.kind !== "sequence") return false; + const element = binding.element; + const included = (id: CodecId): boolean => id.counter < (version.get(id.peer) ?? 0); + if (!included(element.id)) return false; + if (parent._sequence.someDeletion(element, included)) return false; + const childId = child._codecId!; + if (childId.kind !== "normal") return false; + const value = latestIncludedSequenceValue(element.valueHistory, version); + const valueId = value === undefined ? element.id : value.id; + return valueId.peer === childId.peer && valueId.counter === childId.counter; + } + + applyDiff( + diffBatch: readonly (readonly [ContainerID, Diff | JsonDiff])[], + options: ApplyDiffOptions = {}, + ): void { if (!Array.isArray(diffBatch)) throw new TypeError("diff batch must be an array"); if (this.#detached && !this.#detachedEditing) { throw new Error("cannot edit a detached document; call attach() first"); } + const checked = this.#validateDiffBatch(diffBatch); + const containerRemap = new Map(); const treeRemap = new Map(); - for (const entry of diffBatch) { + // Full-state targets: re-activated mergeable children and what they keep. + const targets = options.fullState === true ? new Set() : undefined; + const visited = new Set(); + this.#fullStateTargets = targets; + try { + for (const [index, entry] of diffBatch.entries()) { + const sourceId = entry[0] as ContainerID; + const diff = entry[1]; + const container = this.#resolveDiffContainer(sourceId, containerRemap); + if (container === undefined) continue; + if (targets?.has(container as LoroContainer) === true) { + visited.add(container as LoroContainer); + this.#alignFullState( + container as LoroContainer, + diff, + containerRemap, + treeRemap, + ); + continue; + } + this.#applyContainerDiff( + container, + diff, + containerRemap, + treeRemap, + checked.has(index), + ); + } + // A full-state batch lists every non-empty container it revives, so a + // target without an entry is empty at the target version. + for (const target of targets ?? []) { + if (!visited.has(target)) this.#clearContainer(target); + } + } finally { + this.#fullStateTargets = undefined; + } + } + + /** + * Checks the list deltas and tree items of every entry before anything is + * applied, so an out-of-range list delta or a tree item that cannot apply + * (see validateTreeItems) rejects the batch as a whole. List lengths are + * simulated in batch order: an entry starts from the list's current length + * (looked up without creating anything), the hidden length of a mergeable + * child, or 0 for a child that an earlier entry creates, and later entries + * for the same list see the length the earlier ones leave. + */ + #validateDiffBatch( + diffBatch: readonly (readonly [ContainerID, Diff | JsonDiff])[], + ): Set { + const checked = new Set(); + const lengths = new Map(); + const treeState = newTreeValidationState(); + const created = new Set(); + const noteChild = (value: unknown): void => { + const childId = diffContainerId(value); + // A mergeable child is re-activated with the state it holds here. + if (childId === undefined || isMergeableContainerId(parseContainerId(childId))) { + return; + } + const existing = this.#peekDiffContainer(childId); + if (existing === undefined) created.add(childId); + }; + for (const [index, entry] of diffBatch.entries()) { if (!Array.isArray(entry) || entry.length !== 2 || typeof entry[0] !== "string") { throw new TypeError("each diff entry must be a [ContainerID, Diff] tuple"); } - const sourceId = entry[0] as ContainerID; - const diff = entry[1]; - const container = this.#resolveDiffContainer(sourceId, containerRemap); - if (container === undefined) continue; - this.#applyContainerDiff(container, diff, containerRemap, treeRemap); + const id = entry[0] as ContainerID; + const diff = entry[1] as Diff | JsonDiff; + if (diff?.type === "map") { + for (const value of Object.values(diff.updated ?? {})) noteChild(value); + continue; + } + if (diff?.type === "tree") { + const existing = this.#peekDiffContainer(id); + if (existing instanceof LoroTree || created.has(id)) { + validateTreeItems( + existing instanceof LoroTree ? existing : undefined, + diff.diff, + treeState, + ); + } + continue; + } + if (diff?.type !== "list") continue; + let length = lengths.get(id); + if (length === undefined) { + const parsed = parseContainerId(id); + // A hidden mergeable child keeps its state: re-ensuring it (an earlier + // entry or the map diff that attaches it) resurfaces that length. + const existing = isMergeableContainerId(parsed) + ? this.getContainerById(id) + : this.#peekDiffContainer(id); + if (existing instanceof LoroList) length = existing.length; + else if (created.has(id) || parsed.kind === "root") length = 0; + } + const next = validateListDelta(diff.diff, length); + if (next !== undefined) { + lengths.set(id, next); + checked.add(index); + } + for (const item of diff.diff) { + if ("insert" in item) for (const value of item.insert) noteChild(value); + } + } + return checked; + } + + /** A live container for `id` in this document, without creating one. */ + #peekDiffContainer(id: ContainerID): Container | undefined { + const parsed = parseContainerId(id); + if (parsed.kind === "root" && !isMergeableContainerId(parsed)) { + return this.#roots.get(parsed.name) as Container | undefined; } + const existing = this.getContainerById(id); + return existing !== undefined && !this._isContainerDeleted(existing as LoroContainer) + ? existing + : undefined; } revertTo(frontiers: Frontiers): void { @@ -1344,7 +2463,13 @@ export class LoroDoc = Record = Record = Record, treeRemap: Map, + boundsChecked = false, ): void { if (diff.type === "map") { if (!(container instanceof LoroMap)) throw diffKindMismatch(container, diff.type); @@ -1410,6 +2535,13 @@ export class LoroDoc = Record = Record