@kyneta/schema
v1.8.0
Published
Schema interpreter algebra — pure structure, pluggable interpretations
Readme
@kyneta/schema
Define a schema. Get a live, reactive, syncable document with full TypeScript type safety.
@kyneta/schema is a mathematically rigorous but beautiful and ergonomic building block for representing structured data as it changes over time. You can use plain JS, or bring your own CRDT library (e.g. Loro, Yjs).
import { Schema, createDoc, change, subscribe } from "@kyneta/schema/basic"
const TaskDoc = Schema.struct({
title: Schema.text(),
count: Schema.counter(),
games: Schema.list(
Schema.struct({
type: Schema.string("uno", "catan"),
players: Schema.number(2, 3, 4)
})
),
done: Schema.boolean(),
})
const doc = createDoc(TaskDoc)
change(doc, d => {
d.title.insert(0, "Ship it")
d.done.set(true)
d.games.push({ type: "catan", players: 3 })
})
doc() // { "title": "Ship it", "count": 0, ... }
doc.title() // "Ship it"
doc.title.insert(7, "!") // surgical text edit
doc.count.increment() // counter delta
doc.games.push({ // makes structural doc.games.at(1) available
type: "uno",
players: 4
})
subscribe(doc, (changeset) => {
// fires for any change anywhere in the document
})Zero runtime dependencies.
What you get from one schema
| Capability | How |
|---|---|
| Typed reads | doc.title() returns string, doc() returns the full plain snapshot |
| Typed writes | .set(), .insert(), .increment(), .push(), .delete() — each ref knows its mutation surface |
| Transactions | change(doc, d => { ... }) → Op[] — atomic batching, returns captured ops |
| Sync | applyChanges(docB, ops) — apply ops from another doc, network, or undo stack |
| Observation | subscribe(doc, cb) for tree-level, subscribeNode(ref, cb) for leaf-level |
| Self-removal | remove(ref) — a child ref removes itself from its parent container |
| Version tracking | version(doc), delta(doc, fromVersion), exportSnapshot(doc) |
| Validation | validate(schema, data) — same schema, no separate Zod/Yup definition |
| Template coercion | `Count: ${doc.count}` works via toPrimitive — no .() needed |
The sync story in 5 lines
// Capture mutations on docA
const ops = change(docA, d => {
d.title.insert(0, "✨ ")
d.count.increment(10)
})
// Apply to docB (could be on another machine)
applyChanges(docB, ops, { origin: "sync" })
// docA() deep-equals docB()Schema types
// Scalars
Schema.string() // also Schema.string("a", "b") for constrained values
Schema.number()
Schema.boolean()
// CRDT kinds
Schema.text() // collaborative text
Schema.counter() // increment/decrement counter
Schema.set(itemSchema) // add-wins set (value-addressed: .add(v), .has(v), .delete(v))
Schema.tree(itemSchema) // tree with move semantics
Schema.movableList(itemSchema) // ordered list with move
Schema.richText({ bold: { expand: "after" } }) // collaborative rich text with marks
// Composites
Schema.struct({ ... }) // fixed-key product
Schema.list(itemSchema) // ordered sequence
Schema.record(valueSchema) // dynamic-key map
// Unions
Schema.discriminatedUnion("type", [ // native TS narrowing
Schema.struct({ type: Schema.string("text"), body: Schema.text() }),
Schema.struct({ type: Schema.string("image"), url: Schema.string() }),
])
Schema.string().nullable() // null | string (fluent method on all plain schema types)
// Root
Schema.struct({ ... }) // document root (annotated product)Collections
// Lists
doc.tasks.at(0)?.title() // navigate to child ref
doc.tasks.get(0) // read plain value directly
doc.tasks.length // current length
doc.tasks.push({ ... }) // append
doc.tasks.insert(0, item) // insert at index
doc.tasks.delete(1, 2) // delete range
remove(doc.tasks.at(0)) // item removes itself from parent
for (const task of doc.tasks) { ... } // iterate refs
doc.tasks() // convert tasks to plain JSON
// Records
doc.labels.at("bug")?.() // navigate + read
doc.labels.get("bug") // read plain value
doc.labels.set("bug", "red")
doc.labels.delete("bug")
doc.labels.keys() // string[]
doc.labels.has("bug") // boolean
doc.labels() // convert labels to plain JSON
// Sets — value-addressed (no .at(value); members are not addressable)
doc.tags.add("javascript") // idempotent for an existing member
doc.tags.has("javascript") // boolean (structural content equality)
doc.tags.delete("typescript") // returns boolean (was present)
doc.tags.clear()
doc.tags.size // number
for (const tag of doc.tags) { ... } // iterate plain values
doc.tags() // → string[]Observation
// Tree-level — fires for any change in the subtree
const unsub = subscribe(doc, (changeset) => {
for (const event of changeset.changes) {
console.log(event.path, event.change.type)
}
})
// `subscribe` works on leaves too — a leaf is a tree of size 1, so the
// delivered `Op.path` is the empty relative path.
subscribe(doc.count, (changeset) => {
console.log(changeset.changes[0].path.segments) // []
})
// Node-level — explicit shallow opt-in; delivers Changeset<ChangeBase>
// without paths, for when you don't need the tree shape.
subscribeNode(doc.count, (changeset) => {
console.log(changeset.origin) // "sync", "undo", etc.
})Subscribers receive batched Changeset objects — never partially-applied state. Origin provenance ({ origin: "sync" }) flows through from change() and applyChanges().
Data readiness
// Every ref starts unpopulated — no data has arrived yet
doc.title.isPopulated() // false
change(doc, d => d.title.insert(0, "Hello"))
doc.title.isPopulated() // true (monotonic — never reverts)
doc.isPopulated() // true (parent flips when any child does)
doc.count.isPopulated() // false (untouched siblings stay false)isPopulated is a reactive boolean on every ref. It starts false and flips to true when any mutation — local or remote — touches that ref or a descendant. Once true, it never reverts. Each isPopulated carries its own [CHANGEFEED], so the compiler can emit conditional rendering regions that activate when data arrives.
Validation
// Throws on first error
const data = validate(MySchema, unknownInput)
// data is now Plain<typeof MySchema> — fully narrowed
// Collect all errors
const result = tryValidate(MySchema, unknownInput)
if (!result.ok) {
for (const err of result.errors) {
console.log(err.path, err.expected, err.actual)
// "tasks[0].priority" "one of 1 | 2 | 3" 99
}
}Two import paths
| Path | Audience | What you get |
|---|---|---|
| @kyneta/schema/basic | App developers | createDoc, change, subscribe, validate, sync primitives — batteries included |
| @kyneta/schema | Library authors | The full composable interpreter toolkit — build custom document systems |
Most projects only need @kyneta/schema/basic.
The /basic API is built on a composable interpreter algebra with six stackable layers (navigation, reading, addressing, caching, writing, observation). If you need custom stacks — read-only documents, write-only mutation dispatchers, or your own substrate — import from @kyneta/schema directly. See example/advanced/ for details.
Examples
# Getting started (basic API)
bun run example/basic/main.ts
# Under the hood (interpreter algebra)
bun run example/advanced/main.tsDesign (Math Nerd Corner)
Under the hood:
- the schema is a recursive functor (
Scalar | Product | Sequence | Map | Sum | Annotated) interpret()is a catamorphism- each capability (reading, addressing, writing, caching, observation) is an F-algebra composed via interpreter transformers
subscribeis a coalgebra (Moore machine)- the
step(state, change) → statefunctions are pure - the
change → applyChangesround-trip is verified to be extensionally equal - the change vocabulary is open
This means the reactive system, the sync protocol, and the validation layer are all derived from the same structure — not parallel implementations that drift apart. It also means this representation of schemas is rigorous, and you can depend on it.
See theory/interpreter-algebra.md for the full treatment, or TECHNICAL.md for the implementation map.
License
MIT
