From 4b8ab833de2b76bc0ae793a2ddf104e394b36b56 Mon Sep 17 00:00:00 2001 From: Zixuan Chen Date: Sat, 13 Dec 2025 10:33:27 +0800 Subject: [PATCH] feat: schema.Any() --- README.md | 7 + api.md | 5 + packages/core/README.md | 8 +- packages/core/src/core/diff.ts | 474 ++++++++++++++---- packages/core/src/core/mirror.ts | 261 ++++++++-- packages/core/src/core/utils.ts | 52 +- packages/core/src/inferOptions.ts | 13 + packages/core/src/schema/index.ts | 17 + packages/core/src/schema/types.ts | 86 +++- packages/core/src/schema/validators.ts | 55 +- packages/core/tests/inferType.test-d.ts | 8 + .../core/tests/mirror-movable-list.test.ts | 174 ++++++- .../tests/null-in-map-consistency.test.ts | 13 - packages/core/tests/schema-any.test.ts | 131 +++++ 14 files changed, 1083 insertions(+), 221 deletions(-) create mode 100644 packages/core/src/inferOptions.ts create mode 100644 packages/core/tests/schema-any.test.ts diff --git a/README.md b/README.md index e31d926..6b80212 100644 --- a/README.md +++ b/README.md @@ -110,8 +110,14 @@ Loro Mirror provides a declarative schema system that enables: - `schema.String(options?)` - String type with optional generic constraint - `schema.Number(options?)` - Number type - `schema.Boolean(options?)` - Boolean type + - `schema.Any(options?)` - Runtime-inferred value/container type for JSON-like dynamic fields - `schema.Ignore(options?)` - Field that won't sync with Loro, useful for local computed fields +`schema.Any` options: + +- `defaultLoroText?: boolean` (defaults to `false` for `Any` when omitted) +- `defaultMovableList?: boolean` (inherits from global inference options) + - **Container Types**: - `schema.LoroMap(definition, options?)` - Object container that can nest arbitrary field schemas - Supports dynamic key-value definition with `catchall`: `schema.LoroMap({...}).catchall(valueSchema)` @@ -331,6 +337,7 @@ Loro Mirror uses a typed schema to map your app state to Loro containers. Common - `schema.String(options?)`: string - `schema.Number(options?)`: number - `schema.Boolean(options?)`: boolean +- `schema.Any(options?)`: JSON-like unknown (runtime-inferred) - `schema.Ignore(options?)`: exclude from sync (app-only) - `schema.LoroText(options?)`: rich text (`LoroText`) - `schema.LoroMap(definition, options?)`: object (`LoroMap`) diff --git a/api.md b/api.md index 0c309bc..b53d8d3 100644 --- a/api.md +++ b/api.md @@ -104,6 +104,7 @@ All schema builders live under the `schema` namespace and are exported at the pa - `schema.String(options?)` - `schema.Number(options?)` - `schema.Boolean(options?)` + - `schema.Any(options?)` — runtime-inferred value/container type (useful for dynamic JSON-like fields) - `schema.Ignore(options?)` — present in state, ignored for Loro diffs/validation - Containers @@ -123,6 +124,10 @@ All schema builders live under the `schema` namespace and are exported at the pa - `description?: string` - `validate?: (value) => boolean | string` — custom validator message when not true +- `schema.Any` options (per-Any inference overrides) + - `defaultLoroText?: boolean` — default `false` for `Any` when omitted (primitive string), overriding the global `inferOptions.defaultLoroText`. + - `defaultMovableList?: boolean` — inherits from the global inference options unless explicitly set. + - Type inference - `InferType` — state type produced by a schema - `InferInputType` — input type accepted by `setState` (map `$cid` optional) diff --git a/packages/core/README.md b/packages/core/README.md index 47401c0..5911b2e 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -92,7 +92,7 @@ Types: `SyncDirection`, `UpdateMetadata`, `SetStateOptions`. ### Schema Builder - Root: `schema({ ...fields })` -- Primitives: `schema.String`, `schema.Number`, `schema.Boolean`, `schema.Ignore` +- Primitives: `schema.String`, `schema.Number`, `schema.Boolean`, `schema.Any`, `schema.Ignore` - Containers (core): - `schema.LoroMap({ ...fields })` - `schema.LoroList(itemSchema, idSelector?)` @@ -113,6 +113,12 @@ Signatures: SchemaOptions for any field: `{ required?: boolean; defaultValue?: unknown; description?: string; validate?: (value) => boolean | string }`. +Any options: + +- `schema.Any({ defaultLoroText?: boolean; defaultMovableList?: boolean })` + - `defaultLoroText` defaults to `false` for Any when omitted (primitive string), overriding the global `inferOptions.defaultLoroText`. + - `defaultMovableList` inherits from the global inference options unless specified. + Reserved key `$cid`: - `$cid` is injected into mirrored state for all `LoroMap` schemas; it is never written back to Loro and is ignored by diffs/updates. It’s useful as a stable identifier (e.g., `schema.LoroList(map, x => x.$cid)`). diff --git a/packages/core/src/core/diff.ts b/packages/core/src/core/diff.ts index 419f838..22671a8 100644 --- a/packages/core/src/core/diff.ts +++ b/packages/core/src/core/diff.ts @@ -2,6 +2,7 @@ import { Container, ContainerID, isContainer, + isContainerId, LoroDoc, LoroMap, TreeID, @@ -11,14 +12,12 @@ import { isLoroListSchema, isLoroMapSchema, isLoroMovableListSchema, - isLoroTextSchema, isLoroTreeSchema, isRootSchemaType, LoroListSchema, LoroMapSchema, LoroMapSchemaWithCatchall, LoroMovableListSchema, - LoroTextSchemaType, LoroTreeSchema, RootSchemaType, SchemaType, @@ -31,6 +30,7 @@ import { deepEqual, getRootContainerByType, insertChildToMap, + applySchemaToInferOptions, isObjectLike, isStateAndSchemaOfType, isValueOfContainerType, @@ -38,7 +38,6 @@ import { type ArrayLike, tryInferContainerType, tryUpdateToContainer, - isStringLike, isArrayLike, isTreeID, defineCidProperty, @@ -141,6 +140,7 @@ export function diffContainer( schema: SchemaType | undefined, inferOptions?: InferContainerOptions, ): Change[] { + const effectiveInferOptions = applySchemaToInferOptions(schema, inferOptions); const stateAndSchema = { oldState, newState, schema }; if (containerId === "") { if ( @@ -161,146 +161,124 @@ export function diffContainer( stateAndSchema.newState, containerId, stateAndSchema.schema, - inferOptions, + effectiveInferOptions, ); } const containerType = containerIdToContainerType(containerId); - let changes: Change[] = []; - - let idSelector: IdSelector | undefined; - switch (containerType) { - case "Map": - if ( - !isStateAndSchemaOfType< - ObjectLike, - LoroMapSchema> - >(stateAndSchema, isObjectLike, isLoroMapSchema) - ) { - console.log("stateAndSchema:", stateAndSchema); + case "Map": { + if (!isObjectLike(oldState) || !isObjectLike(newState)) { throw new Error( "Failed to diff container(map). Old and new state must be objects", ); } - changes = diffMap( + const mapSchema = isLoroMapSchema(schema) ? schema : undefined; + + return diffMap( doc, - stateAndSchema.oldState, - stateAndSchema.newState, + oldState, + newState, containerId, - stateAndSchema.schema, - inferOptions, + mapSchema, + effectiveInferOptions, ); - break; - case "List": - if ( - !isStateAndSchemaOfType>( - stateAndSchema, - isArrayLike, - isLoroListSchema, - ) - ) { + } + case "List": { + if (!isArrayLike(oldState) || !isArrayLike(newState)) { throw new Error( "Failed to diff container(list). Old and new state must be arrays", ); } - idSelector = stateAndSchema.schema?.idSelector; + const listSchema = isLoroListSchema(schema) ? schema : undefined; + const idSelector = listSchema?.idSelector; if (idSelector) { - changes = diffListWithIdSelector( + return diffListWithIdSelector( doc, - stateAndSchema.oldState, - stateAndSchema.newState, + oldState, + newState, containerId, - stateAndSchema.schema, + listSchema, idSelector, - inferOptions, - ); - } else { - changes = diffList( - doc, - oldState as Array, - newState as Array, - containerId, - schema as LoroListSchema, - inferOptions, + effectiveInferOptions, ); } - break; - case "MovableList": - if ( - !isStateAndSchemaOfType< - ArrayLike, - LoroMovableListSchema - >(stateAndSchema, isArrayLike, isLoroMovableListSchema) - ) { + + return diffList( + doc, + oldState, + newState, + containerId, + listSchema, + effectiveInferOptions, + ); + } + case "MovableList": { + if (!isArrayLike(oldState) || !isArrayLike(newState)) { throw new Error( "Failed to diff container(movable list). Old and new state must be arrays", ); } - idSelector = stateAndSchema.schema?.idSelector; + const movableSchema = isLoroMovableListSchema(schema) + ? schema + : undefined; + const idSelector = movableSchema?.idSelector; - if (!idSelector) { - throw new Error("Movable list schema must have an idSelector"); + if (idSelector) { + return diffMovableList( + doc, + oldState, + newState, + containerId, + movableSchema, + idSelector, + effectiveInferOptions, + ); } - changes = diffMovableList( + return diffMovableListByIndex( doc, - stateAndSchema.oldState, - stateAndSchema.newState, + oldState, + newState, containerId, - stateAndSchema.schema, - idSelector, - inferOptions, + movableSchema, + effectiveInferOptions, ); - break; - case "Text": - if ( - !isStateAndSchemaOfType( - stateAndSchema, - isStringLike, - isLoroTextSchema, - ) - ) { + } + case "Text": { + if (typeof oldState !== "string" || typeof newState !== "string") { throw new Error( "Failed to diff container(text). Old and new state must be strings", ); } - changes = diffText( - stateAndSchema.oldState, - stateAndSchema.newState, - containerId, - ); - break; - case "Tree": - if ( - !isStateAndSchemaOfType< - ArrayLike, - LoroTreeSchema> - >(stateAndSchema, isArrayLike, isLoroTreeSchema) - ) { + + return diffText(oldState, newState, containerId); + } + case "Tree": { + if (!isArrayLike(oldState) || !isArrayLike(newState)) { throw new Error( "Failed to diff container(tree). Old and new state must be arrays", ); } - changes = diffTree( + + const treeSchema = isLoroTreeSchema(schema) ? schema : undefined; + return diffTree( doc, - stateAndSchema.oldState, - stateAndSchema.newState, + oldState, + newState, containerId, - stateAndSchema.schema, - inferOptions, + treeSchema, + effectiveInferOptions, ); - break; + } default: throw new Error(`Unsupported container type: ${containerType}`); } - - return changes; } /** @@ -647,6 +625,7 @@ export function diffMovableList( }, true, schema?.itemSchema, + inferOptions, ), ); } @@ -679,6 +658,7 @@ export function diffMovableList( }, true, schema?.itemSchema, + inferOptions, ), ); } @@ -791,6 +771,7 @@ export function diffListWithIdSelector( }, useContainer, schema?.itemSchema, + inferOptions, ), ); } @@ -812,6 +793,7 @@ export function diffListWithIdSelector( }, useContainer, schema?.itemSchema, + inferOptions, ), ); @@ -843,6 +825,7 @@ export function diffListWithIdSelector( }, useContainer, schema?.itemSchema, + inferOptions, ), ); offset++; @@ -940,6 +923,7 @@ export function diffList( }, true, schema?.itemSchema, + inferOptions, ), ); } @@ -969,6 +953,262 @@ export function diffList( }, true, schema?.itemSchema, + inferOptions, + ), + ); + } + + return changes; +} + +/** + * Diffs a [LoroMovableList] between two states without requiring an idSelector. + * + * This is an index-based fallback intended for inference-driven movable lists + * (e.g. when `inferOptions.defaultMovableList` is enabled). + */ +export function diffMovableListByIndex( + doc: LoroDoc, + oldState: ArrayLike, + newState: ArrayLike, + containerId: ContainerID, + schema: LoroMovableListSchema | undefined, + inferOptions?: InferContainerOptions, +): Change[] { + /** + * Index-based fallback for MovableList diffs. + * + * Why this exists: + * - A MovableList can appear without an explicit `LoroMovableListSchema` (e.g. when + * `inferOptions.defaultMovableList` is enabled or when `schema.Any()` infers an array + * as MovableList). In such cases we may not have an `idSelector`, so we cannot emit + * true `move` operations. + * + * Algorithm (similar to `diffList`): + * 0) `$cid` idSelector fallback: + * - If every element in both old/new arrays has a `$cid`, use `$cid` as an implicit + * idSelector and delegate to `diffMovableList` (supports complex move detection). + * 1) Best-effort single-move detection: + * - If the list length is unchanged and `newState` equals `oldState` after moving exactly + * one element, emit a single `move` op and stop. Element identity is matched by either + * strict equality (`===`) or a shared `$cid` (for map containers). + * 2) Find a common prefix and suffix by strict reference equality. + * 3) For the overlapping middle region, prefer preserving nested container identity: + * - If the current Loro item is a container and the next JS value is compatible with + * that container kind, recursively diff the child container. + * - Otherwise, emit a `set`/`set-container` at that index (via `tryUpdateToContainer`). + * 4) Delete extra trailing items (when the old middle block is longer). + * 5) Insert extra items (when the new middle block is longer). + * + * Limitations: + * - If `$cid` is missing on any element, move detection is limited to the + * "single move and nothing else changed" case. Complex reorders typically degrade into + * a sequence of `set`/`insert`/`delete` operations. + * - Without stable IDs, "minimal diff" is not guaranteed; large shuffles can cause many writes. + * - Container identity is only preserved when the container stays at the same index AND the + * next value remains compatible with that container kind. + */ + if (oldState === newState) return []; + + const cidSelector: IdSelector = (item) => { + if (!item || typeof item !== "object") return; + const cid = (item as Record)[CID_KEY]; + if (typeof cid === "string" && isContainerId(cid)) { + return cid + } + return undefined; + }; + const allHaveCid = (arr: ArrayLike) => + arr.every((x) => cidSelector(x) !== undefined); + + if (allHaveCid(oldState) && allHaveCid(newState)) { + return diffMovableList( + doc, + oldState, + newState, + containerId, + schema, + cidSelector, + inferOptions, + ); + } + + const identityEquals = (a: unknown, b: unknown) => { + if (a === b) return true; + if (!a || !b) return false; + if (typeof a !== "object" || typeof b !== "object") return false; + const aa = a as Record; + const bb = b as Record; + const ac = aa[CID_KEY]; + const bc = bb[CID_KEY]; + return typeof ac === "string" && typeof bc === "string" && ac === bc; + }; + + const tryDetectSingleMove = ( + oldArr: ArrayLike, + newArr: ArrayLike, + ): { from: number; to: number } | undefined => { + if (oldArr.length !== newArr.length) return; + const n = oldArr.length; + if (n <= 1) return; + + let start = 0; + while (start < n && identityEquals(oldArr[start], newArr[start])) start++; + if (start === n) return; + + // Case A: element moved forward (from=start, to>start) + const movedForward = oldArr[start]; + for (let to = start + 1; to < n; to++) { + if (!identityEquals(newArr[to], movedForward)) continue; + let ok = true; + for (let i = start; i < to; i++) { + if (!identityEquals(newArr[i], oldArr[i + 1])) { + ok = false; + break; + } + } + if (!ok) continue; + for (let i = to + 1; i < n; i++) { + if (!identityEquals(newArr[i], oldArr[i])) { + ok = false; + break; + } + } + if (ok) return { from: start, to }; + } + + // Case B: element moved backward (to=start, from>start) + const movedBackward = newArr[start]; + for (let from = start + 1; from < n; from++) { + if (!identityEquals(oldArr[from], movedBackward)) continue; + let ok = true; + for (let i = start; i < from; i++) { + if (!identityEquals(newArr[i + 1], oldArr[i])) { + ok = false; + break; + } + } + if (!ok) continue; + for (let i = from + 1; i < n; i++) { + if (!identityEquals(newArr[i], oldArr[i])) { + ok = false; + break; + } + } + if (ok) return { from, to: start }; + } + + return; + }; + + const singleMove = tryDetectSingleMove(oldState, newState); + if (singleMove) { + return [ + { + container: containerId, + key: singleMove.from, + value: undefined, + kind: "move", + fromIndex: singleMove.from, + toIndex: singleMove.to, + }, + ]; + } + + const changes: Change[] = []; + const oldLen = oldState.length; + const newLen = newState.length; + const list = doc.getMovableList(containerId); + + // Find common prefix + let start = 0; + while ( + start < oldLen && + start < newLen && + oldState[start] === newState[start] + ) { + start++; + } + + // Find common suffix (after the differing middle), ensuring no overlap with prefix + let suffix = 0; + while ( + suffix < oldLen - start && + suffix < newLen - start && + oldState[oldLen - 1 - suffix] === newState[newLen - 1 - suffix] + ) { + suffix++; + } + + const oldBlockLen = oldLen - start - suffix; + const newBlockLen = newLen - start - suffix; + + // First, handle overlapping part in the middle block as updates (preserve nested containers) + const overlap = Math.min(oldBlockLen, newBlockLen); + for (let j = 0; j < overlap; j++) { + const i = start + j; + if (oldState[i] === newState[i]) continue; + + const itemOnLoro = list.get(i); + const next = newState[i]; + + if (isContainer(itemOnLoro)) { + const ct = containerIdToContainerType(itemOnLoro.id); + if (ct && isValueOfContainerType(ct, next)) { + changes.push( + ...diffContainer( + doc, + oldState[i], + next, + itemOnLoro.id, + schema?.itemSchema, + inferOptions, + ), + ); + continue; + } + } + + changes.push( + tryUpdateToContainer( + { + container: containerId, + key: i, + value: next, + kind: "set", + }, + true, + schema?.itemSchema, + inferOptions, + ), + ); + } + + // Then handle extra deletions (when old middle block is longer) + for (let k = 0; k < oldBlockLen - overlap; k++) { + // Always delete at the same index (start + overlap) to remove a contiguous block + changes.push({ + container: containerId, + key: start + overlap, + value: undefined, + kind: "delete", + }); + } + + // Finally handle extra insertions (when new middle block is longer) + for (let k = 0; k < newBlockLen - overlap; k++) { + const insertIndex = start + overlap + k; + changes.push( + tryUpdateToContainer( + { + container: containerId, + key: insertIndex, + value: newState[insertIndex], + kind: "insert", + }, + true, + schema?.itemSchema, + inferOptions, ), ); } @@ -1010,13 +1250,13 @@ export function diffMap( // Skip ignored fields defined in schema const childSchemaForDelete = getMapChildSchema( schema as - | LoroMapSchema> - | LoroMapSchemaWithCatchall< - Record, - SchemaType - > - | RootSchemaType> - | undefined, + | LoroMapSchema> + | LoroMapSchemaWithCatchall< + Record, + SchemaType + > + | RootSchemaType> + | undefined, key, ); if (childSchemaForDelete && childSchemaForDelete.type === "ignore") { @@ -1044,13 +1284,13 @@ export function diffMap( // Figure out if the modified new value is a container const childSchema = getMapChildSchema( schema as - | LoroMapSchema> - | LoroMapSchemaWithCatchall< - Record, - SchemaType - > - | RootSchemaType> - | undefined, + | LoroMapSchema> + | LoroMapSchemaWithCatchall< + Record, + SchemaType + > + | RootSchemaType> + | undefined, key, ); @@ -1062,9 +1302,13 @@ export function diffMap( // Determine container type with schema-first, but respect actual value. // If schema suggests a container but the provided value doesn't match it, // log a warning and fall back to inferring from the value to avoid divergence. + const childInferOptions = applySchemaToInferOptions( + childSchema, + inferOptions, + ); let containerType = childSchema?.getContainerType() ?? - tryInferContainerType(newItem, inferOptions); + tryInferContainerType(newItem, childInferOptions); if ( childSchema?.getContainerType() && containerType && @@ -1075,7 +1319,7 @@ export function diffMap( newItem, )}. Falling back to value-based inference to avoid divergence.`, ); - containerType = tryInferContainerType(newItem, inferOptions); + containerType = tryInferContainerType(newItem, childInferOptions); } // Added new key: detect by property presence, not truthiness. @@ -1154,7 +1398,12 @@ export function diffMap( const child = map.get(key) as Container | undefined; if (!child || !isContainer(child)) { changes.push( - insertChildToMap(containerId, key, newStateObj[key]), + insertChildToMap( + containerId, + key, + newStateObj[key], + applySchemaToInferOptions(childSchema, inferOptions), + ), ); } else { // Reattach $cid on the incoming object if missing when the child @@ -1183,7 +1432,12 @@ export function diffMap( // or it was not a container and now it is } else { changes.push( - insertChildToMap(containerId, key, newStateObj[key]), + insertChildToMap( + containerId, + key, + newStateObj[key], + applySchemaToInferOptions(childSchema, inferOptions), + ), ); } } diff --git a/packages/core/src/core/mirror.ts b/packages/core/src/core/mirror.ts index 04cef9f..65b6c09 100644 --- a/packages/core/src/core/mirror.ts +++ b/packages/core/src/core/mirror.ts @@ -17,6 +17,7 @@ import { LoroTree, TreeID, } from "loro-crdt"; +import type { InferContainerOptions } from "../inferOptions"; import { applyEventBatchToState } from "./loroEventApply"; import { @@ -42,6 +43,7 @@ import { inferContainerTypeFromValue, isObject, isValueOfContainerType, + applySchemaToInferOptions, schemaToContainerType, tryInferContainerType, getRootContainerByType, @@ -132,10 +134,7 @@ export interface MirrorOptions { inferOptions?: InferContainerOptions; } -export type InferContainerOptions = { - defaultLoroText?: boolean; - defaultMovableList?: boolean; -}; +export type { InferContainerOptions } from "../inferOptions"; export type ChangeKinds = { set: { @@ -296,6 +295,8 @@ export class Mirror { private syncing = false; private options: MirrorOptions; private containerRegistry: ContainerRegistry = new Map(); + private inferOptionsByContainerId: Map = + new Map(); private subscriptions: (() => void)[] = []; // Canonical root path (e.g., ["profile"]) per root container id private rootPathById: Map = new Map(); @@ -528,6 +529,15 @@ export class Mirror { >, key, ); + if (candidate?.type === "any") { + this.inferOptionsByContainerId.set( + value.id, + this.getInferOptionsForChild( + container.id, + candidate, + ), + ); + } if (candidate && isContainerSchema(candidate)) { nestedSchema = candidate; } @@ -550,12 +560,21 @@ export class Mirror { (isLoroListSchema(parentSchema) || isLoroMovableListSchema(parentSchema)) ) { - nestedSchema = - parentSchema.itemSchema as ContainerSchemaType; - } - if (nestedSchema) { - this.registerContainer(value.id, nestedSchema); + const itemSchema = parentSchema.itemSchema; + if (itemSchema?.type === "any") { + this.inferOptionsByContainerId.set( + value.id, + this.getInferOptionsForChild( + container.id, + itemSchema, + ), + ); + } + if (isContainerSchema(itemSchema)) { + nestedSchema = itemSchema; + } } + this.registerContainer(value.id, nestedSchema); } } } else if (container.kind() === "Tree") { @@ -906,11 +925,19 @@ export class Mirror { container.id, key, ); + const childInfer = + fieldSchema?.type === "any" + ? this.getInferOptionsForChild( + container.id, + fieldSchema, + ) + : undefined; const inserted = this.insertContainerIntoMap( map, schema, key as string, value, + childInfer, ); // Stamp $cid into the pendingState value for child maps this.stampCid(value, inserted.id); @@ -942,6 +969,10 @@ export class Mirror { } else if (kind === "insert") { list.insert(index, value); } else if (kind === "insert-container") { + const fieldSchema = this.getSchemaForChild( + container.id, + key, + ); const schema = this.getSchemaForChildContainer( container.id, key, @@ -951,6 +982,12 @@ export class Mirror { schema, index, value, + fieldSchema?.type === "any" + ? this.getInferOptionsForChild( + container.id, + fieldSchema, + ) + : undefined, ); } else { throw new Error("Unsupported change kind for list"); @@ -979,6 +1016,10 @@ export class Mirror { } else if (kind === "insert") { list.insert(index, value); } else if (kind === "insert-container") { + const fieldSchema = this.getSchemaForChild( + container.id, + key, + ); const schema = this.getSchemaForChildContainer( container.id, key, @@ -988,6 +1029,12 @@ export class Mirror { schema, index, value, + fieldSchema?.type === "any" + ? this.getInferOptionsForChild( + container.id, + fieldSchema, + ) + : undefined, ); } else if (kind === "move") { const c = change as ChangeKinds["move"]; @@ -997,17 +1044,40 @@ export class Mirror { } else if (kind === "set") { list.set(index, value); } else if (kind === "set-container") { + const fieldSchema = this.getSchemaForChild( + container.id, + key, + ); const schema = this.getSchemaForChildContainer( container.id, key, ); + const infer = + fieldSchema?.type === "any" + ? this.getInferOptionsForChild( + container.id, + fieldSchema, + ) + : !schema + ? this.getInferOptionsForContainer(container.id) + : undefined; const [detachedContainer, _containerType] = - this.createContainerFromSchema(schema, value); + this.createContainerFromSchema( + schema, + value, + infer, + ); const newContainer = list.setContainer( index, detachedContainer, ); + if (!schema && infer) { + this.inferOptionsByContainerId.set( + newContainer.id, + infer, + ); + } this.registerContainer(newContainer.id, schema); this.initializeContainer(newContainer, schema, value); // Stamp $cid into pending state when replacing with a map container @@ -1358,20 +1428,32 @@ export class Mirror { item: unknown, itemSchema: SchemaType | undefined, ) { - // Determine if the item should be a container - let isContainer = false; + const baseInfer = this.getInferOptionsForContainer(list.id); + const effectiveInfer = this.getInferOptionsForChild( + list.id, + itemSchema, + ); + let containerSchema: ContainerSchemaType | undefined; + let containerType: ContainerType | undefined; + if (itemSchema && isContainerSchema(itemSchema)) { - isContainer = true; containerSchema = itemSchema; + containerType = schemaToContainerType(itemSchema); } else { - isContainer = - tryInferContainerType(item, this.options?.inferOptions) !== - undefined; - } - - if (isContainer && typeof item === "object" && item !== null) { - this.insertContainerIntoList(list, containerSchema, index, item); + containerType = tryInferContainerType(item, effectiveInfer); + } + + if (containerType && isValueOfContainerType(containerType, item)) { + const childInfer = + containerSchema ? undefined : (effectiveInfer || baseInfer); + this.insertContainerIntoList( + list, + containerSchema, + index, + item, + childInfer, + ); return; } @@ -1428,15 +1510,22 @@ export class Mirror { schema: ContainerSchemaType | undefined, key: string, value: unknown, + childInferOptions?: InferContainerOptions, ) { + const infer = + childInferOptions || (!schema ? this.getInferOptionsForContainer(map.id) : undefined); const [detachedContainer, _containerType] = - this.createContainerFromSchema(schema, value); + this.createContainerFromSchema(schema, value, infer); const insertedContainer = map.setContainer(key, detachedContainer); if (!insertedContainer) { throw new Error("Failed to insert container into map"); } + if (!schema && infer) { + this.inferOptionsByContainerId.set(insertedContainer.id, infer); + } + this.registerContainer(insertedContainer.id, schema); this.initializeContainer(insertedContainer, schema, value); @@ -1470,18 +1559,55 @@ export class Mirror { SchemaType > | undefined; + const baseInfer = this.getInferOptionsForContainer(map.id); for (const [key, val] of Object.entries(value)) { // Skip injected CID field if (key === CID_KEY) continue; - const fieldSchema = this.getSchemaForMapKey(mapSchema, key); + if (mapSchema) { + const fieldSchema = this.getSchemaForMapKey(mapSchema, key); - if (fieldSchema && isContainerSchema(fieldSchema)) { - const ct = schemaToContainerType(fieldSchema); - if (ct && isValueOfContainerType(ct, val)) { - this.insertContainerIntoMap(map, fieldSchema, key, val); - } else { - map.set(key, val); + if (fieldSchema?.type === "any") { + const infer = this.getInferOptionsForChild( + map.id, + fieldSchema, + ); + const ct = tryInferContainerType(val, infer); + if (ct && isValueOfContainerType(ct, val)) { + this.insertContainerIntoMap( + map, + undefined, + key, + val, + infer, + ); + } else { + map.set(key, val); + } + continue; } + + if (fieldSchema && isContainerSchema(fieldSchema)) { + const ct = schemaToContainerType(fieldSchema); + if (ct && isValueOfContainerType(ct, val)) { + this.insertContainerIntoMap( + map, + fieldSchema, + key, + val, + ); + } else { + map.set(key, val); + } + continue; + } + + map.set(key, val); + continue; + } + + const ct = tryInferContainerType(val, baseInfer); + if (ct && isValueOfContainerType(ct, val)) { + this.insertContainerIntoMap(map, undefined, key, val, baseInfer); } else { map.set(key, val); } @@ -1496,11 +1622,23 @@ export class Mirror { schema as LoroListSchema | undefined )?.itemSchema; + const baseInfer = this.getInferOptionsForContainer(list.id); const isListItemContainer = isContainerSchema(itemSchema); for (let i = 0; i < value.length; i++) { const item = value[i]; + if (itemSchema?.type === "any") { + const infer = applySchemaToInferOptions(itemSchema, baseInfer) || baseInfer; + const ct = tryInferContainerType(item, infer); + if (ct && isValueOfContainerType(ct, item)) { + this.insertContainerIntoList(list, undefined, i, item, infer); + } else { + list.insert(i, item); + } + continue; + } + if (isListItemContainer) { const ct = schemaToContainerType(itemSchema); if (ct && isValueOfContainerType(ct, item)) { @@ -1508,9 +1646,20 @@ export class Mirror { } else { list.insert(i, item); } - } else { - list.insert(i, item); + continue; } + + if (!itemSchema) { + const ct = tryInferContainerType(item, baseInfer); + if (ct && isValueOfContainerType(ct, item)) { + this.insertContainerIntoList(list, undefined, i, item, baseInfer); + } else { + list.insert(i, item); + } + continue; + } + + list.insert(i, item); } } else if (kind === "Text") { const text = container as LoroText; @@ -1533,10 +1682,11 @@ export class Mirror { private createContainerFromSchema( schema: ContainerSchemaType | undefined, value: unknown, + inferOptions?: InferContainerOptions, ): [Container, ContainerType] { const containerType = schema ? schemaToContainerType(schema) - : tryInferContainerType(value, this.options?.inferOptions); + : tryInferContainerType(value, inferOptions); switch (containerType) { case "Map": @@ -1564,9 +1714,12 @@ export class Mirror { schema: ContainerSchemaType | undefined, index: number, value: unknown, + childInferOptions?: InferContainerOptions, ) { + const infer = + childInferOptions || (!schema ? this.getInferOptionsForContainer(list.id) : undefined); const [detachedContainer, _containerType] = - this.createContainerFromSchema(schema, value); + this.createContainerFromSchema(schema, value, infer); let insertedContainer: Container | undefined; if (index === undefined) { @@ -1579,6 +1732,10 @@ export class Mirror { throw new Error("Failed to insert container into list"); } + if (!schema && infer) { + this.inferOptionsByContainerId.set(insertedContainer.id, infer); + } + this.registerContainer(insertedContainer.id, schema); this.initializeContainer(insertedContainer, schema, value); @@ -1676,13 +1833,11 @@ export class Mirror { this.updateMapEntry(map, key, val, schema); } else { // Infer whether this is a container - const ct = tryInferContainerType( - val, - this.options?.inferOptions, - ); + const infer = this.getInferOptionsForContainer(map.id); + const ct = tryInferContainerType(val, infer); if (ct && isValueOfContainerType(ct, val)) { // No child schema; insert with inferred container type - this.insertContainerIntoMap(map, undefined, key, val); + this.insertContainerIntoMap(map, undefined, key, val, infer); } else { map.set(key, val); } @@ -1719,6 +1874,20 @@ export class Mirror { // Skip ignore fields: they live only in mirrored state return; } + if (fieldSchema?.type === "any") { + const infer = this.getInferOptionsForChild(map.id, fieldSchema); + const ct = tryInferContainerType(value, infer); + if (ct && isValueOfContainerType(ct, value)) { + this.insertContainerIntoMap( + map, + undefined, + key, + value, + infer, + ); + return; + } + } if (fieldSchema && isContainerSchema(fieldSchema)) { const ct = schemaToContainerType(fieldSchema); if (ct && isValueOfContainerType(ct, value)) { @@ -1981,6 +2150,22 @@ export class Mirror { defineCidProperty(target, cid); } + private getInferOptionsForContainer(containerId: ContainerID | "") { + if (containerId !== "") { + const local = this.inferOptionsByContainerId.get(containerId); + if (local) return local; + } + return this.options?.inferOptions || {}; + } + + private getInferOptionsForChild( + parentId: ContainerID, + childSchema: SchemaType | undefined, + ) { + const base = this.getInferOptionsForContainer(parentId); + return applySchemaToInferOptions(childSchema, base) || base; + } + private getSchemaForMapKey( schema: | LoroMapSchema> @@ -2093,7 +2278,7 @@ function restoreCidDescriptors(value: unknown): unknown { } if (isObject(value)) { - const obj = value as Record; + const obj = value; for (const key of Object.keys(obj)) { obj[key] = restoreCidDescriptors(obj[key]); } diff --git a/packages/core/src/core/utils.ts b/packages/core/src/core/utils.ts index 2496047..274768c 100644 --- a/packages/core/src/core/utils.ts +++ b/packages/core/src/core/utils.ts @@ -201,31 +201,25 @@ export function insertChildToMap( containerId: ContainerID | "", key: string, value: unknown, + inferOptions?: InferContainerOptions, ): Change { - if (isObject(value)) { + const ct = tryInferContainerType(value, inferOptions); + if (ct) { return { container: containerId, key, - value: value, + value, kind: "insert-container", - childContainerType: "Map", - }; - } else if (Array.isArray(value)) { - return { - container: containerId, - key, - value: value, - kind: "insert-container", - childContainerType: "List", - }; - } else { - return { - container: containerId, - key, - value: value, - kind: "insert", + childContainerType: ct, }; } + + return { + container: containerId, + key, + value, + kind: "insert", + }; } /* Try to update a change to insert a container */ @@ -233,6 +227,7 @@ export function tryUpdateToContainer( change: Change, toUpdate: boolean, schema: SchemaType | undefined, + inferOptions?: InferContainerOptions, ): Change { if (!toUpdate) { return change; @@ -242,9 +237,11 @@ export function tryUpdateToContainer( return change; } + const effectiveInferOptions = applySchemaToInferOptions(schema, inferOptions); const containerType = schema - ? (schemaToContainerType(schema) ?? tryInferContainerType(change.value)) - : undefined; + ? (schemaToContainerType(schema) ?? + tryInferContainerType(change.value, effectiveInferOptions)) + : tryInferContainerType(change.value, effectiveInferOptions); if (containerType == null) { return change; @@ -302,6 +299,19 @@ export function tryInferContainerType( } } +export function applySchemaToInferOptions( + schema: SchemaType | undefined, + base: InferContainerOptions | undefined, +): InferContainerOptions | undefined { + if (!schema || schema.type !== "any") return base; + const next: InferContainerOptions = { ...base }; + next.defaultLoroText = schema.options.defaultLoroText ?? false; + if (schema.options.defaultMovableList !== undefined) { + next.defaultMovableList = schema.options.defaultMovableList; + } + return next; +} + /* Check if value is of a given container type */ export function isValueOfContainerType( containerType: ContainerType, @@ -312,7 +322,7 @@ export function isValueOfContainerType( case "List": return typeof value === "object" && Array.isArray(value); case "Map": - return typeof value === "object" && value !== null; + return isObject(value); case "Text": return typeof value === "string" && value !== null; case "Tree": diff --git a/packages/core/src/inferOptions.ts b/packages/core/src/inferOptions.ts new file mode 100644 index 0000000..a4a20ed --- /dev/null +++ b/packages/core/src/inferOptions.ts @@ -0,0 +1,13 @@ +export type InferContainerOptions = { + /** + * When true, string values are inferred as `LoroText` containers instead of primitive strings. + */ + defaultLoroText?: boolean; + /** + * When true, array values are inferred as `LoroMovableList` containers instead of `LoroList`. + * + * Note: if a MovableList is created/inferred without an `idSelector` schema, diffs fall back + * to index-based updates and do not emit `move` operations. + */ + defaultMovableList?: boolean; +}; diff --git a/packages/core/src/schema/index.ts b/packages/core/src/schema/index.ts index 45ca00e..9686461 100644 --- a/packages/core/src/schema/index.ts +++ b/packages/core/src/schema/index.ts @@ -4,6 +4,8 @@ * This module provides utilities to define schemas that map between JavaScript types and Loro CRDT types. */ import { + AnySchemaOptions, + AnySchemaType, BooleanSchemaType, ContainerSchemaType, IgnoreSchemaType, @@ -62,6 +64,21 @@ schema.String = function < } as StringSchemaType & { options: O }; }; +/** + * Define an any field (runtime-inferred by Mirror) + */ +schema.Any = function ( + options?: O, +) { + return { + type: "any" as const, + options: options || ({} as O), + getContainerType: () => { + return null; + }, + } as AnySchemaType & { options: O }; +}; + /** * Define a number field */ diff --git a/packages/core/src/schema/types.ts b/packages/core/src/schema/types.ts index 4905c82..a30bd81 100644 --- a/packages/core/src/schema/types.ts +++ b/packages/core/src/schema/types.ts @@ -3,6 +3,8 @@ */ import { ContainerType } from "loro-crdt"; +import type { InferContainerOptions } from "../inferOptions"; +export type { InferContainerOptions } from "../inferOptions"; /** * Options for schema definitions @@ -19,6 +21,19 @@ export interface SchemaOptions { [key: string]: unknown; } +export type AnySchemaOptions = SchemaOptions & { + /** + * Per-Any inference overrides. + * + * Notes: + * - `defaultLoroText` defaults to `false` for Any when omitted (primitive string), + * overriding the global `inferOptions.defaultLoroText`. + * - `defaultMovableList` inherits from the global inference options unless specified. + */ + defaultLoroText?: boolean; + defaultMovableList?: boolean; +}; + /** * Base interface for all schema types */ @@ -28,6 +43,16 @@ export interface BaseSchemaType { getContainerType(): ContainerType | null; } +/** + * Any schema type + * + * This schema defers container inference decisions to the runtime (Mirror). + */ +export interface AnySchemaType extends BaseSchemaType { + type: "any"; + options: AnySchemaOptions; +} + /** * String schema type */ @@ -132,6 +157,7 @@ export interface RootSchemaType> * Union of all schema types */ export type SchemaType = + | AnySchemaType | StringSchemaType | NumberSchemaType | BooleanSchemaType @@ -190,16 +216,20 @@ type IsSchemaRequired = S extends { */ export type InferType = IsSchemaRequired extends false - ? S extends StringSchemaType + ? S extends AnySchemaType + ? unknown + : S extends StringSchemaType ? T | undefined : S extends NumberSchemaType ? number | undefined : S extends BooleanSchemaType ? boolean | undefined - : S extends IgnoreSchemaType - ? unknown - : S extends LoroTextSchemaType - ? string | undefined + : S extends IgnoreSchemaType + ? unknown + : S extends LoroTextSchemaType + ? string | undefined + : S extends AnySchemaType + ? unknown : S extends LoroMapSchemaWithCatchall ? keyof M extends never ? @@ -238,14 +268,16 @@ export type InferType = ? number : S extends BooleanSchemaType ? boolean - : S extends IgnoreSchemaType - ? unknown - : S extends LoroTextSchemaType - ? string - : S extends LoroMapSchemaWithCatchall - ? keyof M extends never - ? { [key: string]: InferType } & { $cid: string } - : ({ [K in keyof M]: InferType } & { + : S extends IgnoreSchemaType + ? unknown + : S extends LoroTextSchemaType + ? string + : S extends AnySchemaType + ? unknown + : S extends LoroMapSchemaWithCatchall + ? keyof M extends never + ? { [key: string]: InferType } & { $cid: string } + : ({ [K in keyof M]: InferType } & { [K in Exclude]: InferType; }) & { $cid: string } : S extends LoroMapSchema @@ -273,16 +305,20 @@ export type InferSchemaType> = { */ export type InferInputType = IsSchemaRequired extends false - ? S extends StringSchemaType + ? S extends AnySchemaType + ? unknown + : S extends StringSchemaType ? T | undefined : S extends NumberSchemaType ? number | undefined : S extends BooleanSchemaType ? boolean | undefined - : S extends IgnoreSchemaType - ? unknown - : S extends LoroTextSchemaType - ? string | undefined + : S extends IgnoreSchemaType + ? unknown + : S extends LoroTextSchemaType + ? string | undefined + : S extends AnySchemaType + ? unknown : S extends LoroMapSchemaWithCatchall ? keyof M extends never ? @@ -321,12 +357,14 @@ export type InferInputType = ? number : S extends BooleanSchemaType ? boolean - : S extends IgnoreSchemaType - ? unknown - : S extends LoroTextSchemaType - ? string - : S extends LoroMapSchemaWithCatchall - ? keyof M extends never + : S extends IgnoreSchemaType + ? unknown + : S extends LoroTextSchemaType + ? string + : S extends AnySchemaType + ? unknown + : S extends LoroMapSchemaWithCatchall + ? keyof M extends never ? { [key: string]: InferInputType } & { $cid?: string; } diff --git a/packages/core/src/schema/validators.ts b/packages/core/src/schema/validators.ts index 8634580..30330d9 100644 --- a/packages/core/src/schema/validators.ts +++ b/packages/core/src/schema/validators.ts @@ -2,6 +2,7 @@ * Validators for schema definitions */ import { + AnySchemaType, BaseSchemaType, ContainerSchemaType, InferType, @@ -41,57 +42,64 @@ function markSchemaValidated(schema: SchemaType, value: unknown): void { * Type guard for LoroMapSchema */ export function isLoroMapSchema>( - schema: SchemaType, + schema?: SchemaType, ): schema is LoroMapSchema { - return (schema as BaseSchemaType).type === "loro-map"; + return !!schema && (schema as BaseSchemaType).type === "loro-map"; } /** * Type guard for LoroListSchema */ export function isLoroListSchema( - schema: SchemaType, + schema?: SchemaType, ): schema is LoroListSchema { - return (schema as BaseSchemaType).type === "loro-list"; + return !!schema && (schema as BaseSchemaType).type === "loro-list"; } export function isListLikeSchema( - schema: SchemaType, + schema?: SchemaType, ): schema is LoroListSchema | LoroMovableListSchema { return isLoroListSchema(schema) || isLoroMovableListSchema(schema); } export function isLoroMovableListSchema( - schema: SchemaType, + schema?: SchemaType, ): schema is LoroMovableListSchema { - return (schema as BaseSchemaType).type === "loro-movable-list"; + return !!schema && (schema as BaseSchemaType).type === "loro-movable-list"; } /** * Type guard for RootSchemaType */ export function isRootSchemaType>( - schema: SchemaType, + schema?: SchemaType, ): schema is RootSchemaType { - return (schema as BaseSchemaType).type === "schema"; + return !!schema && (schema as BaseSchemaType).type === "schema"; } /** * Type guard for LoroTextSchemaType */ export function isLoroTextSchema( - schema: SchemaType, + schema?: SchemaType, ): schema is LoroTextSchemaType { - return (schema as BaseSchemaType).type === "loro-text"; + return !!schema && (schema as BaseSchemaType).type === "loro-text"; } /** * Type guard for LoroTreeSchema */ export function isLoroTreeSchema>( - schema: SchemaType, + schema?: SchemaType, ): schema is LoroTreeSchema { - return (schema as BaseSchemaType).type === "loro-tree"; + return !!schema && (schema as BaseSchemaType).type === "loro-tree"; +} + +/** + * Type guard for AnySchemaType + */ +export function isAnySchema(schema?: SchemaType): schema is AnySchemaType { + return !!schema && (schema as BaseSchemaType).type === "any"; } /** @@ -136,6 +144,22 @@ export function validateSchema( // Validate based on schema type switch ((schema as BaseSchemaType).type) { + case "any": { + // Accept JSON-like values (primitives, arrays, and plain objects) + if ( + typeof value !== "string" && + typeof value !== "number" && + typeof value !== "boolean" && + value !== null && + value !== undefined && + !Array.isArray(value) && + !isObject(value) + ) { + errors.push("Value must be JSON-like"); + } + break; + } + case "string": if (typeof value !== "string") { errors.push("Value must be a string"); @@ -361,6 +385,11 @@ export function getDefaultValue( const schemaType = (schema as BaseSchemaType).type; switch (schemaType) { + case "any": { + // Only honor explicit defaultValue (handled above); otherwise undefined + return undefined; + } + case "string": { const value = schema.options.required ? "" : undefined; if (value === undefined) return undefined; diff --git a/packages/core/tests/inferType.test-d.ts b/packages/core/tests/inferType.test-d.ts index fea4047..3f2b7c2 100644 --- a/packages/core/tests/inferType.test-d.ts +++ b/packages/core/tests/inferType.test-d.ts @@ -2,6 +2,14 @@ import { test, expectTypeOf, describe } from "vitest"; import { InferType, schema } from "../src"; describe("infer type", () => { + test("infer any", () => { + const anySchema = schema.Any(); + + type InferredType = InferType; + + expectTypeOf().toEqualTypeOf(); + }); + test("catchall", () => { const mixedSchema = schema .LoroMap({ diff --git a/packages/core/tests/mirror-movable-list.test.ts b/packages/core/tests/mirror-movable-list.test.ts index 139e4ba..dbb25bc 100644 --- a/packages/core/tests/mirror-movable-list.test.ts +++ b/packages/core/tests/mirror-movable-list.test.ts @@ -1,9 +1,10 @@ /* eslint-disable unicorn/consistent-function-scoping */ import { Mirror } from "../src/core/mirror"; -import { LoroDoc } from "loro-crdt"; +import { isContainer, LoroDoc } from "loro-crdt"; import { schema } from "../src/schema"; import { describe, expect, it } from "vitest"; import { valueIsContainerOfType } from "../src/core/utils"; +import { diffMovableListByIndex } from "../src/core/diff"; // Utility function to wait for sync to complete (three microtasks for better reliability) const waitForSync = async () => { @@ -613,3 +614,174 @@ describe("MovableList", () => { } }); }); + +describe("MovableList (inferred)", () => { + const getMovableListItemContainerIds = (doc: LoroDoc, key: string) => { + const list = doc.getMovableList(key); + const ids: string[] = []; + for (let i = 0; i < list.length; i++) { + const v = list.get(i); + if (!isContainer(v)) { + throw new Error("Expected movable list items to be containers"); + } + ids.push(v.id); + } + return ids; + }; + + const getListState = (state: unknown) => { + if (!state || typeof state !== "object") { + throw new Error("Expected state to be an object"); + } + const list = (state as Record)["list"]; + if (!Array.isArray(list)) { + throw new Error("Expected state.list to be an array"); + } + return list as Array>; + }; + + const getCid = (item: Record) => { + const cid = item["$cid"]; + if (typeof cid !== "string") { + throw new Error("Expected item.$cid to be a string"); + } + return cid; + }; + + it("diffMovableListByIndex emits a single move for a pure one-element reorder", () => { + const doc = new LoroDoc(); + const containerId = doc.getMovableList("list").id; + + const oldState = ["a", "b", "c", "d"]; + const newState = ["b", "c", "a", "d"]; + + const changes = diffMovableListByIndex( + doc, + oldState, + newState, + containerId, + undefined, + { defaultMovableList: true }, + ); + + expect(changes).toEqual([ + { + container: containerId, + key: 0, + value: undefined, + kind: "move", + fromIndex: 0, + toIndex: 2, + }, + ]); + }); + + it("works without schema when defaultMovableList is enabled", async () => { + const doc = new LoroDoc(); + const mirror = new Mirror({ + doc, + inferOptions: { defaultMovableList: true }, + }); + + mirror.setState({ list: ["a", "b"] }); + await waitForSync(); + + let serialized = doc.getDeepValueWithID() as unknown as Record< + string, + unknown + >; + expect(valueIsContainerOfType(serialized["list"], ":MovableList")).toBe( + true, + ); + + mirror.setState({ list: ["a", "c", "d"] }); + await waitForSync(); + + const list = doc.getMovableList("list"); + expect(list.length).toBe(3); + expect(list.get(0)).toBe("a"); + expect(list.get(1)).toBe("c"); + expect(list.get(2)).toBe("d"); + + // Deletions + mirror.setState({ list: ["a"] }); + await waitForSync(); + expect(list.length).toBe(1); + expect(list.get(0)).toBe("a"); + + // Reorder (index-based fallback uses index diffs when items have no $cid) + mirror.setState({ list: ["x", "a"] }); + await waitForSync(); + expect(list.length).toBe(2); + expect(list.get(0)).toBe("x"); + expect(list.get(1)).toBe("a"); + + serialized = doc.getDeepValueWithID() as unknown as Record< + string, + unknown + >; + expect(valueIsContainerOfType(serialized["list"], ":MovableList")).toBe( + true, + ); + }); + + it("preserves nested map container identity for object items", async () => { + const doc = new LoroDoc(); + const mirror = new Mirror({ + doc, + inferOptions: { defaultMovableList: true }, + }); + + mirror.setState({ + list: [{ k: 1 }, { k: 2 }], + }); + await waitForSync(); + + const list = doc.getMovableList("list"); + const first = list.get(0); + expect(isContainer(first)).toBe(true); + const firstId = isContainer(first) ? first.id : ""; + + mirror.setState({ + list: [{ k: 1, x: 3 }, { k: 2 }], + }); + await waitForSync(); + + const firstAfter = list.get(0); + expect(isContainer(firstAfter)).toBe(true); + expect(isContainer(firstAfter) ? firstAfter.id : "").toBe(firstId); + }); + + it("uses $cid as idSelector to preserve containers across complex reorders", async () => { + const doc = new LoroDoc(); + const mirror = new Mirror({ + doc, + inferOptions: { defaultMovableList: true }, + }); + + mirror.setState({ + list: [{ v: "a" }, { v: "b" }, { v: "c" }, { v: "d" }], + }); + await waitForSync(); + + const before = getMovableListItemContainerIds(doc, "list"); + expect(new Set(before).size).toBe(before.length); + + const list0 = getListState(mirror.getState()); + if (list0.length !== 4) { + throw new Error("Expected list to have length 4"); + } + + // Complex reorder (not representable as a single move) + // [a,b,c,d] -> [b,d,a,c] + const next = [list0[1], list0[3], list0[0], list0[2]]; + mirror.setState({ list: next }); + await waitForSync(); + + const after = getMovableListItemContainerIds(doc, "list"); + expect(new Set(after)).toEqual(new Set(before)); + + const expectedOrder = next.map((x) => getCid(x)); + expect(after).toEqual(expectedOrder); + }); +}); diff --git a/packages/core/tests/null-in-map-consistency.test.ts b/packages/core/tests/null-in-map-consistency.test.ts index f48b423..deb9ff9 100644 --- a/packages/core/tests/null-in-map-consistency.test.ts +++ b/packages/core/tests/null-in-map-consistency.test.ts @@ -2,19 +2,6 @@ import { describe, it, expect } from "vitest"; import { LoroDoc, LoroMap, LoroList } from "loro-crdt"; import { Mirror, schema, toNormalizedJson } from "../src"; -function stripCid(value: unknown): unknown { - if (Array.isArray(value)) return value.map(stripCid); - if (value && typeof value === "object") { - const out: Record = {}; - for (const [k, v] of Object.entries(value as Record)) { - if (k === "$cid") continue; - out[k] = stripCid(v); - } - return out; - } - return value; -} - describe("setState consistency with null fields in LoroMap", () => { it("does not diverge when a loro-map field contains null and checkStateConsistency is enabled", async () => { const withSchema = schema({ diff --git a/packages/core/tests/schema-any.test.ts b/packages/core/tests/schema-any.test.ts new file mode 100644 index 0000000..98c3ed3 --- /dev/null +++ b/packages/core/tests/schema-any.test.ts @@ -0,0 +1,131 @@ +import { describe, expect, it } from "vitest"; +import { LoroDoc } from "loro-crdt"; +import { Mirror } from "../src/core/mirror"; +import { schema } from "../src/schema"; +import { valueIsContainer, valueIsContainerOfType } from "../src/core/utils"; + +const waitForSync = async () => { + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); +}; + +describe("schema.Any", () => { + it("infers string as LoroText when preferred", async () => { + const doc = new LoroDoc(); + const s = schema({ + map: schema.LoroMap({}).catchall( + schema.Any({ defaultLoroText: true }), + ), + }); + const mirror = new Mirror({ doc, schema: s }); + + mirror.setState({ + map: { a: "Hello" }, + }); + await waitForSync(); + + const serialized = doc.getDeepValueWithID() as unknown as Record< + string, + unknown + >; + const map = serialized["map"]; + expect(valueIsContainerOfType(map, ":Map")).toBeTruthy(); + + const inner = (map as { value: Record }).value; + const a = inner["a"]; + expect(valueIsContainerOfType(a, ":Text")).toBeTruthy(); + expect((a as { value: string }).value).toBe("Hello"); + + const state = mirror.getState() as unknown as Record; + const stateMap = state["map"] as Record; + const stateA = stateMap["a"]; + expect(stateA).toBe("Hello"); + }); + + it("defaults defaultLoroText to false (overrides global defaultLoroText)", async () => { + const doc = new LoroDoc(); + const s = schema({ + map: schema.LoroMap({}).catchall(schema.Any()), + }); + const mirror = new Mirror({ + doc, + schema: s, + inferOptions: { defaultLoroText: true }, + }); + + mirror.setState({ + map: { a: "Hello" }, + }); + await waitForSync(); + + const serialized = doc.getDeepValueWithID() as unknown as Record< + string, + unknown + >; + const map = serialized["map"] as { value: Record }; + const a = map.value["a"]; + expect(valueIsContainer(a)).toBeFalsy(); + expect(a).toBe("Hello"); + }); + + it("infers list items as LoroText when preferred", async () => { + const doc = new LoroDoc(); + const s = schema({ + list: schema.LoroList(schema.Any({ defaultLoroText: true })), + }); + const mirror = new Mirror({ doc, schema: s }); + + mirror.setState({ + list: ["Hello"], + }); + await waitForSync(); + + const serialized = doc.getDeepValueWithID() as unknown as Record< + string, + unknown + >; + const list = serialized["list"] as { value: unknown[] }; + expect(valueIsContainerOfType(list, ":List")).toBeTruthy(); + expect(valueIsContainerOfType(list.value[0], ":Text")).toBeTruthy(); + }); + + it("propagates Any preference to inferred subtree", async () => { + const doc = new LoroDoc(); + const s = schema({ + map: schema.LoroMap({}).catchall( + schema.Any({ defaultLoroText: true }), + ), + }); + const mirror = new Mirror({ doc, schema: s }); + + mirror.setState({ + map: { a: { b: "Hello" } }, + }); + await waitForSync(); + + let serialized = doc.getDeepValueWithID() as unknown as Record< + string, + unknown + >; + const rootMap = serialized["map"] as { value: Record }; + const a = rootMap.value["a"]; + expect(valueIsContainerOfType(a, ":Map")).toBeTruthy(); + + const aValue = (a as { value: Record }).value; + const b = aValue["b"]; + expect(valueIsContainerOfType(b, ":Text")).toBeTruthy(); + + mirror.setState({ + map: { a: { b: "Hello2" } }, + }); + await waitForSync(); + + serialized = doc.getDeepValueWithID() as unknown as Record; + const rootMap2 = serialized["map"] as { value: Record }; + const a2 = rootMap2.value["a"] as { value: Record }; + const b2 = a2.value["b"]; + expect(valueIsContainerOfType(b2, ":Text")).toBeTruthy(); + expect((b2 as { value: string }).value).toBe("Hello2"); + }); +});