diff --git a/packages/core/package.json b/packages/core/package.json index cbffa70..9e30d4b 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -26,6 +26,7 @@ "test:watch": "vitest", "typecheck": "tsc -p tsconfig.typecheck.json --noEmit", "bench:ephemeral": "pnpm build && node --expose-gc ./scripts/ephemeral-bench.mjs", + "bench:bulk-init": "pnpm build && node --expose-gc ./scripts/bulk-init-bench.mjs", "bench:ignore-unknown-properties": "pnpm build && node --expose-gc ./scripts/ignore-unknown-properties-bench.mjs" }, "keywords": [ diff --git a/packages/core/scripts/bulk-init-bench.mjs b/packages/core/scripts/bulk-init-bench.mjs new file mode 100644 index 0000000..8e53dfc --- /dev/null +++ b/packages/core/scripts/bulk-init-bench.mjs @@ -0,0 +1,261 @@ +import { performance } from "node:perf_hooks"; + +import loroCrdt from "loro-crdt"; + +import { Mirror, schema } from "../dist/index.js"; + +const { LoroDoc, LoroList, LoroMap, LoroText } = loroCrdt; + +const DEFAULT_ENTRIES = 171; +const DEFAULT_ITEMS_PER_ENTRY = 30; +const DEFAULT_WARMUP = 3; +const DEFAULT_ITERATIONS = 10; + +// Doc shape mirrors a real session-history document: `history` is a list of +// turn maps, each turn has an `items` list of maps whose fields are mostly +// LoroText. With 171 turns x 30 items this yields ~67k containers +// (Map ~10k / List ~5k / Text ~51k) — text-dominated, like the real thing. +function itemSchema() { + return schema.LoroMap({ + id: schema.String(), + f0: schema.LoroText(), + f1: schema.LoroText(), + f2: schema.LoroText(), + f3: schema.LoroText(), + f4: schema.LoroText(), + f5: schema.LoroText(), + meta: schema.LoroMap({ + a: schema.LoroText(), + b: schema.LoroText(), + }), + subs: schema.LoroList(schema.LoroText()), + }); +} + +const HISTORY_SCHEMA = schema({ + history: schema.LoroList( + schema.LoroMap({ + id: schema.String(), + items: schema.LoroList(itemSchema()), + }), + ), +}); + +function parsePositiveInt(value, fallback) { + const parsed = Number(value); + if (!Number.isFinite(parsed) || parsed <= 0) { + return fallback; + } + + return Math.floor(parsed); +} + +// Realistic text payloads: session turns hold paragraphs, not 30-char stubs. +const TEXT_CHUNK = + "The quick brown fox jumps over the lazy dog. Pack my box with five dozen liquor jugs. "; + +function text(...parts) { + return (TEXT_CHUNK.repeat(3) + parts.join(" ")).slice(0, 220); +} + +function createHistoryDoc(entryCount, itemsPerEntry) { + const doc = new LoroDoc(); + const history = doc.getList("history"); + + for (let index = 0; index < entryCount; index += 1) { + const turn = history.insertContainer(history.length, new LoroMap()); + turn.set("id", `turn-${index}`); + const items = turn.setContainer("items", new LoroList()); + for (let item = 0; item < itemsPerEntry; item += 1) { + const entry = items.insertContainer(items.length, new LoroMap()); + entry.set("id", `item-${index}-${item}`); + for (let field = 0; field < 6; field += 1) { + const t = entry.setContainer(`f${field}`, new LoroText()); + t.insert(0, text(`field ${field} of item ${item} of ${index}`)); + } + const meta = entry.setContainer("meta", new LoroMap()); + meta.setContainer("a", new LoroText()).insert( + 0, + text(`a-${index}-${item}`), + ); + meta.setContainer("b", new LoroText()).insert( + 0, + text(`b-${index}-${item}`), + ); + const subs = entry.setContainer("subs", new LoroList()); + subs.pushContainer(new LoroText()).insert( + 0, + text(`sub0-${index}-${item}`), + ); + subs.pushContainer(new LoroText()).insert( + 0, + text(`sub1-${index}-${item}`), + ); + } + } + + doc.commit(); + return doc; +} + +function openMirror(doc, entryCount) { + const mirror = new Mirror({ + doc, + schema: HISTORY_SCHEMA, + }); + + if (mirror.getState().history.length !== entryCount) { + mirror.dispose(); + throw new Error("Mirror did not read the complete history"); + } + + return mirror; +} + +function getPrototypeMethod(prototype, name) { + const method = Object.getOwnPropertyDescriptor(prototype, name)?.value; + if (typeof method !== "function") { + throw new Error(`Missing prototype method: ${name}`); + } + + return method; +} + +function countTraversalCalls(doc, entryCount) { + const originalListGet = getPrototypeMethod(LoroList.prototype, "get"); + const originalMapGet = getPrototypeMethod(LoroMap.prototype, "get"); + const originalTextToJSON = getPrototypeMethod(LoroText.prototype, "toJSON"); + const originalGetDeepValueWithID = getPrototypeMethod( + LoroDoc.prototype, + "getDeepValueWithID", + ); + const shallowReads = [LoroMap, LoroList].map((type) => [ + type.prototype, + type.prototype.getShallowValue, + ]); + const calls = { + parentShallow: 0, + getDeepValueWithID: 0, + listGet: 0, + mapGet: 0, + textToJSON: 0, + }; + + for (const [prototype, original] of shallowReads) { + prototype.getShallowValue = function () { + calls.parentShallow += 1; + return Reflect.apply(original, this, []); + }; + } + LoroDoc.prototype.getDeepValueWithID = function () { + calls.getDeepValueWithID += 1; + return Reflect.apply(originalGetDeepValueWithID, this, []); + }; + LoroList.prototype.get = function (index) { + calls.listGet += 1; + return Reflect.apply(originalListGet, this, [index]); + }; + LoroMap.prototype.get = function (key) { + calls.mapGet += 1; + return Reflect.apply(originalMapGet, this, [key]); + }; + LoroText.prototype.toJSON = function () { + calls.textToJSON += 1; + return Reflect.apply(originalTextToJSON, this, []); + }; + + try { + const mirror = openMirror(doc, entryCount); + mirror.dispose(); + return calls; + } finally { + for (const [prototype, original] of shallowReads) + prototype.getShallowValue = original; + LoroDoc.prototype.getDeepValueWithID = originalGetDeepValueWithID; + LoroList.prototype.get = originalListGet; + LoroMap.prototype.get = originalMapGet; + LoroText.prototype.toJSON = originalTextToJSON; + } +} + +function gcIfAvailable() { + if (typeof globalThis.gc === "function") { + globalThis.gc(); + } +} + +function percentile(sorted, ratio) { + const index = Math.min( + sorted.length - 1, + Math.floor((sorted.length - 1) * ratio), + ); + return sorted[index]; +} + +function benchmarkInitialization(doc, entryCount, warmup, iterations) { + for (let index = 0; index < warmup; index += 1) { + const mirror = openMirror(doc, entryCount); + mirror.dispose(); + } + + const samples = []; + for (let index = 0; index < iterations; index += 1) { + gcIfAvailable(); + const start = performance.now(); + const mirror = openMirror(doc, entryCount); + samples.push(performance.now() - start); + mirror.dispose(); + } + + const sorted = [...samples].sort((a, b) => a - b); + return { + p50Ms: percentile(sorted, 0.5), + p95Ms: percentile(sorted, 0.95), + minMs: sorted[0], + }; +} + +function formatMs(value) { + return `${value.toFixed(3)} ms`; +} + +function main() { + const entryCount = parsePositiveInt( + process.env.BULK_INIT_BENCH_ENTRIES, + DEFAULT_ENTRIES, + ); + const itemsPerEntry = parsePositiveInt( + process.env.BULK_INIT_BENCH_ITEMS_PER_ENTRY, + DEFAULT_ITEMS_PER_ENTRY, + ); + const warmup = parsePositiveInt( + process.env.BULK_INIT_BENCH_WARMUP, + DEFAULT_WARMUP, + ); + const iterations = parsePositiveInt( + process.env.BULK_INIT_BENCH_ITERATIONS, + DEFAULT_ITERATIONS, + ); + const doc = createHistoryDoc(entryCount, itemsPerEntry); + const calls = countTraversalCalls(doc, entryCount); + const timing = benchmarkInitialization(doc, entryCount, warmup, iterations); + + console.log("bulk initialization benchmark (getDeepValueWithID)"); + console.log( + `entries=${entryCount} itemsPerEntry=${itemsPerEntry} warmup=${warmup} iterations=${iterations}`, + ); + console.table([ + { + getDeepValueWithID: calls.getDeepValueWithID, + parentShallow: calls.parentShallow, + listGet: calls.listGet, + mapGet: calls.mapGet, + textToJSON: calls.textToJSON, + min: formatMs(timing.minMs), + p50: formatMs(timing.p50Ms), + p95: formatMs(timing.p95Ms), + }, + ]); +} + +main(); diff --git a/packages/core/src/core/mirror.ts b/packages/core/src/core/mirror.ts index 571fe70..02c97ea 100644 --- a/packages/core/src/core/mirror.ts +++ b/packages/core/src/core/mirror.ts @@ -68,6 +68,7 @@ import { schemaToContainerType, tryInferContainerType, getRootContainerByType, + containerIdToContainerType, defineCidProperty, hardenCidDescriptors, stripUndefined, @@ -91,6 +92,84 @@ type RootSnapshotOptions = { registerContainers?: boolean; }; +type BulkWalkSemantics = { + /** + * Read registered schemas for decode/child-schema resolution (mirror init + * semantics). When false, walk with `normalizeContainerForJson` semantics + * (unknown roots under `ignoreUnknownProperties`): no registry lookups, + * no decoding, no registration. + */ + useRegistrySchema: boolean; + /** + * Skip a user-owned `$cid` map key (normalizeContainerForJson behavior) + * instead of reproducing the legacy stamp-then-assign throw. + */ + skipCidKey: boolean; +}; + +type BulkWalkContext = { + semantics: BulkWalkSemantics; + registerContainers: boolean; + /** Parent shallow values, cached per container id for one walk. */ + shallowValues: Map; +}; + +const MIRROR_WALK_SEMANTICS: BulkWalkSemantics = { + useRegistrySchema: true, + skipCidKey: false, +}; + +const UNKNOWN_ROOT_WALK_SEMANTICS: BulkWalkSemantics = { + useRegistrySchema: false, + skipCidKey: true, +}; + +// Debug-like cid format produced by getDeepValueWithID, e.g. +// "idx:2, id:cid:2@4190814119833933475:Map" (roots: "idx:0, id:cid:root-x:Map"). +const DEEP_VALUE_CID_MARKER = "id:cid:"; + +const DEEP_VALUE_CONTAINER_KINDS = new Set([ + "Map", + "List", + "MovableList", + "Text", + "Tree", + "Counter", +]); + +/** + * Parse the cid of a `getDeepValueWithID` container node. Tolerant of both + * the Debug-like format ("idx:2, id:cid:...") and bare container ids. + */ +function parseDeepValueCid(raw: unknown): ContainerID | undefined { + if (typeof raw !== "string") return undefined; + const markerIndex = raw.indexOf(DEEP_VALUE_CID_MARKER); + if (markerIndex >= 0) { + return raw.slice(markerIndex + "id:".length) as ContainerID; + } + if (raw.startsWith("cid:")) return raw as ContainerID; + return undefined; +} + +/** + * Read a `{ value, cid }` container node from a `getDeepValueWithID` payload. + * This only identifies a candidate wrapper; callers must confirm child slots + * against the parent shallow value before unwrapping. An object that + * merely has "value"/"cid" keys whose cid is not a recognizable container id + * is treated as an embedded plain value. + */ +function readDeepValueContainerNode( + node: unknown, +): { cid: ContainerID; value: unknown } | undefined { + if (!isObject(node)) return undefined; + if (!("value" in node) || !("cid" in node)) return undefined; + const cid = parseDeepValueCid((node as { cid: unknown }).cid); + if (!cid) return undefined; + const kind = containerIdToContainerType(cid); + if (!kind || !DEEP_VALUE_CONTAINER_KINDS.has(kind)) return undefined; + return { cid, value: (node as { value: unknown }).value }; +} + type RegisterContainerOptions = { scanNested?: boolean; }; @@ -3076,6 +3155,32 @@ export class Mirror { return toNormalizedJson(this.doc) as Record; } + // Bulk path: one wasm call for the whole document plus a single JS + // walk, instead of ~10 wasm crossings per container. + const docWithDeepValue = this.doc as LoroDoc & { + getDeepValueWithID?: () => unknown; + }; + if (typeof docWithDeepValue.getDeepValueWithID !== "function") { + return this.buildRootStateSnapshotLegacy(prevState, options); + } + + return this.buildRootStateSnapshotFromDeepValue(prevState, options); + } + + /** + * Build a fresh state snapshot from the LoroDoc by walking it container by + * container. Kept intact as the fallback for loro-crdt versions without + * `getDeepValueWithID` and for parity testing. + */ + private buildRootStateSnapshotLegacy( + prevState?: Record, + options: RootSnapshotOptions = {}, + ): Record { + if (!this.schema || this.schema.type !== "schema") { + // Fallback to previous normalization if no schema + return toNormalizedJson(this.doc) as Record; + } + const root: Record = {}; const rootSchema = this.schema as RootSchemaType< Record @@ -3151,6 +3256,494 @@ export class Mirror { return root; } + /** + * Build a fresh state snapshot from a single `doc.getDeepValueWithID()` + * call plus one JS walk over the returned tree. Produces exactly the same + * state, `containerRegistry`, `inferOptionsByContainerId`, and + * `rootPathById` contents as `buildRootStateSnapshotLegacy`. + */ + private buildRootStateSnapshotFromDeepValue( + prevState?: Record, + options: RootSnapshotOptions = {}, + ): Record { + const rootSchema = this.schema as RootSchemaType< + Record + >; + const registerContainers = options.registerContainers === true; + + // Pass 1: touch every schema-declared root container. Accessing a root + // is a no-op in Loro but makes it visible in the deep value, and the + // legacy path does the same before snapshotting. Root registrations + // are refreshed so the walk below finds the root schema in the + // registry, exactly like the legacy path (roots are registered by + // initializeContainers before this runs). + const rootContainers = new Map< + string, + { cid: ContainerID; containerType: ContainerType } + >(); + for (const key in rootSchema.definition) { + const fieldSchema = rootSchema.definition[key]; + if ((fieldSchema as { type: string }).type === "ignore") continue; + const containerType = schemaToContainerType(fieldSchema); + if (!containerType) continue; + const container = getRootContainerByType( + this.doc, + key, + containerType, + ); + rootContainers.set(key, { + cid: container.id, + containerType, + }); + if (registerContainers && isContainerSchema(fieldSchema)) { + this.registerContainerInBulkWalk(container.id, fieldSchema); + } + } + + // Read AFTER pass 1 so roots that were never accessed are included. + const deepValue = ( + this.doc as LoroDoc & { getDeepValueWithID: () => unknown } + ).getDeepValueWithID(); + const deepRoots = isObject(deepValue) ? deepValue : {}; + + const ctx: BulkWalkContext = { + semantics: MIRROR_WALK_SEMANTICS, + registerContainers, + shallowValues: new Map(), + }; + + // Pass 2: build the state, preserving definition key order. + const root: Record = {}; + for (const key in rootSchema.definition) { + const fieldSchema = rootSchema.definition[key]; + // Preserve Ignore fields from previous state — they are memory-only + if ((fieldSchema as { type: string }).type === "ignore") { + if (prevState && key in prevState) { + root[key] = prevState[key]; + } + continue; + } + const info = rootContainers.get(key); + if (!info) continue; + const { cid, containerType } = info; + if (containerType === "Tree") { + // The deep-value tree shape differs from tree.toJSON() and its + // node data maps carry no cids, so trees keep the existing + // handle-based logic. + const arr = this.containerToMirrorState( + this.doc.getTree(key), + options, + ) as unknown[]; + if (!Array.isArray(arr) || arr.length === 0) continue; + root[key] = arr; + continue; + } + const node = deepRoots[key]; + if (node === undefined) { + // Should not happen after pass 1; stay safe and use the + // per-container traversal for this root. + root[key] = this.containerToMirrorState( + getRootContainerByType(this.doc, key, containerType), + options, + ); + continue; + } + const unwrapped = readDeepValueContainerNode(node); + // loro-crdt 1.13.3 drops the { value, cid } wrapper for empty + // containers; the root cid/kind are known here, so a bare value is + // the root container's content. + const rawValue = unwrapped ? unwrapped.value : node; + root[key] = this.bulkContainerStateByKind( + cid, + containerType, + rawValue, + ctx, + ); + } + + // With ignoreUnknownProperties, also mirror doc root keys the schema + // does not declare (e.g. written by peers on a newer schema version), + // so state stays consistent with the incremental event path — which + // always applies them — instead of deleting them on the next write. + if (this.options.ignoreUnknownProperties) { + // LoroDoc roots are always containers, so this returns only + // root key -> container id without descending into their values. + const shallowDocState = this.doc.getShallowValue(); + // Unknown roots are not registered and have no schema: walk them + // with normalizeContainerForJson semantics instead. + const unknownCtx: BulkWalkContext = { + semantics: UNKNOWN_ROOT_WALK_SEMANTICS, + registerContainers: false, + shallowValues: ctx.shallowValues, + }; + for (const [key, containerId] of Object.entries(shallowDocState)) { + if ( + Object.prototype.hasOwnProperty.call( + rootSchema.definition, + key, + ) + ) { + continue; + } + + const cid = containerId; + const kind = containerIdToContainerType(cid); + const node = deepRoots[key]; + if (node === undefined || kind === undefined) { + const container = this.doc.getContainerById(cid); + if (!container) { + // Match toNormalizedJson's error for an unresolved + // container id so this optimization does not hide a + // corrupt or inconsistent document state. + throw new Error(`ContainerID not found: ${cid}`); + } + root[key] = normalizeContainerForJson(container); + continue; + } + const unwrapped = readDeepValueContainerNode(node); + const rawValue = unwrapped ? unwrapped.value : node; + root[key] = this.bulkContainerStateByKind( + cid, + kind, + rawValue, + unknownCtx, + ); + } + } + return root; + } + + /** + * Snapshot one container from its deep-value payload, dispatching on the + * container kind. Anything the walk cannot handle (trees, counters, future + * kinds, malformed payloads) falls back to the handle-based logic. + */ + private bulkContainerStateByKind( + cid: ContainerID, + kind: ContainerType, + rawValue: unknown, + ctx: BulkWalkContext, + ): MirrorState { + switch (kind) { + case "Map": + return this.bulkMapState(cid, rawValue, ctx); + case "List": + case "MovableList": + return this.bulkListState(cid, kind, rawValue, ctx); + case "Text": + return typeof rawValue === "string" + ? rawValue + : this.bulkLegacyContainerFallback(cid, ctx); + default: + return this.bulkLegacyContainerFallback(cid, ctx); + } + } + + private bulkMapState( + cid: ContainerID, + rawValue: unknown, + ctx: BulkWalkContext, + ): MirrorState { + if (!isObject(rawValue)) { + return this.bulkLegacyContainerFallback(cid, ctx); + } + // The registered schema, read after this map was registered by its + // parent (or as a root) — same timing as containerToMirrorState. + const schema = ctx.semantics.useRegistrySchema + ? this.getContainerSchema(cid) + : undefined; + const parentLocalInfer = ctx.registerContainers + ? this.inferOptionsByContainerId.get(cid) + : undefined; + const obj = rawValue as MirrorStateObject; + if (Object.prototype.hasOwnProperty.call(obj, CID_KEY)) { + if (ctx.semantics.skipCidKey) { + // normalizeContainerForJson skips a user-owned "$cid" key. + delete obj[CID_KEY]; + } else { + // Legacy parity: containerToMirrorState stamps $cid on a fresh + // object first, so assigning a user "$cid" key afterwards + // throws on the read-only property (strict mode). Reproduce + // that, including the partial child registration before it. + const fresh: MirrorStateObject = {}; + defineCidProperty(fresh, cid); + for (const k of Object.keys(obj)) { + fresh[k] = this.bulkChildState( + cid, + "Map", + k, + obj[k], + schema, + parentLocalInfer, + ctx, + ) as MirrorState; + } + return fresh; + } + } + // Stamp before assigning children, like containerToMirrorState. The + // deep-value payload is fresh from a single wasm call and is reused + // directly as state. + defineCidProperty(obj, cid); + for (const k of Object.keys(obj)) { + obj[k] = this.bulkChildState( + cid, + "Map", + k, + obj[k], + schema, + parentLocalInfer, + ctx, + ) as MirrorState; + } + return obj; + } + + private bulkListState( + cid: ContainerID, + kind: "List" | "MovableList", + rawValue: unknown, + ctx: BulkWalkContext, + ): MirrorState { + if (!Array.isArray(rawValue)) { + return this.bulkLegacyContainerFallback(cid, ctx); + } + const schema = ctx.semantics.useRegistrySchema + ? this.getContainerSchema(cid) + : undefined; + const parentLocalInfer = ctx.registerContainers + ? this.inferOptionsByContainerId.get(cid) + : undefined; + const arr = rawValue as MirrorState[]; + for (let i = 0; i < arr.length; i++) { + arr[i] = this.bulkChildState( + cid, + kind, + i, + arr[i], + schema, + parentLocalInfer, + ctx, + ) as MirrorState; + } + return arr; + } + + /** + * Snapshot one child slot of a map/list from its deep-value payload, + * applying the same registration and decode semantics as the legacy + * per-container traversal. + */ + private bulkChildState( + parentCid: ContainerID, + parentKind: "Map" | "List" | "MovableList", + key: string | number, + childNode: unknown, + parentSchema: ContainerSchemaType | undefined, + parentLocalInfer: InferContainerOptions | undefined, + ctx: BulkWalkContext, + ): unknown { + if (isObject(childNode) || Array.isArray(childNode)) { + // Both container wrappers and embedded user objects can have + // { cid, value } fields. Check the parent's actual slot before + // interpreting that shape. Cache one shallow read per parent. + // This also identifies empty containers whose wrappers are + // omitted by loro-crdt 1.13.3. + const shallow = this.getBulkParentShallowValue(parentCid, ctx); + const actual = ( + shallow as Record | undefined + )?.[key]; + let childCid = + typeof actual === "string" && actual.startsWith("cid:") + ? (actual as ContainerID) + : undefined; + // Older Loro versions expose mergeable references as binary + // markers. Resolve only these ambiguous slots through get(), + // which distinguishes a real container from user-owned bytes. + if (actual instanceof Uint8Array) { + const parent = this.doc.getContainerById(parentCid) as + | LoroMap + | LoroList + | LoroMovableList + | undefined; + const child = + parentKind === "Map" + ? (parent as LoroMap | undefined)?.get(String(key)) + : ( + parent as LoroList | LoroMovableList | undefined + )?.get(Number(key)); + if (isContainer(child)) childCid = child.id; + } + if (childCid) { + const node = readDeepValueContainerNode(childNode); + return this.bulkContainerChild( + parentCid, + parentKind, + key, + childCid, + node ? node.value : childNode, + parentSchema, + parentLocalInfer, + ctx, + ); + } + } + return applyDecode(getChildSchema(parentSchema, key), childNode); + } + + private bulkContainerChild( + parentCid: ContainerID, + parentKind: "Map" | "List" | "MovableList", + key: string | number, + childCid: ContainerID, + rawValue: unknown, + parentSchema: ContainerSchemaType | undefined, + parentLocalInfer: InferContainerOptions | undefined, + ctx: BulkWalkContext, + ): unknown { + if (ctx.registerContainers) { + this.registerChildContainerInBulkWalk( + parentCid, + parentKind, + key, + childCid, + parentSchema, + parentLocalInfer, + ); + } + const kind = containerIdToContainerType(childCid); + if (!kind) { + return this.bulkLegacyContainerFallback(childCid, ctx); + } + return this.bulkContainerStateByKind(childCid, kind, rawValue, ctx); + } + + /** + * registerChildContainer without the Loro handles and without the + * scanNested side effect (the walk itself descends into every child). + * Replicates its schema-resolution and infer-options semantics exactly. + */ + private registerChildContainerInBulkWalk( + parentCid: ContainerID, + parentKind: "Map" | "List" | "MovableList", + childKey: string | number, + childCid: ContainerID, + parentSchema: ContainerSchemaType | undefined, + parentLocalInfer: InferContainerOptions | undefined, + ) { + let nestedSchema: ContainerSchemaType | undefined; + + if (parentKind === "Map") { + if ( + parentSchema && + isLoroMapSchema(parentSchema) && + typeof childKey === "string" + ) { + const candidate = getMapFieldSchema(parentSchema, childKey); + if (candidate?.type === "any") { + this.inferOptionsByContainerId.set( + childCid, + this.getInferOptionsForChild(parentCid, candidate), + ); + } + if (candidate && isContainerSchema(candidate)) { + nestedSchema = candidate; + } + } + } else if ( + parentSchema && + (isLoroListSchema(parentSchema) || + isLoroMovableListSchema(parentSchema)) + ) { + const itemSchema = parentSchema.itemSchema; + if (itemSchema?.type === "any") { + this.inferOptionsByContainerId.set( + childCid, + this.getInferOptionsForChild(parentCid, itemSchema), + ); + } + if (isContainerSchema(itemSchema)) { + nestedSchema = itemSchema; + } + } + + if ( + !parentSchema && + !nestedSchema && + parentLocalInfer && + !this.inferOptionsByContainerId.has(childCid) + ) { + this.inferOptionsByContainerId.set(childCid, parentLocalInfer); + } + + this.registerContainerInBulkWalk(childCid, nestedSchema); + } + + /** + * registerContainerHandle with scanNested:false, minus the handle. The + * scanNested side effect is irrelevant in the bulk walk because the walk + * itself descends into every nested container. + */ + private registerContainerInBulkWalk( + containerId: ContainerID, + schemaType: ContainerSchemaType | undefined, + ) { + const existing = this.containerRegistry.get(containerId); + if (existing) { + if (!existing.schema && schemaType) { + existing.schema = schemaType; + } + return; + } + this.registerContainerWithRegistry(containerId, schemaType); + } + + /** + * Handle-based fallback for containers the deep-value walk cannot handle. + * Uses the existing per-container logic with the semantics of the current + * walk (mirror schema decoding vs. plain normalization). + */ + private bulkLegacyContainerFallback( + cid: ContainerID, + ctx: BulkWalkContext, + ): MirrorState { + const container = this.doc.getContainerById(cid); + if (!container) { + throw new Error(`ContainerID not found: ${cid}`); + } + if (ctx.semantics.useRegistrySchema) { + return this.containerToMirrorState(container, { + registerContainers: ctx.registerContainers, + }); + } + return normalizeContainerForJson(container) as MirrorState; + } + + /** + * Shallow value of a map/list parent, cached per container for the + * duration of a bulk walk. Consulted for object/array + * children to distinguish real containers from embedded user values, + * including objects that happen to match the { value, cid } wrapper. + */ + private getBulkParentShallowValue( + parentCid: ContainerID, + ctx: BulkWalkContext, + ): unknown { + let shallow = ctx.shallowValues.get(parentCid); + if (shallow === undefined) { + const parent = this.doc.getContainerById(parentCid); + const kind = parent?.kind(); + shallow = + kind === "Map" || kind === "List" || kind === "MovableList" + ? ( + parent as LoroMap | LoroList | LoroMovableList + ).getShallowValue() + : {}; + ctx.shallowValues.set(parentCid, shallow); + } + return shallow; + } + /** * Register a container schema */ diff --git a/packages/core/tests/bulk-init-embedded-values.test.ts b/packages/core/tests/bulk-init-embedded-values.test.ts new file mode 100644 index 0000000..f037332 --- /dev/null +++ b/packages/core/tests/bulk-init-embedded-values.test.ts @@ -0,0 +1,50 @@ +import { expect, it } from "vitest"; +import { LoroDoc, LoroMap } from "loro-crdt"; +import { Mirror, schema } from "../src/index.js"; + +it.each(["cid:root-other:Map", "idx:2, id:cid:root-other:Map"])( + "preserves embedded wrapper-shaped values with cid %s", + (cid) => { + const doc = new LoroDoc(); + const value = { cid, value: { message: "kept" }, extra: 123 }; + doc.getMap("root").set("payload", value); + doc.getList("list").push(value); + doc.getMovableList("movable").push(value); + doc.getMap("unknown").set("payload", value); + const real = doc.getMap("root").setContainer("real", new LoroMap()); + real.set("message", "actual container"); + doc.commit(); + const mirror = new Mirror({ + doc, + schema: schema({ + root: schema.LoroMap({ + payload: schema.Any(), + real: schema.Any(), + }), + list: schema.LoroList(schema.Any()), + movable: schema.LoroMovableList(schema.Any(), () => ""), + }), + ignoreUnknownProperties: true, + }); + expect(mirror.getState().root.payload).toEqual(value); + expect(mirror.getState().list[0]).toEqual(value); + expect(mirror.getState().movable[0]).toEqual(value); + expect( + (mirror.getState() as unknown as { unknown: { payload: unknown } }) + .unknown.payload, + ).toEqual(value); + expect(mirror.getState().root.real).toEqual({ + message: "actual container", + }); + expect( + Object.getOwnPropertyDescriptor( + mirror.getState().root.payload, + "$cid", + ), + ).toBeUndefined(); + expect(() => { + mirror.checkStateConsistency(); + }).not.toThrow(); + mirror.dispose(); + }, +); diff --git a/packages/core/tests/bulk-init-fuzz.test.ts b/packages/core/tests/bulk-init-fuzz.test.ts new file mode 100644 index 0000000..5f7aab5 --- /dev/null +++ b/packages/core/tests/bulk-init-fuzz.test.ts @@ -0,0 +1,695 @@ +/** + * Parity fuzz: the bulk `doc.getDeepValueWithID()` snapshot path must produce + * exactly the same result as the legacy per-container traversal — state, + * `$cid` markers, container registry, and per-container infer options. + * + * The legacy path is exercised by shadowing `getDeepValueWithID` with + * `undefined` on a cloned doc (an own property on the wasm object shadows the + * prototype method), which routes `buildRootStateSnapshot` to the fallback. + */ +import { describe, expect, it } from "vitest"; +import { + ContainerID, + LoroDoc, + LoroList, + LoroMap, + LoroMovableList, + LoroText, + LoroTree, + TreeID, +} from "loro-crdt"; +import { Mirror } from "../src/core/mirror.js"; +import { + ContainerSchemaType, + SchemaType, + schema, +} from "../src/schema/index.js"; +import type { InferContainerOptions } from "../src/schema/types.js"; +import { cidsEqual, deepEqual } from "../src/core/utils.js"; + +// --------------------------------------------------------------------------- +// Seeded RNG +// --------------------------------------------------------------------------- + +function mulberry32(seed: number): () => number { + let a = seed >>> 0; + return () => { + a |= 0; + a = (a + 0x6d2b79f5) | 0; + let t = Math.imul(a ^ (a >>> 15), 1 | a); + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} + +// --------------------------------------------------------------------------- +// Random document + schema generation +// --------------------------------------------------------------------------- + +type ValueSpec = + | { kind: "prim"; value: string | number | boolean | null } + | { kind: "embedded"; value: Record | unknown[] } + | { kind: "map"; fields: [string, ValueSpec][] } + | { kind: "list"; items: ValueSpec[] } + | { kind: "movable"; items: ValueSpec[] } + | { kind: "text"; text: string }; + +type TreeNodeSpec = { + fields: [string, ValueSpec][]; + children: TreeNodeSpec[]; +}; + +type RootSpec = + | { kind: "map"; name: string; fields: [string, ValueSpec][] } + | { kind: "list"; name: string; items: ValueSpec[] } + | { kind: "movable"; name: string; items: ValueSpec[] } + | { kind: "text"; name: string; text: string } + | { kind: "tree"; name: string; nodes: TreeNodeSpec[] } + | { kind: "ignore"; name: string }; + +type FieldCoverage = + | { declared: true; fieldSchema: SchemaType } + | { declared: false }; + +type Generated = { + roots: RootSpec[]; + definition: Record; + /** Declared root maps with a plain (untransformed) string field, for updates. */ + updatableStringFields: { root: string; key: string }[]; +}; + +function randInt(rng: () => number, maxExclusive: number): number { + return Math.floor(rng() * maxExclusive); +} + +function genPrimValue(rng: () => number): string | number | boolean | null { + switch (randInt(rng, 5)) { + case 0: + return `s${randInt(rng, 1000)}`; + case 1: + return randInt(rng, 1000); + case 2: + return rng() < 0.5; + case 3: + return null; + default: + return `text-${randInt(rng, 100)}`; + } +} + +function genEmbeddedValue( + rng: () => number, +): Record | unknown[] { + if (rng() < 0.5) { + const out: Record = {}; + const keys = randInt(rng, 3); + for (let i = 0; i < keys; i++) { + out[`ek${i}`] = rng() < 0.5 ? genPrimValue(rng) : [1, "x"]; + } + return out; + } + const out: unknown[] = []; + const len = randInt(rng, 3); + for (let i = 0; i < len; i++) { + out.push(rng() < 0.5 ? genPrimValue(rng) : { nested: true }); + } + return out; +} + +function genValueSpec(rng: () => number, depth: number): ValueSpec { + const containerKinds = depth < 3 ? 9 : 4; + const r = randInt(rng, containerKinds); + switch (r) { + case 0: + case 1: + return { kind: "prim", value: genPrimValue(rng) }; + case 2: + return { kind: "embedded", value: genEmbeddedValue(rng) }; + case 3: + // Empty container (bare in the deep value on loro-crdt 1.13.3). + return rng() < 0.5 + ? { kind: "map", fields: [] } + : { kind: "list", items: [] }; + case 4: { + const fields: [string, ValueSpec][] = []; + const count = randInt(rng, 5); + for (let i = 0; i < count; i++) { + fields.push([`f${i}`, genValueSpec(rng, depth + 1)]); + } + return { kind: "map", fields }; + } + case 5: + case 6: { + const items: ValueSpec[] = []; + const count = randInt(rng, 5); + for (let i = 0; i < count; i++) { + items.push(genValueSpec(rng, depth + 1)); + } + return r === 5 + ? { kind: "list", items } + : { kind: "movable", items }; + } + default: + return { kind: "text", text: `t${randInt(rng, 500)}` }; + } +} + +function genTreeNodeSpec(rng: () => number, depth: number): TreeNodeSpec { + const fields: [string, ValueSpec][] = []; + const fieldCount = 1 + randInt(rng, 3); + for (let i = 0; i < fieldCount; i++) { + fields.push([`nf${i}`, genValueSpec(rng, depth + 1)]); + } + const children: TreeNodeSpec[] = []; + if (depth < 3) { + const childCount = randInt(rng, 3); + for (let i = 0; i < childCount; i++) { + children.push(genTreeNodeSpec(rng, depth + 1)); + } + } + return { fields, children }; +} + +function genRootSpecs(rng: () => number): RootSpec[] { + const roots: RootSpec[] = []; + const rootCount = 2 + randInt(rng, 4); + for (let i = 0; i < rootCount; i++) { + const name = `root${i}`; + switch (randInt(rng, 8)) { + case 0: + case 1: + case 2: { + const fields: [string, ValueSpec][] = []; + const count = randInt(rng, 6); + for (let j = 0; j < count; j++) { + fields.push([`f${j}`, genValueSpec(rng, 1)]); + } + roots.push({ kind: "map", name, fields }); + break; + } + case 3: { + const items: ValueSpec[] = []; + const count = randInt(rng, 5); + for (let j = 0; j < count; j++) { + items.push(genValueSpec(rng, 0)); + } + roots.push({ kind: "list", name, items }); + break; + } + case 4: { + const items: ValueSpec[] = []; + const count = randInt(rng, 4); + for (let j = 0; j < count; j++) { + items.push(genValueSpec(rng, 0)); + } + roots.push({ kind: "movable", name, items }); + break; + } + case 5: + roots.push({ kind: "text", name, text: `root-text-${i}` }); + break; + case 6: { + const nodes: TreeNodeSpec[] = []; + const count = randInt(rng, 3); + for (let j = 0; j < count; j++) { + nodes.push(genTreeNodeSpec(rng, 0)); + } + roots.push({ kind: "tree", name, nodes }); + break; + } + default: + roots.push({ kind: "ignore", name }); + break; + } + } + return roots; +} + +// --------------------------------------------------------------------------- +// Doc writing +// --------------------------------------------------------------------------- + +function writeSpecToMap(m: LoroMap, key: string, spec: ValueSpec) { + switch (spec.kind) { + case "prim": + m.set(key, spec.value); + break; + case "embedded": + m.set(key, spec.value as never); + break; + case "map": { + const child = m.setContainer(key, new LoroMap()); + for (const [k, v] of spec.fields) writeSpecToMap(child, k, v); + break; + } + case "list": { + const child = m.setContainer(key, new LoroList()); + for (const item of spec.items) writeSpecToList(child, item); + break; + } + case "movable": { + const child = m.setContainer(key, new LoroMovableList()); + for (const item of spec.items) writeSpecToList(child, item); + break; + } + case "text": { + const child = m.setContainer(key, new LoroText()); + child.insert(0, spec.text); + break; + } + } +} + +function writeSpecToList(l: LoroList | LoroMovableList, spec: ValueSpec) { + switch (spec.kind) { + case "prim": + l.push(spec.value); + break; + case "embedded": + l.push(spec.value as never); + break; + case "map": { + const child = l.pushContainer(new LoroMap()); + for (const [k, v] of spec.fields) writeSpecToMap(child, k, v); + break; + } + case "list": { + const child = l.pushContainer(new LoroList()); + for (const item of spec.items) writeSpecToList(child, item); + break; + } + case "movable": { + const child = l.pushContainer(new LoroMovableList()); + for (const item of spec.items) writeSpecToList(child, item); + break; + } + case "text": { + const child = l.pushContainer(new LoroText()); + child.insert(0, spec.text); + break; + } + } +} + +function writeTreeNodes( + tree: LoroTree, + parent: TreeID | undefined, + nodes: TreeNodeSpec[], +) { + nodes.forEach((nodeSpec, index) => { + const node = tree.createNode(parent, index); + for (const [k, v] of nodeSpec.fields) { + writeSpecToMap(node.data, k, v); + } + writeTreeNodes(tree, node.id, nodeSpec.children); + }); +} + +function writeRootSpec(doc: LoroDoc, root: RootSpec) { + switch (root.kind) { + case "map": { + const m = doc.getMap(root.name); + for (const [k, v] of root.fields) writeSpecToMap(m, k, v); + break; + } + case "list": { + const l = doc.getList(root.name); + for (const item of root.items) writeSpecToList(l, item); + break; + } + case "movable": { + const l = doc.getMovableList(root.name); + for (const item of root.items) writeSpecToList(l, item); + break; + } + case "text": + doc.getText(root.name).insert(0, root.text); + break; + case "tree": + writeTreeNodes(doc.getTree(root.name), undefined, root.nodes); + break; + case "ignore": + // Ignore roots are memory-only; nothing is written to the doc. + break; + } +} + +// --------------------------------------------------------------------------- +// Schema generation +// --------------------------------------------------------------------------- + +const PREFIX_TRANSFORM = { + decode: (v: string) => `dec:${v}`, + encode: (v: string) => (v.startsWith("dec:") ? v.slice(4) : v), +}; + +// The fuzz never moves movable-list items, so a best-effort selector is fine. +function safeIdSelector(item: unknown): string { + if (item !== null && typeof item === "object" && "id" in item) { + return String((item as { id: unknown }).id); + } + return ""; +} + +function genAnySchema(rng: () => number) { + const r = rng(); + if (r < 0.5) return schema.Any(); + return schema.Any({ + defaultLoroText: rng() < 0.5, + defaultMovableList: rng() < 0.5, + }); +} + +function genSchemaForValue( + rng: () => number, + spec: ValueSpec, + updatable: { root: string; key: string }[], + path: string[], +): FieldCoverage { + const r = rng(); + if (r < 0.2) return { declared: false }; + if (r < 0.35) return { declared: true, fieldSchema: genAnySchema(rng) }; + + switch (spec.kind) { + case "prim": { + if (typeof spec.value === "string") { + if (rng() < 0.25) { + return { + declared: true, + fieldSchema: schema + .String() + .transform(PREFIX_TRANSFORM), + }; + } + // Only direct children of a declared root map are safe + // setState targets. + if (path.length === 2) { + updatable.push({ + root: path[0], + key: path[path.length - 1], + }); + } + return { declared: true, fieldSchema: schema.String() }; + } + if (typeof spec.value === "number") { + return { declared: true, fieldSchema: schema.Number() }; + } + if (typeof spec.value === "boolean") { + return { declared: true, fieldSchema: schema.Boolean() }; + } + return { declared: true, fieldSchema: genAnySchema(rng) }; + } + case "embedded": + return { declared: true, fieldSchema: genAnySchema(rng) }; + case "map": { + const definition: Record = {}; + for (const [k, v] of spec.fields) { + const cov = genSchemaForValue(rng, v, updatable, [...path, k]); + if (cov.declared) definition[k] = cov.fieldSchema; + } + return { declared: true, fieldSchema: schema.LoroMap(definition) }; + } + case "list": + case "movable": { + // List schemas have a single itemSchema; derive it from the first + // container item when possible, otherwise use Any. + const containerItem = spec.items.find( + (item) => + item.kind === "map" || + item.kind === "list" || + item.kind === "movable" || + item.kind === "text", + ); + let itemSchema: SchemaType; + if (!containerItem || rng() < 0.3) { + itemSchema = genAnySchema(rng); + } else { + const cov = genSchemaForValue(rng, containerItem, updatable, [ + ...path, + "0", + ]); + itemSchema = cov.declared ? cov.fieldSchema : genAnySchema(rng); + } + return { + declared: true, + fieldSchema: + spec.kind === "list" + ? schema.LoroList(itemSchema) + : schema.LoroMovableList(itemSchema, safeIdSelector), + }; + } + case "text": + return { declared: true, fieldSchema: schema.LoroText() }; + } +} + +function genTreeNodeSchemaDef( + rng: () => number, + nodes: TreeNodeSpec[], + updatable: { root: string; key: string }[], + rootName: string, +): Record { + // Union of all node data fields (first spec wins per key). + const byKey = new Map(); + const collect = (list: TreeNodeSpec[]) => { + for (const node of list) { + for (const [k, v] of node.fields) { + if (!byKey.has(k)) byKey.set(k, v); + } + collect(node.children); + } + }; + collect(nodes); + const definition: Record = {}; + for (const [k, v] of byKey) { + // Path depth 3 keeps tree node fields out of the setState targets + // (tree root state is an array, not a map). + const cov = genSchemaForValue(rng, v, updatable, [rootName, "0", k]); + if (cov.declared) definition[k] = cov.fieldSchema; + } + return definition; +} + +function generate(rng: () => number): Generated { + const roots = genRootSpecs(rng); + const definition: Record = {}; + const updatableStringFields: { root: string; key: string }[] = []; + + for (const root of roots) { + switch (root.kind) { + case "ignore": + definition[root.name] = schema.Ignore(); + break; + case "tree": + // Undeclared trees are still mirrored under + // ignoreUnknownProperties. + if (rng() < 0.7) { + definition[root.name] = schema.LoroTree( + schema.LoroMap( + genTreeNodeSchemaDef( + rng, + root.nodes, + updatableStringFields, + root.name, + ), + ), + ); + } + break; + default: { + // Other roots: mostly declared; sometimes left undeclared. + if (rng() < 0.2) break; + const spec: ValueSpec = + root.kind === "map" + ? { kind: "map", fields: root.fields } + : root.kind === "list" + ? { kind: "list", items: root.items } + : root.kind === "movable" + ? { kind: "movable", items: root.items } + : { kind: "text", text: root.text }; + const cov = genSchemaForValue( + rng, + spec, + updatableStringFields, + [root.name], + ); + if (cov.declared) definition[root.name] = cov.fieldSchema; + break; + } + } + } + + return { roots, definition, updatableStringFields }; +} + +// --------------------------------------------------------------------------- +// Mirror construction + comparison +// --------------------------------------------------------------------------- + +function cloneDoc(doc: LoroDoc): LoroDoc { + const clone = new LoroDoc(); + clone.import(doc.export({ mode: "snapshot" })); + return clone; +} + +function compareStrings(a: string, b: string): number { + return a < b ? -1 : a > b ? 1 : 0; +} + +type RegistryEntry = { schema: unknown; registered: boolean }; + +type MirrorInternals = { + containerRegistry: Map; + inferOptionsByContainerId: Map; + buildRootStateSnapshot: ( + prevState?: Record, + ) => Record; +}; + +function expectMirrorsEqual( + bulk: Mirror, + legacy: Mirror, + label: string, +) { + const bulkState = bulk.getState(); + const legacyState = legacy.getState(); + expect(deepEqual(bulkState, legacyState), `${label}: state`).toBe(true); + expect(cidsEqual(bulkState, legacyState), `${label}: $cid markers`).toBe( + true, + ); + expect( + [...bulk.getContainerIds()].sort(compareStrings), + `${label}: getContainerIds`, + ).toEqual([...legacy.getContainerIds()].sort(compareStrings)); + + const bulkInternals = bulk as unknown as MirrorInternals; + const legacyInternals = legacy as unknown as MirrorInternals; + + const regA = bulkInternals.containerRegistry; + const regB = legacyInternals.containerRegistry; + expect(regA.size, `${label}: registry size`).toBe(regB.size); + for (const [id, entry] of regA) { + const other = regB.get(id); + expect(other, `${label}: registry has ${id}`).toBeDefined(); + expect(other?.registered, `${label}: registry flag ${id}`).toBe( + entry.registered, + ); + // Both mirrors share the same schema object, so identity holds. + expect( + other?.schema === entry.schema, + `${label}: registry schema ${id}`, + ).toBe(true); + } + + const infA = bulkInternals.inferOptionsByContainerId; + const infB = legacyInternals.inferOptionsByContainerId; + expect(infA.size, `${label}: infer options size`).toBe(infB.size); + for (const [id, options] of infA) { + expect( + infB.has(id) && deepEqual(options, infB.get(id)), + `${label}: infer options ${id}`, + ).toBe(true); + } +} + +const ITERATIONS = Number(process.env.BULK_INIT_FUZZ_ITERATIONS ?? 300); +const BASE_SEED = 0x5eed0001; + +describe("bulk init snapshot parity fuzz", () => { + it("matches the legacy traversal on random docs and schemas", () => { + for (let i = 0; i < ITERATIONS; i++) { + const seed = BASE_SEED + i; + const rng = mulberry32(seed); + const { roots, definition, updatableStringFields } = generate(rng); + + const doc = new LoroDoc(); + for (const root of roots) writeRootSpec(doc, root); + doc.commit(); + + const rootSchema = schema( + definition as unknown as Record, + ); + const ignoreUnknownProperties = rng() < 0.5; + const checkStateConsistency = i % 4 === 0; + const options = { + schema: rootSchema, + ignoreUnknownProperties, + checkStateConsistency, + // The fuzz generates schemas that are not necessarily + // consistent with every value in the doc (shared list item + // schemas, union tree node schemas). Update validation is + // orthogonal to snapshot parity; keep it off so the setState + // round trips below stay focused on the snapshot paths. + validateUpdates: false, + }; + + const bulkDoc = cloneDoc(doc); + const legacyDoc = cloneDoc(doc); + // Own-property shadowing routes the second mirror to the legacy + // per-container traversal. + ( + legacyDoc as unknown as { getDeepValueWithID?: unknown } + ).getDeepValueWithID = undefined; + + const label = `seed=${seed}`; + const bulk = new Mirror({ doc: bulkDoc, ...options }); + const legacy = new Mirror({ doc: legacyDoc, ...options }); + try { + expectMirrorsEqual( + bulk as never, + legacy as never, + `${label} init`, + ); + + // Snapshot-with-prevState parity (the checkStateConsistency / + // rebuildBaseState path), including Ignore preservation. + const prevState: Record = {}; + for (const root of roots) { + if (root.kind === "ignore") { + prevState[root.name] = { keep: i, name: root.name }; + } + } + const bulkSnap = ( + bulk as unknown as MirrorInternals + ).buildRootStateSnapshot(prevState); + const legacySnap = ( + legacy as unknown as MirrorInternals + ).buildRootStateSnapshot(prevState); + expect( + deepEqual(bulkSnap, legacySnap), + `${label}: snapshot with prevState`, + ).toBe(true); + expect( + cidsEqual(bulkSnap, legacySnap), + `${label}: snapshot $cid with prevState`, + ).toBe(true); + + // setState round trips exercise checkStateConsistency (bulk on + // one mirror, legacy on the other) and must keep parity. + if (checkStateConsistency && updatableStringFields.length > 0) { + const target = + updatableStringFields[ + randInt(rng, updatableStringFields.length) + ]; + const update = (state: Record) => { + const rootObj = state[target.root] as Record< + string, + unknown + >; + rootObj[target.key] = `updated-${i}`; + }; + bulk.setState(update as never); + legacy.setState(update as never); + expectMirrorsEqual( + bulk as never, + legacy as never, + `${label} after setState`, + ); + } + } finally { + bulk.dispose(); + legacy.dispose(); + } + } + }); +}); diff --git a/packages/core/tests/ignore-unknown-properties.test.ts b/packages/core/tests/ignore-unknown-properties.test.ts index 26843ed..555ca26 100644 --- a/packages/core/tests/ignore-unknown-properties.test.ts +++ b/packages/core/tests/ignore-unknown-properties.test.ts @@ -299,7 +299,7 @@ describe("ignoreUnknownProperties", () => { mirror.dispose(); }); - it("does not traverse known deep roots a second time during initialization", () => { + it("reads known deep roots with a single deep-value call during initialization", () => { const historySchema = schema({ history: schema.LoroList( schema.LoroMap({ @@ -326,6 +326,7 @@ describe("ignoreUnknownProperties", () => { const listGetSpy = vi.spyOn(LoroList.prototype, "get"); const mapGetSpy = vi.spyOn(LoroMap.prototype, "get"); const textToJSONSpy = vi.spyOn(LoroText.prototype, "toJSON"); + const deepValueSpy = vi.spyOn(LoroDoc.prototype, "getDeepValueWithID"); let mirror: Mirror | undefined; try { @@ -336,14 +337,18 @@ describe("ignoreUnknownProperties", () => { }); expect(mirror.getState().history).toHaveLength(entryCount); - expect(listGetSpy).toHaveBeenCalledTimes(entryCount); - expect(mapGetSpy).toHaveBeenCalledTimes(entryCount * 3); - expect(textToJSONSpy).toHaveBeenCalledTimes(entryCount); + // Initialization snapshots the whole doc with one deep-value call; + // the per-container getters are never used for known roots. + expect(deepValueSpy).toHaveBeenCalledTimes(1); + expect(listGetSpy).not.toHaveBeenCalled(); + expect(mapGetSpy).not.toHaveBeenCalled(); + expect(textToJSONSpy).not.toHaveBeenCalled(); } finally { mirror?.dispose(); listGetSpy.mockRestore(); mapGetSpy.mockRestore(); textToJSONSpy.mockRestore(); + deepValueSpy.mockRestore(); } }); diff --git a/packages/core/tests/mirror.test.ts b/packages/core/tests/mirror.test.ts index aa2b402..69e4aee 100644 --- a/packages/core/tests/mirror.test.ts +++ b/packages/core/tests/mirror.test.ts @@ -1006,7 +1006,7 @@ describe("Mirror - State Consistency", () => { }); }); - it("reads each root list item once during construction", () => { + it("snapshots root list items with a single deep-value call during construction", () => { const todosSchema = schema({ todos: schema.LoroList( schema.LoroMap({ @@ -1023,6 +1023,7 @@ describe("Mirror - State Consistency", () => { doc.commit(); const getSpy = vi.spyOn(LoroList.prototype, "get"); + const deepValueSpy = vi.spyOn(LoroDoc.prototype, "getDeepValueWithID"); let mirror: Mirror | undefined; try { @@ -1033,10 +1034,14 @@ describe("Mirror - State Consistency", () => { }); expect(mirror.getState().todos).toHaveLength(1); expect(mirror.getContainerIds()).toContain(todo.id); - expect(getSpy).toHaveBeenCalledTimes(1); + // The bulk snapshot reads the whole doc at once instead of + // reading each list item individually. + expect(deepValueSpy).toHaveBeenCalledTimes(1); + expect(getSpy).not.toHaveBeenCalled(); } finally { mirror?.dispose(); getSpy.mockRestore(); + deepValueSpy.mockRestore(); } });