loro-mirror
Version:
Type-safe state management synchronized with Loro CRDT via a declarative schema and bidirectional mirroring.
1,427 lines (1,303 loc) • 75.2 kB
text/typescript
/**
* Mirror core functionality for bidirectional sync between app state and Loro CRDT
*/
import { produce, setAutoFreeze } from "immer";
setAutoFreeze(false);
import {
Container,
ContainerID,
ContainerType,
isContainer,
LoroDoc,
LoroEventBatch,
LoroList,
LoroMap,
LoroMovableList,
LoroText,
LoroTree,
TreeID,
} from "loro-crdt";
import { applyEventBatchToState } from "./loroEventApply";
import {
ContainerSchemaType,
getDefaultValue,
InferInputType,
InferType,
isContainerSchema,
isListLikeSchema,
isLoroListSchema,
isLoroMapSchema,
isLoroMovableListSchema,
isLoroTreeSchema,
LoroListSchema,
LoroMapSchema,
RootSchemaType,
SchemaType,
validateSchema,
} from "../schema";
import {
deepEqual,
inferContainerTypeFromValue,
isObject,
isValueOfContainerType,
schemaToContainerType,
tryInferContainerType,
getRootContainerByType,
} from "./utils";
import { diffContainer, diffTree } from "./diff";
import { CID_KEY } from "../constants";
// Plain JSON-like value used for state snapshots
type JSONPrimitive = string | number | boolean | null | undefined;
type JSONValue = JSONPrimitive | JSONObject | JSONValue[];
interface JSONObject {
[k: string]: JSONValue;
}
function hasKeyProp(c: Change): c is Extract<Change, { key: string | number }> {
return (c as { key?: unknown }).key !== undefined;
}
/**
* Sync direction for handling updates
*/
export enum SyncDirection {
/**
* Changes coming from Loro to application state
*/
FROM_LORO = "FROM_LORO",
/**
* Changes going from application state to Loro
*/
TO_LORO = "TO_LORO",
/**
* Initial sync or manual sync operations
*/
BIDIRECTIONAL = "BIDIRECTIONAL",
}
/**
* Configuration options for the Mirror
*/
export interface MirrorOptions<S extends SchemaType> {
/**
* The Loro document to sync with
*/
doc: LoroDoc;
/**
* The schema definition for the state
*/
schema?: S;
/**
* Initial state (optional)
*/
initialState?: Partial<import("../schema").InferInputType<S>>;
/**
* Whether to validate state updates against the schema
* @default true
*/
validateUpdates?: boolean;
/**
* Whether to throw errors on validation failures
* @default false
*/
throwOnValidationError?: boolean;
/**
* Debug mode - logs operations
* @default false
*/
debug?: boolean;
/**
* When enabled, performs an internal consistency check after setState
* to ensure in-memory state equals the normalized LoroDoc JSON.
* This throws on divergence but does not emit the verbose debug logs.
* @default false
*/
checkStateConsistency?: boolean;
/**
* Default values for new containers
*/
inferOptions?: InferContainerOptions;
}
export type InferContainerOptions = {
defaultLoroText?: boolean;
defaultMovableList?: boolean;
};
export type ChangeKinds = {
set: {
container: ContainerID | "";
key: string | number;
value: unknown;
kind: "set";
childContainerType?: ContainerType;
};
setContainer: {
container: ContainerID | "";
key: string | number;
value: unknown;
kind: "set-container";
childContainerType?: ContainerType;
};
insert: {
container: ContainerID | "";
key: string | number;
value: unknown;
kind: "insert";
};
insertContainer: {
container: ContainerID | "";
key: string | number;
value: unknown;
kind: "insert-container";
childContainerType?: ContainerType;
};
delete: {
container: ContainerID | "";
key: string | number;
value: unknown;
kind: "delete";
};
move: {
container: ContainerID;
key: number;
value: unknown;
kind: "move";
fromIndex: number;
toIndex: number;
childContainerType?: ContainerType;
};
treeCreate: {
container: ContainerID;
kind: "tree-create";
parent?: TreeID;
index: number;
value?: unknown; // initial node.data
// Called immediately after the node is created in Loro so we can:
// 1) write the assigned TreeID back onto the newState node (users cannot know it ahead of time), and
// 2) patch any queued child `tree-create` ops to point to this node as their `parent`.
//
// Note: this implies an ordering requirement when applying changes — tree creates must be
// applied one-by-one and `onCreate` invoked right away to ensure children have the correct parent.
onCreate(id: TreeID): void;
};
treeMove: {
container: ContainerID;
kind: "tree-move";
target: TreeID;
parent?: TreeID;
index: number;
};
treeDelete: {
container: ContainerID;
kind: "tree-delete";
target: TreeID;
};
};
export type Change = ChangeKinds[keyof ChangeKinds];
export type MapChangeKinds =
| ChangeKinds["insert"]
| ChangeKinds["insertContainer"]
| ChangeKinds["delete"];
export type ListChangeKinds =
| ChangeKinds["insert"]
| ChangeKinds["insertContainer"]
| ChangeKinds["delete"];
export type MovableListChangeKinds =
| ChangeKinds["insert"]
| ChangeKinds["insertContainer"]
| ChangeKinds["delete"]
| ChangeKinds["move"]
| ChangeKinds["set"]
| ChangeKinds["setContainer"];
export type TreeChangeKinds =
| ChangeKinds["treeCreate"]
| ChangeKinds["treeMove"]
| ChangeKinds["treeDelete"];
export type TextChangeKinds = ChangeKinds["insert"] | ChangeKinds["delete"];
/**
* Options for setState and sync operations
*/
export interface SetStateOptions {
/**
* Tags to attach to this state update
* Tags can be used for tracking the source of changes or grouping related changes
*/
tags?: string[] | string;
}
type ContainerRegistry = Map<
ContainerID,
{
schema: ContainerSchemaType | undefined;
registered: boolean;
}
>;
/**
* Additional metadata for state updates
*/
export interface UpdateMetadata {
/**
* Direction of the sync operation
*/
direction: SyncDirection;
/**
* Tags associated with this update, if any
*/
tags?: string[];
}
/**
* Callback type for subscribers
*/
export type SubscriberCallback<T> = (
state: T,
metadata: UpdateMetadata,
) => void;
/**
* Mirror class that provides bidirectional sync between application state and Loro
*/
export class Mirror<S extends SchemaType> {
private doc: LoroDoc;
private schema?: S;
private state: InferType<S>;
private subscribers: Set<SubscriberCallback<InferType<S>>> = new Set();
private syncing = false;
private options: MirrorOptions<S>;
private containerRegistry: ContainerRegistry = new Map();
private subscriptions: (() => void)[] = [];
// Canonical root path (e.g., ["profile"]) per root container id
private rootPathById: Map<ContainerID, string[]> = new Map();
/**
* Creates a new Mirror instance
*/
constructor(options: MirrorOptions<S>) {
this.doc = options.doc;
this.schema = options.schema;
// Set default options
this.options = {
doc: options.doc,
schema: options.schema,
initialState: options.initialState || {},
validateUpdates: options.validateUpdates !== false,
throwOnValidationError: options.throwOnValidationError || false,
debug: options.debug || false,
checkStateConsistency: options.checkStateConsistency || false,
inferOptions: options.inferOptions || {},
};
// Pre-create root containers hinted by initialState (no-op in Loro for roots)
// so that doc.toJSON() reflects empty shapes and matches normalized state.
this.ensureRootContainersFromInitialState();
// Initialize in-memory state without writing to LoroDoc:
// 1) Start from schema defaults (if any)
// 2) Overlay current LoroDoc snapshot (normalized)
// 3) Fill any missing top-level keys hinted by initialState with a normalized empty shape
// (arrays -> [], strings -> '', objects -> {}), but do NOT override existing values
// from the doc/defaults. This keeps doc pristine while providing a predictable state shape.
const baseState: Record<string, unknown> = {};
const defaults = (
this.schema ? getDefaultValue(this.schema) : undefined
) as Record<string, unknown> | undefined;
if (defaults && typeof defaults === "object") {
Object.assign(baseState, defaults);
}
// Overlay the current doc snapshot so real data takes precedence over defaults
const docSnapshot = this.buildRootStateSnapshot();
if (docSnapshot && typeof docSnapshot === "object") {
Object.assign(baseState, docSnapshot);
}
// Merge initialState with awareness of schema:
// - Respect Ignore fields by keeping their values in memory only
// - For container fields, fill missing base keys with normalized empties ([], "", {})
// - For primitives, use provided initial values if doc/defaults do not provide them
const initForMerge = (this.options.initialState || {}) as Record<
string,
unknown
>;
if (this.schema && this.schema.type === "schema") {
mergeInitialIntoBaseWithSchema(
baseState,
initForMerge,
this.schema as RootSchemaType<
Record<string, ContainerSchemaType>
>,
);
} else {
const hinted = normalizeInitialShapeShallow(initForMerge);
for (const [k, v] of Object.entries(hinted)) {
if (!(k in baseState)) baseState[k] = v;
}
}
this.state = baseState as InferType<S>;
// Initialize Loro containers and setup subscriptions
this.initializeContainers();
// Subscribe to the root doc for global updates
this.subscriptions.push(this.doc.subscribe(this.handleLoroEvent));
}
/**
* Ensure root containers exist for keys hinted by initialState.
* Creating root containers is a no-op in Loro (no operations are recorded),
* but it makes them visible in doc JSON, staying consistent with Mirror state.
*/
private ensureRootContainersFromInitialState() {
const init = (this.options?.initialState || {}) as Record<
string,
unknown
>;
for (const [key, value] of Object.entries(init)) {
let container: Container | null = null;
if (Array.isArray(value)) {
container = this.doc.getList(key);
} else if (typeof value === "string") {
container = this.doc.getText(key);
} else if (isObject(value)) {
container = this.doc.getMap(key);
}
if (container) {
this.rootPathById.set(container.id, [key]);
this.registerContainerWithRegistry(container.id, undefined);
}
}
}
/**
* Initialize containers based on schema
*/
private initializeContainers() {
if (this.schema && this.schema.type !== "schema") {
throw new Error('Root schema must be of type "schema"');
}
// Register root containers first so registry is ready
if (this.schema) {
for (const key in this.schema.definition) {
if (
Object.prototype.hasOwnProperty.call(
this.schema.definition,
key,
)
) {
const fieldSchema = this.schema.definition[key];
if (
[
"loro-map",
"loro-list",
"loro-text",
"loro-movable-list",
"loro-tree",
].includes(fieldSchema.type)
) {
const containerType =
schemaToContainerType(fieldSchema);
if (!containerType) {
continue;
}
const container = getRootContainerByType(
this.doc,
key,
containerType,
);
// Record canonical root path for this root container id
this.rootPathById.set(container.id, [key]);
this.registerContainer(container.id, fieldSchema);
}
}
}
}
// Build initial state snapshot from the current document
const currentDocState = this.buildRootStateSnapshot();
const newState = produce<InferType<S>>((draft) => {
Object.assign(
draft as unknown as Record<string, unknown>,
(currentDocState ?? {}) as Record<string, unknown>,
);
})(this.state);
this.state = newState;
}
/**
* Register a container with the Mirror
*/
private registerContainer(
containerID: ContainerID,
schemaType: ContainerSchemaType | undefined,
) {
try {
const container = this.doc.getContainerById(containerID);
if (!container) {
if (this.options.debug) {
console.warn(
`registerContainer: container not found for id ${containerID}`,
);
}
return;
}
const containerId = container.id;
// If already registered, optionally upgrade schema, then skip deep re-scan
const existing = this.containerRegistry.get(containerId);
if (existing) {
if (!existing.schema && schemaType) {
existing.schema = schemaType;
}
return;
}
this.registerContainerWithRegistry(containerId, schemaType);
// Register nested containers
this.registerNestedContainers(container);
} catch (error) {
if (this.options.debug) {
console.error(
`Error registering container: ${containerID}`,
error,
);
}
}
}
/**
* Register nested containers within a container
*/
private registerNestedContainers(container: Container) {
if (!container.isAttached) return;
const parentSchema = this.getContainerSchema(container.id);
try {
if (container.kind() === "Map") {
const map = container as LoroMap;
for (const key of map.keys()) {
const value = map.get(key);
if (isContainer(value)) {
let nestedSchema: ContainerSchemaType | undefined;
if (parentSchema && isLoroMapSchema(parentSchema)) {
nestedSchema = parentSchema.definition[
key
] as ContainerSchemaType;
}
this.registerContainer(value.id, nestedSchema);
}
}
} else if (
container.kind() === "List" ||
container.kind() === "MovableList"
) {
const list = container as LoroList | LoroMovableList;
const len = list.length;
for (let i = 0; i < len; i++) {
const value = list.get(i);
if (isContainer(value)) {
let nestedSchema: ContainerSchemaType | undefined;
if (
parentSchema &&
(isLoroListSchema(parentSchema) ||
isLoroMovableListSchema(parentSchema))
) {
nestedSchema =
parentSchema.itemSchema as ContainerSchemaType;
}
if (nestedSchema) {
this.registerContainer(value.id, nestedSchema);
}
}
}
} else if (container.kind() === "Tree") {
const tree = container as LoroTree;
let nodeSchema: ContainerSchemaType | undefined;
if (parentSchema && isLoroTreeSchema(parentSchema)) {
nodeSchema = parentSchema.nodeSchema as ContainerSchemaType;
}
if (nodeSchema) {
const nodes = tree.getNodes();
for (const node of nodes) {
// Register the node.data map and its nested containers
this.registerContainer(node.data.id, nodeSchema);
}
}
}
} catch (error) {
if (this.options.debug) {
console.error(
`Error registering nested containers for ${container.id}:`,
error,
);
}
}
}
/**
* Handle events from the LoroDoc
*/
private handleLoroEvent = (event: LoroEventBatch) => {
if (event.origin === "to-loro") return;
this.syncing = true;
try {
// Pre-register any containers referenced in this batch
this.registerContainersFromLoroEvent(event);
// no-op debug hook removed
// Normalize event paths to canonical root paths when applicable
const normalized = {
...event,
events: event.events.map((e) => {
const canon = this.rootPathById.get(e.target);
if (
canon &&
(!Array.isArray(e.path) || e.path[0] !== canon[0])
) {
return { ...e, path: canon } as typeof e;
}
return e;
}),
} as LoroEventBatch;
// Incrementally update state using event deltas
this.state = applyEventBatchToState(
this.state as unknown as Record<string, unknown>,
normalized,
{
getContainerById: (id) => this.doc.getContainerById(id),
containerToJson: (c) => this.containerToStateJson(c),
nodeDataWithCid: (treeId) => {
const s = this.getContainerSchema(treeId);
return !!(s && isLoroTreeSchema(s));
},
getNodeDataCid: (treeId, nodeId) => {
try {
const node = this.doc
.getTree(treeId)
.getNodeByID(nodeId);
return node ? node.data.id : undefined;
} catch {
return undefined;
}
},
},
) as unknown as InferType<S>;
// With canonicalized paths, applyEventBatchToState updates roots precisely.
// No additional root refresh is required here.
// Notify subscribers of the update
this.notifySubscribers(SyncDirection.FROM_LORO);
} finally {
this.registerContainersFromLoroEvent(event);
this.syncing = false;
}
};
/**
* Processes container additions/removals from the LoroDoc
* and ensures the containers are reflected in the container registry.
*
* TODO: need to handle removing containers from the registry on import
* right now the Diff Delta only returns the number of items removed
* not the container IDs , of those that were removed.
*/
private registerContainersFromLoroEvent(batch: LoroEventBatch) {
for (const event of batch.events) {
if (event.diff.type === "list") {
const diff = event.diff.diff;
const schema = this.getContainerSchema(event.target);
for (const change of diff) {
if (!change.insert) continue;
for (const item of change.insert) {
if (isContainer(item)) {
const container = item;
let containerSchema:
| ContainerSchemaType
| undefined;
if (schema && isListLikeSchema(schema)) {
containerSchema =
schema.itemSchema as ContainerSchemaType;
}
this.registerContainer(
container.id,
containerSchema,
);
if (!containerSchema) {
console.warn(
`Container schema not found for key in list ${event.target}`,
);
}
}
}
}
} else if (event.diff.type === "map") {
const diff = event.diff.updated;
for (const [key, change] of Object.entries(diff)) {
const schema = this.getSchemaForChild(event.target, key);
if (isContainer(change)) {
const containerSchema = isContainerSchema(schema)
? schema
: undefined;
this.registerContainer(change.id, containerSchema);
if (!containerSchema) {
console.warn(
`Container schema not found for key ${key} in map ${event.target}`,
);
}
}
}
} else if (event.diff.type === "tree") {
const tree = this.doc.getTree(event.target);
const schema = this.getContainerSchema(event.target);
let nodeSchema: ContainerSchemaType | undefined;
if (schema && isLoroTreeSchema(schema)) {
nodeSchema = schema.nodeSchema as ContainerSchemaType;
}
if (!nodeSchema) continue;
for (const item of event.diff.diff) {
if (item.action === "create") {
const node = tree.getNodeByID(item.target);
if (node) {
this.registerContainer(node.data.id, nodeSchema);
}
}
}
}
}
}
// Tree node $cid injection happens during event application
/**
* Update Loro based on state changes
*/
private updateLoro(newState: InferType<S>) {
if (this.syncing) return;
this.syncing = true;
try {
// Find the differences between current Loro state and new state
const currentDocState = this.state;
const changes = diffContainer(
this.doc,
currentDocState,
newState,
"",
this.schema,
this.options?.inferOptions,
);
// Apply the changes to the Loro document (and stamp any pending-state metadata like $cid)
this.applyChangesToLoro(changes, newState);
} finally {
this.syncing = false;
}
}
/**
* Apply a set of changes to the Loro document
*/
private applyChangesToLoro(changes: Change[], pendingState?: InferType<S>) {
// Group changes by container for batch processing
const changesByContainer = new Map<ContainerID | "", Change[]>();
for (const change of changes) {
if (!changesByContainer.has(change.container)) {
changesByContainer.set(change.container, []);
}
changesByContainer.get(change.container)!.push(change);
}
// Process changes by container
for (const [
containerId,
containerChanges,
] of changesByContainer.entries()) {
if (containerId === "") {
// Handle root level changes
this.applyRootChanges(containerChanges, pendingState);
} else {
// Handle container-specific changes
const container = this.doc.getContainerById(containerId);
if (container) {
this.applyContainerChanges(
container,
containerChanges,
pendingState,
);
} else {
throw new Error(
`Container not found for ID: ${containerId}.
This is likely due to a stale reference or a synchronization issue.`,
);
}
}
}
// Only commit if we actually applied any changes
if (changes.length > 0) {
this.doc.commit({ origin: "to-loro" });
}
}
/**
* Update root-level fields
*/
private applyRootChanges(changes: Change[], pendingState?: InferType<S>) {
for (const change of changes) {
if (!hasKeyProp(change)) continue;
const { key, value } = change;
const keyStr = key.toString();
const fieldSchema = (
this.schema as RootSchemaType<
Record<string, ContainerSchemaType>
>
)?.definition?.[keyStr];
const type =
fieldSchema?.type ||
inferContainerTypeFromValue(value, this.options?.inferOptions);
let container: Container | null = null;
// Create or get the container based on the schema type
if (type === "loro-map") {
container = this.doc.getMap(keyStr);
} else if (type === "loro-list") {
container = this.doc.getList(keyStr);
} else if (type === "loro-text") {
container = this.doc.getText(keyStr);
} else if (type === "loro-movable-list") {
container = this.doc.getMovableList(keyStr);
} else if (type === "loro-tree") {
container = this.doc.getTree(keyStr);
} else {
throw new Error();
}
this.registerContainerWithRegistry(container.id, fieldSchema);
// Inject $cid for root maps into pending state immediately
if (fieldSchema && isLoroMapSchema(fieldSchema) && pendingState) {
const rootObj = pendingState as Record<string, unknown>;
const child = rootObj[keyStr];
if (isObject(child)) {
child[CID_KEY] = container.id;
}
}
// Apply direct changes to the container
this.updateTopLevelContainer(container, value);
}
}
/**
* Apply multiple changes to a container
*/
private applyContainerChanges(
container: Container,
changes: Change[],
_pendingState?: InferType<S>,
) {
// Apply changes in bulk by container type
switch (container.kind()) {
case "Map": {
const map = container as LoroMap;
for (const change of changes) {
const { key, value, kind } = change as MapChangeKinds;
if (key === "") {
continue; // Skip empty key
}
// If schema marks this key as Ignore, skip writing to Loro
const fieldSchema = this.getSchemaForChild(
container.id,
key,
);
if (fieldSchema && fieldSchema.type === "ignore") {
continue;
}
if (kind === "insert") {
map.set(key as string, value);
} else if (kind === "insert-container") {
const schema = this.getSchemaForChildContainer(
container.id,
key,
);
const inserted = this.insertContainerIntoMap(
map,
schema,
key as string,
value,
);
// Stamp $cid into the pendingState value for child maps
if (
schema &&
isLoroMapSchema(schema) &&
isObject(value)
) {
value[CID_KEY] = inserted.id;
}
} else if (kind === "delete") {
map.delete(key as string);
} else {
throw new Error("Unsupported change kind for map");
}
}
break;
}
case "List": {
const list = container as LoroList;
// Process other changes (add/remove/replace)
for (const change of changes) {
const { key, value, kind } = change as ListChangeKinds;
if (typeof key !== "number") {
throw new Error(`Invalid list index: ${key}`);
}
const index = key;
if (index < 0) {
console.warn(`Invalid list index: ${index}`);
continue;
}
if (kind === "delete") {
list.delete(index, 1);
} else if (kind === "insert") {
list.insert(index, value);
} else if (kind === "insert-container") {
const schema = this.getSchemaForChildContainer(
container.id,
key,
);
this.insertContainerIntoList(
list,
schema,
index,
value,
);
} else {
throw new Error("Unsupported change kind for list");
}
}
break;
}
case "MovableList": {
const list = container as LoroMovableList;
for (const change of changes) {
const { key, value, kind } =
change as MovableListChangeKinds;
if (typeof key !== "number") {
throw new Error(`Invalid list index: ${key}`);
}
const index = key;
if (index < 0) {
console.warn(`Invalid list index: ${index}`);
continue;
}
if (kind === "delete") {
list.delete(index, 1);
} else if (kind === "insert") {
list.insert(index, value);
} else if (kind === "insert-container") {
const schema = this.getSchemaForChildContainer(
container.id,
key,
);
this.insertContainerIntoList(
list,
schema,
index,
value,
);
} else if (kind === "move") {
const c = change as ChangeKinds["move"];
const fromIndex = c.fromIndex;
const toIndex = c.toIndex;
list.move(fromIndex, toIndex);
} else if (kind === "set") {
list.set(index, value);
} else if (kind === "set-container") {
const schema = this.getSchemaForChildContainer(
container.id,
key,
);
const [detachedContainer, _containerType] =
this.createContainerFromSchema(schema, value);
const newContainer = list.setContainer(
index,
detachedContainer,
);
this.registerContainer(newContainer.id, schema);
this.initializeContainer(newContainer, schema, value);
// Stamp $cid into pending state when replacing with a map container
if (
schema &&
isLoroMapSchema(schema) &&
isObject(value)
) {
value[CID_KEY] = newContainer.id;
}
} else {
throw new Error();
}
}
break;
}
case "Text": {
const text = container as LoroText;
// Text containers only support direct value updates
for (const change of changes) {
if (!("value" in change)) continue;
const v = (change as TextChangeKinds).value;
if (typeof v === "string") {
text.update(v);
} else {
// ignore
}
}
break;
}
case "Tree": {
const tree = container as LoroTree;
// Determine node schema for initializing new nodes
let nodeSchema: ContainerSchemaType | undefined;
const parentSchema = this.getContainerSchema(tree.id);
if (parentSchema && isLoroTreeSchema(parentSchema)) {
nodeSchema = parentSchema.nodeSchema as ContainerSchemaType;
}
for (const change of changes) {
if (change.kind === "tree-create") {
const newNode = tree.createNode(
change.parent,
change.index,
);
// Propagate the concrete TreeID back into the in-memory newState and
// fix up any pending child creates that depend on this parent's ID.
change.onCreate(newNode.id);
if (nodeSchema) {
this.registerContainer(newNode.data.id, nodeSchema);
this.initializeContainer(
newNode.data,
nodeSchema,
change.value,
);
// Stamp $cid into node.data in pending state
if (
isLoroMapSchema(nodeSchema) &&
isObject(change.value)
) {
change.value[CID_KEY] = newNode.data.id;
}
}
} else if (change.kind === "tree-move") {
tree.move(change.target, change.parent, change.index);
} else if (change.kind === "tree-delete") {
tree.delete(change.target);
} else {
// ignore non-tree changes for tree container
}
}
break;
}
default:
console.warn(`Unsupported container type: ${container.kind()}`);
}
}
/**
* Update a top-level container directly with a new value
*/
private updateTopLevelContainer(container: Container, value: unknown) {
const kind = container.kind();
switch (kind) {
case "Text":
this.updateTextContainer(container as LoroText, value);
break;
case "List":
this.updateListContainer(container as LoroList, value);
break;
case "Map":
this.updateMapContainer(container as LoroMap, value);
break;
case "MovableList":
this.updateListContainer(container as LoroMovableList, value);
break;
case "Tree":
this.updateTreeContainer(container as LoroTree, value);
break;
default:
throw new Error(
`Unknown container kind for top-level update: ${kind}.
This is likely a programming error or unsupported container type.`,
);
}
}
/**
* Update a Text container
*/
private updateTextContainer(text: LoroText, value: unknown) {
if (typeof value !== "string") {
throw new Error("Text value must be a string");
}
text.update(value);
}
/**
* Update a List container
*/
private updateListContainer(
list: LoroList | LoroMovableList,
value: unknown,
) {
// Replace entire list
if (Array.isArray(value)) {
// Find the schema for this container path
const schema = this.getContainerSchema(list.id);
if (
schema &&
!isLoroListSchema(schema) &&
!isLoroMovableListSchema(schema)
) {
throw new Error(
`Invalid schema for list: ${schema.type}. Expected LoroListSchema`,
);
}
// Get the idSelector function from the schema
const idSelector = schema?.idSelector;
const itemSchema = schema?.itemSchema;
// Clear out the list first to avoid duplicate items
// Instead of clearing the entire list, which can leave it empty if there's an error,
// we'll replace items one by one and only remove items that aren't in the new list
if (idSelector) {
// If we have an ID selector, we can use it for more intelligent updates
this.updateListWithIdSelector(
list,
value,
idSelector,
itemSchema!,
);
} else {
this.updateListByIndex(list, value, itemSchema);
}
} else {
throw new Error("List value must be an array");
}
}
/**
* Update a list using ID selector for efficient updates
*/
private updateListWithIdSelector(
list: LoroList | LoroMovableList,
newItems: unknown[],
idSelector: (item: unknown) => string | null,
itemSchema: SchemaType,
) {
// First, map current items by ID
const currentItemsById = new Map<
string,
{ item: unknown; index: number }
>();
const currentLength = list.length;
for (let i = 0; i < currentLength; i++) {
const item = list.get(i);
try {
if (item) {
const id = idSelector(item);
if (id) {
currentItemsById.set(id, { item, index: i });
}
}
} catch (e) {
if (this.options.debug) {
console.warn(`Error getting ID for current list item:`, e);
}
}
}
// Then map new items by ID
const newItemsById = new Map<
string,
{ item: unknown; index: number }
>();
// Helper function to get ID from either LoroMap or plain object
const getIdFromItem = (item: unknown) => {
if (!item) return null;
try {
// First try using the idSelector directly (for LoroMap objects)
const id = idSelector(item);
if (id) return id;
} catch (e) {
// If that fails, try to extract ID from plain object
if (this.options.debug) {
console.warn(`Error using ID selector directly:`, e);
}
// If idSelector tries to call .get("id"), we can try to access .id directly
const idProp = (item as { id?: unknown }).id;
if (typeof idProp === "string") {
return idProp;
}
}
return null;
};
newItems.forEach((item, index) => {
try {
const id = getIdFromItem(item);
if (id) {
newItemsById.set(id, { item, index });
} else {
throw new Error(`Item at index ${index} has no ID`);
}
} catch (e) {
if (this.options.debug) {
console.warn(
`Error getting ID for new list item at index ${index}:`,
e,
);
}
}
});
// Find items to remove (in current but not in new)
const itemsToRemove: number[] = [];
for (const [id, { index }] of currentItemsById.entries()) {
if (!newItemsById.has(id)) {
itemsToRemove.push(index);
}
}
// Sort in reverse order to remove higher indices first (to avoid index shifting issues)
itemsToRemove.sort((a, b) => b - a);
// Remove items that aren't in the new list
for (const index of itemsToRemove) {
list.delete(index, 1);
}
// Now go through the new list and add or update items
let currentIndex = 0;
for (let i = 0; i < newItems.length; i++) {
const newItem = newItems[i];
let id: string | null = null;
try {
id = getIdFromItem(newItem);
} catch (e) {
console.warn(`Error getting ID for new item at index ${i}:`, e);
continue;
}
if (!id) continue;
const currentEntry = currentItemsById.get(id);
if (currentEntry) {
// Item exists, update if needed
const currentItem = list.get(currentIndex);
if (!deepEqual(currentItem, newItem)) {
// Only update if different
list.delete(currentIndex, 1);
this.insertItemIntoList(
list,
currentIndex,
newItem,
itemSchema,
);
}
} else {
// New item, insert at current position
this.insertItemIntoList(
list,
currentIndex,
newItem,
itemSchema,
);
}
currentIndex++;
}
// Truncate any remaining items if the new list is shorter
if (currentIndex < list.length) {
list.delete(currentIndex, list.length - currentIndex);
}
}
/**
* Update a list by index (for lists without an ID selector)
*/
private updateListByIndex(
list: LoroList | LoroMovableList,
newItems: unknown[],
itemSchema: SchemaType | undefined,
) {
// First, clear the list
const oldLength = list.length;
// Instead of clearing everything and re-adding, update existing items and add/remove as needed
const maxLength = Math.max(oldLength, newItems.length);
for (let i = 0; i < maxLength; i++) {
if (i >= oldLength) {
// Add new item
this.insertItemIntoList(list, i, newItems[i], itemSchema);
} else if (i >= newItems.length) {
// Remove excess items, starting from the end
list.delete(newItems.length, oldLength - newItems.length);
break;
} else {
// Update existing item
const oldItem = list.get(i);
const newItem = newItems[i];
if (!deepEqual(oldItem, newItem)) {
list.delete(i, 1);
this.insertItemIntoList(list, i, newItem, itemSchema);
}
}
}
}
/**
* Helper to insert an item into a list, handling containers appropriately
*/
private insertItemIntoList(
list: LoroList | LoroMovableList,
index: number,
item: unknown,
itemSchema: SchemaType | undefined,
) {
// Determine if the item should be a container
let isContainer = false;
let containerSchema: ContainerSchemaType | undefined;
if (itemSchema && isContainerSchema(itemSchema)) {
isContainer = true;
containerSchema = itemSchema;
} else {
isContainer =
tryInferContainerType(item, this.options?.inferOptions) !==
undefined;
}
if (isContainer && typeof item === "object" && item !== null) {
this.insertContainerIntoList(list, containerSchema, index, item);
return;
}
// Default to simple insert
list.insert(index, item);
}
/**
* Subscribe to state changes
*/
subscribe(callback: SubscriberCallback<InferType<S>>): () => void {
this.subscribers.add(callback);
// Return unsubscribe function
return () => {
this.subscribers.delete(callback);
};
}
/**
* Notify all subscribers of state change
* @param direction The direction of the sync operation
* @param tags Optional tags associated with this update
*/
private notifySubscribers(direction: SyncDirection, tags?: string[]) {
const metadata: UpdateMetadata = {
direction,
tags,
};
for (const subscriber of this.subscribers) {
subscriber(this.state, metadata);
}
}
/**
* Clean up resources
*/
dispose() {
this.subscribers.clear();
this.subscriptions.forEach((x) => {
x();
});
this.subscriptions.length = 0;
}
/**
* Attaches a detached container to a map
*
* If the schema is provided, the container will be registered with the schema
*/
private insertContainerIntoMap(
map: LoroMap,
schema: ContainerSchemaType | undefined,
key: string,
value: unknown,
) {
const [detachedContainer, _containerType] =
this.createContainerFromSchema(schema, value);
const insertedContainer = map.setContainer(key, detachedContainer);
if (!insertedContainer) {
throw new Error("Failed to insert container into map");
}
this.registerContainer(insertedContainer.id, schema);
this.initializeContainer(insertedContainer, schema, value);
// Stamp $cid for child maps directly on the provided value (pending state)
if (schema && isLoroMapSchema(schema) && isObject(value)) {
value[CID_KEY] = insertedContainer.id;
}
return insertedContainer;
}
/**
* Once a container has been created, and attached to its parent
*
* We initialize the inner values using the schema that we previously registered.
*/
private initializeContainer(
container: Container,
schema: ContainerSchemaType | undefined,
value: unknown,
) {
const kind = container.kind();
if (kind === "Map") {
const map = container as LoroMap;
if (!isObject(value)) {
return;
}
for (const [key, val] of Object.entries(value)) {
// Skip injected CID field
if (key === CID_KEY) continue;
const fieldSchema = (
schema as