Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
0332f49
feat(core): add LoroUnionSchema type and inference helpers
typedrat Feb 25, 2026
c0937dd
feat(core): add schema.Union builder
typedrat Feb 25, 2026
12eebda
feat(core): add union validation, type guard, and default value support
typedrat Feb 25, 2026
c2c7524
feat(core): add union diff system and mirror container registration
typedrat Feb 25, 2026
a1a0918
test(core): add comprehensive edge case tests for discriminated unions
typedrat Feb 25, 2026
3d9bc23
docs: add schema.Union to API reference and README documentation
typedrat Feb 25, 2026
60a20a0
fix(core): allow defineCidProperty to overwrite stale user-set $cid
typedrat Feb 25, 2026
db3ae60
fix(core): use post-mutation index for union variant-switch in list diff
typedrat Feb 25, 2026
a4bea92
fix(core): merge initialState data into union fields instead of disca…
typedrat Feb 25, 2026
15992bb
fix(core): address code review issues for discriminated unions (#76)
typedrat Mar 2, 2026
e492ce1
fix(core): resolve build errors from rebase
typedrat Apr 9, 2026
8cc7505
fix(core): handle loro-union in schema resolver
typedrat Apr 9, 2026
8ab7823
fix(core): decode transforms for loro-union in decodeNestedJsonValues
typedrat Apr 9, 2026
0fa5581
fix(core): resolve union variants in containerToMirrorState
typedrat Apr 9, 2026
33a7960
fix(core): register root container schemas before initial snapshot
typedrat Apr 9, 2026
22ee093
fix(core): persist initialState map primitives to LoroDoc for ephemer…
typedrat Apr 9, 2026
8124168
test(core): add coverage for transforms in union variants and Movable…
typedrat Apr 9, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,8 +44,8 @@
- `Mirror(options: MirrorOptions<S>)`
- `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.
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<T>[] }`. Define a tree by passing a node `LoroMap` schema:
Expand Down
200 changes: 199 additions & 1 deletion api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<S>`
- `doc: LoroDoc` — the Loro document to sync with
- `schema?: S` — root schema (enables validation, typed defaults)
- `initialState?: Partial<InferInputType<S>>` — 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<S>` — 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<S>) => void`
- Return a new object: `(prev: Readonly<InferInputType<S>>) => InferInputType<S>`
- Shallow partial: `Partial<InferInputType<S>>`
- `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<T = 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<S>` — state type produced by a schema
- `InferInputType<S>` — input type accepted by `setState` (map `$cid` optional)
- `InferSchemaType<T>` — infers the type of a map definition
- `InferTreeNodeType<M>` / `InferTreeNodeTypeWithCid<M>` — inferred node shapes for trees
- `InferInputTreeNodeType<M>` — 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<S> | undefined`
- Produces defaults for a schema (respects `required` and `defaultValue`).
- `createValueFromSchema(schema, value): InferType<S>`
- 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<S>` — constructor options for `Mirror`
- `SetStateOptions` — `{ tags?: string | string[] }`
- `UpdateMetadata` — `{ direction: SyncDirection; tags?: string[] }`
- `InferType<S>` — state shape produced by a schema (includes `$cid` on maps)
- `InferInputType<S>` — input shape accepted by `setState` (like `InferType` but `$cid` is optional on maps)
- `InferContainerOptions` — `{ defaultLoroText?: boolean; defaultMovableList?: boolean }`
- `SubscriberCallback<T>` — `(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.
5 changes: 4 additions & 1 deletion packages/core/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand All @@ -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 }`.

Expand All @@ -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

Expand Down
Loading