Skip to content

Latest commit

 

History

History

Folders and files

NameName
Last commit message
Last commit date

parent directory

..
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

README.md

@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, 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)
})

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 / 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

API 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. | | 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) |

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"

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 importDelta() — 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