From 0332f499fe45e0ad39f9a8ab78b530505712ba39 Mon Sep 17 00:00:00 2001 From: Alexis Williams Date: Wed, 25 Feb 2026 11:23:27 -0800 Subject: [PATCH 01/17] feat(core): add LoroUnionSchema type and inference helpers --- packages/core/src/schema/types.ts | 417 ++++++++++++++---------- packages/core/tests/inferType.test-d.ts | 131 ++++++-- 2 files changed, 359 insertions(+), 189 deletions(-) diff --git a/packages/core/src/schema/types.ts b/packages/core/src/schema/types.ts index a0285ff..aabb11d 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,136 @@ 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; + +/** + * Helper: Infer a single union variant's type, injecting the discriminant field. + */ +type InferUnionVariant< + D extends string, + K extends string, + M extends Record, +> = { [P in D]: K } & { [F in keyof M]: InferType } & { $cid: string }; + +/** + * Helper: Distribute over all variants to produce a discriminated union type. + */ +type InferUnionType< + D extends string, + V extends Record>>, +> = { + [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, +> = { [P in D]: K } & { [F in keyof M]: InferInputType } & { + $cid?: string; +}; + +/** + * Helper: Distribute over all variants for input (setState) types. + */ +type InferInputUnionType< + D extends string, + V extends Record>>, +> = { + [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 +396,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/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 + >(); + }); }); From c0937dd4976d793be1b7fa45f0a142b94043189f Mon Sep 17 00:00:00 2001 From: Alexis Williams Date: Wed, 25 Feb 2026 11:26:50 -0800 Subject: [PATCH 02/17] feat(core): add schema.Union builder --- packages/core/src/schema/index.ts | 27 ++++++++++++++++++ packages/core/src/schema/types.ts | 47 ++++++++++++++++++++++--------- 2 files changed, 60 insertions(+), 14 deletions(-) diff --git a/packages/core/src/schema/index.ts b/packages/core/src/schema/index.ts index 92316de..694ee5c 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,29 @@ 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 } { + 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/types.ts b/packages/core/src/schema/types.ts index aabb11d..aabc568 100644 --- a/packages/core/src/schema/types.ts +++ b/packages/core/src/schema/types.ts @@ -261,6 +261,11 @@ type IsSchemaRequired = S extends { ? 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. */ @@ -268,7 +273,13 @@ type InferUnionVariant< D extends string, K extends string, M extends Record, -> = { [P in D]: K } & { [F in keyof M]: InferType } & { $cid: string }; +> = { + [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. @@ -276,11 +287,13 @@ type InferUnionVariant< type InferUnionType< D extends string, V extends Record>>, -> = { - [K in keyof V]: V[K] extends LoroMapSchema - ? InferUnionVariant - : never; -}[keyof V]; +> = Simplify< + { + [K in keyof V]: V[K] extends LoroMapSchema + ? InferUnionVariant + : never; + }[keyof V] +>; /** * Helper: Input variant type ($cid optional, fields use InferInputType). @@ -289,9 +302,13 @@ type InferInputUnionVariant< D extends string, K extends string, M extends Record, -> = { [P in D]: K } & { [F in keyof M]: InferInputType } & { - $cid?: string; -}; +> = { + [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. @@ -299,11 +316,13 @@ type InferInputUnionVariant< type InferInputUnionType< D extends string, V extends Record>>, -> = { - [K in keyof V]: V[K] extends LoroMapSchema - ? InferInputUnionVariant - : never; -}[keyof V]; +> = Simplify< + { + [K in keyof V]: V[K] extends LoroMapSchema + ? InferInputUnionVariant + : never; + }[keyof V] +>; /** * Infer the JavaScript type from a schema type. From 12eebda323206f53bad31567f182aac60f915fad Mon Sep 17 00:00:00 2001 From: Alexis Williams Date: Wed, 25 Feb 2026 11:29:37 -0800 Subject: [PATCH 03/17] feat(core): add union validation, type guard, and default value support --- packages/core/src/schema/validators.ts | 50 +++++++++++- packages/core/tests/schema-union.test.ts | 100 +++++++++++++++++++++++ 2 files changed, 149 insertions(+), 1 deletion(-) create mode 100644 packages/core/tests/schema-union.test.ts diff --git a/packages/core/src/schema/validators.ts b/packages/core/src/schema/validators.ts index 289d22c..164c8b6 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,39 @@ export function validateSchema( } break; + case "loro-union": { + if (!isObject(value)) { + errors.push("Value must be an object"); + break; + } + if (!isLoroUnionSchema(schema)) break; + + const tag = (value as Record)[ + 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 +569,9 @@ export function getDefaultValue( return {} as InferType; } + case "loro-union": + return undefined; + default: return 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..b57e43b --- /dev/null +++ b/packages/core/tests/schema-union.test.ts @@ -0,0 +1,100 @@ +import { describe, it, expect } from "vitest"; +import { schema, validateSchema, getDefaultValue } from "../src/index.js"; +import { + isLoroUnionSchema, + isContainerSchema, +} from "../src/schema/validators.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"); + }); + }); + + 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: "", + }); + }); + }); +}); From c2c7524a8d0335af63e3f43fd06e34a50a81e9b8 Mon Sep 17 00:00:00 2001 From: Alexis Williams Date: Wed, 25 Feb 2026 11:49:03 -0800 Subject: [PATCH 04/17] feat(core): add union diff system and mirror container registration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add resolveUnionVariantSchema() helper in diff.ts that resolves a union schema to the concrete variant's LoroMapSchema based on the discriminant value in the data - Handle variant switches in diffListWithIdSelector and diffMap by detecting discriminant changes and emitting delete+insert instead of recursive field updates (prevents CRDT chimera states) - Update mirror.ts initializeContainers() to include "loro-union" in the container type list for root registration - Resolve union→variant in initializeContainer() so fields are properly set on the underlying LoroMap - Handle unions in registerNestedContainers() and getSchemaForChild() so nested containers within union variants get proper schemas - Add "loro-union" handling in applyRootChanges() and mergeInitialIntoBaseWithSchema() - Add 5 Mirror integration tests covering: initial state via setState, same-variant field updates, variant switching, multiple union items, and root-level union fields --- packages/core/src/core/diff.ts | 214 ++++++++++++++--- packages/core/src/core/mirror.ts | 294 ++++++++++++++++------- packages/core/tests/schema-union.test.ts | 146 ++++++++++- 3 files changed, 538 insertions(+), 116 deletions(-) diff --git a/packages/core/src/core/diff.ts b/packages/core/src/core/diff.ts index 66b4ae5..cdf37c3 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, @@ -99,6 +100,24 @@ 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) || !isObjectLike(value)) { + return schema; + } + const tag = (value as Record)[ + schema.discriminant + ] as string; + return schema.variants[tag] ?? schema; +} + /** * Diffs a container between two states * @@ -117,7 +136,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 +174,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 +599,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 +622,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 +686,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 +831,46 @@ 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) && + isObjectLike(oldItem) && + isObjectLike(newItem) + ) { + const unionSchema = schema.itemSchema; + const oldTag = (oldItem as Record)[ + unionSchema.discriminant + ]; + const newTag = (newItem as Record)[ + unionSchema.discriminant + ]; + if (oldTag !== newTag) { + changes.push({ + container: containerId, + key: oldInfo.index, + 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 +880,8 @@ export function diffListWithIdSelector( oldItem, newItem, itemOnLoro.id, - itemSchema, + resolveUnionVariantSchema(schema?.itemSchema, newItem) ?? + schema?.itemSchema, inferOptions, ), ); @@ -873,7 +949,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 +964,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 +981,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 +1110,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 +1149,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 +1223,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 +1238,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 +1255,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 +1393,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 +1425,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 +1471,45 @@ export function diffMap( // Item inside map has changed if (oldItem !== newItem) { + // Union variant switch — replace entire container + if ( + isLoroUnionSchema(childSchema) && + isObjectLike(oldItem) && + isObjectLike(newItem) + ) { + const oldTag = (oldItem as Record)[ + childSchema.discriminant + ]; + const newTag = (newItem as Record)[ + childSchema.discriminant + ]; + if (oldTag !== newTag) { + changes.push( + insertChildToMap( + containerId, + key, + newStateObj[key], + applySchemaToInferOptions( + childSchema, + 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 +1534,7 @@ export function diffMap( oldStateObj[key], newStateObj[key], container.id, - childSchema, + effectiveChildSchema, inferOptions, ), ); @@ -1405,7 +1555,10 @@ export function diffMap( containerId, key, newStateObj[key], - applySchemaToInferOptions(childSchema, inferOptions), + applySchemaToInferOptions( + childSchema, + inferOptions, + ), ), ); } else { @@ -1425,14 +1578,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..8e3421c 100644 --- a/packages/core/src/core/mirror.ts +++ b/packages/core/src/core/mirror.ts @@ -41,6 +41,7 @@ import { isLoroMapSchema, isLoroMovableListSchema, isLoroTreeSchema, + isLoroUnionSchema, LoroListSchema, LoroMapSchema, RootSchemaType, @@ -436,7 +437,9 @@ export class Mirror { // 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), ); @@ -502,6 +505,7 @@ export class Mirror { "loro-text", "loro-movable-list", "loro-tree", + "loro-union", ].includes(fieldSchema.type) ) { const containerType = @@ -590,17 +594,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 = this.getSchemaForMapKey( + effectiveMapSchema as + | LoroMapSchema> + | LoroMapSchemaWithCatchall< + Record, + SchemaType + >, + key, + ); if (candidate?.type === "any") { this.inferOptionsByContainerId.set( value.id, @@ -738,7 +763,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 +790,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 +810,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 +904,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 +914,10 @@ export class Mirror { ); } - this.registerContainer(container.id, containerSchema); + this.registerContainer( + container.id, + containerSchema, + ); if ( schema && @@ -1082,7 +1119,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 +1183,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 +1239,9 @@ export class Mirror { value, fieldSchema?.type === "any" ? this.getInferOptionsForChild( - container.id, - fieldSchema, - ) + container.id, + fieldSchema, + ) : undefined, ); } else { @@ -1249,9 +1286,9 @@ export class Mirror { value, fieldSchema?.type === "any" ? this.getInferOptionsForChild( - container.id, - fieldSchema, - ) + container.id, + fieldSchema, + ) : undefined, ); } else if (kind === "move") { @@ -1270,36 +1307,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 +1705,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 +1797,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 +1820,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 +1870,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 +1910,23 @@ 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 as Record)[ + 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 +2109,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; @@ -2275,7 +2331,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 +2365,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 +2464,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 +2534,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 +2586,16 @@ 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; - } + 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; } + } return obj; } else if (kind === "List" || kind === "MovableList") { const arr: MirrorState[] = []; @@ -2733,7 +2796,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 this.getSchemaForMapKey( + variantSchema as + | LoroMapSchema> + | LoroMapSchemaWithCatchall< + Record, + SchemaType + >, + String(childKey), + ); + } + } + return undefined; + } + + if (isLoroMapSchema(containerSchema)) { + return this.getSchemaForMapKey( + 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 +2861,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") { @@ -2877,6 +2990,11 @@ function mergeInitialIntoBaseWithSchema( >); continue; } + if (t === "loro-union") { + // Union is stored as a Map — ensure an object shape + if (!(k in base) || !isObject(base[k])) base[k] = {}; + continue; + } if (t === "loro-list" || t === "loro-movable-list") { if (!(k in base)) base[k] = []; continue; diff --git a/packages/core/tests/schema-union.test.ts b/packages/core/tests/schema-union.test.ts index b57e43b..8ad41bb 100644 --- a/packages/core/tests/schema-union.test.ts +++ b/packages/core/tests/schema-union.test.ts @@ -1,5 +1,7 @@ -import { describe, it, expect } from "vitest"; +import { describe, it, expect, beforeEach } from "vitest"; +import { LoroDoc } from "loro-crdt"; import { schema, validateSchema, getDefaultValue } from "../src/index.js"; +import { Mirror } from "../src/core/mirror.js"; import { isLoroUnionSchema, isContainerSchema, @@ -98,3 +100,145 @@ describe("schema.Union", () => { }); }); }); + +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, + }); + + mirror.setState((draft) => { + (draft as Record).content = { kind: "article", body: "Hello" }; + }); + + const state = mirror.getState(); + expect(state.content.kind).toBe("article"); + }); +}); From a1a09186b27a9d8f1652a31c5be6aa42d2c25ebe Mon Sep 17 00:00:00 2001 From: Alexis Williams Date: Wed, 25 Feb 2026 11:51:12 -0800 Subject: [PATCH 05/17] test(core): add comprehensive edge case tests for discriminated unions - Nested containers inside union variants (LoroText) - Single-variant unions - Adding new items to a list of unions - Removing union items from a list via splice - Public API export verification (LoroUnionSchema, isLoroUnionSchema) --- packages/core/tests/schema-union.test.ts | 133 +++++++++++++++++++++++ 1 file changed, 133 insertions(+) diff --git a/packages/core/tests/schema-union.test.ts b/packages/core/tests/schema-union.test.ts index 8ad41bb..9114a34 100644 --- a/packages/core/tests/schema-union.test.ts +++ b/packages/core/tests/schema-union.test.ts @@ -6,6 +6,7 @@ import { isLoroUnionSchema, isContainerSchema, } from "../src/schema/validators.js"; +import type { LoroUnionSchema } from "../src/index.js"; describe("schema.Union", () => { const blockSchema = schema.Union("type", { @@ -242,3 +243,135 @@ describe("Mirror with Union schema", () => { expect(state.content.kind).toBe("article"); }); }); + +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 }); + + mirror.setState((draft) => { + (draft as Record).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> = u as never; + void _typeCheck; + }); +}); From 3d9bc238742ad88b23ac90609494316ad48c20c9 Mon Sep 17 00:00:00 2001 From: Alexis Williams Date: Wed, 25 Feb 2026 11:55:04 -0800 Subject: [PATCH 06/17] docs: add schema.Union to API reference and README documentation - Add schema.Union to builder lists in CLAUDE.md/AGENTS.md, api.md, README.md, and packages/core/README.md - Add LoroUnionSchema to re-exported types lists - Add isLoroUnionSchema to type guard lists - Add union code example to api.md Schema Builder examples - Note $cid presence in union variants --- AGENTS.md | 8 +- README.md | 1 + api.md | 200 +++++++++++++++++++++++++++++++++++++++- packages/core/README.md | 5 +- 4 files changed, 208 insertions(+), 6 deletions(-) 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 From 60a20a0cd4df7e78a4b95965af9bcc167a396929 Mon Sep 17 00:00:00 2001 From: Alexis Williams Date: Wed, 25 Feb 2026 12:46:47 -0800 Subject: [PATCH 07/17] fix(core): allow defineCidProperty to overwrite stale user-set $cid Previously, defineCidProperty bailed out if $cid already existed on the target, regardless of how it was set. This is correct for properties stamped by the mirror itself (non-configurable), but wrong when a user passes an explicit $cid in their state object (e.g., to match items by identity during variant switches). In that case the user-set $cid is a regular configurable+enumerable property with the OLD container's id, and the mirror needs to replace it with the NEW container's id after creating the replacement. The fix distinguishes the two cases via the configurable flag: - Non-configurable (stamped by us previously): skip, value is current - Configurable (user-set or first stamp): overwrite with the correct non-enumerable value New properties are created with configurable: true so future overwrites work. This is safe because $cid is already non-enumerable (hidden from Object.keys/JSON.stringify/deepEqual), so the only code path that touches it is defineCidProperty itself. --- packages/core/src/core/utils.ts | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/packages/core/src/core/utils.ts b/packages/core/src/core/utils.ts index f4ea83d..10a96c6 100644 --- a/packages/core/src/core/utils.ts +++ b/packages/core/src/core/utils.ts @@ -181,12 +181,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, + }); } /** From db3ae6072433bea0b8bc1996c1b432b6e586ac99 Mon Sep 17 00:00:00 2001 From: Alexis Williams Date: Wed, 25 Feb 2026 12:51:36 -0800 Subject: [PATCH 08/17] fix(core): use post-mutation index for union variant-switch in list diff MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When diffListWithIdSelector detects a union variant switch on a kept item (phase 3), the delete used oldInfo.index — the item's position in the original array. By the time this change is applied, phase-1 deletes and phase-2 inserts have already shifted the list, so the item is at newInfo.newIndex. Deleting at the stale index removes the wrong element, corrupting the Loro list. Use newInfo.newIndex for the delete, matching the pattern already used by the non-union fallback path (line ~902). --- packages/core/src/core/diff.ts | 2 +- packages/core/tests/schema-union.test.ts | 66 ++++++++++++++++++++++++ 2 files changed, 67 insertions(+), 1 deletion(-) diff --git a/packages/core/src/core/diff.ts b/packages/core/src/core/diff.ts index cdf37c3..06d8460 100644 --- a/packages/core/src/core/diff.ts +++ b/packages/core/src/core/diff.ts @@ -851,7 +851,7 @@ export function diffListWithIdSelector( if (oldTag !== newTag) { changes.push({ container: containerId, - key: oldInfo.index, + key: newInfo.newIndex, value: undefined, kind: "delete", }); diff --git a/packages/core/tests/schema-union.test.ts b/packages/core/tests/schema-union.test.ts index 9114a34..1fd8288 100644 --- a/packages/core/tests/schema-union.test.ts +++ b/packages/core/tests/schema-union.test.ts @@ -374,4 +374,70 @@ describe("Union edge cases", () => { const _typeCheck: LoroUnionSchema> = 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"); + } + }); + }); From a4bea9288771209f85127b3cee3ce2bab7167ff6 Mon Sep 17 00:00:00 2001 From: Alexis Williams Date: Wed, 25 Feb 2026 12:52:49 -0800 Subject: [PATCH 09/17] fix(core): merge initialState data into union fields instead of discarding it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two issues combined to make initialState ineffective for union fields: 1. mergeInitialIntoBaseWithSchema coerced union fields to {} without merging the provided initialState data. Fixed by resolving the variant from the discriminant value, setting it directly (since it's not part of the variant's schema definition), and recursing with the variant's map schema — matching the pattern used by the loro-map branch. 2. initializeContainers used Object.assign to overlay the doc snapshot onto this.state, which shallow-replaced nested objects and wiped out any data merged in step (1). Replaced with deepMergeSnapshot, which walks both trees and stamps $cid metadata from the snapshot without overwriting existing content. This preserves initialState data for all container types while still injecting $cid on initial snapshots. Also cleans up two tests that worked around the bug by using setState with `as Record` casts — they now use initialState directly. --- packages/core/src/core/mirror.ts | 103 ++++++++++++++++++++--- packages/core/tests/schema-union.test.ts | 45 ++++++++-- 2 files changed, 129 insertions(+), 19 deletions(-) diff --git a/packages/core/src/core/mirror.ts b/packages/core/src/core/mirror.ts index 8e3421c..39dedb1 100644 --- a/packages/core/src/core/mirror.ts +++ b/packages/core/src/core/mirror.ts @@ -526,17 +526,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, + ); } /** @@ -2937,6 +2933,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 @@ -2991,8 +3036,44 @@ function mergeInitialIntoBaseWithSchema( continue; } if (t === "loro-union") { - // Union is stored as a Map — ensure an object shape + // 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 tag = nestedInit[unionSchema.discriminant] as + | string + | undefined; + if (tag) { + // 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") { diff --git a/packages/core/tests/schema-union.test.ts b/packages/core/tests/schema-union.test.ts index 1fd8288..acd7cad 100644 --- a/packages/core/tests/schema-union.test.ts +++ b/packages/core/tests/schema-union.test.ts @@ -233,14 +233,16 @@ describe("Mirror with Union schema", () => { const mirror = new Mirror({ doc, schema: rootUnionSchema, - }); - - mirror.setState((draft) => { - (draft as Record).content = { kind: "article", body: "Hello" }; + 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"); + } }); }); @@ -282,10 +284,10 @@ describe("Union edge cases", () => { }); const doc = new LoroDoc(); - const mirror = new Mirror({ doc, schema: s }); - - mirror.setState((draft) => { - (draft as Record).data = { type: "only", value: 42 }; + const mirror = new Mirror({ + doc, + schema: s, + initialState: { data: { type: "only", value: 42 } }, }); expect(mirror.getState().data.type).toBe("only"); @@ -440,4 +442,31 @@ describe("Union edge cases", () => { } }); + 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"); + } + }); }); From 15992bb9fda275ef4d82ebd127935f884d18d68b Mon Sep 17 00:00:00 2001 From: Alexis Williams Date: Sun, 1 Mar 2026 19:09:42 -0800 Subject: [PATCH 10/17] fix(core): address code review issues for discriminated unions (#76) - Replace isObjectLike with isObject at union guard sites in diff.ts to prevent null dereference (isObjectLike returns true for null) - Add configurable: true to restoreCidDescriptors so $cid can be redefined after snapshot restoration - Validate that variant definitions don't contain the discriminant key, both at schema creation time and in validateSchema - Remove incorrect fallback in resolveUnionVariantSchema that returned the parent union schema for unknown tags - Use tryUpdateToContainer instead of insertChildToMap for map-level variant switches so container type is resolved from schema - Resolve union schema to active variant in updateMapContainer so nested container fields are created correctly - Guard mergeInitialIntoBaseWithSchema to preserve existing doc discriminant over initialState - Persist union discriminants to Loro doc during Mirror construction when initialState provides them and the doc doesn't already have them --- packages/core/src/core/diff.ts | 54 +++--- packages/core/src/core/mirror.ts | 62 ++++++- packages/core/src/schema/index.ts | 13 ++ packages/core/src/schema/validators.ts | 21 ++- packages/core/tests/schema-union.test.ts | 213 +++++++++++++++++++++++ 5 files changed, 322 insertions(+), 41 deletions(-) diff --git a/packages/core/src/core/diff.ts b/packages/core/src/core/diff.ts index 06d8460..116d473 100644 --- a/packages/core/src/core/diff.ts +++ b/packages/core/src/core/diff.ts @@ -33,6 +33,7 @@ import { getRootContainerByType, insertChildToMap, applySchemaToInferOptions, + isObject, isObjectLike, isStateAndSchemaOfType, isValueOfContainerType, @@ -109,13 +110,11 @@ function resolveUnionVariantSchema( schema: SchemaType | undefined, value: unknown, ): SchemaType | undefined { - if (!schema || !isLoroUnionSchema(schema) || !isObjectLike(value)) { + if (!schema || !isLoroUnionSchema(schema) || !isObject(value)) { return schema; } - const tag = (value as Record)[ - schema.discriminant - ] as string; - return schema.variants[tag] ?? schema; + const tag = value[schema.discriminant] as string; + return schema.variants[tag]; } /** @@ -838,16 +837,12 @@ export function diffListWithIdSelector( if ( schema && isLoroUnionSchema(schema.itemSchema) && - isObjectLike(oldItem) && - isObjectLike(newItem) + isObject(oldItem) && + isObject(newItem) ) { const unionSchema = schema.itemSchema; - const oldTag = (oldItem as Record)[ - unionSchema.discriminant - ]; - const newTag = (newItem as Record)[ - unionSchema.discriminant - ]; + const oldTag = oldItem[unionSchema.discriminant]; + const newTag = newItem[unionSchema.discriminant]; if (oldTag !== newTag) { changes.push({ container: containerId, @@ -1474,25 +1469,26 @@ export function diffMap( // Union variant switch — replace entire container if ( isLoroUnionSchema(childSchema) && - isObjectLike(oldItem) && - isObjectLike(newItem) + isObject(oldItem) && + isObject(newItem) ) { - const oldTag = (oldItem as Record)[ - childSchema.discriminant - ]; - const newTag = (newItem as Record)[ - childSchema.discriminant - ]; + const oldTag = oldItem[childSchema.discriminant]; + const newTag = newItem[childSchema.discriminant]; if (oldTag !== newTag) { + const variantSchema = + resolveUnionVariantSchema(childSchema, newItem) ?? + childSchema; changes.push( - insertChildToMap( - containerId, - key, - newStateObj[key], - applySchemaToInferOptions( - childSchema, - inferOptions, - ), + tryUpdateToContainer( + { + container: containerId, + key, + value: newStateObj[key], + kind: "set", + }, + true, + variantSchema, + inferOptions, ), ); continue; diff --git a/packages/core/src/core/mirror.ts b/packages/core/src/core/mirror.ts index 39dedb1..9b522f8 100644 --- a/packages/core/src/core/mirror.ts +++ b/packages/core/src/core/mirror.ts @@ -432,7 +432,35 @@ export class Mirror { } } - this.baseState = baseState as InferType; + // 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); + } + } + } + this.state = baseState as InferType; // Initialize ephemeral manager if store provided @@ -1909,9 +1937,7 @@ export class Mirror { // Resolve union schema to the concrete variant's LoroMapSchema let resolvedSchema = schema; if (isLoroUnionSchema(schema) && isObject(value)) { - const tag = (value as Record)[ - schema.discriminant - ] as string; + const tag = value[schema.discriminant] as string; if (tag && schema.variants[tag]) { resolvedSchema = schema.variants[tag]; } @@ -2204,7 +2230,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)) { @@ -2893,7 +2927,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; @@ -3051,12 +3088,19 @@ function mergeInitialIntoBaseWithSchema( LoroMapSchema> >; }; - const tag = nestedInit[unionSchema.discriminant] as + 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) { - // Discriminant is not part of the variant definition — set it directly - nestedBase[unionSchema.discriminant] = 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, { diff --git a/packages/core/src/schema/index.ts b/packages/core/src/schema/index.ts index 694ee5c..e5e02b1 100644 --- a/packages/core/src/schema/index.ts +++ b/packages/core/src/schema/index.ts @@ -368,6 +368,19 @@ schema.Union = function < 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, diff --git a/packages/core/src/schema/validators.ts b/packages/core/src/schema/validators.ts index 164c8b6..ecaa136 100644 --- a/packages/core/src/schema/validators.ts +++ b/packages/core/src/schema/validators.ts @@ -353,9 +353,24 @@ export function validateSchema( } if (!isLoroUnionSchema(schema)) break; - const tag = (value as Record)[ - schema.discriminant - ]; + // 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`, diff --git a/packages/core/tests/schema-union.test.ts b/packages/core/tests/schema-union.test.ts index acd7cad..7a3e30d 100644 --- a/packages/core/tests/schema-union.test.ts +++ b/packages/core/tests/schema-union.test.ts @@ -79,6 +79,31 @@ describe("schema.Union", () => { 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", () => { @@ -244,6 +269,76 @@ describe("Mirror with Union schema", () => { 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", () => { @@ -469,4 +564,122 @@ describe("Union edge cases", () => { 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(); + }); }); From e492ce16ed6ff02a5c86b4b69d201f7ec6bbcd29 Mon Sep 17 00:00:00 2001 From: Alexis Williams Date: Thu, 9 Apr 2026 14:44:38 -0700 Subject: [PATCH 11/17] fix(core): resolve build errors from rebase - Add missing LoroMapSchemaWithCatchall import (used in type casts) - Replace nonexistent this.getSchemaForMapKey() with imported getMapFieldSchema() - Assign this.baseState in constructor (was left uninitialized, causing TS2564) --- packages/core/src/core/mirror.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/packages/core/src/core/mirror.ts b/packages/core/src/core/mirror.ts index 9b522f8..1b614e6 100644 --- a/packages/core/src/core/mirror.ts +++ b/packages/core/src/core/mirror.ts @@ -44,6 +44,7 @@ import { isLoroUnionSchema, LoroListSchema, LoroMapSchema, + LoroMapSchemaWithCatchall, RootSchemaType, SchemaType, validateSchema, @@ -461,7 +462,8 @@ export class Mirror { } } - this.state = baseState as InferType; + this.baseState = baseState as InferType; + this.state = this.baseState; // Initialize ephemeral manager if store provided if (options.ephemeralStore) { @@ -641,7 +643,7 @@ export class Mirror { effectiveMapSchema && isLoroMapSchema(effectiveMapSchema) ) { - const candidate = this.getSchemaForMapKey( + const candidate = getMapFieldSchema( effectiveMapSchema as | LoroMapSchema> | LoroMapSchemaWithCatchall< @@ -2840,7 +2842,7 @@ export class Mirror { const tag = map.get(containerSchema.discriminant); if (typeof tag === "string" && containerSchema.variants[tag]) { const variantSchema = containerSchema.variants[tag]; - return this.getSchemaForMapKey( + return getMapFieldSchema( variantSchema as | LoroMapSchema> | LoroMapSchemaWithCatchall< @@ -2855,7 +2857,7 @@ export class Mirror { } if (isLoroMapSchema(containerSchema)) { - return this.getSchemaForMapKey( + return getMapFieldSchema( containerSchema as | LoroMapSchema> | LoroMapSchemaWithCatchall< From 8cc75054b537639707465d5507285195f219a234 Mon Sep 17 00:00:00 2001 From: Alexis Williams Date: Thu, 9 Apr 2026 14:44:44 -0700 Subject: [PATCH 12/17] fix(core): handle loro-union in schema resolver - Add loro-union case to getChildSchema (returns undefined, variant resolution requires a value) - Add loro-union case to getChildContainerSchema so unions are recognised as container schemas - Add resolveUnionVariant() to resolve a union to its active variant's map schema given a concrete value --- packages/core/src/schema/resolver.ts | 39 +++++++++++++++++++++++++++- 1 file changed, 38 insertions(+), 1 deletion(-) 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; From 8ab7823eb4c38f453d66d37c4850f7c385e1a313 Mon Sep 17 00:00:00 2001 From: Alexis Williams Date: Thu, 9 Apr 2026 14:44:50 -0700 Subject: [PATCH 13/17] fix(core): decode transforms for loro-union in decodeNestedJsonValues Add loro-union case that resolves the active variant via resolveUnionVariant() and delegates to the loro-map decode path. Without this, transform fields inside union variants are not decoded when reading from a LoroDoc snapshot. --- packages/core/src/core/utils.ts | 38 +++++++++++++++++++++++++-------- 1 file changed, 29 insertions(+), 9 deletions(-) diff --git a/packages/core/src/core/utils.ts b/packages/core/src/core/utils.ts index 10a96c6..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; @@ -484,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") { From 0fa558152db6492291de57970748dfaacdd57921 Mon Sep 17 00:00:00 2001 From: Alexis Williams Date: Thu, 9 Apr 2026 14:45:15 -0700 Subject: [PATCH 14/17] fix(core): resolve union variants in containerToMirrorState When rebuilding state from a LoroDoc snapshot, containerToMirrorState now resolves union schemas to the active variant before looking up child field schemas. Without this, getChildSchema returns undefined for union containers and applyDecode is a no-op, causing transform fields inside union variants (and all map transforms on snapshot import) to return raw CRDT values instead of decoded domain objects. --- packages/core/src/core/mirror.ts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/packages/core/src/core/mirror.ts b/packages/core/src/core/mirror.ts index 1b614e6..75ea996 100644 --- a/packages/core/src/core/mirror.ts +++ b/packages/core/src/core/mirror.ts @@ -53,6 +53,7 @@ import { getChildContainerSchema, getChildSchema, getMapFieldSchema, + resolveUnionVariant, } from "../schema/resolver.js"; import { deepEqual, @@ -2618,13 +2619,21 @@ export class Mirror { const m = c as LoroMap; const obj: MirrorStateObject = {}; defineCidProperty(obj, c.id); + // 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(schema, k); + const fieldSchema = getChildSchema(effectiveSchema, k); obj[k] = applyDecode(fieldSchema, v) as MirrorState; } } From 33a796087df73d6662d60bd9c609f83a7dd2b097 Mon Sep 17 00:00:00 2001 From: Alexis Williams Date: Thu, 9 Apr 2026 14:45:44 -0700 Subject: [PATCH 15/17] fix(core): register root container schemas before initial snapshot Call registerRootContainerSchemas() before buildRootStateSnapshot() in the constructor so that containerToMirrorState can find schemas and apply decode transforms when reading from a pre-populated LoroDoc. Previously, schemas were only registered during initializeContainers() which runs after the initial snapshot, causing transforms to be skipped on Mirror construction from an existing doc. --- packages/core/src/core/mirror.ts | 48 ++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/packages/core/src/core/mirror.ts b/packages/core/src/core/mirror.ts index 75ea996..c1231d6 100644 --- a/packages/core/src/core/mirror.ts +++ b/packages/core/src/core/mirror.ts @@ -391,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) @@ -510,6 +516,48 @@ export class Mirror { } } + /** + * 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 */ From 22ee093a923293832df6fe076287448a0176ede2 Mon Sep 17 00:00:00 2001 From: Alexis Williams Date: Thu, 9 Apr 2026 14:46:20 -0700 Subject: [PATCH 16/17] fix(core): persist initialState map primitives to LoroDoc for ephemeral routing When initialState provides primitive values for root-level LoroMap fields, write them to the LoroDoc if the doc doesn't already have those keys. This ensures EphemeralPatchManager.isEligible() can recognise the keys as existing and route changes to the EphemeralStore instead of writing directly to LoroDoc. Previously, initialState only populated in-memory state, leaving the LoroDoc Map empty. The ephemeral eligibility check requires keys to exist on the LoroMap, so all changes were incorrectly classified as non-ephemeral. --- packages/core/src/core/mirror.ts | 60 ++++++++++++++++++++++++++++++++ 1 file changed, 60 insertions(+) diff --git a/packages/core/src/core/mirror.ts b/packages/core/src/core/mirror.ts index c1231d6..736b4a0 100644 --- a/packages/core/src/core/mirror.ts +++ b/packages/core/src/core/mirror.ts @@ -469,6 +469,23 @@ export class Mirror { } } + // 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 = this.baseState; @@ -516,6 +533,49 @@ 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(). From 812416867ebfdc3ce0a311bcfa624a1ffa40fabc Mon Sep 17 00:00:00 2001 From: Alexis Williams Date: Thu, 9 Apr 2026 14:46:27 -0700 Subject: [PATCH 17/17] test(core): add coverage for transforms in union variants and MovableList of unions - Transform inside union variant fields: verifies Date decode works for fields with .transform() inside a union variant, both on initial push and same-variant update - MovableList of unions: verifies push, in-place mutation, variant switch, and removal for union items inside a LoroMovableList --- packages/core/tests/schema-union.test.ts | 215 ++++++++++++++++++++++- 1 file changed, 211 insertions(+), 4 deletions(-) diff --git a/packages/core/tests/schema-union.test.ts b/packages/core/tests/schema-union.test.ts index 7a3e30d..6dd2988 100644 --- a/packages/core/tests/schema-union.test.ts +++ b/packages/core/tests/schema-union.test.ts @@ -1,6 +1,11 @@ import { describe, it, expect, beforeEach } from "vitest"; import { LoroDoc } from "loro-crdt"; -import { schema, validateSchema, getDefaultValue } from "../src/index.js"; +import { + schema, + validateSchema, + getDefaultValue, + type TransformDefinition, +} from "../src/index.js"; import { Mirror } from "../src/core/mirror.js"; import { isLoroUnionSchema, @@ -83,7 +88,10 @@ describe("schema.Union", () => { it("rejects discriminant key in variant definition at schema creation", () => { expect(() => { schema.Union("type", { - bad: schema.LoroMap({ type: schema.String(), value: schema.Number() }), + bad: schema.LoroMap({ + type: schema.String(), + value: schema.Number(), + }), }); }).toThrow(/must not contain the discriminant key/); }); @@ -102,7 +110,9 @@ describe("schema.Union", () => { const result = validateSchema(badUnion, { kind: "bad" }); expect(result.valid).toBe(false); - expect(result.errors?.[0]).toContain("must not contain the discriminant key"); + expect(result.errors?.[0]).toContain( + "must not contain the discriminant key", + ); }); }); @@ -468,7 +478,10 @@ describe("Union edge cases", () => { const _guard: boolean = isLoroUnionSchema(u); expect(_guard).toBe(true); // Verify the type is accessible (compile-time check) - const _typeCheck: LoroUnionSchema> = u as never; + const _typeCheck: LoroUnionSchema< + string, + Record + > = u as never; void _typeCheck; }); @@ -683,3 +696,197 @@ describe("Union edge cases", () => { 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"); + }); +});