A schema-driven, type-safe wrapper for Loro CRDT that provides natural JavaScript syntax for collaborative data mutations
npm install @loro-extended/changeA schema-driven, type-safe wrapper for Loro CRDT that provides natural JavaScript syntax for collaborative document editing. Build local-first applications with intuitive APIs while maintaining full CRDT capabilities.
Loro is a high-performance CRDT (Conflict-free Replicated Data Type) library that enables real-time collaborative editing without conflicts. It's perfect for building local-first applications like collaborative editors, task managers, and (turn-based) multiplayer games.
change?Working with Loro directly involves somewhat verbose container operations and complex type management. The change package provides:
- Schema-First Design: Define your document structure with type-safe schemas
- Natural Syntax: Write doc.title.insert(0, "Hello") instead of verbose CRDT operations
- Placeholders: Seamlessly blend default values with CRDT state
- Full Type Safety: Complete TypeScript support with compile-time validation
- Transactional Changes: All mutations within a change() block are atomic
- Loro Compatible: Works seamlessly with existing Loro code (loro(doc).doc is a familiar LoroDoc)
``bash`
npm install @loro-extended/change loro-crdtor
pnpm add @loro-extended/change loro-crdt
`typescript
import { createTypedDoc, Shape, change } from "@loro-extended/change";
// Define your document schema
const schema = Shape.doc({
title: Shape.text().placeholder("My Todo List"),
count: Shape.counter(),
users: Shape.record(
Shape.plain.struct({
name: Shape.plain.string(),
}),
),
});
// Create a typed document
const doc = createTypedDoc(schema);
// Direct mutations - commit immediately (auto-commit mode)
doc.title.insert(0, "📝 Todo");
doc.count.increment(5);
doc.users.set("alice", { name: "Alice" });
// Check existence
if (doc.users.has("alice")) {
console.log("Alice exists!");
}
if ("alice" in doc.users) {
console.log("Also works with 'in' operator!");
}
// Batched mutations - commit together (optional, for performance)
// Using functional helper (recommended)
doc.change((draft) => {
draft.title.insert(0, "Change: ");
draft.count.increment(10);
draft.users.set("bob", { name: "Bob" });
});
// All changes commit as one transaction
// Get JSON snapshot using functional helper
console.log(doc.toJSON());
// { title: "Change: 📝 Todo", count: 15, users: { alice: { name: "Alice" }, bob: { name: "Bob" } } }
`
Note that this is even more useful in combination with @loro-extended/react (if your app uses React) and @loro-extended/repo for syncing between client/server or among peers.
Define your document structure using Shape builders:
`typescript
import { Shape } from "@loro-extended/change";
const blogSchema = Shape.doc({
// CRDT containers for collaborative editing
title: Shape.text(), // Collaborative text
viewCount: Shape.counter(), // Collaborative increment/decrement counter
// Lists for ordered data
tags: Shape.list(Shape.plain.string()), // List of strings
// Structs for structured data with fixed keys
metadata: Shape.struct({
author: Shape.plain.string(), // Plain values (POJOs)
publishedAt: Shape.plain.string(), // ISO date string
featured: Shape.plain.boolean(),
}),
// Movable lists for reorderable content
sections: Shape.movableList(
Shape.struct({
heading: Shape.text(), // Collaborative headings
content: Shape.text(), // Collaborative content
order: Shape.plain.number(), // Plain metadata
}),
),
});
`
NOTE: Use Shape. for collaborative containers and Shape.plain. for plain values. Only put plain values inside Loro containers - a Loro container inside a plain JS struct or array won't work.
Placeholders provide default values that are merged when CRDT containers are empty, ensuring the entire document remains type-safe even before any data has been written.
#### Why placeholders matter in distributed systems:
In traditional client-server architectures, you typically have a single source of truth that initializes default values. But in CRDTs, multiple peers can start working independently without coordination. This creates a challenge: who initializes the defaults?
Placeholders solve this elegantly:
- No initialization race conditions - Every peer sees the same defaults without needing to coordinate who writes them first
- Zero-cost defaults - Placeholders aren't stored in the CRDT; they're computed on read. This means no wasted storage or sync bandwidth for default values
- Conflict-free - Since placeholders aren't written to the CRDT, there's no possibility of conflicts between peers trying to initialize the same field
- Lazy materialization - Defaults only become "real" CRDT data when a peer explicitly modifies them
`typescript
// Use .placeholder() to set default values
const blogSchemaWithDefaults = Shape.doc({
title: Shape.text().placeholder("Untitled Document"),
viewCount: Shape.counter(), // defaults to 0
tags: Shape.list(Shape.plain.string()), // defaults to []
metadata: Shape.struct({
author: Shape.plain.string().placeholder("Anonymous"),
publishedAt: Shape.plain.string(), // defaults to ""
featured: Shape.plain.boolean(), // defaults to false
}),
sections: Shape.movableList(
Shape.struct({
heading: Shape.text(),
content: Shape.text(),
order: Shape.plain.number(),
}),
),
});
const doc = createTypedDoc(blogSchemaWithDefaults);
// Initially returns empty state
console.log(doc.toJSON());
// { title: "Untitled Document", viewCount: 0, ... }
// After changes, CRDT values take priority over empty state
doc.change((draft) => {
draft.title.insert(0, "My Blog Post");
draft.viewCount.increment(10);
});
console.log(doc.toJSON());
// { title: "My Blog Post", viewCount: 10, tags: [], ... }
// ↑ CRDT value ↑ CRDT value ↑ empty state preserved
`
You can access and write schema properties directly on a TypedDoc. Mutations commit immediately by default:
`typescript`
// Direct mutations - each commits immediately
doc.title.insert(0, "📝");
doc.viewCount.increment(1);
doc.tags.push("typescript");
For batched operations (better performance, atomic undo), use change():
`typescript
doc.change((draft) => {
// Text operations
draft.title.insert(0, "📝");
draft.title.delete(5, 3);
// Counter operations
draft.viewCount.increment(1);
draft.viewCount.decrement(2);
// List operations
draft.tags.push("typescript");
draft.tags.insert(0, "loro");
draft.tags.delete(1, 1);
// Struct operations (property assignment)
draft.metadata.author = "John Doe";
delete draft.metadata.featured;
// Movable list operations
draft.sections.push({
heading: "Introduction",
content: "Welcome to my blog...",
order: 1,
});
draft.sections.move(0, 1); // Reorder sections
});
// All changes are committed atomically as one transaction
console.log(doc.toJSON()); // Updated document state
`
| Use Case | Approach |
| --------------------------------- | ------------------------------------ |
| Single mutation | Direct: doc.count.increment(1) |change(doc, d => { ... })
| Multiple related mutations | Batched: |change(doc, d => { ... })
| Atomic undo/redo | Batched: |change(doc, d => { ... })
| Performance-critical bulk updates | Batched: |doc.users.set(...)
| Simple reads + writes | Direct: |change(ref, d => {...})
| Encapsulated ref operations | Ref-level: |
The change() function also works on individual refs (ListRef, TextRef, TreeRef, etc.), enabling better encapsulation when you want to pass refs around without exposing the entire document:
`typescript
import { change } from "@loro-extended/change";
// Library code - expose only the ref, not the doc
class StateMachine {
private doc: TypedDoc<...>;
get states(): TreeRef
return this.doc.states;
}
}
// User code - works with just the ref
function addStates(states: TreeRef
change(states, draft => {
const idle = draft.createNode();
idle.data.name.insert(0, "idle");
const running = draft.createNode();
running.data.name.insert(0, "running");
});
}
// Usage
const machine = new StateMachine();
addStates(machine.states); // No access to the underlying doc needed!
`
This pattern is useful for:
- Library APIs: Expose typed refs without leaking document structure
- Component isolation: Pass refs to components that only need partial access
- Testing: Mock or stub individual refs without full document setup
All ref types support change():
`typescript
// ListRef
change(doc.items, (draft) => {
draft.push("item1");
draft.push("item2");
});
// TextRef
change(doc.title, (draft) => {
draft.insert(0, "Hello ");
draft.insert(6, "World");
});
// CounterRef
change(doc.count, (draft) => {
draft.increment(5);
draft.decrement(2);
});
// StructRef
change(doc.profile, (draft) => {
draft.bio.insert(0, "Hello");
draft.age.increment(1);
});
// RecordRef
change(doc.users, (draft) => {
draft.set("alice", { name: "Alice" });
draft.set("bob", { name: "Bob" });
});
// TreeRef
change(doc.tree, (draft) => {
const node = draft.createNode();
node.data.name.insert(0, "root");
});
`
Nested change() calls are safe - Loro's commit is idempotent:
`typescript
change(doc.items, (outer) => {
outer.push("from outer");
// Nested change on a different ref - works correctly
change(doc.count, (inner) => {
inner.increment(10);
});
outer.push("still in outer");
});
// All mutations are committed
`
For type-safe tagged unions (like different message types or presence states), use Shape.plain.discriminatedUnion():
`typescript
import { Shape } from "@loro-extended/change";
// Define variant shapes - each must have the discriminant key
const ClientPresenceShape = Shape.plain.struct({
type: Shape.plain.string("client"), // Literal type for discrimination
name: Shape.plain.string().placeholder("Anonymous"),
input: Shape.plain.struct({
force: Shape.plain.number(),
angle: Shape.plain.number(),
}),
});
const ServerPresenceShape = Shape.plain.struct({
type: Shape.plain.string("server"), // Literal type for discrimination
cars: Shape.plain.record(
Shape.plain.struct({
x: Shape.plain.number(),
y: Shape.plain.number(),
}),
),
tick: Shape.plain.number(),
});
// Create the discriminated union
const GamePresenceSchema = Shape.plain.discriminatedUnion("type", {
client: ClientPresenceShape,
server: ServerPresenceShape,
});
// Type-safe handling based on discriminant
function handlePresence(presence: Infer
if (presence.type === "server") {
// TypeScript knows this is ServerPresence
console.log(presence.cars, presence.tick);
} else {
// TypeScript knows this is ClientPresence
console.log(presence.name, presence.input);
}
}
`
Key features:
- The discriminant (e.g., "type") determines which variant shape to use.placeholder()
- Use on fields to provide defaults (placeholders are applied automatically)@loro-extended/repo
- Works seamlessly with 's presence system
- Full TypeScript support for discriminated union types
When integrating with external libraries that manage their own document structure (like loro-prosemirror), you may want typed presence but untyped document content. Use Shape.any() as an escape hatch:
`typescript
import { Shape } from "@loro-extended/change";
// Fully typed presence with binary cursor data
const CursorPresenceSchema = Shape.plain.struct({
anchor: Shape.plain.bytes().nullable(), // Uint8Array | null
focus: Shape.plain.bytes().nullable(),
user: Shape.plain
.struct({
name: Shape.plain.string(),
color: Shape.plain.string(),
})
.nullable(),
});
// With @loro-extended/repo:
// Shape.any() in a container - one container is untyped
const ProseMirrorDocShape = Shape.doc({
doc: Shape.any(), // loro-prosemirror manages this
metadata: Shape.struct({
// But we can still have typed containers
title: Shape.text(),
}),
});
const handle2 = repo.get(docId, ProseMirrorDocShape, {
presence: CursorPresenceSchema,
});
handle2.doc.toJSON(); // { doc: unknown, metadata: { title: string } }
`
Key features:
- Shape.any() creates an AnyContainerShape - type inference produces unknownShape.plain.any()
- creates an AnyValueShape - type inference produces Loro's Value typeShape.plain.bytes()
- is an alias for Shape.plain.uint8Array() for better discoverability.nullable()
- All support for optional values
When to use:
| Scenario | Shape to Use |
| ---------------------------------------- | ------------------------------------------------------------ |
| External library manages entire document | repo.get(docId, Shape.any(), { presence: presenceSchema }) |Shape.doc({ doc: Shape.any(), ... })
| External library manages one container | |Shape.plain.any()
| Flexible metadata in presence | for dynamic values |Shape.plain.bytes().nullable()
| Binary cursor/selection data | for Uint8Array \| null |Shape.struct()
| Full type safety | Use specific shapes like , Shape.text() |
Handle complex nested documents with ease:
`typescript
const complexSchema = Shape.doc({
article: Shape.struct({
title: Shape.text(),
metadata: Shape.struct({
views: Shape.counter(),
author: Shape.struct({
name: Shape.plain.string().placeholder("Anonymous),
email: Shape.plain.string(),
}),
}),
}),
});
const doc = createTypedDoc(complexSchema);
doc.change((draft) => {
draft.article.title.insert(0, "Deep Nesting Example");
draft.article.metadata.views.increment(5);
draft.article.metadata.author.name = "Alice"; // plain string update is captured and applied after closure
draft.article.metadata.author.email = "alice@example.com"; // same here
});
`
For struct containers (fixed-key objects), use direct property access:
`typescript
const schema = Shape.doc({
settings: Shape.struct({
theme: Shape.plain.string(),
collapsed: Shape.plain.boolean(),
width: Shape.plain.number(),
}),
});
change(doc, (draft) => {
// Set individual values
draft.settings.theme = "dark";
draft.settings.collapsed = true;
draft.settings.width = 250;
});
`
Create lists containing CRDT containers for collaborative nested structures:
`typescript
const collaborativeSchema = Shape.doc({
articles: Shape.list(
Shape.struct({
title: Shape.text(), // Collaborative title
content: Shape.text(), // Collaborative content
tags: Shape.list(Shape.plain.string()), // Collaborative tag list
metadata: Shape.plain.struct({
// Static metadata
authorId: Shape.plain.string(),
publishedAt: Shape.plain.string(),
}),
})
),
});
doc.change((draft) => {
// Push creates and configures nested containers automatically
draft.articles.push({
title: "Collaborative Article",
content: "This content can be edited by multiple users...",
tags: ["collaboration", "crdt"],
metadata: {
authorId: "user123",
publishedAt: new Date().toISOString(),
},
});
// Later, edit the collaborative parts
draft.articles.[0]?.title.insert(0, "✨ ");
draft.articles.[0]?.tags.push("real-time");
});
`
The @loro-extended/change package exports a type-safe path selector DSL for building (a subset of) JSONPath expressions with full TypeScript type inference. This is primarily used by handle.subscribe() in @loro-extended/repo for efficient, type-safe subscriptions:
`typescript
// In @loro-extended/repo, use with Handle.subscribe():
handle.subscribe(
(p) => p.books.$each.title, // Type-safe path selector
(titles, prev) => {
// titles: string[], prev: string[] | undefined
console.log("Titles changed:", titles);
},
);
// DSL constructs:
// p.config.theme - Property access
// p.books.$each - All items in list/record
// p.books.$at(0) - Item at index (supports negative: -1 = last)
// p.books.$first - First item (alias for $at(0))
// p.books.$last - Last item (alias for $at(-1))
// p.users.$key("alice") - Record value by key
`
See @loro-extended/repo documentation for full details on Handle.subscribe().
#### createTypedDoc
Creates a new typed Loro document. This is the recommended way to create documents.
`typescript
import { createTypedDoc, Shape } from "@loro-extended/change";
const doc = createTypedDoc(schema);
const docFromExisting = createTypedDoc(schema, existingLoroDoc);
`
The loro() function provides access to CRDT internals and container-specific operations. It follows a simple design principle:
> If it takes a plain JavaScript value, keep it on the ref. > If it takes a Loro container or exposes CRDT internals, use loro().
`typescript
import { loro } from "@loro-extended/change";
// Access underlying Loro primitives
loro(ref).doc; // LoroDoc
loro(ref).container; // LoroList, LoroMap, etc. (correctly typed)
loro(ref).subscribe(cb); // Subscribe to changes
// Container operations (take Loro containers, not plain values)
loro(list).pushContainer(loroMap);
loro(list).insertContainer(0, loroMap);
loro(struct).setContainer("key", loroMap);
loro(record).setContainer("key", loroMap);
// TypedDoc operations
loro(doc).doc; // Raw LoroDoc access
loro(doc).applyPatch(patch); // JSON Patch operations
loro(doc).docShape; // Schema access
loro(doc).rawValue; // Unmerged CRDT value
`
#### API Surface by Ref Type
ListRef / MovableListRef
| Direct Access | Only via loro() |push(item)
| ---------------------- | ----------------------------------- |
| | pushContainer(container) |insert(index, item)
| | insertContainer(index, container) |delete(index, len)
| | subscribe(callback) |find(predicate)
| | doc |filter(predicate)
| | container |map(callback)
| | |forEach(callback)
| | |some(predicate)
| | |every(predicate)
| | |slice(start, end)
| | |findIndex(predicate)
| | |length
| , [index] | |toJSON()
| | |
StructRef
| Direct Access | Only via loro() |obj.property
| ---------------------------- | ------------------------------ |
| (get) | setContainer(key, container) |obj.property = value
| (set) | subscribe(callback) |Object.keys(obj)
| | doc |'key' in obj
| | container |delete obj.key
| | |toJSON()
| | |
RecordRef (Map-like interface)
| Direct Access | Only via loro() |get(key)
| --------------------------------- | ------------------------------ |
| | setContainer(key, container) |set(key, value)
| | subscribe(callback) |delete(key)
| | doc |has(key)
| | container |keys()
| , values(), entries() | |size
| | |replace(values)
| | |merge(values)
| | |clear()
| | |toJSON()
| | |
TextRef
| Direct Access | Only via loro() |insert(index, content)
| -------------------------------- | --------------------- |
| | subscribe(callback) |delete(index, len)
| | doc |update(text)
| | container |mark(range, key, value)
| | |unmark(range, key)
| | |toDelta()
| , applyDelta(delta) | |toString()
| , valueOf() | |length
| , toJSON() | |
CounterRef
| Direct Access | Only via loro() |increment(value)
| -------------------- | --------------------- |
| | subscribe(callback) |decrement(value)
| | doc |value
| , valueOf() | container |toJSON()
| | |
TypedDoc
| Direct Access | Only via loro() |doc.property
| ------------------------------ | --------------------- |
| (schema access) | doc (raw LoroDoc) |toJSON()
| | subscribe(callback) |change(fn)
| | applyPatch(patch) |docShape
| | |rawValue
| | |
The loro() function enables the "pass around a ref" pattern where components can receive a ref and subscribe to its changes without needing the full document:
`typescript
import { loro } from "@loro-extended/change";
function TextEditor({ textRef }: { textRef: TextRef }) {
useEffect(() => {
return loro(textRef).subscribe((event) => {
// Handle text changes
});
}, [textRef]);
return
$3
Use
getTransition() to build { before, after } TypedDocs from a
subscription event using the diff overlay (no checkout or fork required):`typescript
import { getTransition, loro } from "@loro-extended/change";const unsubscribe = loro(doc).subscribe((event) => {
if (event.by === "checkout") return;
const { before, after } = getTransition(doc, event);
if (!before.users.has("alice") && after.users.has("alice")) {
console.log("Alice just joined");
}
});
`$3
####
Shape.doc(shape)Creates a document schema.
`typescript
const schema = Shape.doc({
field1: Shape.text(),
field2: Shape.counter(),
});
`#### Container Types
-
Shape.text() - Collaborative text editing
- Shape.counter() - Collaborative increment/decrement counters
- Shape.list(itemSchema) - Collaborative ordered lists
- Shape.movableList(itemSchema) - Collaborative reorderable lists
- Shape.struct(shape) - Collaborative structs with fixed keys (uses LoroMap internally)
- Shape.record(valueSchema) - Collaborative key-value maps with dynamic string keys
- Shape.tree(dataShape) - Collaborative hierarchical tree structures with typed node metadata
- Shape.any() - Escape hatch for untyped containers (see Untyped Integration)#### Value Types
-
Shape.plain.string() - String values (optionally with literal union types)
- Shape.plain.number() - Number values
- Shape.plain.boolean() - Boolean values
- Shape.plain.null() - Null values
- Shape.plain.undefined() - Undefined values
- Shape.plain.uint8Array() - Binary data values
- Shape.plain.bytes() - Alias for uint8Array() for better discoverability
- Shape.plain.struct(shape) - Struct values with fixed keys
- Shape.plain.record(valueShape) - Object values with dynamic string keys
- Shape.plain.array(itemShape) - Array values
- Shape.plain.union(shapes) - Union of value types (e.g., string | null)
- Shape.plain.discriminatedUnion(key, variants) - Tagged union types with a discriminant key
- Shape.plain.any() - Escape hatch for untyped values (see Untyped Integration)#### Nullable Values
Use
.nullable() on value types to create nullable fields with null as the default placeholder:`typescript
const schema = Shape.doc({
profile: Shape.struct({
name: Shape.plain.string().placeholder("Anonymous"),
email: Shape.plain.string().nullable(), // string | null, defaults to null
age: Shape.plain.number().nullable(), // number | null, defaults to null
verified: Shape.plain.boolean().nullable(), // boolean | null, defaults to null
tags: Shape.plain.array(Shape.plain.string()).nullable(), // string[] | null
metadata: Shape.plain.record(Shape.plain.string()).nullable(), // Record | null
location: Shape.plain
.struct({
// { lat: number, lng: number } | null
lat: Shape.plain.number(),
lng: Shape.plain.number(),
})
.nullable(),
}),
});
`You can chain
.placeholder() after .nullable() to customize the default value:`typescript
const schema = Shape.doc({
settings: Shape.struct({
// Nullable string with custom default
nickname: Shape.plain.string().nullable().placeholder("Guest"),
}),
});
`This is syntactic sugar for the more verbose union pattern:
`typescript
// These are equivalent:
email: Shape.plain.string().nullable();
email: Shape.plain
.union([Shape.plain.null(), Shape.plain.string()])
.placeholder(null);
`$3
With the proxy-based API, schema properties are accessed directly on the doc object, and CRDT internals are accessed via the
loro() function.#### Direct Schema Access
Access schema properties directly on the doc. Mutations commit immediately (auto-commit mode).
`typescript
// Read values
const title = doc.title.toString();
const count = doc.count;// Mutate directly - commits immediately
doc.title.insert(0, "Hello");
doc.count.increment(5);
doc.users.set("alice", { name: "Alice" });
// Check existence
doc.users.has("alice"); // true
"alice" in doc.users; // true
`For batched mutations, use
change(doc, fn).####
doc.toJSON()Returns the full plain JavaScript object representation.
`typescript
const snapshot = doc.toJSON();
`####
loro(doc).rawValueReturns raw CRDT state without placeholders (empty state overlay).
`typescript
import { loro } from "@loro-extended/change";
const crdtState = loro(doc).rawValue;
`####
loro(doc).docAccess the underlying LoroDoc.
`typescript
import { loro } from "@loro-extended/change";
const loroDoc = loro(doc).doc;
`CRDT Container Operations
$3
`typescript
draft.title.insert(index, content);
draft.title.delete(index, length);
draft.title.update(newContent); // Replace entire content
draft.title.mark(range, key, value); // Add formatting
draft.title.unmark(range, key); // Remove formatting
draft.title.toDelta(); // Get Delta format
draft.title.applyDelta(delta); // Apply Delta operations
`$3
`typescript
draft.count.increment(value);
draft.count.decrement(value);
const current = draft.count.value;
`$3
`typescript
draft.items.push(item);
draft.items.insert(index, item);
draft.items.delete(index, length);
const item = draft.items.get(index);
const array = draft.items.toArray();
const length = draft.items.length;
`#### Array-like Methods
Lists support familiar JavaScript array methods for filtering and finding items:
`typescript
// Find items (returns mutable draft objects)
const foundItem = draft.todos.find((todo) => todo.completed);
const foundIndex = draft.todos.findIndex((todo) => todo.id === "123");// Filter items (returns array of mutable draft objects)
const completedTodos = draft.todos.filter((todo) => todo.completed);
const activeTodos = draft.todos.filter((todo) => !todo.completed);
// Transform items (returns plain array, not mutable)
const todoTexts = draft.todos.map((todo) => todo.text);
const todoIds = draft.todos.map((todo) => todo.id);
// Check conditions
const hasCompleted = draft.todos.some((todo) => todo.completed);
const allCompleted = draft.todos.every((todo) => todo.completed);
// Iterate over items
draft.todos.forEach((todo, index) => {
console.log(
Todo ${index}: ${todo.text});
});
`Methods like
find() and filter() return mutable draft objects that you can modify directly:`typescript
change(doc, (draft) => {
// Find and mutate pattern - very common!
const todo = draft.todos.find((t) => t.id === "123");
if (todo) {
todo.completed = true; // ✅ This mutation will persist!
todo.text = "Updated text"; // ✅ This too!
} // Filter and modify multiple items
const activeTodos = draft.todos.filter((t) => !t.completed);
activeTodos.forEach((todo) => {
todo.priority = "high"; // ✅ All mutations persist!
});
});
`This dual interface ensures predicates work with current data (including previous mutations in the same
change() block) while returned objects remain mutable.$3
`typescript
draft.tasks.push(item);
draft.tasks.insert(index, item);
draft.tasks.set(index, item); // Replace item
draft.tasks.move(fromIndex, toIndex); // Reorder
draft.tasks.delete(index, length);
`$3
`typescript
draft.metadata.set(key, value);
draft.metadata.get(key);
draft.metadata.delete(key);
draft.metadata.has(key);
draft.metadata.keys();
draft.metadata.values();// Access nested values
const value = draft.metadata.get("key");
`$3
Records support bulk update methods for efficient batch operations:
`typescript
// Replace entire contents - keys not in the new object are removed
draft.players.replace({
alice: { name: "Alice", score: 100 },
bob: { name: "Bob", score: 50 },
});
// Result: only alice and bob exist, any previous entries are removed// Merge values - existing keys not in the new object are kept
draft.scores.merge({
alice: 150, // updates alice
charlie: 25, // adds charlie
});
// Result: alice=150, bob=50 (unchanged), charlie=25
// Clear all entries
draft.history.clear();
// Result: empty record
`Method semantics:
| Method | Adds new | Updates existing | Removes absent |
| ----------------- | -------- | ---------------- | -------------- |
|
replace(values) | ✅ | ✅ | ✅ |
| merge(values) | ✅ | ✅ | ❌ |
| clear() | ❌ | ❌ | ✅ (all) |These methods batch all operations into a single commit, avoiding multiple subscription notifications.
$3
Trees are hierarchical structures where each node has typed metadata. Perfect for state machines, file systems, org charts, and nested data.
`typescript
// Define node data shape
const StateNodeDataShape = Shape.struct({
name: Shape.text(),
facts: Shape.record(Shape.plain.any()),
rules: Shape.list(
Shape.plain.struct({
name: Shape.plain.string(),
rego: Shape.plain.string(),
description: Shape.plain.string().nullable(),
}),
),
});const schema = Shape.doc({
states: Shape.tree(StateNodeDataShape),
});
const doc = createTypedDoc(schema);
change(doc, (draft) => {
// Create root nodes
const idle = draft.states.createNode();
idle.data.name.insert(0, "idle");
const running = draft.states.createNode();
running.data.name.insert(0, "running");
// Create child nodes
const processing = idle.createNode();
processing.data.name.insert(0, "processing");
// Access typed node data
processing.data.rules.push({
name: "validate",
rego: "package validate",
description: null,
});
// Navigate the tree
const parent = processing.parent(); // Returns idle node
const children = idle.children(); // Returns [processing]
// Move nodes between parents
processing.move(running); // Move to different parent
processing.move(); // Move to root (no parent)
// Query the tree
const roots = draft.states.roots(); // All root nodes
const allNodes = draft.states.nodes(); // All nodes (flat)
const node = draft.states.getNodeByID(idle.id); // Find by ID
const exists = draft.states.has(idle.id); // Check existence
// Delete nodes (and all descendants)
draft.states.delete(running);
// Enable fractional indexing for ordering
draft.states.enableFractionalIndex(8);
const index = idle.index(); // Position among siblings
const fractionalIndex = idle.fractionalIndex(); // Fractional index string
});
// Serialize to JSON (nested structure)
const json = doc.toJSON();
// {
// states: [{
// id: "0@123",
// parent: null,
// index: 0,
// fractionalIndex: "80",
// data: { name: "idle", facts: {}, rules: [] },
// children: [...]
// }]
// }
// Get flat array representation
change(doc, (draft) => {
const flatArray = draft.states.toArray();
// [{ id, parent, index, fractionalIndex, data }, ...]
});
`Tree Node Properties:
-
node.id - Unique TreeID for the node
- node.data - Typed StructRef for node metadata (access like node.data.name)
- node.parent() - Get parent node (or undefined for roots)
- node.children() - Get child nodes in order
- node.index() - Position among siblings
- node.fractionalIndex() - Fractional index string for ordering
- node.isDeleted() - Check if node has been deletedTree Node Methods:
-
node.createNode(initialData?, index?) - Create child node
- node.move(newParent?, index?) - Move to new parent (undefined = root)
- node.moveAfter(sibling) - Move after sibling
- node.moveBefore(sibling) - Move before siblingTreeRef Methods:
-
tree.createNode(initialData?) - Create root node
- tree.roots() - Get all root nodes
- tree.nodes() - Get all nodes (flat)
- tree.getNodeByID(id) - Find node by TreeID
- tree.has(id) - Check if node exists
- tree.delete(target) - Delete node and descendants
- tree.enableFractionalIndex(jitter?) - Enable ordering
- tree.toJSON() - Nested JSON structure
- tree.toArray() - Flat array representation$3
You can easily get a plain JavaScript object snapshot of any part of the document using
JSON.stringify() or .toJSON(). This works for the entire document, nested containers, and even during loading states (placeholders).`typescript
// Get full document snapshot
const snapshot = doc.toJSON();// Get snapshot of a specific list
const todos = doc.todos.toJSON(); // returns plain array of todos
// Works with nested structures
const metadata = doc.metadata.toJSON(); // returns plain object
// Serialize as JSON
const serializedMetadata = JSON.stringify(doc.metadata); // returns string
`Note:
JSON.stringify() is recommended for serialization as it handles all data types correctly. .toJSON() is available on all TypedRef objects and proxied placeholders for convenience when you need a direct object snapshot.Type Safety
Full TypeScript support with compile-time validation:
`typescript
import { TypedDoc, Shape, type InferPlainType } from "@loro-extended/change";// Define your desired interface
interface TodoDoc {
title: string;
todos: Array<{ id: string; text: string; done: boolean }>;
}
// Define the schema that matches your interface
const todoSchema = Shape.doc({
title: Shape.text(),
todos: Shape.list(
Shape.plain.struct({
id: Shape.plain.string(),
text: Shape.plain.string(),
done: Shape.plain.boolean(),
}),
),
});
// TypeScript will ensure the schema produces the correct type
const doc = createTypedDoc(todoSchema);
// Mutations are type-safe
change(doc, (draft) => {
draft.title.insert(0, "Hello"); // ✅ Valid - TypeScript knows this is LoroText
draft.todos.push({
// ✅ Valid - TypeScript knows the expected shape
id: "1",
text: "Learn Loro",
done: false,
});
// draft.title.insert(0, 123); // ❌ TypeScript error
// draft.todos.push({ invalid: true }); // ❌ TypeScript error
});
// The result is properly typed as TodoDoc
const result: TodoDoc = doc.toJSON();
// You can also use type assertion to ensure schema compatibility
type SchemaType = InferPlainType;
const _typeCheck: TodoDoc = {} as SchemaType; // ✅ Will error if types don't match
`Note: Use
Shape.plain.null() for nullable fields, as Loro treats null and undefined equivalently.Integration with Existing Loro Code
TypedDoc works seamlessly with existing Loro applications:`typescript
import { LoroDoc } from "loro-crdt";
import { createTypedDoc, getLoroDoc } from "@loro-extended/change";// Wrap existing LoroDoc
const existingDoc = new LoroDoc();
const typedDoc = createTypedDoc(schema, existingDoc);
// Access underlying LoroDoc
const loroDoc = getLoroDoc(typedDoc);
// Use with existing Loro APIs
loroDoc.subscribe((event) => {
console.log("Document changed:", event);
});
`TypedEphemeral (Presence)
The
TypedEphemeral interface in @loro-extended/repo provides type-safe access to ephemeral presence data with placeholder defaults. Define your presence schema and use it with repo.get():`typescript
import { Shape } from "@loro-extended/change";// Define a presence schema with placeholders
const PresenceSchema = Shape.plain.struct({
cursor: Shape.plain.struct({
x: Shape.plain.number(),
y: Shape.plain.number(),
}),
name: Shape.plain.string().placeholder("Anonymous"),
status: Shape.plain.string().placeholder("online"),
});
// Use with @loro-extended/repo
const handle = repo.get("doc-id", DocSchema, { presence: PresenceSchema });
// Read your presence (with placeholder defaults merged in)
console.log(handle.presence.self);
// { cursor: { x: 0, y: 0 }, name: "Anonymous", status: "online" }
// Set presence values
handle.presence.setSelf({ cursor: { x: 100, y: 200 }, name: "Alice" });
// Read other peers' presence
for (const [peerId, presence] of handle.presence.peers) {
console.log(
${peerId}: ${presence.name});
}// Subscribe to presence changes
handle.presence.subscribe(({ key, value, source }) => {
console.log(
Peer ${key} updated:, value);
});
`See
@loro-extended/repo documentation for full details on the TypedEphemeral interface.Performance Considerations
- All changes within a
change() call are batched into a single transaction
- Empty state overlay is computed on-demand, not stored
- Container creation is lazy - containers are only created when accessed
- Type validation occurs at development time, not runtimeContributing
This package is part of the loro-extended ecosystem. Contributions welcome!
- Build:
pnpm build
- Test: pnpm test
- Lint: pnpm check`MIT