diff --git a/AGENTS.md b/AGENTS.md index f681bc9..c28c1d3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -44,8 +44,8 @@ - `Mirror(options: MirrorOptions)` - `doc` (required), `schema?`, `initialState?`, `validateUpdates?`, `debug?`, `checkStateConsistency?`, `inferOptions?`. - Methods: `getState()`, `setState(updater, options?)`, `subscribe(cb)`, `dispose()`, `checkStateConsistency()`, `getContainerIds()`. - - `SetStateOptions` supports `{ tags?: string | string[] }`; subscriber metadata includes `{ source: UpdateSource; tags?: string[] }`. -- `schema(definition, options?)` plus builders: `.String()`, `.Number()`, `.Boolean()`, `.Ignore()`, `.LoroMap()`, `.LoroMapRecord()`, `.LoroList()`, `.LoroMovableList()`, `.LoroText()`, `.LoroTree()`. -- Runtime helpers from the schema module: `validateSchema`, `getDefaultValue`, `createValueFromSchema`, and type guards such as `isContainerSchema`, `isLoroMapSchema`, `isLoroListSchema`, `isLoroMovableListSchema`, `isLoroTextSchema`, `isLoroTreeSchema`, `isRootSchemaType`, `isListLikeSchema`. -- Types re-exported at the root: `MirrorOptions`, `SetStateOptions`, `UpdateMetadata`, `InferType`, `InferInputType`, `InferContainerOptions`, `SchemaType`, `ContainerSchemaType`, `RootSchemaType`, `LoroMapSchema`, `LoroListSchema`, `LoroMovableListSchema`, `LoroTextSchemaType`, `LoroTreeSchema`, `SchemaOptions`, `ChangeKinds`, `MapChangeKinds`, `ListChangeKinds`, `MovableListChangeKinds`, `TreeChangeKinds`, `TextChangeKinds`, `SubscriberCallback`, `UpdateSource`. + - `SetStateOptions` supports `{ tags?: string | string[] }`; subscriber metadata includes `{ direction: SyncDirection; tags?: string[] }`. +- `schema(definition, options?)` plus builders: `.String()`, `.Number()`, `.Boolean()`, `.Ignore()`, `.LoroMap()`, `.LoroMapRecord()`, `.LoroList()`, `.LoroMovableList()`, `.LoroText()`, `.LoroTree()`, `.Union(discriminant, variants, options?)`. +- Runtime helpers from the schema module: `validateSchema`, `getDefaultValue`, `createValueFromSchema`, and type guards such as `isContainerSchema`, `isLoroMapSchema`, `isLoroListSchema`, `isLoroMovableListSchema`, `isLoroTextSchema`, `isLoroTreeSchema`, `isLoroUnionSchema`, `isRootSchemaType`, `isListLikeSchema`. +- Types re-exported at the root: `MirrorOptions`, `SetStateOptions`, `UpdateMetadata`, `InferType`, `InferInputType`, `InferContainerOptions`, `SchemaType`, `ContainerSchemaType`, `RootSchemaType`, `LoroMapSchema`, `LoroListSchema`, `LoroMovableListSchema`, `LoroTextSchemaType`, `LoroTreeSchema`, `LoroUnionSchema`, `SchemaOptions`, `ChangeKinds`, `MapChangeKinds`, `ListChangeKinds`, `MovableListChangeKinds`, `TreeChangeKinds`, `TextChangeKinds`, `SubscriberCallback`, `SyncDirection`. - Utilities: `toNormalizedJson(doc)` for tree normalization. `$cid` is a reserved property injected into mirrored map values but there is no exported constant. diff --git a/README.md b/README.md index 59d6201..ba33445 100644 --- a/README.md +++ b/README.md @@ -387,6 +387,7 @@ Loro Mirror uses a typed schema to map your app state to Loro containers. Common - `schema.LoroMap(definition, options?)`: object (`LoroMap`) - `schema.LoroList(itemSchema, idSelector?, options?)`: list (`LoroList`) - `schema.LoroMovableList(itemSchema, idSelector, options?)`: movable list that emits move ops (requires `idSelector`) +- `schema.Union(discriminant, variants, options?)`: discriminated union of `LoroMap` variants - `schema.LoroTree(nodeSchema, options?)`: hierarchical tree (`LoroTree`) with per-node `data` map Tree nodes have the shape `{ id?: string; data: T; children: Node[] }`. Define a tree by passing a node `LoroMap` schema: diff --git a/api.md b/api.md index 6778634..01ae260 100644 --- a/api.md +++ b/api.md @@ -7,4 +7,202 @@ The canonical API docs now live in: - [README.md](./README.md) - [packages/core/README.md](./packages/core/README.md) -`api.md` used to duplicate the same content and drifted out of date. Keep the docs in the README files to avoid maintaining two full copies. +## Installation & Imports + +- Install: `npm install loro-mirror loro-crdt` +- Import styles: + - Named imports (recommended): `import { Mirror, schema } from "loro-mirror"` + - Default (convenience bundle of `schema` + `core`): `import loroMirror from "loro-mirror"` + +## Core: Mirror + +### Mirror + +- Constructor: `new Mirror(options)` + - `options: MirrorOptions` + - `doc: LoroDoc` — the Loro document to sync with + - `schema?: S` — root schema (enables validation, typed defaults) + - `initialState?: Partial>` — shallow overlay onto doc snapshot and schema defaults (does not write to Loro) + - `validateUpdates?: boolean` (default `true`) — validate on `setState` + - `throwOnValidationError?: boolean` (default `false`) — throw on schema validation errors + - `debug?: boolean` (default `false`) — verbose logging to console for diagnostics + - `checkStateConsistency?: boolean` (default `false`) — verify after each `setState` that in-memory state matches the normalized `LoroDoc` + - `inferOptions?: { defaultLoroText?: boolean; defaultMovableList?: boolean }` — inference hints when no schema covers a field + +- Methods + - `getState(): InferType` — returns the current mirror state (immutable snapshot) + - `setState(updater, options?): void` + - Synchronous; the state, validation, and subscriber notifications all finish before `setState` returns. + - `updater` supports both styles: + - Mutate a draft: `(draft: InferType) => void` + - Return a new object: `(prev: Readonly>) => InferInputType` + - Shallow partial: `Partial>` + - `options?: { tags?: string | string[]; origin?: string; timestamp?: number; message?: string }` — tags surface in subscriber metadata; commit metadata (`origin`, `timestamp`, `message`) is forwarded to the underlying Loro commit + - `subscribe((state, metadata) => void): () => void` + - `metadata: { direction: SyncDirection; tags?: string[] }` + - Returns an unsubscribe function + - `dispose(): void` — removes all internal subscriptions and listeners + - `checkStateConsistency(): void` — manually triggers the consistency assertion described above + - `getContainerIds(): ContainerID[]` — advanced helper that lists registered Loro container IDs for debugging + +- Behavior & Notes + - Sync directions: + - `FROM_LORO` — changes applied from the Loro document + - `TO_LORO` — changes produced by `setState` + - `BIDIRECTIONAL` — manual/initial sync context + - Mirror suppresses document events emitted during its own `setState` commits to prevent feedback loops; provide `origin`, `timestamp`, or `message` when you need to tag those commits. + - Initial state precedence: defaults (from schema) → `doc` snapshot (normalized) → hinted shapes from `initialState` (no writes to Loro). + - Trees: mirror state uses `{ id: string; data: object; children: Node[] }`. Loro tree `meta` is normalized to `data`. + - `$cid` on maps: Mirror injects a read-only `$cid` field into every LoroMap shape in state. It equals the Loro container ID, is not written back to Loro, and is ignored by diffs. + - Inference: with no schema, Mirror can infer containers from values; configure via `inferOptions`. + +#### Example + +```ts +import { Mirror, schema } from "loro-mirror"; +import { LoroDoc } from "loro-crdt"; + +const appSchema = schema({ + settings: schema.LoroMap({ + title: schema.String(), + dark: schema.Boolean(), + }), + todos: schema.LoroMovableList( + schema.LoroMap({ id: schema.String(), text: schema.String() }), + (t) => t.id, + ), +}); + +const mirror = new Mirror({ doc: new LoroDoc(), schema: appSchema }); + +mirror.setState((s) => { + s.settings.title = "Docs"; + s.todos.push({ id: "1", text: "Ship" }); +}); + +const unsub = mirror.subscribe((state, { direction, tags }) => { + // ... +}); + +unsub(); +``` + +## Schema Builder + +All schema builders live under the `schema` namespace and are exported at the package root. + +- Root schema: `schema(definition, options?)` + - `definition: { [key: string]: ContainerSchemaType }` + - `options?: SchemaOptions` + +- Primitives + - `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 + - `schema.LoroMap(definition)` + - Returns an object with `.catchall(valueSchema)` to allow mixed fixed keys + dynamic keys + - `schema.LoroMapRecord(valueSchema, options?)` — dynamic record (all keys share `valueSchema`) + - `schema.LoroList(itemSchema, idSelector?, options?)` + - `idSelector?: (item) => string` enables identity‑aware minimal updates + - `schema.LoroMovableList(itemSchema, idSelector, options?)` + - Emits explicit list `move` ops on reorder (strongly recommended for reordering UIs) + - `schema.LoroText(options?)` — collaborative text represented as `string` in state + - `schema.LoroTree(nodeMapSchema, options?)` — hierarchical data. Node shape in state: `{ id: string; data: {...}; children: Node[] }` + - `schema.Union(discriminant, variants, options?)` — discriminated union of `LoroMap` variants. The discriminant key (e.g., `"type"`) is auto-injected into each variant's TypeScript type. At the Loro level, stored as a `LoroMap`. Switching variants replaces the entire container to prevent chimera states. + +- Options & Validation on fields (`SchemaOptions`) + - `required?: boolean` — default `true`; set `false` to allow `undefined` + - `defaultValue?: unknown` — default value when not present + - `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) + - `InferSchemaType` — infers the type of a map definition + - `InferTreeNodeType` / `InferTreeNodeTypeWithCid` — inferred node shapes for trees + - `InferInputTreeNodeType` — input node shape for trees (node `data.$cid` optional) + - `$cid` is present in inferred types for all map schemas (including list items, tree `data` maps, and union variants) + +Examples + +```ts +const App = schema({ + user: schema.LoroMap({ + name: schema.String(), + // cache is local-only and will not sync to Loro + cache: schema.Ignore<{ hits: number }>(), + }), + notes: schema.LoroText(), + tags: schema.LoroList(schema.String()), +}); + +// Dynamic record +const KV = schema.LoroMapRecord(schema.String()); + +// Mixed fixed + dynamic keys +const Mixed = schema + .LoroMap({ fixed: schema.Number() }) + .catchall(schema.String()); + +// Discriminated union +const Block = schema.Union("type", { + paragraph: schema.LoroMap({ text: schema.String() }), + image: schema.LoroMap({ src: schema.String(), alt: schema.String() }), +}); +// InferType: { type: "paragraph"; text: string; $cid: string } +// | { type: "image"; src: string; alt: string; $cid: string } +``` + +## Validation & Defaults + +- `validateSchema(schema, value): { valid: boolean; errors?: string[] }` + - Validates recursively according to the schema. `ignore` fields are skipped. +- `getDefaultValue(schema): InferType | undefined` + - Produces defaults for a schema (respects `required` and `defaultValue`). +- `createValueFromSchema(schema, value): InferType` + - Casts/wraps a value into the shape expected by a schema (primitives pass through). + +## Utilities (Advanced) + +Most applications will not need the low-level helpers below, but they are part of the published surface for tooling and testing. + +- `toNormalizedJson(doc: LoroDoc): unknown` — returns `doc.toJSON()` with tree `meta` data normalized into `data` so it matches Mirror state. +- Schema guards exported from `schema/validators`: + - `isContainerSchema`, `isRootSchemaType`, `isLoroMapSchema`, `isLoroListSchema`, `isListLikeSchema`, `isLoroMovableListSchema`, `isLoroTextSchema`, `isLoroTreeSchema`, `isLoroUnionSchema` + +## Types & Constants + +- `SyncDirection` — enum: `FROM_LORO`, `TO_LORO`, `BIDIRECTIONAL` +- `MirrorOptions` — constructor options for `Mirror` +- `SetStateOptions` — `{ tags?: string | string[] }` +- `UpdateMetadata` — `{ direction: SyncDirection; tags?: string[] }` +- `InferType` — state shape produced by a schema (includes `$cid` on maps) +- `InferInputType` — input shape accepted by `setState` (like `InferType` but `$cid` is optional on maps) +- `InferContainerOptions` — `{ defaultLoroText?: boolean; defaultMovableList?: boolean }` +- `SubscriberCallback` — `(state: T, metadata: UpdateMetadata) => void` +- Change types (advanced): `ChangeKinds`, `Change`, `MapChangeKinds`, `ListChangeKinds`, `MovableListChangeKinds`, `TreeChangeKinds`, `TextChangeKinds` +- Schema types: `SchemaType`, `ContainerSchemaType`, `RootSchemaType`, `LoroMapSchema`, `LoroListSchema`, `LoroMovableListSchema`, `LoroTextSchemaType`, `LoroTreeSchema`, `LoroUnionSchema`, `SchemaOptions`, … + +## Tips & Recipes + +- Lists: always provide an `idSelector` if items have stable IDs — enables minimal add/update/move/delete instead of positional churn. Prefer `LoroMovableList` when reorder operations are common. +- `$cid` for IDs: Every `LoroMap` includes a stable `$cid` you can use as a React `key` or as a `LoroList` item selector: `(item) => item.$cid`. +- `setState` styles: choose your favorite — draft mutation or returning a new object. Both run synchronously, so follow-up logic can safely read the updated state immediately. +- Tagging updates: pass `{ tags: ["analytics", "user"] }` to `setState` and inspect `metadata.tags` in subscribers. +- Trees: you can create/move/delete nodes in state (Mirror emits precise `tree-create/move/delete`). Node `data` is a normal Loro map — nested containers (text, list, map) update incrementally. +- Initial state: providing `initialState` hints shapes and defaults in memory, but does not write into the LoroDoc until a real change occurs. +- Validation: keep `validateUpdates` on during development; flip `throwOnValidationError` as you see fit. +- Inference: if you work schemaless but prefer text containers for strings or movable lists for arrays by default, set `inferOptions: { defaultLoroText: true, defaultMovableList: true }`. + +--- + +Questions or gaps? If you need deeper internals (diff pipelines, event application), explore the source under `src/core/` — but for most apps, `Mirror` and the schema builders are all you need. diff --git a/packages/core/README.md b/packages/core/README.md index 7e0894c..dd98dcc 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -102,6 +102,7 @@ Types: `UpdateSource`, `UpdateMetadata`, `SetStateOptions`. - `schema.LoroTree(nodeMapSchema)` — hierarchical data (advanced) - `schema.LoroMapRecord(valueSchema)` — dynamic key map with a single value schema - `schema.LoroMap({...}).catchall(valueSchema)` — mix fixed keys with a catchall value schema + - `schema.Union(discriminant, variants, options?)` — discriminated union of `LoroMap` variants; stored as a `LoroMap` at the Loro level Signatures: @@ -110,6 +111,7 @@ Signatures: - `schema.LoroMovableList(itemSchema, idSelector: (item) => string, options?)` - `schema.LoroText(options?)` - `schema.LoroTree(nodeMapSchema, options?)` +- `schema.Union(discriminant: string, variants: { [name]: LoroMapSchema }, options?)` — the discriminant key is auto-injected into each variant's inferred TypeScript type. Switching variants replaces the entire container. SchemaOptions for any field: `{ required?: boolean; defaultValue?: unknown; description?: string; validate?: (value) => boolean | string }`. @@ -127,7 +129,8 @@ Reserved key `$cid`: - `validateSchema(schema, value)` — returns `{ valid: boolean; errors?: string[] }` - `getDefaultValue(schema)` — default value inferred from schema/options -- `toNormalizedJson(doc)` — JSON matching Mirror’s state shape (e.g., Tree `meta` -> `data`) +- `toNormalizedJson(doc)` — JSON matching Mirror's state shape (e.g., Tree `meta` -> `data`) +- Type guards: `isLoroMapSchema`, `isLoroListSchema`, `isLoroMovableListSchema`, `isLoroTextSchema`, `isLoroTreeSchema`, `isLoroUnionSchema`, `isContainerSchema`, `isRootSchemaType`, `isListLikeSchema` ## Ephemeral Patches diff --git a/packages/core/src/core/diff.ts b/packages/core/src/core/diff.ts index 66b4ae5..116d473 100644 --- a/packages/core/src/core/diff.ts +++ b/packages/core/src/core/diff.ts @@ -13,6 +13,7 @@ import { isLoroMapSchema, isLoroMovableListSchema, isLoroTreeSchema, + isLoroUnionSchema, isRootSchemaType, LoroListSchema, LoroMapSchema, @@ -32,6 +33,7 @@ import { getRootContainerByType, insertChildToMap, applySchemaToInferOptions, + isObject, isObjectLike, isStateAndSchemaOfType, isValueOfContainerType, @@ -99,6 +101,22 @@ type CommonListItemInfo = { type IdSelector = (item: T) => string | undefined; +/** + * If `schema` is a LoroUnionSchema, resolves to the active variant's + * LoroMapSchema by reading the discriminant from `value`. + * Returns `schema` unchanged for all other schema types. + */ +function resolveUnionVariantSchema( + schema: SchemaType | undefined, + value: unknown, +): SchemaType | undefined { + if (!schema || !isLoroUnionSchema(schema) || !isObject(value)) { + return schema; + } + const tag = value[schema.discriminant] as string; + return schema.variants[tag]; +} + /** * Diffs a container between two states * @@ -117,7 +135,10 @@ export function diffContainer( schema: SchemaType | undefined, inferOptions?: InferContainerOptions, ): Change[] { - const effectiveInferOptions = applySchemaToInferOptions(schema, inferOptions); + const effectiveInferOptions = applySchemaToInferOptions( + schema, + inferOptions, + ); const stateAndSchema = { oldState, newState, schema }; if (containerId === "") { if ( @@ -152,7 +173,13 @@ export function diffContainer( ); } - const mapSchema = isLoroMapSchema(schema) ? schema : undefined; + // Resolve union schema to the active variant's LoroMapSchema + const resolvedSchema = isLoroUnionSchema(schema) + ? resolveUnionVariantSchema(schema, newState) + : schema; + const mapSchema = isLoroMapSchema(resolvedSchema) + ? resolvedSchema + : undefined; return diffMap( doc, @@ -571,7 +598,9 @@ export function diffMovableList( const oldPositionsInNewOrder = newCommonIds.map((id) => { const pos = oldPosById.get(id); if (pos == null) { - throw new Error("Invariant violation: common id missing in old state"); + throw new Error( + "Invariant violation: common id missing in old state", + ); } return pos; }); @@ -592,10 +621,13 @@ export function diffMovableList( // Place `id` right before the already-processed "anchor" (the next id in new order). // This matches the standard LIS-based list diff strategy and avoids index drift when // earlier moves shift later indices. - const anchorId = i + 1 < newCommonIds.length ? newCommonIds[i + 1] : undefined; + const anchorId = + i + 1 < newCommonIds.length ? newCommonIds[i + 1] : undefined; const anchorIndex = anchorId ? idxOf.get(anchorId) : undefined; if (anchorId && anchorIndex == null) { - throw new Error("Invariant violation: anchor id missing in current order"); + throw new Error( + "Invariant violation: anchor id missing in current order", + ); } // `toIndex` is defined in the list *after* the removal. @@ -653,7 +685,10 @@ export function diffMovableList( // 5) Updates (for items present in both states) for (const info of common) { - if (valuesEqual(itemSchema, info.oldItem, info.newItem, "deep-equality")) continue; + if ( + valuesEqual(itemSchema, info.oldItem, info.newItem, "deep-equality") + ) + continue; const movableList = doc.getMovableList(containerId); const currentItem = movableList.get(info.oldIndex); @@ -795,7 +830,42 @@ export function diffListWithIdSelector( const oldItem = oldInfo.item; const newItem = newInfo.item; - if (valuesEqual(itemSchema, oldItem, newItem, "deep-equality")) continue; + if (valuesEqual(itemSchema, oldItem, newItem, "deep-equality")) + continue; + + // Union variant switch — replace entire container + if ( + schema && + isLoroUnionSchema(schema.itemSchema) && + isObject(oldItem) && + isObject(newItem) + ) { + const unionSchema = schema.itemSchema; + const oldTag = oldItem[unionSchema.discriminant]; + const newTag = newItem[unionSchema.discriminant]; + if (oldTag !== newTag) { + changes.push({ + container: containerId, + key: newInfo.newIndex, + value: undefined, + kind: "delete", + }); + changes.push( + tryUpdateToContainer( + { + container: containerId, + key: newInfo.newIndex, + value: newItem, + kind: "insert", + }, + useContainer, + schema?.itemSchema, + inferOptions, + ), + ); + continue; + } + } const itemOnLoro = list.get(oldInfo.index); if (isContainer(itemOnLoro)) { @@ -805,7 +875,8 @@ export function diffListWithIdSelector( oldItem, newItem, itemOnLoro.id, - itemSchema, + resolveUnionVariantSchema(schema?.itemSchema, newItem) ?? + schema?.itemSchema, inferOptions, ), ); @@ -873,7 +944,12 @@ export function diffList( while ( start < oldLen && start < newLen && - valuesEqual(itemSchema, oldState[start], newState[start], "reference-equality") + valuesEqual( + itemSchema, + oldState[start], + newState[start], + "reference-equality", + ) ) { start++; } @@ -883,7 +959,12 @@ export function diffList( while ( suffix < oldLen - start && suffix < newLen - start && - valuesEqual(itemSchema, oldState[oldLen - 1 - suffix], newState[newLen - 1 - suffix], "reference-equality") + valuesEqual( + itemSchema, + oldState[oldLen - 1 - suffix], + newState[newLen - 1 - suffix], + "reference-equality", + ) ) { suffix++; } @@ -895,7 +976,15 @@ export function diffList( const overlap = Math.min(oldBlockLen, newBlockLen); for (let j = 0; j < overlap; j++) { const i = start + j; - if (valuesEqual(itemSchema, oldState[i], newState[i], "reference-equality")) continue; + if ( + valuesEqual( + itemSchema, + oldState[i], + newState[i], + "reference-equality", + ) + ) + continue; const itemOnLoro = list.get(i); if (isContainer(itemOnLoro)) { @@ -1016,7 +1105,7 @@ export function diffMovableListByIndex( if (!item || typeof item !== "object") return; const cid = (item as Record)[CID_KEY]; if (typeof cid === "string" && isContainerId(cid)) { - return cid + return cid; } return undefined; }; @@ -1055,7 +1144,8 @@ export function diffMovableListByIndex( if (n <= 1) return; let start = 0; - while (start < n && identityEquals(oldArr[start], newArr[start])) start++; + while (start < n && identityEquals(oldArr[start], newArr[start])) + start++; if (start === n) return; // Case A: element moved forward (from=start, to>start) @@ -1128,7 +1218,12 @@ export function diffMovableListByIndex( while ( start < oldLen && start < newLen && - valuesEqual(itemSchema, oldState[start], newState[start], "reference-equality") + valuesEqual( + itemSchema, + oldState[start], + newState[start], + "reference-equality", + ) ) { start++; } @@ -1138,7 +1233,12 @@ export function diffMovableListByIndex( while ( suffix < oldLen - start && suffix < newLen - start && - valuesEqual(itemSchema, oldState[oldLen - 1 - suffix], newState[newLen - 1 - suffix], "reference-equality") + valuesEqual( + itemSchema, + oldState[oldLen - 1 - suffix], + newState[newLen - 1 - suffix], + "reference-equality", + ) ) { suffix++; } @@ -1150,7 +1250,15 @@ export function diffMovableListByIndex( const overlap = Math.min(oldBlockLen, newBlockLen); for (let j = 0; j < overlap; j++) { const i = start + j; - if (valuesEqual(itemSchema, oldState[i], newState[i], "reference-equality")) continue; + if ( + valuesEqual( + itemSchema, + oldState[i], + newState[i], + "reference-equality", + ) + ) + continue; const itemOnLoro = list.get(i); const next = newState[i]; @@ -1280,7 +1388,12 @@ export function diffMap( // If old item exists, we need to delete it if (key in oldStateObj && oldItem !== undefined) { const childSchemaForDelete = getMapFieldSchema(schema, key); - if (!(childSchemaForDelete && childSchemaForDelete.type === "ignore")) { + if ( + !( + childSchemaForDelete && + childSchemaForDelete.type === "ignore" + ) + ) { changes.push({ container: containerId, key, @@ -1307,11 +1420,10 @@ export function diffMap( childSchema, inferOptions, ); - let containerType = - hasTransform(childSchema) - ? undefined - : (childSchema?.getContainerType() ?? - tryInferContainerType(newItem, childInferOptions)); + let containerType = hasTransform(childSchema) + ? undefined + : (childSchema?.getContainerType() ?? + tryInferContainerType(newItem, childInferOptions)); if ( childSchema?.getContainerType() && containerType && @@ -1354,12 +1466,46 @@ export function diffMap( // Item inside map has changed if (oldItem !== newItem) { + // Union variant switch — replace entire container + if ( + isLoroUnionSchema(childSchema) && + isObject(oldItem) && + isObject(newItem) + ) { + const oldTag = oldItem[childSchema.discriminant]; + const newTag = newItem[childSchema.discriminant]; + if (oldTag !== newTag) { + const variantSchema = + resolveUnionVariantSchema(childSchema, newItem) ?? + childSchema; + changes.push( + tryUpdateToContainer( + { + container: containerId, + key, + value: newStateObj[key], + kind: "set", + }, + true, + variantSchema, + inferOptions, + ), + ); + continue; + } + } + // The key was previously a container and new item is also a container if ( containerType && isValueOfContainerType(containerType, newItem) && isValueOfContainerType(containerType, oldItem) ) { + // Resolve union schema to active variant for recursion + const effectiveChildSchema = + resolveUnionVariantSchema(childSchema, newItem) ?? + childSchema; + // the parent is the root container if (containerId === "") { const container = getRootContainerByType( @@ -1384,7 +1530,7 @@ export function diffMap( oldStateObj[key], newStateObj[key], container.id, - childSchema, + effectiveChildSchema, inferOptions, ), ); @@ -1405,7 +1551,10 @@ export function diffMap( containerId, key, newStateObj[key], - applySchemaToInferOptions(childSchema, inferOptions), + applySchemaToInferOptions( + childSchema, + inferOptions, + ), ), ); } else { @@ -1425,14 +1574,21 @@ export function diffMap( oldStateObj[key], newStateObj[key], child.id, - childSchema, + effectiveChildSchema, inferOptions, ), ); } } else { - if (valuesEqual(childSchema, oldItem, newItem, "reference-equality")) { - continue; + if ( + valuesEqual( + childSchema, + oldItem, + newItem, + "reference-equality", + ) + ) { + continue; } // The type or value of the child has changed diff --git a/packages/core/src/core/mirror.ts b/packages/core/src/core/mirror.ts index 3f9e04e..736b4a0 100644 --- a/packages/core/src/core/mirror.ts +++ b/packages/core/src/core/mirror.ts @@ -41,8 +41,10 @@ import { isLoroMapSchema, isLoroMovableListSchema, isLoroTreeSchema, + isLoroUnionSchema, LoroListSchema, LoroMapSchema, + LoroMapSchemaWithCatchall, RootSchemaType, SchemaType, validateSchema, @@ -51,6 +53,7 @@ import { getChildContainerSchema, getChildSchema, getMapFieldSchema, + resolveUnionVariant, } from "../schema/resolver.js"; import { deepEqual, @@ -388,6 +391,12 @@ export class Mirror { // so that doc.toJSON() reflects empty shapes and matches normalized state. this.ensureRootContainersFromInitialState(); + // Register root container schemas early so that buildRootStateSnapshot() + // can apply decode transforms (e.g. String->Date) when reading the doc. + // Without this, containerToMirrorState would find no schema in the + // registry and return raw CRDT values instead of decoded domain values. + this.registerRootContainerSchemas(); + // Initialize in-memory state without writing to LoroDoc: // 1) Start from schema defaults (if any) // 2) Overlay current LoroDoc snapshot (normalized) @@ -431,12 +440,60 @@ export class Mirror { } } + // Persist union discriminants to the Loro doc when initialState + // provides them and the doc doesn't already have them. + // + // Unlike regular primitive fields (where losing an initialState default + // just means falling back to the schema default), the discriminant is + // structural metadata that determines which variant schema interprets + // the rest of the map's data. Without it in the doc, a fresh Mirror on + // the same doc would have no way to know which variant is active, + // making the stored fields semantically ambiguous. + if (this.schema && this.schema.type === "schema") { + const rootDef = ( + this.schema as RootSchemaType< + Record + > + ).definition; + for (const key in rootDef) { + const fieldSchema = rootDef[key]; + if (!isLoroUnionSchema(fieldSchema)) continue; + const stateVal = baseState[key]; + if (!isObject(stateVal)) continue; + const tag = stateVal[fieldSchema.discriminant]; + if (typeof tag !== "string") continue; + const map = this.doc.getMap(key); + if (map.get(fieldSchema.discriminant) === undefined) { + map.set(fieldSchema.discriminant, tag); + } + } + } + + // Persist initialState primitive values to the LoroDoc for root-level + // LoroMap containers when the doc doesn't already have those keys. + // This ensures EphemeralPatchManager.isEligible() can recognise these + // keys as "existing Map keys" for ephemeral routing. Without this, + // initialState values stay in-memory only and ephemeral writes are + // incorrectly sent to LoroDoc instead of the EphemeralStore. + if (this.schema && this.schema.type === "schema") { + this.persistInitialMapPrimitives( + baseState, + ( + this.schema as RootSchemaType< + Record + > + ).definition, + ); + } + this.baseState = baseState as InferType; - this.state = baseState as InferType; + this.state = this.baseState; // Initialize ephemeral manager if store provided if (options.ephemeralStore) { - this.ephemeralManager = new EphemeralPatchManager(options.ephemeralStore); + this.ephemeralManager = new EphemeralPatchManager( + options.ephemeralStore, + ); this.subscriptions.push( this.ephemeralManager.subscribe(this.handleEphemeralEvent), ); @@ -476,6 +533,91 @@ export class Mirror { } } + /** + * Write primitive values from the in-memory baseState into root-level + * LoroMap containers when the doc doesn't already have those keys. + * + * This is needed because initialState populates the in-memory state but + * doesn't write to LoroDoc. When an EphemeralStore is configured, the + * ephemeral eligibility check (`isEligible`) requires the key to already + * exist on the LoroMap. Without this, ephemeral-eligible changes are + * incorrectly routed to LoroDoc. + */ + private persistInitialMapPrimitives( + baseState: Record, + rootDef: Record, + ): void { + let committed = false; + for (const key in rootDef) { + const fieldSchema = rootDef[key]; + if (fieldSchema.type !== "loro-map") continue; + const stateVal = baseState[key]; + if (!isObject(stateVal)) continue; + const map = this.doc.getMap(key); + for (const [fieldKey, fieldVal] of Object.entries(stateVal)) { + if (fieldKey === CID_KEY) continue; + // Only persist primitives; containers are handled separately + if (fieldVal !== null && typeof fieldVal === "object") continue; + if (fieldVal === undefined) continue; + if (map.get(fieldKey) === undefined) { + map.set( + fieldKey, + applyEncode( + getMapFieldSchema(fieldSchema, fieldKey), + fieldVal, + ), + ); + committed = true; + } + } + } + if (committed) { + this.doc.commit(); + } + } + + /** + * Register root container schemas so that containerToMirrorState can + * apply decode transforms during the initial buildRootStateSnapshot(). + * This must run before the first snapshot read in the constructor. + */ + private registerRootContainerSchemas() { + if (!this.schema || this.schema.type !== "schema") return; + + for (const key in this.schema.definition) { + if ( + !Object.prototype.hasOwnProperty.call( + this.schema.definition, + key, + ) + ) { + continue; + } + const fieldSchema = this.schema.definition[key]; + if ( + ![ + "loro-map", + "loro-list", + "loro-text", + "loro-movable-list", + "loro-tree", + "loro-union", + ].includes(fieldSchema.type) + ) { + continue; + } + const containerType = schemaToContainerType(fieldSchema); + if (!containerType) continue; + const container = getRootContainerByType( + this.doc, + key, + containerType, + ); + this.rootPathById.set(container.id, [key]); + this.registerContainer(container.id, fieldSchema); + } + } + /** * Initialize containers based on schema */ @@ -502,6 +644,7 @@ export class Mirror { "loro-text", "loro-movable-list", "loro-tree", + "loro-union", ].includes(fieldSchema.type) ) { const containerType = @@ -522,17 +665,13 @@ export class Mirror { } } - // Build initial state snapshot from the current document + // Stamp $cid and other metadata from the doc snapshot into the + // existing state without overwriting data populated by initialState. const currentDocState = this.buildRootStateSnapshot(); - const newState = produce>((draft) => { - Object.assign( - draft as unknown as Record, - currentDocState, - ); - })(this.state); - - this.baseState = newState; - this.state = this.composeState(newState); + deepMergeSnapshot( + this.state as unknown as Record, + currentDocState, + ); } /** @@ -590,17 +729,38 @@ export class Mirror { if (!container.isAttached) return; const parentSchema = this.getContainerSchema(container.id); - const parentLocalInfer = this.inferOptionsByContainerId.get(container.id); + const parentLocalInfer = this.inferOptionsByContainerId.get( + container.id, + ); try { if (container.kind() === "Map") { const map = container as LoroMap; + // Resolve union schema → variant map schema for child lookups + let effectiveMapSchema = parentSchema; + if (isLoroUnionSchema(parentSchema)) { + const tag = map.get(parentSchema.discriminant); + if (typeof tag === "string" && parentSchema.variants[tag]) { + effectiveMapSchema = parentSchema.variants[tag]; + } + } for (const key of map.keys()) { const value = map.get(key); if (isContainer(value)) { let nestedSchema: ContainerSchemaType | undefined; - if (parentSchema && isLoroMapSchema(parentSchema)) { - const candidate = getMapFieldSchema(parentSchema, key); + if ( + effectiveMapSchema && + isLoroMapSchema(effectiveMapSchema) + ) { + const candidate = getMapFieldSchema( + effectiveMapSchema as + | LoroMapSchema> + | LoroMapSchemaWithCatchall< + Record, + SchemaType + >, + key, + ); if (candidate?.type === "any") { this.inferOptionsByContainerId.set( value.id, @@ -738,7 +898,10 @@ export class Mirror { ...event, events: event.events.map((e) => { const canon = this.rootPathById.get(e.target); - if (canon && (!Array.isArray(e.path) || e.path[0] !== canon[0])) { + if ( + canon && + (!Array.isArray(e.path) || e.path[0] !== canon[0]) + ) { return { ...e, path: canon } as typeof e; } return e; @@ -762,7 +925,9 @@ export class Mirror { }, getNodeDataCid: (treeId, nodeId) => { try { - const node = this.doc.getTree(treeId).getNodeByID(nodeId); + const node = this.doc + .getTree(treeId) + .getNodeByID(nodeId); return node ? node.data.id : undefined; } catch { return undefined; @@ -780,7 +945,9 @@ export class Mirror { return nextState; } - private captureLocalDocEvent(callback: () => void): LoroEventBatch | undefined { + private captureLocalDocEvent( + callback: () => void, + ): LoroEventBatch | undefined { let captured: LoroEventBatch | undefined; const unsubscribe = this.doc.subscribe((event) => { if (event.by === "local") { @@ -872,7 +1039,9 @@ export class Mirror { } else if ( !schema && parentLocalInfer && - !this.inferOptionsByContainerId.has(container.id) + !this.inferOptionsByContainerId.has( + container.id, + ) ) { this.inferOptionsByContainerId.set( container.id, @@ -880,7 +1049,10 @@ export class Mirror { ); } - this.registerContainer(container.id, containerSchema); + this.registerContainer( + container.id, + containerSchema, + ); if ( schema && @@ -1082,7 +1254,7 @@ export class Mirror { let container: Container | null = null; // Create or get the container based on the schema type - if (type === "loro-map") { + if (type === "loro-map" || type === "loro-union") { container = this.doc.getMap(keyStr); } else if (type === "loro-list") { container = this.doc.getList(keyStr); @@ -1146,9 +1318,9 @@ export class Mirror { const childInfer = fieldSchema?.type === "any" ? this.getInferOptionsForChild( - container.id, - fieldSchema, - ) + container.id, + fieldSchema, + ) : undefined; const inserted = this.insertContainerIntoMap( map, @@ -1202,9 +1374,9 @@ export class Mirror { value, fieldSchema?.type === "any" ? this.getInferOptionsForChild( - container.id, - fieldSchema, - ) + container.id, + fieldSchema, + ) : undefined, ); } else { @@ -1249,9 +1421,9 @@ export class Mirror { value, fieldSchema?.type === "any" ? this.getInferOptionsForChild( - container.id, - fieldSchema, - ) + container.id, + fieldSchema, + ) : undefined, ); } else if (kind === "move") { @@ -1270,36 +1442,38 @@ export class Mirror { 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, - 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 - this.stampCid(value, newContainer.id); + const infer = + fieldSchema?.type === "any" + ? this.getInferOptionsForChild( + container.id, + fieldSchema, + ) + : !schema + ? this.getInferOptionsForContainer( + container.id, + ) + : undefined; + const [detachedContainer, _containerType] = + 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 + this.stampCid(value, newContainer.id); } else { throw new Error(); } @@ -1666,8 +1840,9 @@ export class Mirror { containerType && (!containerSchema || isValueOfContainerType(containerType, item)) ) { - const childInfer = - containerSchema ? undefined : (effectiveInfer || baseInfer); + const childInfer = containerSchema + ? undefined + : effectiveInfer || baseInfer; this.insertContainerIntoList( list, containerSchema, @@ -1757,10 +1932,7 @@ export class Mirror { */ private handleEphemeralEvent = (event: EphemeralStoreChangeEvent) => { if (this.syncing) return; - if ( - event.by === "local" && - this.suppressLocalEphemeralEvents > 0 - ) { + if (event.by === "local" && this.suppressLocalEphemeralEvents > 0) { return; } this.state = this.applyEphemeralDeltas(event.deltas); @@ -1783,7 +1955,8 @@ export class Mirror { * No-op if there are no pending local ephemeral patches. */ finalizeEphemeralPatches(): void { - if (!this.ephemeralManager || !this.ephemeralManager.hasLocalPatches) return; + if (!this.ephemeralManager || !this.ephemeralManager.hasLocalPatches) + return; this.syncing = true; try { @@ -1832,7 +2005,8 @@ export class Mirror { childInferOptions?: InferContainerOptions, ) { const infer = - childInferOptions || (!schema ? this.getInferOptionsForContainer(map.id) : undefined); + childInferOptions || + (!schema ? this.getInferOptionsForContainer(map.id) : undefined); const [detachedContainer, _containerType] = this.createContainerFromSchema(schema, value, infer); const insertedContainer = map.setContainer(key, detachedContainer); @@ -1871,7 +2045,21 @@ export class Mirror { if (!isObject(value)) { return; } - const mapSchema = schema?.type === "loro-map" ? schema : undefined; + // Resolve union schema to the concrete variant's LoroMapSchema + let resolvedSchema = schema; + if (isLoroUnionSchema(schema) && isObject(value)) { + const tag = value[schema.discriminant] as string; + if (tag && schema.variants[tag]) { + resolvedSchema = schema.variants[tag]; + } + } + const mapSchema = resolvedSchema as + | LoroMapSchema> + | LoroMapSchemaWithCatchall< + Record, + SchemaType + > + | undefined; const baseInfer = this.getInferOptionsForContainer(map.id); for (const [key, val] of Object.entries(value)) { // Skip injected CID field @@ -2054,7 +2242,8 @@ export class Mirror { childInferOptions?: InferContainerOptions, ) { const infer = - childInferOptions || (!schema ? this.getInferOptionsForContainer(list.id) : undefined); + childInferOptions || + (!schema ? this.getInferOptionsForContainer(list.id) : undefined); const [detachedContainer, _containerType] = this.createContainerFromSchema(schema, value, infer); let insertedContainer: Container | undefined; @@ -2152,7 +2341,15 @@ export class Mirror { } // Schema for this container (optional) - const schema = this.getContainerSchema(map.id); + let schema = this.getContainerSchema(map.id); + + // Resolve union schema to the active variant's LoroMapSchema + if (schema && isLoroUnionSchema(schema)) { + const tag = value[schema.discriminant] as string | undefined; + if (tag && schema.variants[tag]) { + schema = schema.variants[tag]; + } + } // Stamp $cid on the pending value if (schema && isObject(value)) { @@ -2275,7 +2472,10 @@ export class Mirror { this.baseState, normalized, ); - this.state = this.applyNormalizedLoroEventToState(this.state, normalized); + this.state = this.applyNormalizedLoroEventToState( + this.state, + normalized, + ); return true; } @@ -2306,8 +2506,10 @@ export class Mirror { ); } - const deltas = this.withSuppressedLocalEphemeralEvents(() => - this.ephemeralManager?.writeValue(containerId, key, value) ?? [], + const deltas = this.withSuppressedLocalEphemeralEvents( + () => + this.ephemeralManager?.writeValue(containerId, key, value) ?? + [], ); if (deltas.length === 0) return; @@ -2403,20 +2605,20 @@ 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 res as unknown as typeof draft; - } - }) + const res = ( + updater as ( + state: InferType, + ) => InferType | void + )(draft as InferType); + if (res && res !== (draft as unknown)) { + 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) { @@ -2473,8 +2675,10 @@ export class Mirror { // Write ephemeral-eligible changes to EphemeralStore if (ephemeralChanges.length > 0) { - const deltas = this.withSuppressedLocalEphemeralEvents(() => - this.ephemeralManager?.writeChanges(ephemeralChanges) ?? [], + const deltas = this.withSuppressedLocalEphemeralEvents( + () => + this.ephemeralManager?.writeChanges(ephemeralChanges) ?? + [], ); // Schedule debounced finalization this.ephemeralManager.scheduleFinalizeAfter( @@ -2523,16 +2727,24 @@ export class Mirror { const m = c as LoroMap; const obj: MirrorStateObject = {}; defineCidProperty(obj, c.id); - for (const k of m.keys()) { - const v = m.get(k); - if (isContainer(v)) { - obj[k] = this.containerToMirrorState(v); - } else { - // Decode primitive values using field schema - const fieldSchema = getChildSchema(schema, k); - obj[k] = applyDecode(fieldSchema, v) as MirrorState; - } + // Resolve union schema to the active variant's map schema so + // field-level decode (transforms) can find the correct child schema. + const effectiveSchema = isLoroUnionSchema(schema) + ? resolveUnionVariant( + schema, + Object.fromEntries(m.keys().map((k) => [k, m.get(k)])), + ) + : schema; + for (const k of m.keys()) { + const v = m.get(k); + if (isContainer(v)) { + obj[k] = this.containerToMirrorState(v); + } else { + // Decode primitive values using field schema + const fieldSchema = getChildSchema(effectiveSchema, k); + obj[k] = applyDecode(fieldSchema, v) as MirrorState; } + } return obj; } else if (kind === "List" || kind === "MovableList") { const arr: MirrorState[] = []; @@ -2733,7 +2945,55 @@ export class Mirror { containerId: ContainerID, childKey: string | number, ): SchemaType | undefined { - return getChildSchema(this.getContainerSchema(containerId), childKey); + const containerSchema = this.getContainerSchema(containerId); + + if (!containerSchema) { + return undefined; + } + + // Resolve union → variant map schema for child key lookups + if (isLoroUnionSchema(containerSchema)) { + const container = this.doc.getContainerById(containerId); + if (container && container.kind() === "Map") { + const map = container as LoroMap; + const tag = map.get(containerSchema.discriminant); + if (typeof tag === "string" && containerSchema.variants[tag]) { + const variantSchema = containerSchema.variants[tag]; + return getMapFieldSchema( + variantSchema as + | LoroMapSchema> + | LoroMapSchemaWithCatchall< + Record, + SchemaType + >, + String(childKey), + ); + } + } + return undefined; + } + + if (isLoroMapSchema(containerSchema)) { + return getMapFieldSchema( + containerSchema as + | LoroMapSchema> + | LoroMapSchemaWithCatchall< + Record, + SchemaType + >, + String(childKey), + ); + } else if ( + isLoroListSchema(containerSchema) || + isLoroMovableListSchema(containerSchema) + ) { + return containerSchema.itemSchema; + } else if (isLoroTreeSchema(containerSchema)) { + // Tree nodes' data map schema + return containerSchema.nodeSchema; + } + + return undefined; } /* Get all container IDs registered with the mirror */ @@ -2750,7 +3010,9 @@ export class Mirror { export function toNormalizedJson(doc: LoroDoc) { const withEnumerableCid = doc.toJsonWithReplacer((_k, v) => { if (isContainer(v) && v.kind() === "Tree") { - return normalizeTreeJsonForMirror(v.toJSON()) as unknown as typeof v; + return normalizeTreeJsonForMirror( + v.toJSON(), + ) as unknown as typeof v; } if (isContainer(v) && v.kind() === "Map") { @@ -2784,7 +3046,10 @@ function restoreCidDescriptors(value: unknown): unknown { if (!descriptor || descriptor.enumerable) { const cidValue = obj[CID_KEY]; delete obj[CID_KEY]; - Object.defineProperty(obj, CID_KEY, { value: cidValue }); + Object.defineProperty(obj, CID_KEY, { + value: cidValue, + configurable: true, + }); } } return obj; @@ -2824,6 +3089,55 @@ function normalizeTreeJsonForMirror(input: unknown) { }); } +/** + * Recursively merge a doc snapshot into the current state so that metadata + * like $cid is stamped without overwriting data populated by initialState. + * For each key in the snapshot: + * - If the state has an object and the snapshot has an object, recurse and + * stamp $cid from the snapshot. + * - If the state key is missing, use the snapshot value. + * - Otherwise keep the existing state value. + */ +function deepMergeSnapshot( + state: Record, + snapshot: Record, +) { + for (const key of Object.keys(snapshot)) { + const sv = snapshot[key]; + const tv = state[key]; + if (isObject(sv) && isObject(tv)) { + // Stamp $cid from the snapshot object onto the state object + const cidDesc = Object.getOwnPropertyDescriptor(sv, CID_KEY); + if (cidDesc) { + defineCidProperty(tv, cidDesc.value as ContainerID); + } + deepMergeSnapshot( + tv as Record, + sv as Record, + ); + } else if (Array.isArray(sv) && Array.isArray(tv)) { + // For arrays, recurse into matching elements to stamp $cid + for (let i = 0; i < Math.min(sv.length, tv.length); i++) { + if (isObject(sv[i]) && isObject(tv[i])) { + const cidDesc = Object.getOwnPropertyDescriptor( + sv[i], + CID_KEY, + ); + if (cidDesc) { + defineCidProperty(tv[i], cidDesc.value as ContainerID); + } + deepMergeSnapshot( + tv[i] as Record, + sv[i] as Record, + ); + } + } + } else if (!(key in state)) { + state[key] = sv; + } + } +} + // Deep merge initialState into a base state with awareness of the provided root schema. // - Does not override values already present in base (doc/defaults take precedence) // - For Ignore fields, copies values verbatim into in-memory state only @@ -2877,6 +3191,54 @@ function mergeInitialIntoBaseWithSchema( >); continue; } + if (t === "loro-union") { + // Union is stored as a Map — ensure an object shape and merge init data + if (!(k in base) || !isObject(base[k])) base[k] = {}; + const nestedBase = base[k] as Record; + const nestedInit: Record = isObject(initVal) + ? initVal + : {}; + // Resolve the variant from the discriminant so we recurse + // with the correct variant schema + const unionSchema = fieldSchema as unknown as { + discriminant: string; + variants: Record< + string, + LoroMapSchema> + >; + }; + const existingTag = nestedBase[unionSchema.discriminant] as + | string + | undefined; + const initTag = nestedInit[unionSchema.discriminant] as + | string + | undefined; + // Prefer existing tag over initial — don't overwrite if already set + const tag = existingTag ?? initTag; + if (tag) { + if (!existingTag) { + // Discriminant is not part of the variant definition — set it directly + nestedBase[unionSchema.discriminant] = tag; + } + const variantSchema = unionSchema.variants[tag]; + if (variantSchema) { + mergeInitialIntoBaseWithSchema(nestedBase, nestedInit, { + type: "schema", + definition: variantSchema.definition as Record< + string, + ContainerSchemaType + >, + options: {}, + getContainerType() { + return "Map"; + }, + } as unknown as RootSchemaType< + Record + >); + } + } + continue; + } if (t === "loro-list" || t === "loro-movable-list") { if (!(k in base)) base[k] = []; continue; diff --git a/packages/core/src/core/utils.ts b/packages/core/src/core/utils.ts index f4ea83d..aac6b0b 100644 --- a/packages/core/src/core/utils.ts +++ b/packages/core/src/core/utils.ts @@ -8,7 +8,7 @@ import { SchemaType, TransformDefinition, } from "../schema/index.js"; -import { getChildSchema } from "../schema/resolver.js"; +import { getChildSchema, resolveUnionVariant } from "../schema/resolver.js"; import { Change, InferContainerOptions } from "./mirror.js"; import { CID_KEY } from "../constants.js"; import { @@ -114,9 +114,15 @@ export function decodeNestedJsonValues( for (const node of nodes) { if (node != null && typeof node == "object") { if ("data" in node && node.data !== undefined) { - node.data = decodeNestedJsonValues(node.data, nodeSchema); + node.data = decodeNestedJsonValues( + node.data, + nodeSchema, + ); } - if ("children" in node && Array.isArray(node.children)) { + if ( + "children" in node && + Array.isArray(node.children) + ) { walk(node.children); } } @@ -125,6 +131,15 @@ export function decodeNestedJsonValues( walk(json); return json; } + case "loro-union": { + // Resolve to the active variant's map schema and decode its fields. + if (!isObject(json)) return json; + const resolved = resolveUnionVariant(schema, json); + if (resolved && resolved.type === "loro-map") { + return decodeNestedJsonValues(json, resolved); + } + return json; + } case "loro-text": case "ignore": return json; @@ -181,12 +196,14 @@ export function valuesEqual( } export function defineCidProperty(target: unknown, cid: ContainerID) { - if ( - !isObject(target) || - Object.prototype.hasOwnProperty.call(target, CID_KEY) - ) - return; - Object.defineProperty(target, CID_KEY, { value: cid }); + if (!isObject(target)) return; + const existing = Object.getOwnPropertyDescriptor(target, CID_KEY); + if (existing && !existing.configurable) return; + Object.defineProperty(target, CID_KEY, { + value: cid, + enumerable: false, + configurable: true, + }); } /** @@ -482,20 +499,25 @@ export function tryUpdateToContainer( return change; } - const effectiveInferOptions = applySchemaToInferOptions(schema, inferOptions); + const effectiveInferOptions = applySchemaToInferOptions( + schema, + inferOptions, + ); const containerType = schema ? (schemaToContainerType(schema) ?? tryInferContainerType(change.value, effectiveInferOptions)) : tryInferContainerType(change.value, effectiveInferOptions); - // If containerType is nullish, or schema has a transform (in which case we shouldn't infer container type), + // If containerType is nullish, or schema has a transform (in which case we shouldn't infer container type), // apply encode transform if it exists and return change if (containerType == null || (schema && hasTransform(schema))) { const encodedValue = applyEncode(schema, change.value); - return encodedValue !== change.value ? { - ...change, - value: encodedValue - } : change; + return encodedValue !== change.value + ? { + ...change, + value: encodedValue, + } + : change; } if (change.kind === "insert") { diff --git a/packages/core/src/schema/index.ts b/packages/core/src/schema/index.ts index 92316de..e5e02b1 100644 --- a/packages/core/src/schema/index.ts +++ b/packages/core/src/schema/index.ts @@ -15,6 +15,7 @@ import { LoroMovableListSchema, LoroTextSchemaType, LoroTreeSchema, + LoroUnionSchema, NumberSchemaType, RootSchemaDefinition, RootSchemaType, @@ -351,3 +352,42 @@ schema.LoroTree = function >( }, }; }; + +/** + * Define a discriminated union. + * + * Each variant is a LoroMap. The discriminant key (e.g., "type") is + * auto-injected into each variant's inferred TypeScript type. + */ +schema.Union = function < + D extends string, + V extends Record>>, + O extends SchemaOptions = {}, +>( + discriminant: D, + variants: V, + options?: O, +): LoroUnionSchema & { options: O } { + for (const [variantName, variantSchema] of Object.entries(variants)) { + if ( + Object.prototype.hasOwnProperty.call( + variantSchema.definition, + discriminant, + ) + ) { + throw new Error( + `Union variant "${variantName}" must not contain the discriminant key "${discriminant}" in its definition. ` + + `The discriminant is managed automatically by the union.`, + ); + } + } + return { + type: "loro-union" as const, + discriminant, + variants, + options: options || ({} as O), + getContainerType: () => { + return "Map"; + }, + } as LoroUnionSchema & { options: O }; +}; diff --git a/packages/core/src/schema/resolver.ts b/packages/core/src/schema/resolver.ts index d941435..3d72726 100644 --- a/packages/core/src/schema/resolver.ts +++ b/packages/core/src/schema/resolver.ts @@ -2,6 +2,7 @@ import { ContainerSchemaType, LoroMapSchema, LoroMapSchemaWithCatchall, + LoroUnionSchema, RootSchemaType, SchemaType, } from "./types.js"; @@ -12,7 +13,10 @@ type MapSchemaWithCatchallRecord = LoroMapSchemaWithCatchall< Record, SchemaType >; -type MapLikeSchema = RootSchemaRecord | MapSchemaRecord | MapSchemaWithCatchallRecord; +type MapLikeSchema = + | RootSchemaRecord + | MapSchemaRecord + | MapSchemaWithCatchallRecord; export function getMapFieldSchema( schema: MapLikeSchema | undefined, @@ -43,6 +47,10 @@ export function getChildSchema( return childKey === undefined ? undefined : getMapFieldSchema(schema, String(childKey)); + case "loro-union": + // Without a concrete value we cannot resolve the active variant, + // so we cannot look up a child key on the union itself. + return undefined; case "loro-list": case "loro-movable-list": return schema.itemSchema; @@ -53,6 +61,34 @@ export function getChildSchema( } } +/** + * If `schema` is a LoroUnionSchema, resolve to the active variant's + * LoroMapSchema by reading the discriminant from `value`. + * Returns `schema` unchanged for all other schema types. + */ +export function resolveUnionVariant( + schema: SchemaType | undefined, + value: unknown, +): SchemaType | undefined { + if ( + !schema || + schema.type !== "loro-union" || + !value || + typeof value !== "object" + ) { + return schema; + } + const union = schema as LoroUnionSchema< + string, + Record>> + >; + const tag = (value as Record)[union.discriminant]; + if (typeof tag === "string" && union.variants[tag]) { + return union.variants[tag]; + } + return schema; +} + export function getChildContainerSchema( schema: SchemaType | undefined, childKey?: string | number, @@ -66,6 +102,7 @@ export function getChildContainerSchema( case "loro-movable-list": case "loro-text": case "loro-tree": + case "loro-union": return childSchema; default: return undefined; diff --git a/packages/core/src/schema/types.ts b/packages/core/src/schema/types.ts index a0285ff..aabc568 100644 --- a/packages/core/src/schema/types.ts +++ b/packages/core/src/schema/types.ts @@ -77,8 +77,9 @@ export interface AnySchemaType extends BaseSchemaType { /** * String schema type */ -export interface StringSchemaType - extends BaseSchemaType { +export interface StringSchemaType< + T extends string = string, +> extends BaseSchemaType { type: "string"; _t: T; } @@ -107,8 +108,9 @@ export interface IgnoreSchemaType extends BaseSchemaType { /** * Loro Map schema type */ -export interface LoroMapSchema> - extends BaseSchemaType { +export interface LoroMapSchema< + T extends Record, +> extends BaseSchemaType { type: "loro-map"; definition: SchemaDefinition; } @@ -140,8 +142,9 @@ export interface LoroListSchema extends BaseSchemaType { /** * Loro Movable List schema type */ -export interface LoroMovableListSchema - extends BaseSchemaType { +export interface LoroMovableListSchema< + T extends SchemaType, +> extends BaseSchemaType { type: "loro-movable-list"; itemSchema: T; idSelector?: (item: unknown) => string; @@ -159,17 +162,35 @@ export interface LoroTextSchemaType extends BaseSchemaType { * * Represents a tree where each node has a `data` map described by `nodeSchema`. */ -export interface LoroTreeSchema> - extends BaseSchemaType { +export interface LoroTreeSchema< + T extends Record, +> extends BaseSchemaType { type: "loro-tree"; nodeSchema: LoroMapSchema; } +/** + * Loro Union (discriminated union) schema type. + * + * Each variant is a LoroMap. The discriminant key is auto-injected + * into each variant's inferred type with the variant name as its + * string literal value. + */ +export interface LoroUnionSchema< + D extends string, + V extends Record>>, +> extends BaseSchemaType { + type: "loro-union"; + discriminant: D; + variants: V; +} + /** * Root schema type */ -export interface RootSchemaType> - extends BaseSchemaType { +export interface RootSchemaType< + T extends Record, +> extends BaseSchemaType { type: "schema"; definition: RootSchemaDefinition; } @@ -189,6 +210,10 @@ export type SchemaType = | LoroMovableListSchema | LoroTextSchemaType | LoroTreeSchema> + | LoroUnionSchema< + string, + Record>> + > | RootSchemaType>; export type ContainerSchemaType = @@ -197,7 +222,11 @@ export type ContainerSchemaType = | LoroListSchema | LoroMovableListSchema | LoroTextSchemaType - | LoroTreeSchema>; + | LoroTreeSchema> + | LoroUnionSchema< + string, + Record>> + >; /** * Schema definition type @@ -205,8 +234,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 @@ -225,87 +254,155 @@ 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; + +/** + * Distributive simplifier: flattens intersections and distributes over unions. + */ +type Simplify = T extends infer U ? { [K in keyof U]: U[K] } : never; + +/** + * Helper: Infer a single union variant's type, injecting the discriminant field. + */ +type InferUnionVariant< + D extends string, + K extends string, + M extends Record, +> = { + [F in D | keyof M]: F extends D + ? K + : F extends keyof M + ? InferType + : never; +} & { $cid: string }; + +/** + * Helper: Distribute over all variants to produce a discriminated union type. + */ +type InferUnionType< + D extends string, + V extends Record>>, +> = Simplify< + { + [K in keyof V]: V[K] extends LoroMapSchema + ? InferUnionVariant + : never; + }[keyof V] +>; + +/** + * Helper: Input variant type ($cid optional, fields use InferInputType). + */ +type InferInputUnionVariant< + D extends string, + K extends string, + M extends Record, +> = { + [F in D | keyof M]: F extends D + ? K + : F extends keyof M + ? InferInputType + : never; +} & { $cid?: string }; + +/** + * Helper: Distribute over all variants for input (setState) types. + */ +type InferInputUnionType< + D extends string, + V extends Record>>, +> = Simplify< + { + [K in keyof V]: V[K] extends LoroMapSchema + ? InferInputUnionVariant + : never; + }[keyof V] +>; /** * Infer the JavaScript type from a schema type. */ -export type InferType = - S extends { transform: TransformDefinition } +export type InferType = S extends { + transform: TransformDefinition; +} ? WithTransformStartupOptionality : S extends StringSchemaType - ? InferStringType - : S extends NumberSchemaType - ? InferNumberType - : S extends BooleanSchemaType - ? InferBooleanType - : IsSchemaRequired extends false - ? S extends AnySchemaType - ? unknown - : 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 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; + ? InferStringType + : S extends NumberSchemaType + ? InferNumberType + : S extends BooleanSchemaType + ? InferBooleanType + : IsSchemaRequired extends false + ? S extends AnySchemaType + ? unknown + : S extends IgnoreSchemaType + ? unknown + : S extends LoroTextSchemaType + ? string | undefined + : S extends LoroUnionSchema + ? InferUnionType | 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 IgnoreSchemaType + ? unknown + : S extends LoroTextSchemaType + ? string + : S extends AnySchemaType + ? unknown + : S extends LoroUnionSchema + ? InferUnionType + : 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 @@ -318,84 +415,95 @@ export type InferSchemaType> = { * Infer the input (write) type for setState updates. * Identical to InferType except that for any LoroMap shape, the `$cid` field is optional. */ -export type InferInputType = - S extends { transform: TransformDefinition } +export type InferInputType = S extends { + transform: TransformDefinition; +} ? WithTransformStartupOptionality : S extends StringSchemaType - ? InferStringType - : S extends NumberSchemaType - ? InferNumberType - : S extends BooleanSchemaType - ? InferBooleanType - : IsSchemaRequired extends false - ? S extends AnySchemaType - ? unknown - : 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 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; + ? InferStringType + : S extends NumberSchemaType + ? InferNumberType + : S extends BooleanSchemaType + ? InferBooleanType + : IsSchemaRequired extends false + ? S extends AnySchemaType + ? unknown + : S extends IgnoreSchemaType + ? unknown + : S extends LoroTextSchemaType + ? string | undefined + : S extends LoroUnionSchema + ? InferInputUnionType | 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< + R[K] + >; + } + | undefined + : never + : S extends IgnoreSchemaType + ? unknown + : S extends LoroTextSchemaType + ? string + : S extends AnySchemaType + ? unknown + : S extends LoroUnionSchema + ? InferInputUnionType + : 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 289d22c..ecaa136 100644 --- a/packages/core/src/schema/validators.ts +++ b/packages/core/src/schema/validators.ts @@ -11,6 +11,7 @@ import { LoroMovableListSchema, LoroTextSchemaType, LoroTreeSchema, + LoroUnionSchema, RootSchemaType, SchemaType, } from "./types.js"; @@ -107,6 +108,16 @@ export function isAnySchema(schema?: SchemaType): schema is AnySchemaType { return !!schema && (schema as BaseSchemaType).type === "any"; } +/** + * Type guard for LoroUnionSchema + */ +export function isLoroUnionSchema< + D extends string, + V extends Record>>, +>(schema?: SchemaType): schema is LoroUnionSchema { + return !!schema && (schema as BaseSchemaType).type === "loro-union"; +} + /** * Check if a schema is for a Loro container */ @@ -119,7 +130,8 @@ export function isContainerSchema( schema.type === "loro-list" || schema.type === "loro-text" || schema.type === "loro-movable-list" || - schema.type === "loro-tree") + schema.type === "loro-tree" || + schema.type === "loro-union") ); } @@ -334,6 +346,54 @@ export function validateSchema( } break; + case "loro-union": { + if (!isObject(value)) { + errors.push("Value must be an object"); + break; + } + if (!isLoroUnionSchema(schema)) break; + + // Structural check: no variant definition should contain the discriminant key + for (const [variantName, variant] of Object.entries( + schema.variants, + )) { + if ( + Object.prototype.hasOwnProperty.call( + variant.definition, + schema.discriminant, + ) + ) { + errors.push( + `Union variant "${variantName}" must not contain the discriminant key "${schema.discriminant}" in its definition`, + ); + } + } + if (errors.length > 0) break; + + const tag = value[schema.discriminant]; + if (typeof tag !== "string") { + errors.push( + `Discriminant "${schema.discriminant}" must be a string`, + ); + break; + } + + const variantSchema = schema.variants[tag]; + if (!variantSchema) { + const allowed = Object.keys(schema.variants).join(", "); + errors.push( + `Unknown variant "${tag}" for discriminant "${schema.discriminant}". Allowed: ${allowed}`, + ); + break; + } + + const variantResult = validateSchema(variantSchema, value); + if (!variantResult.valid && variantResult.errors) { + errors.push(...variantResult.errors); + } + break; + } + default: errors.push( `Unknown schema type: ${actualType}` @@ -524,6 +584,9 @@ export function getDefaultValue( return {} as InferType; } + case "loro-union": + return undefined; + default: return undefined; } diff --git a/packages/core/tests/inferType.test-d.ts b/packages/core/tests/inferType.test-d.ts index b8e94e2..05683da 100644 --- a/packages/core/tests/inferType.test-d.ts +++ b/packages/core/tests/inferType.test-d.ts @@ -18,7 +18,7 @@ const numberDateTransform: TransformDefinition = { }; const booleanNumberTransform: TransformDefinition = { - decode: (s: boolean) => s ? 1 : 0, + decode: (s: boolean) => (s ? 1 : 0), encode: (d: number) => !!d, }; @@ -123,69 +123,117 @@ describe("infer type", () => { }); test("infer string transform to domain type | undefined when no defaultValue", () => { - const transformedSchema = schema.String().transform(stringDateTransform); + const transformedSchema = schema + .String() + .transform(stringDateTransform); // Transform decode/encode have correct types - expectTypeOf(transformedSchema.transform.decode).toEqualTypeOf<(value: string) => Date>(); - expectTypeOf(transformedSchema.transform.encode).toEqualTypeOf<(value: Date) => string>(); + expectTypeOf(transformedSchema.transform.decode).toEqualTypeOf< + (value: string) => Date + >(); + expectTypeOf(transformedSchema.transform.encode).toEqualTypeOf< + (value: Date) => string + >(); // InferType resolves to domain type | undefined because empty docs can omit the field - expectTypeOf>().toEqualTypeOf(); + expectTypeOf>().toEqualTypeOf< + Date | undefined + >(); }); test("infer string transform with required: false", () => { - const transformedSchema = schema.String({ required: false }).transform(stringDateTransform); + const transformedSchema = schema + .String({ required: false }) + .transform(stringDateTransform); // Transform decode/encode have correct types - expectTypeOf(transformedSchema.transform.decode).toEqualTypeOf<(value: string) => Date>(); - expectTypeOf(transformedSchema.transform.encode).toEqualTypeOf<(value: Date) => string>(); + expectTypeOf(transformedSchema.transform.decode).toEqualTypeOf< + (value: string) => Date + >(); + expectTypeOf(transformedSchema.transform.encode).toEqualTypeOf< + (value: Date) => string + >(); // InferType resolves to domain type | undefined - expectTypeOf>().toEqualTypeOf(); + expectTypeOf>().toEqualTypeOf< + Date | undefined + >(); }); test("infer number transform to domain type | undefined when no defaultValue", () => { - const transformedSchema = schema.Number().transform(numberDateTransform); + const transformedSchema = schema + .Number() + .transform(numberDateTransform); // Transform decode/encode have correct types - expectTypeOf(transformedSchema.transform.decode).toEqualTypeOf<(value: number) => Date>(); - expectTypeOf(transformedSchema.transform.encode).toEqualTypeOf<(value: Date) => number>(); + expectTypeOf(transformedSchema.transform.decode).toEqualTypeOf< + (value: number) => Date + >(); + expectTypeOf(transformedSchema.transform.encode).toEqualTypeOf< + (value: Date) => number + >(); // InferType resolves to domain type | undefined because empty docs can omit the field - expectTypeOf>().toEqualTypeOf(); + expectTypeOf>().toEqualTypeOf< + Date | undefined + >(); }); test("infer number transform with required: false", () => { - const transformedSchema = schema.Number({ required: false }).transform(numberDateTransform); + const transformedSchema = schema + .Number({ required: false }) + .transform(numberDateTransform); // Transform decode/encode have correct types - expectTypeOf(transformedSchema.transform.decode).toEqualTypeOf<(value: number) => Date>(); - expectTypeOf(transformedSchema.transform.encode).toEqualTypeOf<(value: Date) => number>(); + expectTypeOf(transformedSchema.transform.decode).toEqualTypeOf< + (value: number) => Date + >(); + expectTypeOf(transformedSchema.transform.encode).toEqualTypeOf< + (value: Date) => number + >(); // InferType resolves to domain type | undefined - expectTypeOf>().toEqualTypeOf(); + expectTypeOf>().toEqualTypeOf< + Date | undefined + >(); }); test("infer boolean transform to domain type | undefined when no defaultValue", () => { - const transformedSchema = schema.Boolean().transform(booleanNumberTransform); + const transformedSchema = schema + .Boolean() + .transform(booleanNumberTransform); // Transform decode/encode have correct types - expectTypeOf(transformedSchema.transform.decode).toEqualTypeOf<(value: boolean) => number>(); - expectTypeOf(transformedSchema.transform.encode).toEqualTypeOf<(value: number) => boolean>(); + expectTypeOf(transformedSchema.transform.decode).toEqualTypeOf< + (value: boolean) => number + >(); + expectTypeOf(transformedSchema.transform.encode).toEqualTypeOf< + (value: number) => boolean + >(); // InferType resolves to domain type | undefined because empty docs can omit the field - expectTypeOf>().toEqualTypeOf(); + expectTypeOf>().toEqualTypeOf< + number | undefined + >(); }); test("infer boolean transform with required: false", () => { - const transformedSchema = schema.Boolean({ required: false }).transform(booleanNumberTransform); + const transformedSchema = schema + .Boolean({ required: false }) + .transform(booleanNumberTransform); // Transform decode/encode have correct types - expectTypeOf(transformedSchema.transform.decode).toEqualTypeOf<(value: boolean) => number>(); - expectTypeOf(transformedSchema.transform.encode).toEqualTypeOf<(value: number) => boolean>(); + expectTypeOf(transformedSchema.transform.decode).toEqualTypeOf< + (value: boolean) => number + >(); + expectTypeOf(transformedSchema.transform.encode).toEqualTypeOf< + (value: number) => boolean + >(); // InferType resolves to domain type | undefined - expectTypeOf>().toEqualTypeOf(); + expectTypeOf>().toEqualTypeOf< + number | undefined + >(); }); test("infer transform in LoroMap", () => { @@ -426,4 +474,37 @@ describe("infer type", () => { expectTypeOf().toEqualTypeOf(); }); + + test("infer union", () => { + const unionSchema = schema.Union("type", { + dog: schema.LoroMap({ breed: schema.String() }), + cat: schema.LoroMap({ color: schema.String() }), + }); + + type InferredType = InferType; + + expectTypeOf().toEqualTypeOf< + | { type: "dog"; breed: string; $cid: string } + | { type: "cat"; color: string; $cid: string } + >(); + }); + + test("infer optional union", () => { + const unionSchema = schema.Union( + "kind", + { + text: schema.LoroMap({ content: schema.String() }), + image: schema.LoroMap({ src: schema.String() }), + }, + { required: false }, + ); + + type InferredType = InferType; + + expectTypeOf().toEqualTypeOf< + | { kind: "text"; content: string; $cid: string } + | { kind: "image"; src: string; $cid: string } + | undefined + >(); + }); }); diff --git a/packages/core/tests/schema-union.test.ts b/packages/core/tests/schema-union.test.ts new file mode 100644 index 0000000..6dd2988 --- /dev/null +++ b/packages/core/tests/schema-union.test.ts @@ -0,0 +1,892 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { LoroDoc } from "loro-crdt"; +import { + schema, + validateSchema, + getDefaultValue, + type TransformDefinition, +} from "../src/index.js"; +import { Mirror } from "../src/core/mirror.js"; +import { + isLoroUnionSchema, + isContainerSchema, +} from "../src/schema/validators.js"; +import type { LoroUnionSchema } from "../src/index.js"; + +describe("schema.Union", () => { + const blockSchema = schema.Union("type", { + paragraph: schema.LoroMap({ text: schema.String() }), + image: schema.LoroMap({ src: schema.String(), alt: schema.String() }), + }); + + describe("type guards", () => { + it("isLoroUnionSchema returns true for union schemas", () => { + expect(isLoroUnionSchema(blockSchema)).toBe(true); + }); + + it("isLoroUnionSchema returns false for non-union schemas", () => { + expect(isLoroUnionSchema(schema.LoroMap({}))).toBe(false); + expect(isLoroUnionSchema(schema.String())).toBe(false); + expect(isLoroUnionSchema(undefined)).toBe(false); + }); + + it("isContainerSchema returns true for union schemas", () => { + expect(isContainerSchema(blockSchema)).toBe(true); + }); + }); + + describe("validation", () => { + it("validates a correct paragraph variant", () => { + const result = validateSchema(blockSchema, { + type: "paragraph", + text: "Hello", + }); + expect(result.valid).toBe(true); + }); + + it("validates a correct image variant", () => { + const result = validateSchema(blockSchema, { + type: "image", + src: "photo.png", + alt: "A photo", + }); + expect(result.valid).toBe(true); + }); + + it("rejects non-object values", () => { + const result = validateSchema(blockSchema, "not an object"); + expect(result.valid).toBe(false); + expect(result.errors).toContain("Value must be an object"); + }); + + it("rejects missing discriminant", () => { + const result = validateSchema(blockSchema, { text: "Hello" }); + expect(result.valid).toBe(false); + expect(result.errors?.[0]).toContain("must be a string"); + }); + + it("rejects unknown variant", () => { + const result = validateSchema(blockSchema, { + type: "video", + url: "test.mp4", + }); + expect(result.valid).toBe(false); + expect(result.errors?.[0]).toContain("Unknown variant"); + expect(result.errors?.[0]).toContain("paragraph"); + expect(result.errors?.[0]).toContain("image"); + }); + + it("rejects variant with invalid fields", () => { + const result = validateSchema(blockSchema, { + type: "paragraph", + text: 123, + }); + expect(result.valid).toBe(false); + expect(result.errors?.[0]).toContain("text"); + }); + + it("rejects discriminant key in variant definition at schema creation", () => { + expect(() => { + schema.Union("type", { + bad: schema.LoroMap({ + type: schema.String(), + value: schema.Number(), + }), + }); + }).toThrow(/must not contain the discriminant key/); + }); + + it("rejects discriminant key in variant definition at validation time", () => { + // Construct manually to bypass builder check + const badUnion = { + type: "loro-union" as const, + discriminant: "kind", + variants: { + bad: schema.LoroMap({ kind: schema.String() }), + }, + options: {}, + getContainerType: () => "Map" as const, + }; + + const result = validateSchema(badUnion, { kind: "bad" }); + expect(result.valid).toBe(false); + expect(result.errors?.[0]).toContain( + "must not contain the discriminant key", + ); + }); + }); + + describe("default values", () => { + it("returns undefined (no implicit default for unions)", () => { + expect(getDefaultValue(blockSchema)).toBeUndefined(); + }); + + it("respects explicit defaultValue", () => { + const withDefault = schema.Union( + "type", + { + paragraph: schema.LoroMap({ text: schema.String() }), + }, + { defaultValue: { type: "paragraph", text: "" } }, + ); + expect(getDefaultValue(withDefault)).toEqual({ + type: "paragraph", + text: "", + }); + }); + }); +}); + +describe("Mirror with Union schema", () => { + const blockSchema = schema.Union("type", { + paragraph: schema.LoroMap({ text: schema.String() }), + image: schema.LoroMap({ src: schema.String(), alt: schema.String() }), + heading: schema.LoroMap({ + level: schema.Number(), + text: schema.String(), + }), + }); + + const docSchema = schema({ + blocks: schema.LoroList(blockSchema, (b) => b.$cid), + }); + + let doc: LoroDoc; + + beforeEach(() => { + doc = new LoroDoc(); + }); + + it("sets initial state with union items", () => { + const mirror = new Mirror({ + doc, + schema: docSchema, + initialState: { blocks: [] }, + }); + + mirror.setState((draft) => { + draft.blocks.push({ type: "paragraph", text: "Hello" }); + }); + + const state = mirror.getState(); + expect(state.blocks).toHaveLength(1); + expect(state.blocks[0].type).toBe("paragraph"); + if (state.blocks[0].type === "paragraph") { + expect(state.blocks[0].text).toBe("Hello"); + } + }); + + it("updates fields within the same variant", () => { + const mirror = new Mirror({ + doc, + schema: docSchema, + initialState: { blocks: [] }, + }); + + mirror.setState((draft) => { + draft.blocks.push({ type: "paragraph", text: "Hello" }); + }); + + const cidBefore = mirror.getState().blocks[0].$cid; + + mirror.setState((draft) => { + draft.blocks[0] = { + type: "paragraph", + text: "Updated", + $cid: draft.blocks[0].$cid, + }; + }); + + const state = mirror.getState(); + expect(state.blocks[0].type).toBe("paragraph"); + if (state.blocks[0].type === "paragraph") { + expect(state.blocks[0].text).toBe("Updated"); + } + // $cid preserved — same container, just updated fields + expect(state.blocks[0].$cid).toBe(cidBefore); + }); + + it("switches variant by replacing container", () => { + const mirror = new Mirror({ + doc, + schema: docSchema, + initialState: { blocks: [] }, + }); + + mirror.setState((draft) => { + draft.blocks.push({ type: "paragraph", text: "Hello" }); + }); + + const cidBefore = mirror.getState().blocks[0].$cid; + + mirror.setState((draft) => { + draft.blocks[0] = { type: "heading", level: 1, text: "Title" }; + }); + + const state = mirror.getState(); + expect(state.blocks[0].type).toBe("heading"); + if (state.blocks[0].type === "heading") { + expect(state.blocks[0].level).toBe(1); + expect(state.blocks[0].text).toBe("Title"); + } + // $cid changes — different container + expect(state.blocks[0].$cid).not.toBe(cidBefore); + }); + + it("supports multiple union items in a list", () => { + const mirror = new Mirror({ + doc, + schema: docSchema, + initialState: { blocks: [] }, + }); + + mirror.setState((draft) => { + draft.blocks.push( + { type: "heading", level: 1, text: "Title" }, + { type: "paragraph", text: "Body" }, + { type: "image", src: "photo.png", alt: "A photo" }, + ); + }); + + const state = mirror.getState(); + expect(state.blocks).toHaveLength(3); + expect(state.blocks[0].type).toBe("heading"); + expect(state.blocks[1].type).toBe("paragraph"); + expect(state.blocks[2].type).toBe("image"); + }); + + it("union field at root level", () => { + const rootUnionSchema = schema({ + content: schema.Union("kind", { + article: schema.LoroMap({ body: schema.String() }), + gallery: schema.LoroMap({ + images: schema.LoroList(schema.String()), + }), + }), + }); + + const mirror = new Mirror({ + doc, + schema: rootUnionSchema, + initialState: { + content: { kind: "article", body: "Hello" }, + }, + }); + + const state = mirror.getState(); + expect(state.content.kind).toBe("article"); + if (state.content.kind === "article") { + expect(state.content.body).toBe("Hello"); + } + }); + + it("root-level union update within same variant preserves nested containers", () => { + const rootUnionSchema = schema({ + content: schema.Union("kind", { + article: schema.LoroMap({ + body: schema.String(), + tags: schema.LoroList(schema.String()), + }), + gallery: schema.LoroMap({ + count: schema.Number(), + }), + }), + }); + + const doc = new LoroDoc(); + const mirror = new Mirror({ + doc, + schema: rootUnionSchema, + initialState: { + content: { kind: "article", body: "", tags: [] }, + }, + }); + + mirror.setState((draft) => { + if (draft.content.kind === "article") { + draft.content.body = "Hello"; + draft.content.tags.push("a", "b"); + } + }); + + const state = mirror.getState(); + expect(state.content.kind).toBe("article"); + if (state.content.kind === "article") { + expect(state.content.body).toBe("Hello"); + expect(state.content.tags).toEqual(["a", "b"]); + } + }); + + it("root-level union variant switch produces correct state", () => { + const rootUnionSchema = schema({ + content: schema.Union("kind", { + article: schema.LoroMap({ body: schema.String() }), + gallery: schema.LoroMap({ + count: schema.Number(), + }), + }), + }); + + const doc = new LoroDoc(); + const mirror = new Mirror({ + doc, + schema: rootUnionSchema, + initialState: { + content: { kind: "article", body: "Hello" }, + }, + checkStateConsistency: true, + }); + + // Switch variant at root level: article -> gallery + mirror.setState((draft) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (draft as any).content = { kind: "gallery", count: 5 }; + }); + + const state = mirror.getState(); + expect(state.content.kind).toBe("gallery"); + if (state.content.kind === "gallery") { + expect(state.content.count).toBe(5); + } + }); +}); + +describe("Union edge cases", () => { + it("union with nested containers inside variants", () => { + const s = schema({ + items: schema.LoroList( + schema.Union("type", { + rich: schema.LoroMap({ content: schema.LoroText() }), + plain: schema.LoroMap({ text: schema.String() }), + }), + (item) => item.$cid, + ), + }); + + const doc = new LoroDoc(); + const mirror = new Mirror({ + doc, + schema: s, + initialState: { items: [] }, + }); + + mirror.setState((draft) => { + draft.items.push({ type: "rich", content: "Hello world" }); + }); + + const state = mirror.getState(); + expect(state.items[0].type).toBe("rich"); + if (state.items[0].type === "rich") { + expect(state.items[0].content).toBe("Hello world"); + } + }); + + it("union with single variant", () => { + const s = schema({ + data: schema.Union("type", { + only: schema.LoroMap({ value: schema.Number() }), + }), + }); + + const doc = new LoroDoc(); + const mirror = new Mirror({ + doc, + schema: s, + initialState: { data: { type: "only", value: 42 } }, + }); + + expect(mirror.getState().data.type).toBe("only"); + if (mirror.getState().data.type === "only") { + expect(mirror.getState().data.value).toBe(42); + } + }); + + it("adding new items to a list of unions", () => { + const s = schema({ + blocks: schema.LoroList( + schema.Union("type", { + text: schema.LoroMap({ body: schema.String() }), + divider: schema.LoroMap({}), + }), + (b) => b.$cid, + ), + }); + + const doc = new LoroDoc(); + const mirror = new Mirror({ + doc, + schema: s, + initialState: { blocks: [] }, + }); + + mirror.setState((draft) => { + draft.blocks.push({ type: "text", body: "First" }); + }); + + mirror.setState((draft) => { + draft.blocks.push({ type: "divider" }); + draft.blocks.push({ type: "text", body: "Second" }); + }); + + const state = mirror.getState(); + expect(state.blocks).toHaveLength(3); + expect(state.blocks[0].type).toBe("text"); + expect(state.blocks[1].type).toBe("divider"); + expect(state.blocks[2].type).toBe("text"); + }); + + it("removing union items from a list", () => { + const s = schema({ + blocks: schema.LoroList( + schema.Union("type", { + a: schema.LoroMap({ x: schema.Number() }), + b: schema.LoroMap({ y: schema.String() }), + }), + (b) => b.$cid, + ), + }); + + const doc = new LoroDoc(); + const mirror = new Mirror({ + doc, + schema: s, + initialState: { blocks: [] }, + }); + + mirror.setState((draft) => { + draft.blocks.push( + { type: "a", x: 1 }, + { type: "b", y: "hello" }, + { type: "a", x: 2 }, + ); + }); + + mirror.setState((draft) => { + draft.blocks.splice(1, 1); + }); + + const state = mirror.getState(); + expect(state.blocks).toHaveLength(2); + expect(state.blocks[0].type).toBe("a"); + expect(state.blocks[1].type).toBe("a"); + }); + + it("exports union types from public API", () => { + const u = schema.Union("type", { + a: schema.LoroMap({ x: schema.Number() }), + }); + const _guard: boolean = isLoroUnionSchema(u); + expect(_guard).toBe(true); + // Verify the type is accessible (compile-time check) + const _typeCheck: LoroUnionSchema< + string, + Record + > = u as never; + void _typeCheck; + }); + + it("variant switch with simultaneous insert before does not corrupt list", () => { + // Regression: variant-switch delete used oldInfo.index which becomes + // stale after phase-1/2 deletions and insertions shift the list. + const s = schema({ + blocks: schema.LoroList( + schema.Union("type", { + paragraph: schema.LoroMap({ text: schema.String() }), + heading: schema.LoroMap({ + level: schema.Number(), + text: schema.String(), + }), + }), + (b) => b.$cid, + ), + }); + + const doc = new LoroDoc(); + const mirror = new Mirror({ + doc, + schema: s, + initialState: { blocks: [] }, + checkStateConsistency: true, + }); + + // Start with two paragraph items + mirror.setState((draft) => { + draft.blocks.push( + { type: "paragraph", text: "A" }, + { type: "paragraph", text: "B" }, + ); + }); + + const cidB = mirror.getState().blocks[1].$cid; + + // In one update: insert a new item at the front AND switch B's variant. + // The insert shifts indices, exposing the stale-index bug. + mirror.setState((draft) => { + draft.blocks.splice(0, 0, { + type: "paragraph", + text: "New", + }); + // B is now at index 2 (was 1) after the splice + draft.blocks[2] = { + type: "heading", + level: 2, + text: "B-heading", + $cid: cidB, + }; + }); + + const state = mirror.getState(); + expect(state.blocks).toHaveLength(3); + expect(state.blocks[0].type).toBe("paragraph"); + expect(state.blocks[1].type).toBe("paragraph"); + // Verify it's A that survived, not the old B + if (state.blocks[1].type === "paragraph") { + expect(state.blocks[1].text).toBe("A"); + } + expect(state.blocks[2].type).toBe("heading"); + if (state.blocks[2].type === "heading") { + expect(state.blocks[2].level).toBe(2); + expect(state.blocks[2].text).toBe("B-heading"); + } + }); + + it("initialState populates union fields", () => { + // Regression: mergeInitialIntoBaseWithSchema coerced union fields to {} + // instead of merging the provided initialState data. + const s = schema({ + content: schema.Union("kind", { + article: schema.LoroMap({ body: schema.String() }), + gallery: schema.LoroMap({ + count: schema.Number(), + }), + }), + }); + + const doc = new LoroDoc(); + const mirror = new Mirror({ + doc, + schema: s, + initialState: { + content: { kind: "article", body: "Hello from init" }, + }, + }); + + const state = mirror.getState(); + expect(state.content.kind).toBe("article"); + if (state.content.kind === "article") { + expect(state.content.body).toBe("Hello from init"); + } + }); + + it("$cid descriptor is configurable so it can be overwritten", () => { + const s = schema({ + blocks: schema.LoroList( + schema.Union("type", { + paragraph: schema.LoroMap({ text: schema.String() }), + }), + (b) => b.$cid, + ), + }); + + const doc = new LoroDoc(); + const mirror = new Mirror({ + doc, + schema: s, + initialState: { blocks: [] }, + }); + + mirror.setState((draft) => { + draft.blocks.push({ type: "paragraph", text: "Hello" }); + }); + + const item = mirror.getState().blocks[0]; + const descriptor = Object.getOwnPropertyDescriptor(item, "$cid"); + expect(descriptor).toBeDefined(); + expect(descriptor!.configurable).toBe(true); + expect(descriptor!.enumerable).toBe(false); + }); + + it("initialState discriminant is persisted to Loro doc", () => { + const s = schema({ + content: schema.Union("kind", { + article: schema.LoroMap({ body: schema.String() }), + gallery: schema.LoroMap({ + count: schema.Number(), + }), + }), + }); + + const doc = new LoroDoc(); + + // Mirror writes the discriminant to Loro even without a setState call + const mirror1 = new Mirror({ + doc, + schema: s, + initialState: { + content: { kind: "gallery", count: 0 }, + }, + }); + mirror1.dispose(); + + // Verify discriminant is in the doc + expect(doc.getMap("content").get("kind")).toBe("gallery"); + }); + + it("existing doc discriminant is preserved when new Mirror has different initialState", () => { + const s = schema({ + content: schema.Union("kind", { + article: schema.LoroMap({ body: schema.String() }), + gallery: schema.LoroMap({ + count: schema.Number(), + }), + }), + }); + + const doc = new LoroDoc(); + + // First mirror writes gallery data into doc + const mirror1 = new Mirror({ + doc, + schema: s, + initialState: { + content: { kind: "gallery", count: 0 }, + }, + }); + mirror1.setState((draft) => { + if (draft.content.kind === "gallery") { + draft.content.count = 10; + } + }); + expect(mirror1.getState().content.kind).toBe("gallery"); + mirror1.dispose(); + + // Second mirror with different initialState should see the + // existing doc data, not the new initialState's discriminant + const mirror2 = new Mirror({ + doc, + schema: s, + initialState: { + content: { kind: "article", body: "Override attempt" }, + }, + }); + + expect(mirror2.getState().content.kind).toBe("gallery"); + mirror2.dispose(); + }); + + it("$cid is stamped on union containers from doc snapshot", () => { + const s = schema({ + content: schema.Union("kind", { + article: schema.LoroMap({ body: schema.String() }), + }), + }); + + const doc = new LoroDoc(); + const mirror = new Mirror({ + doc, + schema: s, + initialState: { + content: { kind: "article", body: "Test" }, + }, + }); + + const state = mirror.getState(); + expect(state.content.$cid).toBeDefined(); + expect(typeof state.content.$cid).toBe("string"); + mirror.dispose(); + }); +}); + +describe("Union with transforms inside variant fields", () => { + const epochTransform: TransformDefinition = { + decode: (n: number) => new Date(n), + encode: (d: Date) => d.getTime(), + }; + + it("transform decode/encode works for fields in union variants", () => { + const s = schema({ + events: schema.LoroList( + schema.Union("type", { + meeting: schema.LoroMap({ + title: schema.String(), + startAt: schema.Number().transform(epochTransform), + }), + reminder: schema.LoroMap({ + note: schema.String(), + }), + }), + (item) => item.$cid, + ), + }); + + const doc = new LoroDoc(); + const mirror = new Mirror({ + doc, + schema: s, + initialState: { events: [] }, + checkStateConsistency: true, + }); + + const epoch = new Date("2025-06-15T10:00:00Z").getTime(); + + mirror.setState((draft) => { + draft.events.push({ + type: "meeting", + title: "Standup", + startAt: new Date(epoch), + }); + draft.events.push({ type: "reminder", note: "Buy milk" }); + }); + + const state = mirror.getState(); + expect(state.events).toHaveLength(2); + expect(state.events[0].type).toBe("meeting"); + if (state.events[0].type === "meeting") { + // The value should be decoded back to a Date + expect(state.events[0].startAt).toBeInstanceOf(Date); + expect((state.events[0].startAt as Date).getTime()).toBe(epoch); + } + expect(state.events[1].type).toBe("reminder"); + }); + + it("transform works after same-variant update", () => { + const s = schema({ + item: schema.Union("kind", { + timestamped: schema.LoroMap({ + value: schema.String(), + updatedAt: schema.Number().transform(epochTransform), + }), + plain: schema.LoroMap({ value: schema.String() }), + }), + }); + + const doc = new LoroDoc(); + const mirror = new Mirror({ + doc, + schema: s, + initialState: { + item: { + kind: "timestamped", + value: "hello", + updatedAt: new Date("2025-01-01"), + }, + }, + }); + + const cidBefore = mirror.getState().item.$cid; + + mirror.setState((draft) => { + if (draft.item.kind === "timestamped") { + draft.item.value = "updated"; + draft.item.updatedAt = new Date("2025-06-01"); + } + }); + + const state = mirror.getState(); + expect(state.item.kind).toBe("timestamped"); + expect(state.item.$cid).toBe(cidBefore); // same container + if (state.item.kind === "timestamped") { + expect(state.item.value).toBe("updated"); + expect(state.item.updatedAt).toBeInstanceOf(Date); + expect((state.item.updatedAt as Date).getFullYear()).toBe(2025); + } + }); +}); + +describe("MovableList of unions", () => { + it("supports push, update, and variant switch", () => { + const s = schema({ + items: schema.LoroMovableList( + schema.Union("type", { + text: schema.LoroMap({ body: schema.String() }), + number: schema.LoroMap({ value: schema.Number() }), + }), + (item) => item.$cid, + ), + }); + + const doc = new LoroDoc(); + const mirror = new Mirror({ + doc, + schema: s, + initialState: { items: [] }, + }); + + // Push items + mirror.setState((draft) => { + draft.items.push( + { type: "text", body: "Hello" }, + { type: "number", value: 42 }, + ); + }); + + let state = mirror.getState(); + expect(state.items).toHaveLength(2); + expect(state.items[0].type).toBe("text"); + expect(state.items[1].type).toBe("number"); + + // Same-variant update (use Immer mutation to avoid enumerable $cid issues) + mirror.setState((draft) => { + if (draft.items[0].type === "text") { + draft.items[0].body = "Updated"; + } + }); + + state = mirror.getState(); + expect(state.items[0].type).toBe("text"); + if (state.items[0].type === "text") { + expect(state.items[0].body).toBe("Updated"); + } + + // Variant switch + mirror.setState((draft) => { + draft.items[1] = { + type: "text", + body: "Was a number", + $cid: draft.items[1].$cid, + }; + }); + + state = mirror.getState(); + expect(state.items[1].type).toBe("text"); + if (state.items[1].type === "text") { + expect(state.items[1].body).toBe("Was a number"); + } + }); + + it("supports removal from movable list of unions", () => { + const s = schema({ + items: schema.LoroMovableList( + schema.Union("type", { + a: schema.LoroMap({ x: schema.Number() }), + b: schema.LoroMap({ y: schema.String() }), + }), + (item) => item.$cid, + ), + }); + + const doc = new LoroDoc(); + const mirror = new Mirror({ + doc, + schema: s, + initialState: { items: [] }, + }); + + mirror.setState((draft) => { + draft.items.push( + { type: "a", x: 1 }, + { type: "b", y: "hi" }, + { type: "a", x: 2 }, + ); + }); + + mirror.setState((draft) => { + draft.items.splice(1, 1); + }); + + const state = mirror.getState(); + expect(state.items).toHaveLength(2); + expect(state.items[0].type).toBe("a"); + expect(state.items[1].type).toBe("a"); + }); +});