UNPKG

loro-mirror

Version:

Type-safe state management synchronized with Loro CRDT via a declarative schema and bidirectional mirroring.

520 lines 19.2 kB
import { produce } from "immer"; import { isContainer, } from "loro-crdt"; import { isTreeID } from "./utils"; function isJSONObject(v) { return typeof v === "object" && v !== null && !Array.isArray(v); } function isJSONArray(v) { return Array.isArray(v); } /** * Apply a Loro event batch to a JSON-like state object. * Returns a new state object (using immer) with deltas applied. */ export function applyEventBatchToState(currentState, event, options) { const opts = typeof options === "function" ? { getContainerById: options } : options || {}; const next = produce((draft) => { const ignoreSet = new Set(); for (const e of event.events) { applySingleEventToDraft(draft, e, ignoreSet, opts.getContainerById, opts.containerToJson, opts.nodeDataWithCid, opts.getNodeDataCid); } })(currentState); return next; } /** * Apply a single event to the immer draft state */ function applySingleEventToDraft(draftRoot, e, ignoreSet, getContainerById, containerToJson, nodeDataWithCid, getNodeDataCid) { if (isIgnoredByAncestor(e.target, ignoreSet, getContainerById)) { return; } // Resolve the container node in state at the event path const { parent, key, node } = getParentKeyNodeByPath(draftRoot, e.path); // If target node is missing, initialize a neutral baseline for applying deltas let target = node; if (target === undefined) { if (e.diff.type === "map") { if (parent && key !== undefined) setAt(parent, key, {}); target = parent && key !== undefined ? getAt(parent, key) : draftRoot; } else if (e.diff.type === "list") { if (parent && key !== undefined) setAt(parent, key, []); target = parent && key !== undefined ? getAt(parent, key) : draftRoot; } else if (e.diff.type === "text") { if (parent && key !== undefined) setAt(parent, key, ""); target = parent && key !== undefined ? getAt(parent, key) : draftRoot; } else if (e.diff.type === "tree") { if (parent && key !== undefined) setAt(parent, key, []); target = parent && key !== undefined ? getAt(parent, key) : draftRoot; } else if (e.diff.type === "counter") { if (parent && key !== undefined) setAt(parent, key, 0); target = parent && key !== undefined ? getAt(parent, key) : draftRoot; } else { console.error("Unknown diff type:", e.diff); return; } } // Apply diff based on container type switch (e.diff.type) { case "map": if (!isJSONObject(target)) { if (parent && key !== undefined) setAt(parent, key, {}); target = parent && key !== undefined ? getAt(parent, key) : draftRoot; } if (isJSONObject(target)) { applyMapDiff(target, e.diff.updated, ignoreSet, containerToJson); } break; case "list": if (!isJSONArray(target)) { // Initialize if not array if (parent && key !== undefined) setAt(parent, key, []); target = parent && key !== undefined ? getAt(parent, key) : []; } if (isJSONArray(target)) { applyListDelta(target, e.diff.diff, ignoreSet, containerToJson); } break; case "text": { const base = typeof target === "string" ? target : ""; const next = applyTextDelta(base, e.diff.diff); if (parent && key !== undefined) setAt(parent, key, next); break; } case "tree": if (!isJSONArray(target)) { if (parent && key !== undefined) setAt(parent, key, []); target = parent && key !== undefined ? getAt(parent, key) : []; } if (isJSONArray(target)) { applyTreeDiff(target, e.diff.diff, e.target, nodeDataWithCid, getNodeDataCid); // Invalidate cache for this roots array after structural change ROOTS_TREE_INDEX_CACHE.delete(target); } break; case "counter": // Update number value incrementally if present if (parent && key !== undefined) { const baseNum = typeof target === "number" ? target : 0; const next = baseNum + (e.diff.increment ?? 0); setAt(parent, key, next); } break; } } /** * Resolve an event path into the mirror JSON state and return the parent, key and node. * * Tree node ID handling (important/tricky): * - Loro events reference tree nodes by a stable TreeID (e.g. "0@123...") rather than by * positional indices. Our mirror state, however, stores trees as nested arrays of nodes * with the shape: { id: string, data: object, children: Node[] }. * - When we see a path segment that looks like a TreeID and the current parent is the tree * roots array, we resolve that ID anywhere within the tree (not just the direct children). * Then we treat the resolved node's application data as the target for subsequent segments: * - If the TreeID is the final segment in the path, we consider it a reference to the * node's data map, and we return parent=node and key="data" so that diffs read/write * node.data directly. * - If there are more segments after the TreeID, we first jump into node.data and continue * resolving the remaining segments there (e.g. ["tree", "0@123...", "text"] resolves to * node.data["text"]). * * Why this is needed: The event path uses TreeIDs (stable) while the mirror JSON uses indices * through the nested children arrays. For example: * - A LoroMap on a LoroTreeNode whose LoroTree is on the root may have an event path like * ["tree", "0@123..."] but the corresponding JSON path looks like something along the lines of * ["tree", 0, "children", 0, "data"] depending on where that node sits in the hierarchy. * - A LoroText inside a LoroMap on a LoroTreeNode would have an event path like * ["tree", "0@123...", "text"], while the JSON path could be * ["tree", 0, "children", 0, "data", "text"]. * * This function bridges those two representations by: * - Resolving TreeIDs to node objects via a cached index of the current roots array. * - Implicitly inserting the "data" hop when a TreeID segment is encountered, so that * subsequent segments operate on the node's data map rather than the node wrapper. * * 中文说明(简要):事件路径里树节点用 TreeID(如 "0@123...")来定位,但镜像的 JSON * 状态里树是按 children 层级数组存放(节点为 { id, data, children }),因此实际 JSON 路径会像 * ["tree", 0, "children", 0, "data", "text"] 这样。这里在遇到 TreeID 段时,会在整棵树中 * 定位到对应节点,并把后续的访问都指向该节点的 data(若 TreeID 是最后一段,则等价于访问 node.data)。 */ function getParentKeyNodeByPath(root, path) { if (!path || path.length === 0) { return { parent: undefined, key: undefined, node: root }; } let parent = undefined; let current = root; let key = undefined; for (let i = 0; i < path.length; i++) { const seg = path[i]; // Parent should reflect the container we will index into at this step parent = isJSONArray(current) || isJSONObject(current) ? current : undefined; key = seg; if (typeof seg === "number") { if (Array.isArray(parent)) { current = parent[seg]; } else { current = undefined; } } else if (typeof seg === "string") { let segKey = seg; if (parent && Array.isArray(parent) && isTreeID(seg)) { // Resolve by id anywhere in the tree (recursive), not just direct children const roots = parent; const loc = getTreeNodeLocation(roots, seg); if (loc) { // If this TreeID is the final segment, treat it as the node's data map if (i === path.length - 1) { parent = loc.node; key = "data"; current = getOrInitNodeData(loc.node); } else { // Otherwise, navigate into the node's data map for subsequent keys const dataObj = getOrInitNodeData(loc.node); current = dataObj; } } else { // Not found current = undefined; } } else if (parent && !Array.isArray(parent)) { current = parent[segKey]; } else { current = undefined; } } else { throw new Error(`Unsupported path segment: ${String(seg)}`); } } return { parent, key, node: current }; } // Build or reuse a per-roots index to resolve a node by id quickly // PERF: this can be slow function getTreeNodeLocation(roots, id) { let index = ROOTS_TREE_INDEX_CACHE.get(roots); if (!index) { index = buildTreeIndex(roots); ROOTS_TREE_INDEX_CACHE.set(roots, index); } let loc = index.get(id); if (!loc) { // If not found (e.g., structure changed earlier in this batch), rebuild once index = buildTreeIndex(roots); ROOTS_TREE_INDEX_CACHE.set(roots, index); loc = index.get(id); } return loc; } function buildTreeIndex(roots) { const map = new Map(); // Depth-first traversal without using any const stack = []; for (let i = 0; i < roots.length; i++) { stack.push({ list: roots, index: i }); } while (stack.length) { const item = stack.pop(); if ("list" in item) { const raw = item.list[item.index]; if (!isJSONObject(raw)) continue; const node = raw; const idVal = node["id"]; if (typeof idVal === "string") { map.set(idVal, { list: item.list, index: item.index, node }); } const childrenVal = node["children"]; if (Array.isArray(childrenVal)) { // push children entries for (let j = 0; j < childrenVal.length; j++) { stack.push({ list: childrenVal, index: j }); } } } } return map; } function getOrInitNodeData(node) { const dataVal = node["data"]; if (isJSONObject(dataVal)) return dataVal; const fresh = {}; node["data"] = fresh; return fresh; } // Normalize LoroTree JSON (with `meta`) to Mirror tree node shape `{ id, data, children }`. function normalizeTreeJson(input) { if (!Array.isArray(input)) return []; return input.map(mapRawTreeNode); } function mapRawTreeNode(n) { const rawId = n?.id; const id = typeof rawId === "string" ? rawId : ""; const meta = n?.meta; const data = isJSONObject(meta) ? meta : {}; const rawChildren = n?.children; const children = Array.isArray(rawChildren) ? rawChildren.map(mapRawTreeNode) : []; return { id, data, children }; } /** * Apply Map updates to a plain object */ function applyMapDiff(targetObj, updated, ignoreSet, containerToJson) { if (!isJSONObject(targetObj)) return; for (const [k, v] of Object.entries(updated)) { // In Loro map diffs, `undefined` signals deletion. `null` is a valid value // and must be preserved. if (v === undefined) { delete targetObj[k]; continue; } if (isContainer(v)) { const c = v; // Mark this child container so its own events are ignored later in this batch ignoreSet.add(c.id); targetObj[k] = containerToJson ? containerToJson(c) : containerToMirrorJson(c); continue; } targetObj[k] = v; } } /** * Apply a list delta to a JS array */ function applyListDelta(targetArr, deltas, ignoreSet, containerToJson) { let index = 0; for (const d of deltas) { if (d.retain !== undefined) { index += d.retain; } else if (d.delete !== undefined) { const count = d.delete; if (count > 0) { targetArr.splice(index, count); } } else if (d.insert !== undefined) { const items = d.insert.map((it) => { if (isContainer(it)) { const c = it; // Mark this child container so its own events are ignored later in this batch ignoreSet.add(c.id); return containerToJson ? containerToJson(c) : containerToMirrorJson(c); } return it; }); targetArr.splice(index, 0, ...items); index += items.length; } } } /** * Apply a tree diff to a JS array of nodes of shape { id, data, children } */ function applyTreeDiff(roots, deltas, treeId, nodeDataWithCid, getNodeDataCid) { const getChildrenArray = (parent) => { if (!parent) return roots; const found = findNodeAndParent(roots, parent); return found ? found.node.children : roots; }; for (const d of deltas) { if (d.action === "create") { const arr = getChildrenArray(d.parent); const node = { id: d.target, data: {}, children: [], }; if (treeId && nodeDataWithCid?.(treeId)) { const cid = getNodeDataCid?.(treeId, d.target); if (cid) node.data.$cid = cid; } const idx = clampIndex(d.index, arr.length + 1); arr.splice(idx, 0, node); } else if (d.action === "delete") { const arr = getChildrenArray(d.oldParent); if (!arr) continue; const idx = clampIndex(d.oldIndex, arr.length); if (idx >= 0 && idx < arr.length) { arr.splice(idx, 1); } else { // fallback: search by id const pos = arr.findIndex((n) => n.id === d.target); if (pos >= 0) arr.splice(pos, 1); } } else if (d.action === "move") { // remove from old const fromArr = getChildrenArray(d.oldParent); const oldIdx = clampIndex(d.oldIndex, fromArr.length); let moved; if (oldIdx >= 0 && oldIdx < fromArr.length) { moved = fromArr.splice(oldIdx, 1)[0]; } else { const pos = fromArr.findIndex((n) => n.id === d.target); if (pos >= 0) moved = fromArr.splice(pos, 1)[0]; } if (!moved) continue; const toArr = getChildrenArray(d.parent); // Use the target index as the final index in the destination const toIdx = clampIndex(d.index, toArr.length + 1); toArr.splice(toIdx, 0, moved); } } } function clampIndex(idx, len) { if (idx < 0) return 0; if (idx > len) return len; return idx; } function findNodeAndParent(roots, id) { const stack = [{ parent: undefined, list: roots }]; while (stack.length) { const { parent, list } = stack.pop(); for (let i = 0; i < list.length; i++) { const n = list[i]; if (n && n.id === id) { return { parent, node: n }; } if (n && Array.isArray(n.children)) { stack.push({ parent: n, list: n.children }); } } } return undefined; } /** * Apply a text delta to a string value */ function applyTextDelta(base, deltas) { if (deltas.length === 0) { return base; } const original = base; let result = ""; let sourceIndex = 0; for (const d of deltas) { if (d.retain !== undefined) { // Append the retained portion from the original string result += original.slice(sourceIndex, sourceIndex + d.retain); sourceIndex += d.retain; } else if (d.delete !== undefined) { // Skip the deleted portion by advancing sourceIndex without appending sourceIndex += d.delete; } else if (d.insert !== undefined) { // Insert new text without advancing sourceIndex result += d.insert ?? ""; } } // Append any remaining text from the original string if (sourceIndex < original.length) { result += original.slice(sourceIndex); } return result; } // Helpers for JSON state manipulation function setAt(parent, key, value) { if (Array.isArray(parent) && typeof key === "number") { parent[key] = value; } else if (!Array.isArray(parent) && typeof key === "string") { parent[key] = value; } } function getAt(parent, key) { if (Array.isArray(parent) && typeof key === "number") { return parent[key]; } else if (!Array.isArray(parent) && typeof key === "string") { return parent[key]; } return undefined; } // Module-level cache: for each roots array, map TreeID -> node location const ROOTS_TREE_INDEX_CACHE = new WeakMap(); // Convert a loro container into mirror JSON value consistently function containerToMirrorJson(c) { const kind = c.kind(); if (kind === "Counter") { return c.getShallowValue(); } if (kind === "Tree") { const raw = c.toJSON(); return normalizeTreeJson(raw); } return c.toJSON(); } // Check if a target or any of its ancestors is in ignore set function isIgnoredByAncestor(id, ignoreSet, getContainerById) { if (ignoreSet.has(id)) return true; if (!getContainerById) return false; const start = getContainerById(id); let cur = start; // Walk up through parents; if any ancestor id is ignored, skip while (cur) { const p = cur.parent(); if (!p) break; if (ignoreSet.has(p.id)) return true; cur = p; } return false; } //# sourceMappingURL=loroEventApply.js.map