import { ContainerID, ContainerType, EphemeralStore, LoroDoc, TreeID } from "loro-crdt"; import type { InferContainerOptions } from "../schema/types.js"; export type { InferContainerOptions } from "../schema/types.js"; import { InferInputType, InferType, SchemaType } from "../schema/index.js"; /** * Values allowed for root-level keys of `initialState`. LoroDoc only stores * container types at the root, so primitives (bare `number`/`boolean`) are * disallowed. A root-level `string` is permitted because it maps to a root * `LoroText` container. */ export type RootInitialValue = string | ReadonlyArray | { readonly [key: string]: unknown; } | null | undefined; /** * Source channel that caused this state update. */ export declare enum UpdateSource { /** * Changes coming from Loro to application state. */ LORO = "LORO", /** * Local state changes initiated through Mirror APIs. */ MIRROR = "MIRROR", /** * State recomposed due to an EphemeralStore change. */ EPHEMERAL = "EPHEMERAL" } /** * Configuration options for the Mirror */ export interface MirrorOptions { /** * The Loro document to sync with */ doc: LoroDoc; /** * The schema definition for the state */ schema?: S; /** * Initial state (optional). * * LoroDoc can only hold container values at the root (Map, List, * MovableList, Text, Tree), so root entries must be container-shaped: * objects, arrays, strings, or `null`/`undefined`. Bare numbers and * booleans are rejected at the type level (and at runtime). */ initialState?: Partial> & { [key: string]: RootInitialValue; }; /** * Whether to validate state updates against the schema * @default true */ validateUpdates?: 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; /** * Optional `EphemeralStore` (from `loro-crdt`) for syncing temporary state * without polluting LoroDoc editing history. * * **Setting this changes how {@link Mirror.setState} works:** when * present, `setState` automatically classifies each change and routes * eligible ones (primitive values on existing Map keys) to the * EphemeralStore instead of LoroDoc. Structural changes (new keys, * containers, list/text/tree ops) still go to LoroDoc as usual. * * State returned by {@link Mirror.getState} is always the composed * result: `LoroDoc state + EphemeralStore overlay`. Callers don't need * to be aware of where each value lives. * * Ephemeral values are committed to LoroDoc when the * {@link SetStateOptions.finalizeTimeout | finalizeTimeout} expires * (default 50 000 ms) or when {@link Mirror.finalizeEphemeralPatches} * is called manually (e.g. on `mouseup`). * * Network sync of the EphemeralStore is the caller's responsibility: * ```ts * eph.subscribeLocalUpdates((bytes) => channel.send(bytes)); * channel.on("ephemeral", (bytes) => eph.apply(bytes)); * ``` * * @see {@link Mirror.setState} for routing rules and examples * @see {@link Mirror.finalizeEphemeralPatches} for committing ephemeral values */ ephemeralStore?: EphemeralStore; } 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; 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; /** * Optional origin metadata forwarded to the underlying Loro commit. * Useful when callers need to tag commits with application-specific provenance. */ origin?: string; /** * Optional timestamp forwarded to the underlying Loro commit metadata. */ timestamp?: number; /** * Optional message forwarded to the underlying Loro commit metadata. */ message?: string; /** * Debounce delay (in ms) before ephemeral values are automatically * committed to LoroDoc. The timer resets on every `setState` call that * produces ephemeral changes, so it only fires after the user stops * updating. * * Has no effect when no `ephemeralStore` is configured. * * Typical values: * - `1_000` (1 s) — drag/resize interactions where you want quick persistence * - `50_000` (50 s, default) — long-lived ephemeral state (e.g. cursor position) * * To commit immediately instead of waiting, call * {@link Mirror.finalizeEphemeralPatches}. * * @default 50_000 */ finalizeTimeout?: number; } /** * Additional metadata for state updates */ export interface UpdateMetadata { /** * Source channel that caused this update. */ source: UpdateSource; /** * Tags associated with this update, if any */ tags?: string[]; } /** * Callback type for subscribers */ export type SubscriberCallback = (state: T, metadata: UpdateMetadata) => void; /** * Mirror class that provides bidirectional sync between application state and Loro */ export declare class Mirror { private doc; private schema?; private state; /** Pure LoroDoc state without ephemeral overlay */ private baseState; private subscribers; private syncing; private options; private containerRegistry; private inferOptionsByContainerId; private subscriptions; private rootPathById; private ephemeralManager?; private suppressLocalEphemeralEvents; /** * Creates a new Mirror instance */ constructor(options: MirrorOptions); /** * 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; private inferRootContainerTypeFromInitialValue; /** * Initialize containers based on schema */ private initializeContainers; private registerContainerHandle; /** * Register nested containers within a container */ private registerNestedContainers; /** * Handle events from the LoroDoc */ private handleLoroEvent; private normalizeLoroEventBatch; private applyNormalizedLoroEventToState; private captureLocalDocEvent; private stampCidForEventTargets; /** * 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; /** * Update Loro based on state changes */ private updateLoro; /** * Apply a set of changes to the Loro document */ private applyChangesToLoro; /** * Update root-level fields */ private applyRootChanges; /** * Apply multiple changes to a container */ private applyContainerChanges; /** * Update a top-level container directly with a new value */ private updateTopLevelContainer; /** * Update a Text container */ private updateTextContainer; /** * Update a List container */ private updateListContainer; /** * Update a list using ID selector for efficient updates */ private updateListWithIdSelector; /** * Update a list by index (for lists without an ID selector) */ private updateListByIndex; /** * Helper to insert an item into a list, handling containers appropriately */ private insertItemIntoList; /** * Subscribe to state changes */ subscribe(callback: SubscriberCallback>): () => void; /** * Notify all subscribers of state change * @param source The source channel for the sync operation * @param tags Optional tags associated with this update */ private notifySubscribers; /** Path resolver context for EphemeralPatchManager */ private get ephemeralCtx(); /** * Compose state by overlaying ephemeral patches on top of base state. * If no ephemeral manager or no patches, returns base unchanged. */ private composeState; private applyEphemeralDeltas; private withSuppressedLocalEphemeralEvents; /** * Handle events from the EphemeralStore (both local and remote) */ private handleEphemeralEvent; /** * Immediately commit all pending ephemeral patches to LoroDoc. * * Call this when the temporary operation ends (e.g. on `mouseup` after a drag) * to flush ephemeral values into permanent LoroDoc history without waiting for * the debounced timeout. * * Only values that still match what this peer last wrote to the EphemeralStore * are committed. Values overwritten by a remote peer are skipped. * * After finalization, the EphemeralStore patches are cleaned up and the * debounce timer is cancelled. * * No-op if there are no pending local ephemeral patches. */ finalizeEphemeralPatches(): void; /** * Clean up resources */ dispose(): void; /** * Attaches a detached container to a map * * If the schema is provided, the container will be registered with the schema */ private insertContainerIntoMap; private insertMergeableContainerIntoMap; /** * 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; /** * Resolve the container type for an insert without allocating a detached container. */ private getContainerTypeForInsert; /** * Create a new detached container based on a given schema. * * If the schema is undefined, we infer the container type from the value. */ private createContainerFromSchema; /** * Attaches a detached container to a list * * If the schema is provided, the container will be registered with the schema */ private insertContainerIntoList; /** * Update a Tree container using existing tree diff to generate precise create/move/delete * and nested node.data changes, then apply via container change appliers. */ private updateTreeContainer; /** * Update a Map container */ private updateMapContainer; /** * Helper to update a single entry in a map */ private updateMapEntry; /** * Get current state */ getState(): InferType; private applyLocalLoroChanges; /** * @internal * Advanced ephemeral hot path. Prefer `setState` for the public API surface. */ private patchEphemeral; /** * Update state and propagate changes. * * `updater` may be: * - An **object** — shallow-merged into the current state. * - A **function** — receives a mutable Immer draft (mutate in place) * or returns a new immutable state object. * * --- * * ### Behavior depends on `ephemeralStore` configuration * * **Without `ephemeralStore`** (default) — all changes are written to * LoroDoc synchronously. This is the standard path for persistent edits. * * **With `ephemeralStore`** — Mirror diffs the new state against the * current composed state (`LoroDoc + EphemeralStore overlay`) and * classifies each change automatically: * * | Condition | Destination | * |---|---| * | Primitive value (`string`, `number`, `boolean`, `null`) on an **existing key** of an **existing LoroMap** | → `EphemeralStore` (no LoroDoc write) | * | Everything else (new keys, new containers, list/text/tree ops) | → `LoroDoc` directly | * * This means a single `setState` call can write some changes to * EphemeralStore and others to LoroDoc in the same invocation. * * Ephemeral values are committed to LoroDoc when: * 1. The {@link SetStateOptions.finalizeTimeout | finalizeTimeout} * expires (default 50 000 ms), or * 2. You call {@link finalizeEphemeralPatches} manually (e.g. on `mouseup`). * * The debounce timer resets on each `setState` call that produces * ephemeral changes, so it only fires after the user stops updating. * * @example * ```ts * // Without ephemeralStore — writes directly to LoroDoc * mirror.setState((s) => { s.count = 1; }); * * // With ephemeralStore — x/y go to EphemeralStore, push goes to LoroDoc * mirror.setState( * (s) => { * s.items[0].x = e.clientX; // → EphemeralStore (primitive on existing key) * s.items[0].y = e.clientY; // → EphemeralStore * s.items.push({ x: 0, y: 0, name: "new" }); // → LoroDoc (structural change) * }, * { finalizeTimeout: 1_000 }, * ); * ``` */ setState(updater: (state: Readonly>) => InferInputType, options?: SetStateOptions): void; setState(updater: (state: InferType) => void, options?: SetStateOptions): void; setState(updater: Partial>, options?: SetStateOptions): void; checkStateConsistency(): void; private containerToMirrorState; /** * Build a state snapshot from LoroDoc. * When `prevState` is provided, Ignore fields are preserved from it * (they only exist in memory and are not backed by LoroDoc). */ private rebuildBaseState; /** * Build a fresh state snapshot from the LoroDoc. * * When `prevState` is provided, Ignore fields are preserved from it * (they only exist in memory and are not backed by LoroDoc). */ private buildRootStateSnapshot; /** * Register a container schema */ private registerContainerWithRegistry; private stampCid; private getInferOptionsForContainer; private getInferOptionsForChild; private getInferOptionsForMapChild; private getContainerSchema; private getSchemaForChildContainer; private getSchemaForChild; getContainerIds(): ContainerID[]; } export declare function toNormalizedJson(doc: LoroDoc): unknown; //# sourceMappingURL=mirror.d.ts.map