npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

loro-mirror

v2.2.0

Published

Type-safe state management synchronized with Loro CRDT via a declarative schema and bidirectional mirroring.

Readme

Loro Mirror

Quick Start

Define a schema and instantiate a Mirror with a LoroDoc.

import { Mirror, schema } from "loro-mirror";
import { LoroDoc } from "loro-crdt";

const doc = new LoroDoc();

const appSchema = schema({
    // LoroMap: structured object
    settings: schema.LoroMap({
        title: schema.String({ defaultValue: "Docs" }),
        darkMode: schema.Boolean({ defaultValue: false }),
    }),
    // LoroList: array of items (use `$cid` from maps)
    todos: schema.LoroList(
        schema.LoroMap({
            text: schema.String(),
        }),
        (t) => t.$cid, // `$cid` reuses Loro container id (explained later)
    ),
    // LoroText: collaborative text (string in state)
    notes: schema.LoroText(),
});

const store = new Mirror({ doc, schema: appSchema });

// Read state
const state = store.getState();

// Update (return a new state) — synchronous; the next line sees the new state.
store.setState({
    ...state,
    settings: { ...state.settings, darkMode: true },
    todos: [...state.todos, { text: "Add milk" }],
    notes: "Hello, team!",
});

// Or mutate a draft (Immer-style)
store.setState((s) => {
    s.todos.push({ text: "Ship" });
    s.settings.title = "Project";
});

// Subscribe
const unsubscribe = store.subscribe((next, { source }) => {
    // source: "LORO" | "MIRROR" | "EPHEMERAL"
});

Installation

npm install loro-mirror loro-crdt

API Reference

Core structures first:

  • LoroMap: Structured object synced to a Loro map. Update fields by setting plain JS values. Nested containers (maps/lists/text) are created automatically from schema.
  • LoroList: Array of items. With an idSelector, Mirror performs minimal add/update/move/delete; without it, updates are by index.
  • LoroText: Collaborative rich text represented as a string in state; Mirror calls LoroText.update on changes.

Trees are advanced usage; see Advanced: Trees at the end.

Mirror

  • Constructor: new Mirror({ doc, schema?, initialState?, validateUpdates?=true, debug?=false, checkStateConsistency?=false, inferOptions? })
    • doc: LoroDoc to sync with
    • schema: Root schema; enables validation and typed defaults
    • initialState: Shallow-merged over schema defaults and current doc JSON
    • validateUpdates: Validate on setState
    • debug: Verbose logging
    • checkStateConsistency: Extra runtime check that the doc-backed base state still matches toNormalizedJson(doc) after non-ephemeral setState updates
    • inferOptions: { defaultLoroText?: boolean; defaultMovableList?: boolean; mergeableMapChildContainers?: boolean } for container inference and Map child-container creation
  • Methods:
    • getState(): Current state
    • setState(updater | partial, options?): Mutate a draft or return a new object. Runs synchronously so downstream logic can immediately read the latest state. When ephemeralStore is configured, eligible changes are automatically routed through EphemeralStore. See Ephemeral Patches below.
      • options: { tags?: string | string[]; origin?: string; timestamp?: number; message?: string; finalizeTimeout?: number } — tags surface in subscriber metadata; commit metadata is forwarded to the underlying Loro commit; finalizeTimeout controls the debounce delay before ephemeral values auto-commit.
    • finalizeEphemeralPatches(): Immediately commit pending ephemeral patches to LoroDoc (e.g. on mouseup).
    • subscribe((state, metadata) => void): Subscribe; returns unsubscribe
      • metadata: { source: LORO | MIRROR | EPHEMERAL; tags?: string[] }
    • dispose(): Remove all subscriptions

Types: UpdateSource, UpdateMetadata, SetStateOptions.

Schema Builder

  • Root: schema({ ...fields })
  • Primitives: schema.String, schema.Number, schema.Boolean, schema.Any, schema.Ignore
  • Containers (core):
    • schema.LoroMap({ ...fields })
    • schema.LoroList(itemSchema, idSelector?)
    • schema.LoroText()
  • Containers (additional):
    • schema.LoroMovableList(itemSchema, idSelector) — emits move ops on reorder
    • 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

Signatures:

  • schema.LoroMap(definition, options?) — mirrored state always includes a read-only $cid field equal to the underlying Loro container id (applies to root/nested maps, list items, and tree node data maps).
  • schema.LoroList(itemSchema, idSelector?: (item) => string, options?)
  • schema.LoroMovableList(itemSchema, idSelector: (item) => string, options?)
  • schema.LoroText(options?)
  • schema.LoroTree(nodeMapSchema, options?)

