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/package.json b/package.json index 85d9b77..b67739a 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,8 @@ "test": "vitest run", "lint": "oxlint --type-aware", "format": "prettier --write .", - "typecheck": "pnpm -r typecheck" + "typecheck": "pnpm -r typecheck", + "check": "pnpm build && pnpm test && pnpm lint && pnpm typecheck" }, "keywords": [ "loro", 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 eb6043a..cae8fbe 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,19 +12,18 @@ import { isLoroListSchema, isLoroMapSchema, isLoroMovableListSchema, - isLoroTextSchema, isLoroTreeSchema, isRootSchemaType, LoroListSchema, LoroMapSchema, LoroMapSchemaWithCatchall, LoroMovableListSchema, - LoroTextSchemaType, LoroTreeSchema, RootSchemaType, SchemaType, + type InferContainerOptions, } from "../schema/index.js"; -import { ChangeKinds, InferContainerOptions, type Change } from "./mirror.js"; +import { ChangeKinds, type Change } from "./mirror.js"; import { CID_KEY } from "../constants.js"; import { @@ -31,6 +31,7 @@ import { deepEqual, getRootContainerByType, insertChildToMap, + applySchemaToInferOptions, isObjectLike, isStateAndSchemaOfType, isValueOfContainerType, @@ -38,7 +39,6 @@ import { type ArrayLike, tryInferContainerType, tryUpdateToContainer, - isStringLike, isArrayLike, isTreeID, defineCidProperty, @@ -141,6 +141,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 +162,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 +626,7 @@ export function diffMovableList( }, true, schema?.itemSchema, + inferOptions, ), ); } @@ -679,6 +659,7 @@ export function diffMovableList( }, true, schema?.itemSchema, + inferOptions, ), ); } @@ -791,6 +772,7 @@ export function diffListWithIdSelector( }, useContainer, schema?.itemSchema, + inferOptions, ), ); } @@ -812,6 +794,7 @@ export function diffListWithIdSelector( }, useContainer, schema?.itemSchema, + inferOptions, ), ); @@ -843,6 +826,7 @@ export function diffListWithIdSelector( }, useContainer, schema?.itemSchema, + inferOptions, ), ); offset++; @@ -940,6 +924,7 @@ export function diffList( }, true, schema?.itemSchema, + inferOptions, ), ); } @@ -969,6 +954,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 +1251,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 +1285,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 +1303,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 +1320,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 +1399,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 +1433,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/index.ts b/packages/core/src/core/index.ts index 41c7ba6..6ad2701 100644 --- a/packages/core/src/core/index.ts +++ b/packages/core/src/core/index.ts @@ -4,9 +4,9 @@ export { Mirror, SyncDirection, toNormalizedJson } from "./mirror.js"; export type { - InferContainerOptions, MirrorOptions, SetStateOptions, SubscriberCallback, UpdateMetadata, + InferContainerOptions } from "./mirror.js"; diff --git a/packages/core/src/core/mirror.ts b/packages/core/src/core/mirror.ts index 505969e..f63bce9 100644 --- a/packages/core/src/core/mirror.ts +++ b/packages/core/src/core/mirror.ts @@ -17,6 +17,8 @@ import { LoroTree, TreeID, } from "loro-crdt"; +import type { InferContainerOptions } from "../schema/types.js"; +export type { InferContainerOptions } from "../schema/types.js"; import { applyEventBatchToState } from "./loroEventApply.js"; import { @@ -42,6 +44,7 @@ import { inferContainerTypeFromValue, isObject, isValueOfContainerType, + applySchemaToInferOptions, schemaToContainerType, tryInferContainerType, getRootContainerByType, @@ -132,11 +135,6 @@ export interface MirrorOptions { inferOptions?: InferContainerOptions; } -export type InferContainerOptions = { - defaultLoroText?: boolean; - defaultMovableList?: boolean; -}; - export type ChangeKinds = { set: { container: ContainerID | ""; @@ -296,6 +294,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(); @@ -521,13 +521,22 @@ export class Mirror { if (parentSchema && isLoroMapSchema(parentSchema)) { const candidate = this.getSchemaForMapKey( parentSchema as - | LoroMapSchema> - | LoroMapSchemaWithCatchall< - Record, - SchemaType - >, + | LoroMapSchema> + | LoroMapSchemaWithCatchall< + Record, + SchemaType + >, key, ); + if (candidate?.type === "any") { + this.inferOptionsByContainerId.set( + value.id, + this.getInferOptionsForChild( + container.id, + candidate, + ), + ); + } if (candidate && isContainerSchema(candidate)) { nestedSchema = candidate; } @@ -550,12 +559,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") { @@ -903,11 +921,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); @@ -939,6 +965,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, @@ -948,6 +978,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"); @@ -976,6 +1012,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, @@ -985,6 +1025,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"]; @@ -994,17 +1040,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 @@ -1355,20 +1424,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; } @@ -1425,15 +1506,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); @@ -1463,22 +1551,59 @@ export class Mirror { const mapSchema = schema as | LoroMapSchema> | LoroMapSchemaWithCatchall< - Record, - SchemaType - > + Record, + 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); } @@ -1493,11 +1618,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)) { @@ -1505,9 +1642,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; @@ -1530,10 +1678,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": @@ -1561,9 +1710,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) { @@ -1576,6 +1728,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); @@ -1673,13 +1829,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); } @@ -1708,14 +1862,28 @@ export class Mirror { const mapSchema = schema as | LoroMapSchema> | LoroMapSchemaWithCatchall< - Record, - SchemaType - >; + Record, + SchemaType + >; const fieldSchema = this.getSchemaForMapKey(mapSchema, key); if (fieldSchema && fieldSchema.type === "ignore") { // 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)) { @@ -1770,21 +1938,21 @@ export class Mirror { const newState = typeof updater === "function" ? produce>(this.state, (draft) => { - const res = ( - updater as ( - state: InferType, - ) => InferType | void - )(draft as InferType); - if (res && res !== (draft as unknown)) { - // Return a replacement so Immer finalizes it - return res as unknown as typeof draft; - } - }) + const res = ( + updater as ( + state: InferType, + ) => InferType | void + )(draft as InferType); + if (res && res !== (draft as unknown)) { + // Return a replacement so Immer finalizes it + return res as unknown as typeof draft; + } + }) : (Object.assign( - {}, - this.state as unknown as Record, - updater as Record, - ) as InferType); + {}, + this.state as unknown as Record, + updater as Record, + ) as InferType); // Validate state if needed if (this.options.validateUpdates) { @@ -1978,6 +2146,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> @@ -2031,11 +2215,11 @@ export class Mirror { if (isLoroMapSchema(containerSchema)) { return this.getSchemaForMapKey( containerSchema as - | LoroMapSchema> - | LoroMapSchemaWithCatchall< - Record, - SchemaType - >, + | LoroMapSchema> + | LoroMapSchemaWithCatchall< + Record, + SchemaType + >, String(childKey), ); } else if ( @@ -2090,7 +2274,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 71f31ce..0a1967f 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/schema/index.ts b/packages/core/src/schema/index.ts index 8298dec..56304a4 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..e964a18 100644 --- a/packages/core/src/schema/types.ts +++ b/packages/core/src/schema/types.ts @@ -3,6 +3,19 @@ */ import { ContainerType } from "loro-crdt"; +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; +}; /** * Options for schema definitions @@ -19,6 +32,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 +54,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 +168,7 @@ export interface RootSchemaType> * Union of all schema types */ export type SchemaType = + | AnySchemaType | StringSchemaType | NumberSchemaType | BooleanSchemaType @@ -158,8 +195,8 @@ export type ContainerSchemaType = export type RootSchemaDefinition< T extends Record, > = { - [K in keyof T]: T[K]; -}; + [K in keyof T]: T[K]; + }; /** * Schema definition type @@ -178,87 +215,93 @@ type IsSchemaRequired = S extends { } ? true : S extends { options: { required: false } } - ? false - : S extends { options: { required?: undefined } } - ? true - : S extends { options: {} } - ? true - : true; + ? false + : S extends { options: { required?: undefined } } + ? true + : S extends { options: {} } + ? true + : true; /** * Infer the JavaScript type from a schema type */ export type InferType = IsSchemaRequired extends false - ? 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 LoroMapSchemaWithCatchall - ? keyof M extends never - ? - | ({ [key: string]: InferType } & { - $cid: string; - }) - | undefined - : - | (({ [K in keyof M]: InferType } & { - [K in Exclude< - string, - keyof M - >]: InferType; - }) & { $cid: string }) - | undefined - : S extends LoroMapSchema - ? - | ({ [K in keyof M]: InferType } & { - $cid: string; - }) - | undefined - : S extends LoroListSchema - ? Array> | undefined - : S extends LoroMovableListSchema - ? Array> | undefined - : S extends LoroTreeSchema - ? Array> | undefined - : S extends RootSchemaType - ? - | { [K in keyof R]: InferType } - | undefined - : never - : S extends StringSchemaType - ? T - : S extends NumberSchemaType - ? 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 } & { - [K in Exclude]: InferType; - }) & { $cid: string } - : S extends LoroMapSchema - ? { [K in keyof M]: InferType } & { $cid: string } - : S extends LoroListSchema - ? Array> - : S extends LoroMovableListSchema - ? Array> - : S extends LoroTreeSchema - ? Array> - : S extends RootSchemaType - ? { [K in keyof R]: InferType } - : never; + ? 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 AnySchemaType + ? unknown + : S extends LoroMapSchemaWithCatchall + ? keyof M extends never + ? + | ({ [key: string]: InferType } & { + $cid: string; + }) + | undefined + : + | (({ [K in keyof M]: InferType } & { + [K in Exclude< + string, + keyof M + >]: InferType; + }) & { $cid: string }) + | undefined + : S extends LoroMapSchema + ? + | ({ [K in keyof M]: InferType } & { + $cid: string; + }) + | undefined + : S extends LoroListSchema + ? Array> | undefined + : S extends LoroMovableListSchema + ? Array> | undefined + : S extends LoroTreeSchema + ? Array> | undefined + : S extends RootSchemaType + ? + | { [K in keyof R]: InferType } + | undefined + : never + : S extends StringSchemaType + ? T + : S extends NumberSchemaType + ? number + : S extends BooleanSchemaType + ? boolean + : 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 + ? { [K in keyof M]: InferType } & { $cid: string } + : S extends LoroListSchema + ? Array> + : S extends LoroMovableListSchema + ? Array> + : S extends LoroTreeSchema + ? Array> + : S extends RootSchemaType + ? { [K in keyof R]: InferType } + : never; /** * Infer the JavaScript type from a schema definition @@ -273,82 +316,88 @@ export type InferSchemaType> = { */ export type InferInputType = IsSchemaRequired extends false - ? 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 LoroMapSchemaWithCatchall - ? keyof M extends never - ? - | ({ [key: string]: InferInputType } & { - $cid?: string; - }) - | undefined - : - | (({ [K in keyof M]: InferInputType } & { - [K in Exclude< - string, - keyof M - >]: InferInputType; - }) & { $cid?: string }) - | undefined - : S extends LoroMapSchema - ? - | ({ [K in keyof M]: InferInputType } & { - $cid?: string; - }) - | undefined - : S extends LoroListSchema - ? Array> | undefined - : S extends LoroMovableListSchema - ? Array> | undefined - : S extends LoroTreeSchema - ? Array> | undefined - : S extends RootSchemaType - ? - | { [K in keyof R]: InferInputType } - | undefined - : never - : S extends StringSchemaType - ? T - : S extends NumberSchemaType - ? number - : S extends BooleanSchemaType - ? boolean - : S extends IgnoreSchemaType - ? unknown - : S extends LoroTextSchemaType - ? string - : S extends LoroMapSchemaWithCatchall - ? keyof M extends never - ? { [key: string]: InferInputType } & { - $cid?: string; - } - : ({ [K in keyof M]: InferInputType } & { - [K in Exclude< - string, - keyof M - >]: InferInputType; - }) & { $cid?: string } - : S extends LoroMapSchema - ? { [K in keyof M]: InferInputType } & { - $cid?: string; - } - : S extends LoroListSchema - ? Array> - : S extends LoroMovableListSchema - ? Array> - : S extends LoroTreeSchema - ? Array> - : S extends RootSchemaType - ? { [K in keyof R]: InferInputType } - : never; + ? 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 AnySchemaType + ? unknown + : S extends LoroMapSchemaWithCatchall + ? keyof M extends never + ? + | ({ [key: string]: InferInputType } & { + $cid?: string; + }) + | undefined + : + | (({ [K in keyof M]: InferInputType } & { + [K in Exclude< + string, + keyof M + >]: InferInputType; + }) & { $cid?: string }) + | undefined + : S extends LoroMapSchema + ? + | ({ [K in keyof M]: InferInputType } & { + $cid?: string; + }) + | undefined + : S extends LoroListSchema + ? Array> | undefined + : S extends LoroMovableListSchema + ? Array> | undefined + : S extends LoroTreeSchema + ? Array> | undefined + : S extends RootSchemaType + ? + | { [K in keyof R]: InferInputType } + | undefined + : never + : S extends StringSchemaType + ? T + : S extends NumberSchemaType + ? number + : S extends BooleanSchemaType + ? boolean + : S extends IgnoreSchemaType + ? unknown + : S extends LoroTextSchemaType + ? string + : S extends AnySchemaType + ? unknown + : S extends LoroMapSchemaWithCatchall + ? keyof M extends never + ? { [key: string]: InferInputType } & { + $cid?: string; + } + : ({ [K in keyof M]: InferInputType } & { + [K in Exclude< + string, + keyof M + >]: InferInputType; + }) & { $cid?: string } + : S extends LoroMapSchema + ? { [K in keyof M]: InferInputType } & { + $cid?: string; + } + : S extends LoroListSchema + ? Array> + : S extends LoroMovableListSchema + ? Array> + : S extends LoroTreeSchema + ? Array> + : S extends RootSchemaType + ? { [K in keyof R]: InferInputType } + : never; /** * Helper: Infer the node type for a tree schema diff --git a/packages/core/src/schema/validators.ts b/packages/core/src/schema/validators.ts index 862920e..c11d84d 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 17d5ed1..7433b87 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/index.js"; 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 dda53c1..6cbc25f 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.js"; -import { LoroDoc } from "loro-crdt"; +import { isContainer, LoroDoc } from "loro-crdt"; import { schema } from "../src/schema/index.js"; import { describe, expect, it } from "vitest"; import { valueIsContainerOfType } from "../src/core/utils.js"; +import { diffMovableListByIndex } from "../src/core/diff.js"; // 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 69faca1..7741a64 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/index.js"; -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..db708a9 --- /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.js"; +import { schema } from "../src/schema/index.js"; +import { valueIsContainer, valueIsContainerOfType } from "../src/core/utils.js"; + +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"); + }); +});