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.
import { Schema } from "@kyneta/schema"
import { createDoc, change, 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)
})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.
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).
Two peers exchange state via exportSince / importDelta:
import {
createDoc, change, subscribe,
version, exportSince, importDelta,
} 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)
importDelta(docB, delta!, "sync")
// → "B updated"
// docB.title() === "Hello from A"For full state transfer (SSR, reconnection), use snapshots via the exchange:
import { exportSnapshot, createDoc } from "@kyneta/loro-schema"
const snapshot = exportSnapshot(docA)
const docB = createDoc(myBoundSchema)
// restore from snapshot via the exchange or substrate| 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. |
| 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. |
| exportSnapshot(doc) | Full state as a binary SubstratePayload. |
| exportSince(doc, since) | Delta payload since a version. |
| importDelta(doc, payload, origin?) | 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) |
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 |
| 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" |
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
importDelta()— remote sync - External
doc.import()— e.g. from a state bus - External raw Loro API calls +
doc.commit()— e.g. from another library
{
"peerDependencies": {
"@kyneta/schema": ">=0.0.1",
"loro-crdt": ">=1.8.0"
}
}MIT