SchemaOptions for any field: { required?: boolean; defaultValue?: unknown; description?: string; validate?: (value) => boolean | string }. Map-only option: { mergeableMapChildContainers?: boolean } on schema.LoroMap or schema.LoroMapRecord.

Any options:

  • schema.Any({ defaultLoroText?: boolean; defaultMovableList?: boolean })
    • defaultLoroText defaults to false for Any when omitted (primitive string), overriding the global inferOptions.defaultLoroText.
    • defaultMovableList inherits from the global inference options unless specified.

Mergeable Map child containers:

By default, child containers are created with LoroMap.setContainer. If two peers concurrently create a child container under the same Map key (e.g. both first-write records.note), each peer's child gets a distinct container ID. Because the Map slot is last-writer-wins, one peer's child — and everything written into it — is silently dropped after sync.

Mergeable containers fix this: the child's identity is derived from its logical position (parent container id + key + type) instead of the creating operation, so every peer's ensureMergeable* call resolves to the same CRDT object and their content merges. See https://loro.dev/blog/mergeable-containers for the full model.

Enable it on the parent Map when multiple peers may create the same child for the first time concurrently:

const appSchema = schema({
    records: schema.LoroMapRecord(
        schema.LoroMap({ entries: schema.LoroList(schema.String()) }),
        { mergeableMapChildContainers: true }, // ← opt in on the parent map
    ),
});

// docA.setState({ records: { note: { entries: ["A"] } } }) and
// docB.setState({ records: { note: { entries: ["B"] } } }) now converge to one
// `note` map whose `entries` contains both "A" and "B" after sync — instead of
// one peer's `note` clobbering the other's.
  • Prefer configuring the parent Map: schema.LoroMap(..., { mergeableMapChildContainers: true }) or schema.LoroMapRecord(..., { mergeableMapChildContainers: true }).
  • inferOptions.mergeableMapChildContainers remains the schema-less/global fallback and defaults to false for backward-compatible document format and LoroMap.setContainer semantics.
  • When enabled for a parent Map, new direct child containers under that LoroMap's keys are created with Loro's ensureMergeableMap/List/MovableList/Text/Tree APIs, so concurrent first creation at the same parent/key/type shares one logical child container.
  • Existing same-type child containers are reused as-is, including regular setContainer children from old data.
  • All collaborating clients must use loro-crdt >= 1.13.3 to interpret mergeable child markers. Older clients preserve the data as ordinary binary values but do not expose the mergeable child semantics.
  • Concurrent first writes merge CRDT content rather than acting like LWW replacement. If two peers insert the same text or list item while offline, both writes can be visible after sync.
  • Avoid very deep chains of mergeable child maps because mergeable container IDs encode the logical path and grow with depth.
  • List items and tree nodes keep the existing insertContainer/node-data behavior because they are not identified by a stable Map key.

Reserved key $cid:

  • $cid is injected into mirrored state for all LoroMap schemas; it is never written back to Loro and is ignored by diffs/updates. It’s useful as a stable identifier (e.g., schema.LoroList(map, x => x.$cid)).

Validators & Helpers

  • 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)

Ephemeral Patches

When users drag or scale canvas elements, syncing every intermediate position through LoroDoc creates redundant editing history. Ephemeral patches solve this: pass an EphemeralStore and setState automatically routes temporary changes through it for real-time sync, then commits once to LoroDoc when the operation ends.

Key point: Adding ephemeralStore to MirrorOptions changes how setState behaves. You don't need a separate API — the same setState call is used for both persistent and ephemeral updates.

How setState changes with ephemeralStore

Without ephemeralStore (default):

setState(updater)  →  all changes  →  LoroDoc

With ephemeralStore:

setState(updater)  →  classify each change:
    primitive on existing Map key  →  EphemeralStore (temporary)
    everything else                →  LoroDoc (permanent)

A single setState call can write to both destinations in one invocation. getState() always returns the composed result (LoroDoc + EphemeralStore overlay), so callers don't need to know where each value lives.

Routing rules

| Change type | Destination | Example | | --------------------------------------------------------------------------------------------------------- | ---------------- | --------------------- | | Primitive value (string, number, boolean, null) on an existing key of an existing LoroMap | EphemeralStore | s.items[0].x = 100 | | New Map / new key / container value | LoroDoc | s.items.push({...}) | | List / Text / Tree operations | LoroDoc | s.items.splice(...) |

