@kyneta/loro-schema
v3.0.0
Published
Loro CRDT substrate for @kyneta/schema — collaborative data types with typed refs (formerly @kyneta/schema-loro)
Readme
@kyneta/loro-schema
Loro CRDT substrate for @kyneta/schema. Provides collaborative data types with typed refs — same schema, same API, but backed by a Loro document with automatic conflict resolution and sync.
Schemas are defined once with Schema.*, then bound to Loro via loro.bind(). Write once, bind anywhere.
Getting Started
import { Schema } from "@kyneta/schema"
import { createDoc, batch, subscribe, loro } from "@kyneta/loro-schema"
// Define a schema and bind to Loro
const TodoDoc = loro.bind(Schema.struct({
title: Schema.text(),
count: Schema.counter(),
items: Schema.list(
Schema.struct.json({
name: Schema.string(),
done: Schema.boolean()
}),
),
}))
// Create a live, collaborative document
const doc = createDoc(TodoDoc, { title: "Hello" })
// Read and write through the typed ref API
doc.title() // "Hello"
batch(doc, d => {
d.title.insert(5, " World")
d.count.increment(1)
d.items.push({ name: "First task", done: false })
})
doc.title() // "Hello World"
doc.count() // 1
doc.items.at(0).name() // "First task"
// Observe all mutations (local, remote, or external)
subscribe(doc, changeset => {
console.log("Changed:", changeset)
})Write Once, Bind Anywhere
Schemas are backend-agnostic. The same Schema.* definition works with any substrate — Loro, Yjs, or plain JSON:
import { Schema } from "@kyneta/schema"
import { loro } from "@kyneta/loro-schema"
import { json } from "@kyneta/schema"
const schema = Schema.struct({
title: Schema.text(),
count: Schema.counter(),
items: Schema.list(
Schema.struct.json({ name: Schema.string(), done: Schema.boolean() }),
),
})
// Bind to Loro for collaborative editing
const loroBound = loro.bind(schema)
// Bind to JSON for server-side or non-collaborative use
const jsonBound = json.bind(schema)loro.bind() enforces Loro's composition-law constraints at compile time via LoroLaws. If your schema uses a composition law Loro doesn't support (e.g. Schema.set()), loro.bind() produces a type error.
Bring Your Own LoroDoc
Every createDoc document is backed by a real LoroDoc. Use loro.unwrap() to access it — e.g. for interop with a state bus or another Loro-aware library:
import { createDoc, subscribe, loro } from "@kyneta/loro-schema"
const doc = createDoc(myBoundSchema)
// Escape hatch — access the underlying LoroDoc
const loroDoc = loro.unwrap(doc)
// External mutations to the LoroDoc fire kyneta subscribers
subscribe(doc, () => console.log("Something changed"))
loroDoc.getText("title").insert(0, "External edit")
loroDoc.commit()
// → "Something changed"Note for raw LoroDoc consumers: Subscribers attached directly to the underlying LoroDoc will newly see options.origin faithfully on batch.origin (where previously it was overwritten by a kyneta sentinel).
Sync
Two peers exchange state via exportSince / merge:
import {
createDoc, batch, subscribe,
version, exportSince, merge,
} from "@kyneta/loro-schema"
// Peer A
const docA = createDoc(myBoundSchema)
batch(docA, d => d.title.insert(0, "Hello from A"))
// Peer B
const docB = createDoc(myBoundSchema)
subscribe(docB, () => console.log("B updated"))
// Sync A → B
const sinceVersion = version(docB)
const delta = exportSince(docA, sinceVersion)
merge(docB, delta!, { origin: "sync" })
// → "B updated"
// docB.title() === "Hello from A"For full state transfer (SSR, reconnection), use snapshots via the exchange:
import { exportEntirety, createDoc } from "@kyneta/loro-schema"
const snapshot = exportEntirety(docA)
const docB = createDoc(myBoundSchema)
// restore from snapshot via the exchange or substrateAPI Reference
Bind & Escape Hatch
| Export | Description |
|--------|-------------|
| loro.bind(schema) | Bind a schema to the Loro CRDT substrate. Enforces LoroLaws constraints at compile time — schemas containing unsupported composition laws (e.g. Schema.set()) are rejected. Returns a BoundSchema<S> for use with exchange.get(). The factory builder injects a deterministic numeric Loro PeerID derived from the exchange's string peerId. |
| loro.unwrap(ref) | Escape hatch — returns the LoroDoc backing a root document ref. Throws if the ref is not backed by a Loro substrate. Currently supports root refs only; child-level resolution is future work. |
Batteries-Included (most users)
| Export | Description |
|--------|-------------|
| createDoc(boundSchema, seed?) | Create a live Loro-backed document. Pass a bound schema (result of loro.bind()) and an optional seed object. (re-exported from @kyneta/schema) |
| version(doc) | Current version as a LoroVersion. |
| exportEntirety(doc) | Full state as a binary SubstratePayload. |
| exportSince(doc, since) | Delta payload since a version. |
| merge(doc, payload, options?) | Apply a delta from another peer. |
| batch(doc, fn) | Run mutations in a transaction. (re-exported from @kyneta/schema) |
| subscribe(doc, cb) | Observe all mutations. (re-exported from @kyneta/schema) |
| applyChanges(doc, ops, opts?) | Apply a list of ops declaratively. (re-exported from @kyneta/schema) |
Schema Constructors
Schemas are defined with Schema.* from @kyneta/schema. All constructors are backend-agnostic:
| Constructor | Description |
|-------------|-------------|
| Schema.struct(fields) | Product type → LoroMap container |
| Schema.list(item) | Sequence type → LoroList container |
| Schema.record(item) | Map type → LoroMap container |
| Schema.text() | Collaborative text → LoroText |
| Schema.counter() | CRDT counter → LoroCounter |
| Schema.movableList(item) | Movable list → LoroMovableList |
| Schema.tree(nodeData) | Tree → LoroTree |
| Schema.struct.json(fields) | JSON merge boundary — struct stored as opaque JSON in parent container |
| Schema.list.json(item) | JSON merge boundary — array stored as opaque JSON |
| Schema.record.json(item) | JSON merge boundary — record stored as opaque JSON |
| Schema.string() | Plain string scalar (stored in _props at root) |
| Schema.number() | Plain number scalar |
| Schema.boolean() | Plain boolean scalar |
| Schema.nullable(inner) | Nullable wrapper |
Low-Level Primitives (power users)
| Export | Description |
|--------|-------------|
| createLoroSubstrate(doc, schema) | Wrap a LoroDoc in a Substrate<LoroVersion>. |
| loroSubstrateFactory | SubstrateFactory<LoroVersion> with create, fromSnapshot, parseVersion. |
| loroStoreReader(doc, schema) | Create a StoreReader over a Loro container tree. |
| resolveContainer(doc, schema, path) | Resolve a Loro container at a kyneta path. |
| changeToDiff(path, change, schema, doc) | Convert a kyneta Change to Loro [ContainerID, Diff][] tuples. |
| batchToOps(batch, schema) | Convert a Loro event batch to kyneta Op[]. |
| LoroVersion | Version implementation wrapping Loro's VersionVector. |
| LoroLaws | Composition-law type: "lww" | "additive" | "positional-ot" | "positional-ot-move" | "lww-per-key" | "tree-move" | "lww-tag-replaced". |
Event Bridge Contract
Wrapping a LoroDoc in a kyneta substrate means subscribe() observes all mutations to the underlying doc, regardless of source:
- Mutations via
batch()— the normal path - Mutations via
merge()— remote sync - External
doc.import()— e.g. from a state bus - External raw Loro API calls +
doc.commit()— e.g. from another library
Peer Dependencies
{
"peerDependencies": {
"@kyneta/schema": ">=0.0.1",
"loro-crdt": ">=1.8.0"
}
}License
MIT