The rule is intentionally conservative: only simple value-swaps on known keys go to EphemeralStore. Anything structural always goes to LoroDoc.

Setup

import { LoroDoc, EphemeralStore } from "loro-crdt";
import { Mirror, schema } from "loro-mirror";

const doc = new LoroDoc();
const eph = new EphemeralStore();

const mirror = new Mirror({
    doc,
    schema: mySchema,
    ephemeralStore: eph, // ← this changes setState behavior
});

// Network sync for ephemeral state (your responsibility)
eph.subscribeLocalUpdates((bytes) => channel.send(bytes));
channel.on("ephemeral", (bytes) => eph.apply(bytes));

Usage

// During drag — called on every mousemove (~60fps)
// x/y are primitives on existing keys → routed to EphemeralStore.
// LoroDoc stays clean — no editing history for intermediate positions.
mirror.setState(
    (s) => {
        s.items[i].x = e.clientX;
        s.items[i].y = e.clientY;
    },
    { finalizeTimeout: 1_000 }, // auto-commit after 1s of inactivity
);

// Mixed: push goes to LoroDoc, x/y go to EphemeralStore (same call)
mirror.setState((s) => {
    s.items.push({ x: 0, y: 0, name: "new" }); // → LoroDoc
    s.items[0].x = 999; // → EphemeralStore
});

// On mouseup — commit ephemeral values to LoroDoc immediately
mirror.finalizeEphemeralPatches();

Finalization

Ephemeral values are committed to LoroDoc when:

  1. The debounced finalizeTimeout expires (default: 50 000 ms). The timer resets on each setState call that produces ephemeral changes, so it only fires after the user stops updating.
  2. You call finalizeEphemeralPatches() manually (e.g. on mouseup).

On finalize, only values that still match what this peer last wrote are committed. If a remote peer overwrote a value in the EphemeralStore, it is skipped to prevent stale writes.

Cross-peer compatibility

Because LoroDoc and EphemeralStore are independent, ephemeralStore is fully optional:

  • Peers without ephemeralStore sync normally via LoroDoc. They see final values after finalizeEphemeralPatches(), but not intermediate ephemeral changes.
  • Peers that share an EphemeralStore channel see real-time intermediate updates as well.

Subscriber source

Subscribers receive source: "EPHEMERAL" when state changes due to an EphemeralStore update. Use this to distinguish ephemeral changes from permanent LoroDoc changes:

mirror.subscribe((state, { source }) => {
    if (source === "EPHEMERAL") {
        // Lightweight update — no LoroDoc history created
    }
});

Advanced: Trees

Trees are for hierarchical data where each node has a data map. The state shape is { id?: string; data: {...}; children: Node[] }.

const node = schema.LoroMap({ name: schema.String({ required: true }) });
const s = schema({ tree: schema.LoroTree(node) });
const mirror = new Mirror({ doc: new LoroDoc(), schema: s });

mirror.setState((st) => {
    st.tree.push({ data: { name: "root" }, children: [] });
});

Note: If you omit id when creating a node, Loro assigns one; Mirror writes it back on the next state after sync.

Tiny React Example

Prefer the React helpers in loro-mirror-react for a clean setup.

import { useLoroStore } from "loro-mirror-react";
import { schema } from "loro-mirror";
import { LoroDoc } from "loro-crdt";

const todosSchema = schema({
    todos: schema.LoroList(
        schema.LoroMap({ text: schema.String() }),
        (t) => t.$cid, // list selector uses `$cid` (Loro container id)
    ),
});

export function App() {
    const { state, setState } = useLoroStore({
        doc: new LoroDoc(),
        schema: todosSchema,
    });
    return (
        <div>
            <button
                onClick={() =>
                    setState((s) => {
                        s.todos.push({ text: "New" });
                    })
                }
            >
                Add
            </button>
            <ul>
                {state.todos.map((t) => (
                    <li key={t.$cid /* stable key from Loro container id */}>
                        {t.text}
                    </li>
                ))}
            </ul>
        </div>
    );
}

For more React patterns (selectors, actions, provider), see packages/react/README.md or the useLoroContext helpers.

Notes & Tips

  • Use an idSelector whenever list items have stable IDs to get efficient moves instead of delete+insert.
  • setState accepts an updater that either mutates a draft or returns a new object — use whichever style you prefer.
  • Subscriptions receive { source: LORO | MIRROR | EPHEMERAL, tags?: string[] }.

License

MIT