Package:
@kyneta/schemaRole: The schema interpreter algebra — one recursive grammar for document structure, a reactive observation surface ([CHANGEFEED]on every ref, with tree-level composed changefeeds for composites), a substrate boundary that separates state management from replication, a migration system that derives stable identity from structure, and a position algebra for cursor-stable text and sequences. Depends on:@kyneta/changefeedDepended on by:@kyneta/exchange,@kyneta/loro-schema,@kyneta/yjs-schema,@kyneta/index,@kyneta/react,@kyneta/compiler,@kyneta/cast,@kyneta/transportCanonical symbols:Schema,Schema.*constructors,KIND,LAWS,bind,BoundSchema,BoundReplica,BindingTarget,createBindingTarget,json,ephemeral,Interpret,Replicate,Defer,Reject,interpret,Interpreter,InterpreterLayer,createDoc,createRef,change,applyChanges,subscribe,subscribeNode,Substrate,SubstrateFactory,SubstrateCapabilities,Replica,ReplicaFactory,SubstratePayload,Version,SyncMode,SYNC_AUTHORITATIVE,SYNC_COLLABORATIVE,SYNC_EPHEMERAL,requiresBidirectionalSync,computeSchemaHash,BACKING_DOC,Op,RecursiveChangefeedProtocol,Change,ChangeBase,TextChange,SequenceChange,MapChange,TreeChange,ReplaceChange,IncrementChange,RichTextChange,transformIndex,textInstructionsToPatches,Migration,MIGRATION_CHAIN,deriveIdentity,deriveManifest,deriveSchemaBinding,deriveTier,validateChain,Position,POSITION,PlainPosition,hasPosition,decodePlainPosition,Side,NATIVE,SUBSTRATE,NativeMap,unwrap,versionVectorMeet,versionVectorCompare,Zero,validate,tryValidate,SchemaValidationError,foldPath,pathSchema,PathStepper,PathFoldResult,extendSchemaPathKeyKey invariant(s): The schema grammar is one recursive type with eleven node kinds; substrates declare closed composition-law sets via phantom[LAWS]brands;bind()enforces law compatibility at compile time. Four named binding targets (json,ephemeral,loro,yjs) each bundle a substrate factory, aSyncMode, and a set of allowed laws. No runtime law dispatch; no open-world subtyping; no hidden backend coupling.
The algebraic core of every document in Kyneta. You write a schema once — a tree of structural composites and CRDT leaves — and hand it to a substrate (plain JS, Loro, Yjs). The substrate stores state; the interpreter stack gives you a typed, navigable, writable reference (Ref<S>) over that state, with reactive observation baked in — every ref carries a [CHANGEFEED] that emits one Changeset<Op> per transaction covering own-path + descendants via subscribeDescendants. Migration primitives derive a content-addressed identity from the schema tree so that documents can evolve across schema versions without losing peer-to-peer identity.
Imported by every other Kyneta package that touches documents: the CRDT backends to implement Substrate<V>, the exchange to sync SubstratePayload blobs, the index to build live views, react to bind refs into hooks, compiler/cast to detect reactive references at compile time.
- What is a
Schemaand how does it relate to TypeScript types? → The grammar - Why are
text,counter,set,tree,movablefirst-class and not annotations? → First-class CRDT types - What does a
Substratedo that aReplicadoes not? → The substrate / replica split - What is
bind()enforcing at compile time? → Binding a schema to a substrate - What is the six-layer interpreter stack? → The interpreter stack
- How does
batch(ref, fn)end up as a wireoffer? → The write path - What is a
Positionand why can't I just use an integer index? → Position algebra - How do migrations keep a document's identity stable across schema changes? → Migration and identity
- How does the exchange decide whether two peers' docs are compatible? →
schemaHashand compatibility - What is the
CHANGEFEEDsurface over a composite ref? → Composed changefeeds
| Term | Means | Not to be confused with |
|---|---|---|
Schema |
The recursive union type ScalarSchema | ProductSchema | SequenceSchema | MapSchema | SumSchema | TextSchema | CounterSchema | SetSchema | TreeSchema | MovableSequenceSchema | RichTextSchema. Every node carries [KIND] and [LAWS] phantom brands. |
A JSON Schema, a TypeScript type, a Zod schema — this is an interpreter grammar, not a validator |
Schema.* constructors |
Schema.struct, Schema.list, Schema.record, Schema.string, Schema.number, Schema.boolean, Schema.union, Schema.discriminatedUnion, Schema.text, Schema.counter, Schema.set, Schema.tree, Schema.movableList, Schema.richText, plus low-level Schema.scalar, Schema.product, Schema.sequence, Schema.map, Schema.sum. Fluent .nullable() is available on all plain schema types. |
Any per-backend namespace — the Schema.* constructors are backend-agnostic |
[KIND] |
Symbol("kyneta:kind") — runtime discriminant on every schema node. Narrows in TypeScript via structural matching. |
A string tag, a class instanceof check |
[LAWS] |
Symbol("kyneta:laws") — phantom type-level composition-law accumulator. Never populated at runtime. Tags are algebraic properties of the merge semantics: "lww", "additive", "positional-ot", "positional-ot-move", "tree-move", "lww-per-key", "lww-tag-replaced", "add-wins-per-key". |
A capability flag on the runtime object |
PlainSchema |
The subset of Schema that excludes all CRDT kinds (text, counter, set, tree, movable, richtext). Used where a plain-JSON substrate is the only option (inside .json(), sum variants). |
Schema — PlainSchema ⊂ Schema |
Substrate<V> |
State-management + transfer interface: version(), exportEntirety(), exportSince(since?), merge(payload, options?), context(), plus reader(), writable(), prepare(). V is the substrate's version type (Lamport vector, Loro version, wall clock, …). |
A database, a backend — this is an interface the backends implement |
Replica<V> |
The replication surface alone — version, exportEntirety, exportSince, merge. No schema knowledge. |
Substrate<V>, which adds reader, writable, prepare, and schema awareness |
ReplicaFactory<V> / SubstrateFactory<V> |
Constructors for replicas / substrates. Every SubstrateFactory exposes a replica accessor yielding a ReplicaFactory. |
A runtime singleton — factories are reusable and stateless |
BindingTarget<AllowedLaws, N> |
A fixed (substrate factory, sync mode, allowed laws) bundle. Named targets: json (authoritative, all laws), ephemeral (LWW-family only), loro (CRDT laws), yjs (Yjs-supported laws). Each exposes .bind(schema) → BoundSchema and .replica() → BoundReplica. |
SubstrateFactory — the target wraps a factory; it is not one |
BoundSchema<S> |
The triple (schema, factory, syncMode) captured at module scope via target.bind(schema). The static declaration of a document type. |
A runtime instance — BoundSchema is a value describing how to make one |
BoundReplica<V> |
BoundSchema minus the schema — used by replication conduits that persist state without reading it. |
BoundSchema |
Interpret / Replicate / Defer / Reject |
The four variants of an exchange resolve callback outcome. Return values from application-level logic that decides how to handle an unknown doc. |
Handlers, error types — these are discriminated-union constructors |
Interpreter<Ctx, A> |
The F-algebra: one method per [KIND] value, collapsing a schema tree into a value of type A. |
A parser, a visitor, a validator alone |
InterpreterLayer |
A typed transformer from one interpreter to another (e.g. withReadable transforms Interpreter<Ctx, R> into Interpreter<Ctx, R & Readable>). |
A middleware — layers compose statically via .with() |
Ref<S> |
The developer-facing handle: callable, navigable, readable, writable, observable. The result of interpret(schema, ctx)...done(). |
A React ref, a DOM ref — this is a substrate-backed document reference |
Change |
The universal currency of change — discriminated union with type ("text" | "sequence" | "map" | "tree" | "replace" | "increment" and extensible). Flows both inbound (intent) and outbound (notification). |
A diff, a patch — Change is applied atomically by the substrate |
SubstratePayload |
{ kind: "entirety" | "since", encoding: "json" | "binary", data: string | Uint8Array } — opaque state carrier. Produced by the substrate, carried by the exchange. |
A ChannelMsg — payloads ride inside offer messages |
| SyncMode | Structured record decomposing sync semantics into three orthogonal axes: WriterModel ("serialized" / "concurrent"), Delivery ("delta-capable" / "snapshot-only"), Durability ("persistent" / "transient"). Three constants: SYNC_AUTHORITATIVE, SYNC_COLLABORATIVE, SYNC_EPHEMERAL. requiresBidirectionalSync(mode) is the helper predicate. | A string enum, a CRDT algorithm |
| NativeMap<S> | Type-level functor mapping each schema kind to its substrate-native type (e.g. Loro's LoroText, Yjs's Y.Text, plain JS string). | A runtime Map<K,V> |
| NATIVE / SUBSTRATE / BACKING_DOC | Symbol-keyed accessors for the underlying native container, the substrate instance, and the backing document object. | User-facing APIs — these are escape hatches |
| Position | Substrate-mediated stable reference to a location within text or a sequence. Survives concurrent edits. | A numeric index, a character position |
| POSITION | Capability symbol: hasPosition(ref) returns true when the substrate supports positions for this ref. | The Position interface |
| Migration | The namespace of 14 migration primitives (renameField, dropField, extractField, mergeFields, splitField, transformField, setDefault, addField, wrapField, unwrapField, promoteField, demoteField, epoch, identity) organized into four tiers. | A database migration — this is a pure algebraic operation on schema + data |
| MIGRATION_CHAIN | Symbol-keyed slot on a ProductSchema carrying its MigrationChain (sequence of migration steps + epochs). Invisible to JSON.stringify / Object.keys. | The chain's content — the symbol is just the slot |
| SchemaBinding | { forward: Map<string, Hash>, backward: Map<Hash, string> } — the identity map from human-facing field names to content-addressed identity hashes for one schema snapshot. | Schema validation rules |
| Op | The expanded-to-leaves notification emitted by the composed changefeed. { path, change }. | Change alone — Op adds the path |
Thesis: one recursive grammar for structure, one composition-law phantom for compile-time safety, one substrate interface for state, one interpreter algebra for capabilities, one change vocabulary for updates. Everything else — backends, transports, reactive bindings, compilers — lives above this surface.
Five orthogonal sub-systems:
| Sub-system | Source file | Role |
|---|---|---|
| Grammar | src/schema.ts |
The recursive Schema type and its constructors. |
| Binding | src/bind.ts |
BoundSchema, BindingTarget, createBindingTarget, json, ephemeral, bind(), law enforcement. |
| Interpretation | src/interpret.ts, src/interpreters/*, src/layers.ts, src/ref.ts |
The six-layer interpreter stack. |
| Substrate | src/substrate.ts, src/substrates/* |
The state / replication interface. |
| Migration | src/migration.ts, src/hash.ts |
Identity derivation and schema evolution. |
Plus three cross-cutting facilities:
- Change (
src/change.ts,src/step.ts,src/facade/batch.ts) — the universal delta vocabulary andbatch(ref, fn)transaction facade. - Position (
src/position.ts) — cursor-stable references inside text and sequences. - Observation (
src/changefeed.ts,src/interpreters/with-changefeed.ts,src/facade/observe.ts) — the composed changefeed layer over refs.
- Not a JSON Schema. JSON Schema describes valid JSON;
Schemadescribes the structure and capabilities of a document that is not necessarily JSON. ASchema.text()node is not a string — it is a live CRDT with its own change vocabulary. - Not a TypeScript type.
Schemavalues are runtime values. TypeScript types are derived from schemas viaPlain<S>,Ref<S>,Op<S>, not the other way around. - Not a validator.
validate(schema, value)exists (src/interpreters/validate.ts), but validation is one interpretation of the schema, not its identity. The same schema drives validation, reading, writing, observation, and migration. - Not extensible at the grammar layer. Users compose schemas; they do not add new
[KIND]values. Extending the grammar requires a new[KIND], a new interpreter case, and substrate support — a Kyneta-level change.
- Not a database. It is an interface. Plain JS objects, Loro CRDTs, and Yjs docs all satisfy it.
- Not a backend in the framework sense. No framework choices leak through the substrate boundary — there is no "Loro mode" that propagates upward. The interpreter stack treats every substrate identically.
- Not responsible for sync. The substrate produces and consumes
SubstratePayload. The exchange owns when and to whom to send it. - Not symmetric across sync modes. A collaborative substrate (Loro, Yjs) has concurrent versions (
SYNC_COLLABORATIVE); an authoritative substrate (json) has a total order (SYNC_AUTHORITATIVE); an ephemeral substrate has wall-clock-timestamped overwrite (SYNC_EPHEMERAL). TheSyncMode— decomposed intoWriterModel,Delivery, andDurabilityaxes — tells the exchange which mode shape to run.requiresBidirectionalSync(mode)is the predicate the exchange uses to decide whether to establish a bidirectional causal exchange or a unidirectional push. - Not a monolithic capability provider. Producer-side capability attachment uses a typed bag (
SubstrateCapabilities); consumer-side capability discovery uses optional fields onRefContextplus theHasTreeNodeAllocationmarker interface. The asymmetry is deliberate — substrates declare what they have; consumers ask only when they need it.
Source: packages/schema/src/schema.ts. The recursive type Schema has eleven cases distinguished by [KIND]:
[KIND] |
Constructor | Category | Children | Role |
|---|---|---|---|---|
scalar |
scalar(kind, constraint?) |
Structural leaf | — | Leaf values (string, number, boolean, null, bytes, any) |
product |
product(fields) |
Structural composite | Record<string, () => Schema> |
Fixed-key record (struct) |
sequence |
sequence(item) |
Structural composite | () => Schema |
Ordered list with plain array semantics |
map |
map(item) |
Structural composite | () => Schema |
Dynamic-key record |
sum |
sum(variants) |
Structural composite | SumVariants |
Tagged or positional union |
text |
text() |
CRDT | — | Character-level collaborative text |
counter |
counter() |
CRDT | — | Additive counter |
set |
set(item) |
CRDT | () => Schema |
Add-wins unordered collection |
tree |
tree(item) |
CRDT | () => Schema |
Hierarchical forest with move operations; each node carries item-typed data |
movable |
movableList(item) |
CRDT | () => Schema |
Ordered collection with move operations |
richtext |
richText(marks) |
CRDT | — | Collaborative rich text with formatting marks |
Five structural kinds describe composition. Six CRDT kinds are first-class leaves or composites that carry merge semantics.
Schema.* also exposes ergonomic aliases: Schema.struct(fields) = product, Schema.list(item) = sequence, Schema.record(item) = map, Schema.string() / Schema.number() / Schema.boolean() wrap scalar, Schema.union / Schema.discriminatedUnion wrap sum. Fluent .nullable() is available on all plain schema types: Schema.string().nullable() produces a positional sum [null, string]. Not available on CRDT types (text, counter, set, tree, movableList, richText). See src/schema.ts for the full list.
Why text, counter, set, tree, movable, richtext are grammar nodes rather than annotations on structural types:
- Their change vocabulary differs. A
sequencehasSequenceChange(retain / insert / delete of items); amovablehas that plus move operations. - Their composition-law requirements differ. A
textnode carries the"positional-ot"law; acountercarries"additive"; a plain JSON substrate only satisfies"lww". Encoding this as a phantom[LAWS]tag on the grammar means the type system catches incompatibilities atbind(), not at runtime. - Their identity semantics differ. Two concurrent insertions into a
sequenceare ordered arbitrarily; two concurrent insertions into amovablecarry identity-bearing positions. - Rich text has the same positional algebra as text but extends the instruction stream with
format— a cursor instruction that annotates characters with marks. treecarries a per-itemdata: Islot — the schema-level "data" that hangs off each tree node. Each substrate stores that slot in its own way: the plain substrate keeps it as a property on the flat-forest entry, and the Loro substrate stores it in the node's.dataLoroMap(so per-node field writes dispatch as ordinary map navigation). TheTreeChange { create, delete, move }vocabulary is uniform across substrates; the per-item storage strategy is a substrate detail.
Each CRDT kind contributes to the [LAWS] phantom of every ancestor node. A Schema.struct({ body: Schema.text() }) has "positional-ot" in its [LAWS] accumulator even though struct itself is structural. The tags are algebraic properties ("lww", "additive", "positional-ot", "positional-ot-move", "tree-move", "lww-per-key", "lww-tag-replaced", "add-wins-per-key"), not kind names.
Schema.set(item) is structurally distinct from Schema.record(item). Where map is a key→value relation (Record<string, V> at the user surface), set is an unordered uniqued bag of values:
Plain<SetSchema<I>>isPlain<I>[]. The user-facing shape is an array, not a keyed record. Storage on the plain substrate is alsoT[];materialize.setprojects CRDT-backed storage toT[]for shadow construction.- Change vocabulary is
SetChange { add, remove }— value-addressed, not key-addressed. Distinct fromMapChange { set, delete }. On overlap (an item appears in bothaddandremove), remove-wins (mirrorsstepMap's asymmetric set-wins-on-set-then-delete). stepSetis total over arbitrary input and produces normalized output: no duplicates (viaisSameSetMember), stable order (existing members retain relative position; new adds appended inadd[]order). ThesetOpChange(add?, remove?)constructor is a thin passthrough — the invariant lives at the operation boundary, not the constructor.SetRefis leaf-shaped at the ref layer. The interface is.has(value),.add(value),.delete(value),.clear(),.size,[Symbol.iterator]over plain values, and a callable returningT[]. No.at(value)and no per-member child refs — sets have no addressable positions, and writing through a member ref would silently violate the set's uniqueness invariant.- Membership is content-equal (via
isSameSetMemberinguards.ts) — single source of truth shared bystepSet,validate, andSetRef.has(value).Schema.set(Schema.struct({...}))correctly recognises structurally-equal object members as duplicates; native JSSet(which uses identity equality for objects) is not used because it can't fulfil this contract. - Native JS
Set<T>is not the plain shape. Three concrete reasons: (1)JSON.stringify(new Set([1,2,3])) === "{}"breaks the plain substrate's export/merge; (2)new Set([1,2]) !== new Set([1,2])— referential inequality breaks structural test comparisons and identity-based caching; (3)Set.prototype.hasuses identity equality for objects.SetRefprovides native-Set-like ergonomics at the ref boundary; storage stays JSON-compatibleT[]. - Currently plain-substrate-only. Both
LoroLawsandYjsLawsexclude"add-wins-per-key", soloro.bind(schema)/yjs.bind(schema)reject any set-bearing schema at compile time. Thecase "set-op"branches in the Loro/Yjs change-mapping modules are intentionally unreachable today — they throw a clear "not supported" error and are kept against the newSetChangevocabulary in case the law restriction is dropped in the future.
PlainSchema is Schema restricted to structural kinds. It appears in two places:
.json()modifier.Schema.struct({...}).json()marks a product as a plain-JSON merge boundary — the entire subtree is replaced atomically on write, not composed CRDT-style. Inside.json(), onlyPlainSchemais permitted. The boundary is part of the schema's identity:computeSchemaHashemits it as a["j", …]tag, sostructandstruct.jsonof the same fields hash differently (they materialize differently — nested CRDT containers vs. one opaque JSON value).- Sum variants. Variants of a
summust all bePlainSchemabecause discriminated-union semantics are structural — a union of CRDTs would require merging across variants, which is not well-defined.
type ExtractLaws<S> = /* walk S, collect every node's [LAWS] */
type RestrictLaws<S, AllowedLaws> = ExtractLaws<S> extends AllowedLaws ? S : never
Each binding target declares its closed law set:
| Target | Laws | Algebraic meaning |
|---|---|---|
json |
AllowedLaws = string (all) |
Authoritative — any law is fine because writes are serialized. |
ephemeral |
EphemeralLaws = "lww" | "lww-per-key" | "lww-tag-replaced" |
LWW-family only — no concurrent merge needed. |
loro |
LoroLaws = "lww" | "additive" | "positional-ot" | "positional-ot-move" | "lww-per-key" | "tree-move" | "lww-tag-replaced" |
Full CRDT law set minus "add-wins-per-key". |
yjs |
YjsLaws = "lww" | "positional-ot" | "lww-per-key" | "lww-tag-replaced" |
Text and structural laws — no "additive", "positional-ot-move", "tree-move", "add-wins-per-key". |
target.bind(schema) applies RestrictLaws<S, AllowedLaws> at the type level. A schema with "additive" in its [LAWS] (from Schema.counter()) cannot be bound to the yjs target — the compiler refuses.
No runtime dispatch, no substrate-specific error messages. The type system is the enforcement mechanism.
- Not closed.
sumvariants are open (you can add more) andproductfields are open (you can nest arbitrary schemas). The eleven kinds are closed; user composition is not. - Not validated at construction, but finite, eager, and acyclic by typing. Product fields are eager
Schemavalues — there is no lazy/thunk field variant and nolazy/recursiveconstructor — so a cyclic schema graph cannot be built through the typed API (struct({ next: () => self })does not typecheck). Recursive/hierarchical data is modeled viaSchema.tree(item), whose schema is finite.canonicalizeSchemarelies on this precondition; anas any-forced cycle is the only way to violate it, and it is caught by a depth cap that throws a clear error (not an opaque stack overflow). - Not self-describing at runtime.
[KIND]is the only tag. Fields, variants, etc. are discovered structurally. NeverObject.keys(schema)to enumerate its kind — pattern-match on[KIND].
Source: packages/schema/src/bind.ts.
Rather than parameterizing a generic namespace with a merge strategy, each substrate is a named BindingTarget — a fixed bundle of (factory, syncMode, allowedLaws):
| Target | Import | SyncMode |
Allowed laws | Substrate |
|---|---|---|---|---|
json |
@kyneta/schema |
SYNC_AUTHORITATIVE |
all (string) |
Plain JS objects, Lamport version |
ephemeral |
@kyneta/schema |
SYNC_EPHEMERAL |
EphemeralLaws ("lww", "lww-per-key", "lww-tag-replaced") |
LWW substrate, wall-clock version |
loro |
@kyneta/loro-schema |
SYNC_COLLABORATIVE |
LoroLaws (full CRDT set minus "add-wins-per-key") |
Loro CRDT doc |
yjs |
@kyneta/yjs-schema |
SYNC_COLLABORATIVE |
YjsLaws (text + structural laws) |
Yjs doc |
Usage:
import { json, ephemeral, Schema } from "@kyneta/schema"
import { loro } from "@kyneta/loro-schema"
import { yjs } from "@kyneta/yjs-schema"
const Config = json.bind(Schema.struct({ theme: Schema.string() }))
const Cursor = ephemeral.bind(Schema.struct({ x: Schema.number(), y: Schema.number() }))
const Todo = loro.bind(Schema.struct({ title: Schema.text(), done: Schema.boolean() }))
const Note = yjs.bind(Schema.struct({ body: Schema.text() }))
No strategy parameter — the sync mode is fixed per target.
bind({ schema, factory, syncMode }) returns a BoundSchema<S>. It captures three decisions at module scope:
- Which schema — the recursive
Schemavalue. - Which factory builder —
(context: { peerId, binding }) => SubstrateFactory<V>. The builder receives the peer's identity and the schema binding; this is how a fresh factory instance is produced per exchange. - Which sync mode — a
SyncModevalue (one ofSYNC_AUTHORITATIVE,SYNC_COLLABORATIVE,SYNC_EPHEMERAL, or a custom record).
The result is a static value: const Todo = loro.bind(schema). The exchange consumes it as exchange.get(docId, Todo).
type BoundSchema<S extends Schema> = {
schema: S
factory: FactoryBuilder<V>
syncMode: SyncMode
manifest: IdentityManifest
schemaHash: string
}
The manifest is derived eagerly by deriveManifest(schema) — a pure function over the canonicalized schema tree. The schemaHash is computeSchemaHash(manifest). Both are cached on the BoundSchema value.
export function createBindingTarget<AllowedLaws, N>(config: {
factory: FactoryBuilder<any>
replicaFactory: ReplicaFactory
syncMode: SyncMode
}): BindingTarget<AllowedLaws, N>
Custom substrate authors use createBindingTarget to build their own targets. The built-in json, ephemeral, loro, and yjs are all constructed this way.
- Not lazy. Both
manifestandschemaHashare computed on construction. Binding at module scope does the work once at import time. - Not runtime-variable. The schema, factory, and sync mode are all captured as values;
BoundSchemahas no runtime parameters. - Not magic.
bindvalidates the migration chain (if any) viavalidateChain, derives the binding, and stores the fields. No side effects on the schema or the factory.
A pure replication conduit (a routing server, a CDN edge, a store-only peer) does not need to interpret document state. It only needs to receive, persist, and re-emit payloads. BoundReplica<V> is BoundSchema<S> minus the schema — it carries the replica factory, sync mode, and schema hash, but not the grammar itself.
This is the three-tier participation model:
| Tier | Interface | Typical role |
|---|---|---|
| Opaque conduit | None beyond SubstratePayload |
Object store, relay |
| Replication conduit | BoundReplica<V> + ReplicaFactory<V> |
Sync server, durability layer |
| Full interpreter | BoundSchema<S> + SubstrateFactory<V> |
Any participant that reads, writes, or observes state |
Source: packages/schema/src/substrate.ts.
Three interfaces, connected by the variance-safe -Like convention:
interface ReplicaLike {
version(): Version
baseVersion(): Version
exportEntirety(): SubstratePayload
exportSince(since: Version): SubstratePayload | null
advance(to: Version): void
merge(payload: SubstratePayload, options?: BatchOptions): void
}
interface Replica<V> extends ReplicaLike {
version(): V
baseVersion(): V
// exportSince, advance, merge inherited from ReplicaLike
}
interface Substrate<V> extends Replica<V> {
reader(): Reader
writable(): WritableContext
prepare(): SubstratePrepare
context(): RefContext
}
ReplicaLike is the minimal replication contract — what the synchronizer needs. All version-typed positions use the base Version type so the synchronizer can hold heterogeneous replicas in a single Map without variance escapes. Named after the TypeScript -Like convention (PromiseLike, ArrayLike): a structural interface that the full Replica<V> satisfies.
Replica<V> extends ReplicaLike with concrete version types. External consumers (binding targets, factories) use this for compile-time version-type safety on return values (version(): V, baseVersion(): V). Input methods (exportSince, advance) inherit the wider Version parameter type from ReplicaLike.
ReplicaFactoryLike / ReplicaFactory<V> follow the same pattern: a variance-safe structural interface and a narrow extension with concrete return types.
The split exists because TypeScript treats generics as invariant: Replica<LoroVersion> is NOT assignable to Replica<Version>, even though LoroVersion extends Version. The -Like interfaces solve this by using Version in all positions, making them assignable from any concrete Replica<V>.
Every replica exposes six methods:
version()→ the current state's version.baseVersion()→ the earliest version retained (trimmed history starts here).exportEntirety()→ full state as an opaque payload.exportSince(since)→ delta relative to the given version, ornullif not possible.advance(to)→ trim history up to the given version.merge(payload, options?)→ fold an incoming payload into local state.options.originpropagates through the changefeed as an app-level label; the substrate forcesreplay: trueon the resultingChangesetso layered consumers (e.g. the exchange's echo filter) can discriminate the merge from a local write.
A Substrate adds interpretation:
reader()→ plain reads by path.writable()→ mutation primitives (replace,insert,delete,increment, etc.).prepare()→ the flush pipeline that turns accumulated mutations into a singlemergecall (plus notifications).context()→ theRefContextthe interpreter stack closes over.
Mutations apply eagerly per the σ-eager design (jj:kqnkxrkl). For each prepare(path, change):
- Capture
pre = path.read(σ)(deep-cloned) before the change applies. - Compute
inverse = invert(pre, change)— the reverse arrow in the change groupoid. - Push
{ path, inverse }on the active runBatch frame's inverse stack via theRECORD_INVERSEcallback threaded through options. - Advance σ via
applyChange(shadow, path, change). - Advance λ via the substrate-native path (Loro: applyDiff or coalescing buffer; Yjs: applyChangeToYjs inside the ambient transact).
The inverse stack belongs to the bracket primitive (WritableContext.runBatch's wrapper). On the bracket's depth-0 success release, the frame's inverse range is discarded and ctx.flush(opts) fires. On a throw, the catch path replays the frame's inverses LIFO through ctx.prepare(path, inverse, { compensating: true }) (the substrate skips inverse recording under the undo-replay handler), then flushes with aborted: true, then rethrows. The bracket's commit contains forward + inverse ops with net-zero delta when the outermost throws.
This is how batch(doc, d => { d.title.insert(0, "hi"); d.items.push(x); }) becomes one atomic changefeed emission with read-your-writes inside the block, and how a throwing block becomes one batched native event with net-zero delta plus one Changeset with aborted: true.
resolveContainer in substrate backends (e.g. loro-resolve.ts) handles sum boundaries by switching to plain JS property navigation for remaining path segments once a sum schema node is encountered. This is sound because sum variants are always PlainSchema — no Loro containers (or other CRDT containers) exist inside sums. The Yjs backend's resolveYjsType follows the same pattern. When advanceSchema reaches a sum, remaining segments are resolved via plain obj[key] access rather than substrate-specific container descent.
Source: packages/schema/src/version-vector.ts.
For substrates whose V is a map of PeerId → number (Lamport-style vectors), two helpers are provided:
versionVectorMeet(a, b)→ the greatest lower bound. Component-wise minimum.versionVectorCompare(a, b)→-1 | 0 | 1 | "concurrent". Determines whether one version strictly precedes the other, equals it, or is concurrent.
Both are pure. Loro and Yjs substrates use these directly for their Lamport vectors; substrates with different version shapes (wall clock, Loro's opaque version) implement their own comparison.
Source: packages/schema/src/hash.ts → computeSchemaHash, HASH_ALGORITHM_VERSION, fnv1aHex.
computeSchemaHash(schema) is a pure, content-addressed function:
- Build a canonical tuple (
canonicalTuple): a recursively-nested value of arrays and strings only — never objects (object key order is engine-defined; array order is positional and stable). Field names are alphabetized; theJSON_BOUNDARYmarker (.json()) is emitted as a["j", inner]tag; scalar constraint values go throughserializeConstraintValue(shared withdescribe/validate). - Serialize once with
JSON.stringify. Because JSON escapes every user-controlled string (field names, constraint values, mark names), they cannot forge structural delimiters — canonicalization is injective by construction (distinct schemas ⟹ distinct bytes), not by a per-site escaping discipline. - Hash with single-pass FNV-1a-128 over UTF-8 bytes (
@sindresorhus/fnv1aatsize: 128). - Return a 34-character lowercase string:
HASH_ALGORITHM_VERSION(2 chars) + 32-char hex of the 128-bit hash.
canonicalTuple assumes a finite, eager, acyclic node tree — guaranteed by the grammar (see What the grammar is NOT) — and guards the unsupported as any-forced-cycle case with a recursion depth cap that throws a clear error rather than overflowing the stack.
The hash is carried in every present message (the exchange's doc-announcement protocol). Receivers compare the incoming hash against their local BoundSchema.schemaHash:
- Match → structurally identical schemas; safe to sync.
- Mismatch → different schemas; receiver consults
supportedHashes(from theMigrationChainwalk) to see if a compatible ancestor exists. - No compatible version → reject.
The 2-char prefix is a TLV-style algorithm-version tag. Bumping it signals a coordinated change to the hash bytes (algorithm swap, canonicalization change, or input-encoding shift). Current value is "02". Retired versions:
"00"— two-pass FNV-1a-64 with a shared prime over UTF-16 code units; overstated its effective entropy (jj:snrmsznm)."01"— single-pass FNV-1a-128 over UTF-8, but with an S-expression canonicalization that dispatched on[KIND]only: it was boundary-blind (struct≡struct.json) and non-injective (unescaped field names / constraint values could collide). Replaced by the injective JSON-tuple form (jj:qnmtvtwn).
Ecosystem code that asserts on the prefix (wire-format validators, store-migration tooling) should import HASH_ALGORITHM_VERSION rather than hardcoding the string.
- Fast and deterministic across JS runtimes (no
crypto.subtle, no WASM). BigInt-native in the library. - 128 bits is wide enough to eliminate collision concern for the hundreds-to-millions of distinct schemas any real deployment will see.
- Hex-encoded for readability in logs, wire frames, and test assertions.
- Standards-conformant —
@sindresorhus/fnv1ahashes UTF-8 bytes (the canonical FNV-1a interpretation). The previous in-house implementation hashed UTF-16 code units; standards-conformance was one motivation for the swap.
- Not cryptographic. FNV-1a is not collision-resistant against adversaries. It is collision-resistant against natural schema variation. Kyneta does not use the hash for authentication.
- Not random. Rebuilding a schema identically produces the same hash in every run. This is what makes the wire protocol deterministic across deployments.
- Not a version number. Two different schemas do not have "newer" / "older" hashes; they are different identities. Migration chains express evolution.
Source: packages/schema/src/interpret.ts, src/interpreters/*, src/layers.ts, src/ref.ts.
An Interpreter<Ctx, A> is an F-algebra over the schema functor — one method per [KIND]:
interface Interpreter<Ctx, A> {
scalar: (ctx: Ctx, path: Path, schema: ScalarSchema) => A
product: (ctx: Ctx, path: Path, schema: ProductSchema, fields: Record<string, () => A>) => A
sequence: (ctx: Ctx, path: Path, schema: SequenceSchema, item: (i: number) => A) => A
map: (ctx: Ctx, path: Path, schema: MapSchema, item: (k: string) => A) => A
sum: (ctx: Ctx, path: Path, schema: SumSchema, variants: SumVariants<A>) => A
text: (ctx: Ctx, path: Path, schema: TextSchema) => A
counter: (ctx: Ctx, path: Path, schema: CounterSchema) => A
set: (ctx: Ctx, path: Path, schema: SetSchema, item: (k: string) => A) => A
tree: (
ctx: Ctx,
path: Path,
schema: TreeSchema,
nodes: () => readonly FlatTreeNode<A>[],
node: (id: string) => A,
) => A
movable: (ctx: Ctx, path: Path, schema: MovableSequenceSchema, item: (i: number) => A) => A
}
interpret(schema, ctx) walks the schema tree, invoking the interpreter at each node. The child thunks (() => A, (i) => A, (k) => A) preserve laziness — composite interpreters can short-circuit recursion when capability requirements are not met.
Pre-built layers compose fluently via InterpretBuilder.with(layer).done():
| Layer | Transformer | Adds capability |
|---|---|---|
| 1. Bottom | bottomInterpreter |
Identity ref: [CHANGEFEED], [NATIVE], [SUBSTRATE], [CALL] carrier |
| 2. Navigation | withNavigation |
Structural descent (.fieldName, .index(i), .key(k)) |
| 3. Readable | withReadable |
.current, (), read(path) — requires navigation |
| 4. Addressing | withAddressing |
Stable identity: [ADDRESS_TABLE] — requires navigation |
| 5. Caching | withCaching |
INVALIDATE + identity-preserving memoization — interposes above readable. registerCacheHandler composes handlers at the same path key (rather than overwriting), which is critical for sum fields where the parent product and the variant product both register handlers at the same path — both must fire on invalidation. |
| 6. Writable | withWritable |
Mutation primitives: REMOVE, TRANSACT, insert, delete, replace, increment, text/sequence builders |
Substrate Capabilities: Substrates declare optional capabilities (nativeResolver, positionResolver, treeNodeAllocate) via the SubstrateCapabilities bag — the builder (buildWritableContext) attaches them as non-enumerable, non-writable properties keyed by the canonical names (or symbols, for TREE_NODE_ALLOCATE). Consumers narrow via type guards (hasTreeNodeAllocation) or the typed optional fields on RefContext.
DevTools history (DEVTOOLS_HISTORY): an optional, substrate-neutral pull capability (sibling of BACKING_DOC/TREE_NODE_ALLOCATE) for DevTools — summary() (serialized version + opCount + per-actor counters) and optional valueAt(version) time-travel. Guard with hasDevtoolsHistory(); absence is graceful. Loro implements it deeply (fork()-based valueAt), Yjs gives a summary, plain omits it. Read lazily via exchange.docHistory(docId) — never pushed through the observation bus.
WritableDiscriminantProductRef — the writable surface for discriminated unions. For a DiscriminatedSumSchema<D, V>, the writable ref exposes all fields (discriminant and non-discriminant) as Plain<F[K]> — that is, read-only values. Non-discriminant fields are callable (you can read them) but carry no .set(). The only mutation primitive is .set() on the union ref itself (via ProductRef) for whole-value replacement. This follows from sum interiors being opaque LWW values: variant fields are not independently addressable CRDT positions, and individual field mutation would violate the atomic replacement semantics of lww-tag-replaced.
Plus the orthogonal observation layer:
| Layer | Transformer | Adds |
|---|---|---|
| Observation | withChangefeed |
subscribe, subscribeNode, RecursiveChangefeedProtocol<S> |
The canonical "everything" stack:
const ref = interpret(schema, ctx)
.with(navigation)
.with(readable)
.with(addressing)
.with(writable)
.with(observation)
.done()
Or equivalently, createRef(schema, ctx) which produces this stack.
Source: packages/schema/src/interpreters/materialize.ts.
createMaterializeInterpreter(resolver) produces a generic Interpreter<void, unknown> that builds plain values from any CRDT backend. The MaterializeResolver interface abstracts the 6 backend-specific operations into two families:
Leaf resolvers (return typed value or undefined = not present):
resolveValue(path)— scalar and sum valuesresolveText(path)— text content as stringresolveCounter(path)— counter value as numberresolveRichText(path)— rich text delta
Container shape resolvers (return structure metadata):
resolveLength(path)— item count for sequences and movable listsresolveKeys(path)— key enumeration for maps and sets
The 11 interpreter cases partition into container cases (product, tree — structurally identical for all backends, no resolver calls) and resolution cases (the remaining 9, each calling one of the 6 resolver methods). Zero fallback is delegated to zeroInterpreter (scalars) and Zero.structural (sums), making the materializer the canonical consumer of zero defaults for CRDT substrates.
Three resolution cases — sequence, movable, set — share an array-collection pattern, factored into collectArrayByLength(length, item) and collectArrayByKeys(keys, item). Sequence and movable use the length-based helper; set uses the keys-based helper. All three produce Plain<I>[] — materialize.set is not identical to materialize.map: sets project to T[] while maps project to Record<string, T>. The catamorphism's separate set branch carries semantic weight here, even though the storage-layer key enumeration is the same as map's.
Each backend provides a thin resolver factory (~50 lines): createLoroResolver(doc, schema, binding) and createYjsResolver(rootMap, schema, binding). The closure-based design parallels plainReader(state) → Reader — the resolver closes over backend state, eliminating Ctx threading.
- Not a visitor pattern. Interpreters return values; visitors mutate state.
interpretis a catamorphism, not a traversal. - Not layered dynamically. Layers compose at the type level. Once
.done()is called, the stack is fixed. - Not framework-aware. No React, no DOM. The
Ref<S>the stack produces is a pure object with a[CHANGEFEED]surface; framework bindings (@kyneta/react,@kyneta/cast) adapt it.
The 11 interpreter cases fall into four structural categories. The first three are duplication families — groups of cases that share identical logic across every transformer, captured by shared helper modules. The fourth has unique per-case logic.
| Family | Cases | Shared helpers | Shared algebra |
|---|---|---|---|
| Indexed (positional) | text, sequence, movable, richtext |
sequence-helpers.ts — at(), installTextWriteOps, installListWriteOps, installRichTextWriteOps, installSequenceReadable, installSequenceNavigation, installSequenceAddressing, installSequenceCaching |
Instruction, foldInstructions, transformIndex, advanceAddresses |
| Keyed (named) | map |
keyed-helpers.ts — installKeyedWriteOps, installKeyedReadable, installKeyedNavigation, installKeyedAddressing, installKeyedCaching |
MapChange, keyed addressing/tombstoning |
| Leaf (terminal) | scalar, text, counter, richtext, set |
wireChangefeed in with-changefeed.ts unifies changefeed boilerplate; set-helpers.ts provides installSetReadable and installSetWriteOps for the value-addressed set surface |
createLeafChangefeed, SetChange, isSameSetMember |
| Structural (unique) | product, sum, tree |
None — each has unique per-case logic | Product: schema-driven fields + discriminant. Sum: store-based variant dispatch. Tree: thin pass-through. |
text and richtext straddle two families. They are indexed for writable (share at() and the retain/insert/delete instruction stream with sequence/movable) but leaf for readable, navigation, and changefeed (return string / delta directly, not a fold over children). Characters are not independently addressable refs.
set is leaf-shaped at the ref layer. Although the catamorphism dispatches set children by string key (mirroring map), there are no per-member child refs at the user-facing API. The surface is .has(value), .add(value), .delete(value), .clear(), .size, [Symbol.iterator], callable returning Plain<I>[] — narrower than map's, and value-addressed (no .at(value)). Invalidation is whole-carrier on any SetChange (same pattern as text/counter). See §Set: value-addressed leaf.
The Interpreter interface retains separate cases per kind — the sharing is internal to the built-in transformers. Substrate authors implement one case per kind; they never see the shared helpers.
The materialize interpreter is another duplication family — all CRDT backends share the same 11-case structure, varying only in resolution. The MaterializeResolver abstraction captures this by decomposing resolution into leaf resolvers (value, text, counter, richtext) and container shape resolvers (length, keys) that mirror the indexed/keyed duplication families.
attachNative is intentionally skipped for sums in interpretImpl. Sums are structurally transparent — the result carrier is the dispatched variant's carrier, which already has the correct [NATIVE] from its own interpreter case (product, scalar, etc.). Calling attachNative on the sum would double-define the property, crashing in substrates where the product resolves to a real container but the sum resolves to undefined (configurable: false + different value → TypeError).
NativeMap<S> is a type-level mapping from schema kinds to substrate-native types. ref[NATIVE] returns the underlying container — LoroText for a text on Loro, Y.Map for a product on Yjs, a plain object for the plain substrate. unwrap(ref) (src/unwrap.ts) is the typed escape hatch that returns NativeMap<S>.
Application code rarely touches [NATIVE]. Backends use it to dispatch to substrate-specific APIs. It is the only path through which substrate-specific behaviour leaks through the interpreter stack — and it is explicit at the call site.
Source: packages/schema/src/facade/batch.ts, src/step.ts, src/inverse.ts, src/interpreters/with-changefeed.ts, src/interpreters/writable.ts.
batch(doc, fn) is the atomic mutation facade. Under the three-primitive substrate contract (jj:ryquprut), it is implemented as a thin runWriter / execWriter wrapper around ctx.runBatch:
batch(doc, fn) = ctx.runBatch(() => {
const marker = ctx[FORWARD_OPS_MARKER]()
fn(doc)
return ctx[FORWARD_OPS_SINCE](marker)
}, opts)Convention. A single mutation needs no batch() — a bare helper call (doc.x.set(v)) opens an implicit single-op runBatch and auto-commits (jj:kqnkxrkl). Reach for batch() only to (a) group ≥2 writes into one atomic commit + one Changeset, (b) capture the returned Op[], or (c) attach origin/source provenance. The name leads with batching; the atomic-abort guarantee (a throwing block compensates LIFO and emits one Changeset with aborted: true) is the contract that makes a multi-write batch safe — it is still a transaction in the algebraic sense, just not a DB-style transaction with isolation/durability.
End-to-end flow:
changeresolvesref[TRANSACT]→ theWritableContext.ctx.runBatch(work, opts)opens a frame (push onframeStarts/inverseStack). At depth-0 entry it invokes the substrate'srunBatchbracket (Lorodoc.commit(), YjsY.transact) wrapping the whole body.fn(doc)runs. Insidefn, each helper (.set,.push,.insert, …) routes throughctx.dispatch(path, change)— the depth-aware combinator. Inside a frame, dispatch is justctx.prepare; outside any frame it opens an implicit single-op runBatch (auto-commit).ctx.preparewrites to the writer log (forbatch()'s return value), callssubstrate.prepare. The substrate captures σ at the change's target path, computes the inverse viainvert(pre, change)and records it on the active frame, then advances σ and λ in lockstep.- After
fnreturns, the bracket's depth-0 release callsctx.flush(opts)exactly once →wrappedFlush→planNotifications→deliverNotifications. OneChangesetper affected subscriber path. - If
fnthrows, the catch path replays this frame's recorded inverses LIFO throughctx.prepare(path, inverse, { compensating: true }), then flushes withaborted: true, then rethrows. External observers see one batched native event whose ops net to zero.
The substrate's runBatch bracket invocation is gated on frameStarts.length === 0: substrate.runBatch is invoked at most once per outermost block, regardless of how deeply dispatch nests. The exchange sees the transaction as a single merge source: after commit the substrate's exportSince() captures the entire delta.
Source: src/facade/batch.ts.
Sometimes changes arrive as data (from the network, from undo history, from tests). applyChanges(ref, changes) applies a readonly Change[] via the same substrate write path — no prepare facade, just direct substrate writes + notification planning.
Source: src/facade/batch.ts.
A container's child ref carries [REMOVE]() (a symbol method — see Removable<T> = T & HasRemove in src/ref.ts), symbol-keyed for collision safety: a child can be any schema kind, including a struct with a user field literally named remove, so a plain .remove() method would shadow it. remove(ref) is the free-function facade over that symbol — the same collision-safe symbol-protocol + free-function-facade pattern as unwrap ([NATIVE]), changefeed ([CHANGEFEED]), and batch ([TRANSACT]). Prefer remove(ref) at call sites; reach for ref[REMOVE]() only when you already hold the symbol. Like any single mutation, a lone remove() auto-commits (no batch() needed). It throws on a dead ref, and its HasRemove parameter type rejects non-removable refs (product fields, top-level docs) at compile time.
Source: packages/schema/src/step.ts.
For testing and reasoning, step(state, change) → state is the pure transition function. It handles every built-in change type (stepText, stepSequence, stepMap, stepReplace, stepIncrement, stepFold). The plain substrate uses step internally; tests use it to verify change semantics without constructing a substrate.
- Read-your-writes inside
fn(post-jj:ryquprut). σ advances eagerly on every prepare, so reads insidebatch(doc, fn)reflect prior writes within the same block.d.todos.push("a"); d.todos.push("b")appends in order. Pre-refactor this silently reordered because length-derived helpers read a stale σ. - Not async.
batch()is synchronous. The substrate's writes happen synchronously duringfn. Notifications for the originating transaction fire synchronously at commit; re-entrantbatch()calls from inside a subscriber land in the per-context dispatcher's pending queue and produce a separateChangesetin a fresh sub-tick of the same outer call — still synchronous from the caller's perspective. - Not an effect system. Side effects inside
fn(network calls, DOM writes) run where they are called. Only the substrate-writable mutations are captured.
Subscriber callbacks may mutate freely. batch() invoked from inside subscribe(doc, ...) or subscribeNode(doc.field, ...) does not throw — with-changefeed's per-context dispatcher (from @kyneta/machine's createDispatcher) enqueues an accumulate Msg and the drain-to-quiescence loop processes it in a fresh sub-tick.
Substrate writes inside the re-entrant batch() remain synchronous — subsequent reads see the new state. The sub-tick's mutations produce their own Changeset once the inner batch() commits, delivered to subscribers after the originating Changeset.
When the host is an Exchange, every per-doc dispatcher shares the Exchange's Lease with the Synchronizer. Cross-doc A→B→A cascades, and tick-induced re-entry through the synchronizer, are bounded by one cooperating budget. A runaway oscillation throws BudgetExhaustedError whose message names the cascade's entry-point frame, a top-N message-type histogram, and a recent-event tail — the label histogram is the cascade topology and the count distribution names the hot path, so users can locate the responsible subscriber without ad-hoc instrumentation (jj:tozwpvuu).
See @kyneta/machine's TECHNICAL.md §"Drain to quiescence and shared leases" for the primitive.
deliverNotifications iterates subscribers [S1, S2, S3]. If S1 calls batch(doc, ...) synchronously, S1's substrate writes land before S2 fires. S2 receives the Changeset describing the originating transaction, but reads from — and may write through — a substrate that already includes S1's mutations.
This invariant is uniform across all substrates — plain, Loro, Yjs — because every substrate now advances both of its state stores in lockstep at prepare-time:
- σ (the shadow, the reader's view) advances eagerly via
applyChange(shadow, path, change). - λ (the native container tree, the change-mapping's view) advances eagerly too: PlainSubstrate has λ ≡ σ; CRDT substrates run their native mutation primitive immediately during
prepare(Loro coalesces plain MapDiff writes and applies structural inserts on the spot; Yjs invokesapplyChangeToYjsagainst the liveY.Docinside the ambient transact opened byrunBatch).
Concretely, the projection law σ ≡ Π(λ) (the naturality condition of the materialisation catamorphism) holds at every prepare boundary. A re-entrant subscriber may either read through σ (via the Reader / the ref [CALL]) or write through λ (via re-entrant batch(), which itself walks λ through changeToDiff/applyChangeToYjs) — both views are coherent.
When the outer batch is a replay batch from a substrate event bridge (e.g. an incoming sync merge), S1's local re-entrant write inside the replay-batch delivery is not a replay (the user code constructs a normal batch(doc, ...) with no replay flag), so the substrate's prepare/afterBatch apply it natively. Pre-fix this case was the source of a hidden invariant hole on CRDT substrates: an inEventHandler/inOurTransaction global flag wrapped the entire event-bridge call and caused the substrate to silently drop S1's write. Resolved by threading BatchOptions.replay as a typed parameter; see §Origin vs replay.
Two guidances:
- The
Changesetyou receive describes the transaction that triggered your callback. - The substrate state you read (and can safely write through) reflects everything up to now, including re-entrant writes from earlier subscribers in the same deliver batch.
To derive "pure pre-mutation state," consume the Changeset semantically; do not infer it by reading the substrate. This was always true in spirit — subscribers run after substrate commit — and the dispatcher refactor only changes whether re-entry from S1 succeeds (now) or throws (pre-1.6.0).
A Changeset with aborted: true is the bracket's signal that the outermost batch(doc, fn) block threw and was wholly compensated via inverse replay. The op list contains forward + inverse pairs that net to identity at every path. Inner batch()s that threw and were caught by an outer batch()'s try/catch produce a NON-aborted outermost Changeset; the absorbed forward + inverse pair sits in the op list alongside surviving outer ops. Consumers needing to identify absorbed inner aborts pair the ops semantically (the framework doesn't surface a separate flag for this).
The aborted flag is tightened: true iff the outermost block threw. Auto-commit blocks and successful outermost blocks have aborted: undefined (== falsy). Replay batches have aborted: undefined.
Under the three-primitive substrate contract (jj:ryquprut), ctx.runBatch is one bracket primitive with three handlers, not three concentric brackets. Inside the bracket, prepare is the single effect; the three handlers all key off the same frameStarts.length depth:
-
Substrate handler — invoked only at the depth-0 entry. Loro:
doc.commit()at the wrap-end. Yjs:Y.transact(doc, work, KYNETA_ORIGIN). PlainSubstrate omits this method; the ctx-level wrapper invokes the body directly. The Loro per-substrate depth counter is no longer needed — ctx-level outermost detection subsumes it. -
Changefeed-flush handler — fires exactly once at the depth 1→0 transition. Success path:
ctx.flush(opts). Catch path:ctx.flush({ ...opts, aborted: true }). Inner frames push/pop without flushing — the depth-0 release is the single delivery point per outermost block. -
Inverse-stack handler — every successful
preparepushes anInverseEntry(path + reverse arrow). On throw, the frame's range is replayed LIFO throughctx.prepare(path, inverse, { compensating: true }). Substrates skip inverse recording under the undo-replay handler (thecompensatingflag signals "this prepare is replaying an inverse, not applying a new forward change"). External observers see one batched native event whose ops net to zero.
The three handlers are co-extensive — they all open and close at the same boundary. executeBatch invokes ctx.runBatch for local-write batches; replay batches bypass it (the substrate's native state already absorbed those ops at the event-bridge call site, so there's no need for a bracket).
Substrate.runBatch is invoked at most once per outermost batch(doc, fn) — re-entrant subscriber writes open their own outermost runBatch (frameStarts goes to 0 between outer flush and subscriber re-entry), each block is its own atomic abort unit and gets its own commit.
Gotcha: Compensation masking with buffered substrates. If a substrate (like Loro) buffers changes (e.g., coalesceBuffer) or throws synchronously during prepare, Kyneta's eager inverse recording causes the compensation loop to apply inverses for changes that were never actually committed to the substrate. This can cause the compensation loop itself to crash (e.g., throwing "Index out of bound" when attempting to revert an uncommitted insert). A try/catch in the compensation loop ensures the original error is chained via Error.cause, but the architectural mismatch between eager inverse recording and buffered substrate application remains a known limitation.
Future Direction: This will eventually be resolved by a deeper architectural shift, such as a "two-phase prepare" (recording inverses only after successful substrate application) or by pushing transaction boundaries and rollback responsibilities down to the substrate.
BatchOptions extends BatchMetadata (defined in @kyneta/changefeed) with one upstream-only field compensating. Four channels ride on every batch through executeBatch → ctx.prepare → ctx.flush → substrate.prepare → substrate.onFlush, all surfacing on the delivered Changeset via BatchMetadata:
-
origin— opaque application-level label. Propagates toChangeset.originso subscribers can categorize batches ("sync","undo","migration"— or anything else). The schema layer and the exchange never branch on origin's value. It is free vocabulary for app code. -
replay— kyneta-internal structural directive.trueiff the batch represents state authored elsewhere: substrate event bridge replayingdoc.import, amergepayload, or version travel. Substrates with external mutation paths (Loro, Yjs) skip native-side work inprepare/onFlushwhenreplay: true(the native state already absorbed the change); the changefeed layer still deliversChangesetnotifications, and surfacesreplay: trueto subscribers. The plain substrate ignoresreplayinpreparebecause it has no out-of-band mutation path. User-facing APIs (change,applyChanges) never constructreplay: true— only substrate event bridges andmergepaths do. -
source— identity-typed echo-suppression token. Compared with===by subscribers that issued the change. Unlikeorigin(app vocabulary) andreplay(kyneta-internal structural directive),sourceis a kyneta-managed handshake between writer and reader: the originatingbatch()caller mints a token (Symbol("...")or{}), passes it viaoptions.source, and the same token round-trips toChangeset.sourceso the caller's subscriber can identify and skip its own writes. The schema layer NEVER branches onsource's identity — it threads it through the pipeline unchanged, the same way it threadsorigin. Substrate replay paths explicitly dropsource—sourcenever survives a CRDT round-trip; any value reaching a subscriber is therefore from a localbatch()on this peer. -
aborted— kyneta-internal outcome directive. See §"Changeset.aborted" above.
These four fields are orthogonal — they form a two-axis classification (app-set / subscriber-set / kyneta-set × provenance / outcome). See BatchMetadata in @kyneta/changefeed's TECHNICAL.md for the full table.
Layered consumers that need to discriminate "echo from sync" from "local write" — notably @kyneta/exchange's auto-subscribe filter — read Changeset.replay rather than parsing the origin string. This closes a fragile string-collision surface where batch(doc, fn, { origin: "sync" }) was accidentally suppressed and doc.import(payload, "from-some-other-pubsub") would echo to peers. Context: jj:qpultxsw.
The "schema layer and exchange never branch on origin's value" invariant is again structurally true after jj:wpvtoxmw — the conflation that had crept into text-adapter (origin === "local" for echo suppression) and Line (a dead origin === "local" filter) was rectified by introducing the identity-typed source channel and removing the dead Line filter.
Kyneta is a translucent layer over the underlying CRDT. The user-facing origin slot (batch.origin in Loro, transaction.origin in Yjs) is reserved for options.origin round-trip — providers and ecosystem libraries (Yjs UndoManager, y-websocket, etc.) depend on this slot being app-controlled. The substrate's "is this event mine?" discriminator must travel via the CRDT's own event-machinery channels, not via the origin slot.
Kyneta's "bring your own doc" position is that the underlying CRDT remains fully usable by raw consumers and ecosystem tooling. The Yjs ecosystem in particular routes provider identity (y-websocket, y-indexeddb, y-webrtc) and orchestration filters (UndoManager.addTrackedOrigin) through transaction.origin — colonizing that slot with a kyneta sentinel forces every kyneta-using app to either fork those tools or accept that kyneta is opaque to the rest of its ecosystem. The same logic applies to Loro's batch.origin as its provider ecosystem matures. Translucency isn't a stylistic choice — it's load-bearing for interop. Any future "let me just put a small flag on transaction.origin" proposal must answer: how does this not break the provider ecosystem?
subscribePreCommit hook captures per-commit identity (peer, counter+length-1) synchronously inside doc.commit(); subscribe handler matches via batch.to entries.
transaction.meta.set(MARK, true) inscribed from inside the Y.transact body; observeDeep checks transaction.meta.get(MARK). Survives Yjs's nested-transact collapse.
Loro models commits as a discrete API call (doc.commit() is separate from the pending mutations); Yjs models transactions as a body callback (Y.transact(body) runs work inside an opened transaction object). The pre-commit hook is Loro's analog of "code that runs inside the transaction"; transaction.meta is Yjs's analog of "intrinsic per-commit identity that travels with the event." Same principle (origin-free, CRDT-native machinery), substrate-shaped expression.
Mixing raw CRDT mutations with kyneta batch() calls inside the same atomic unit (Yjs transact body, or Loro pending ops accumulated before a kyneta-issued commit) is unsupported. The raw mutations will be silently absorbed into kyneta's own-commit skip and not bridged to the changefeed. Use separate transacts/commits for raw mutations. This is a fundamental limit of commit-level discrimination — no origin-free approach can address it without op-level provenance, which neither CRDT exposes.
Context: jj:uvykupvx.
The substrate is a functor Π : ChangeGroupoid → NativeStateCategory. Three names show up across prepare, afterBatch, runBatch, the inverse stack, and the materialisation interpreter:
- σ — the shadow, a plain JS object materialized from the native CRDT tree. The Reader closes over σ; all
ref[CALL]reads bottom out here. - λ — the native CRDT container tree. For Loro:
LoroDoc+ itsLoroMap/LoroList/LoroText/LoroTreechildren. For Yjs:Y.Doc+Y.Map/Y.Array/Y.Text. For PlainSubstrate: λ ≡ σ. - Π — the materialisation catamorphism:
materializeLoroShadow,materializeYjsShadow. Produces σ from λ in one pass.
The projection law σ ≡ Π(λ) is the naturality of Π between the abstract state and the CRDT-native state. It holds at every prepare boundary. Stated as two naturality conditions over the change groupoid:
- Forward:
Π ∘ step_λ(c) = step_σ(c) ∘ Π - Inverse:
Π ∘ step_λ(invert(c)) = step_σ(invert(c)) ∘ Π
Both must hold. Naturality over invert is what makes the abort path correct: when the bracket replays inverses inside the same commit, the σ-side compensation matches the λ-side compensation step-for-step, so external observers see one batched event with net-zero delta simultaneously on σ AND λ. A backend whose applyChange is not natural over invert would fail abort silently (σ revert, λ partial — or vice versa).
Substrate-implementation contract: any backend whose applyChange is a natural transformation over the change groupoid (forward AND inverse arrows) automatically gets correct abort for free. PlainSubstrate is the degenerate case (σ ≡ λ, Π = id; both naturality squares hold trivially). Loro and Yjs satisfy naturality by design.
Replay can't use incremental σ-step: CRDT merge is a lattice join with no sequential decomposition. The correct response is syncShadow(materialize(λ)) in afterBatch on replay — re-materialise σ from λ in one Π pass.
Source: packages/schema/src/inverse.ts.
The change algebra ⟨State, Change, step⟩ is extended into a groupoid by invert(pre, change): a reverse arrow such that step(step(pre, change), invert(pre, change)) = pre. This is the groupoid identity law c ∘ c⁻¹ = id written in coordinates; the per-type test table pins it for every ChangeBase constructor.
| Type | Inverse shape |
|---|---|
replace |
swap value (replaceChange(pre)) |
increment |
negate amount |
text |
OT inverse: retain → retain, insert → delete, delete → insert (text from pre at preCursor) |
sequence |
OT inverse with deep-cloned items |
map |
restore prior entries; new keys → delete; overwritten keys → set to prior value |
set |
swap add/remove (set membership equality, not order) |
richtext |
OT inverse with mark restoration |
tree |
per-instruction inverse with pre-state topology lookup; reversed instruction order for LIFO undo |
Substrates capture pre = path.read(σ) (deep-cloned via deepClonePreState) before applying the forward change, compute the inverse, push it onto the active runBatch frame's stack via the RECORD_INVERSE callback threaded through prepare options. On throw, the bracket's catch path replays inverses LIFO inside the same commit — observers see one batched event with net-zero delta.
WritableContext.dispatch is a depth-aware combinator. The 5 ref-helper files (scalar.set, sequence.push, etc.) and the addressing layer's REMOVE handler all route through it. Its polymorphism shifted under jj:ryquprut:
- Pre-refactor:
dispatch = inTransaction ? buffer : applyImmediately. Buffered changes accumulated inpending; commit flushed them. - Post-refactor:
dispatch = frameStarts.length === 0 ? implicitSingleOpRunBatch : justPrepare. In-block dispatch is just a prepare (the outer frame owns the flush boundary); out-of-block dispatch opens an auto-commit single-op runBatch.
Both shapes are polymorphic combinators with a local condition; the new role is structurally simpler. Keeping the combinator avoids 5+1 files of mechanical helper conversion and eliminates per-helper substrate-bracket re-entry overhead — in-block helpers collapse into one substrate commit + one Changeset.
Source: packages/schema/src/changefeed.ts, src/interpreters/with-changefeed.ts.
Every schema-issued changefeed implements RecursiveChangefeedProtocol — the schema-specific extension of @kyneta/changefeed's universal ChangefeedProtocol. It adds subscribeDescendants, which delivers own-path + every descendant in one Changeset<Op> where each Op = { path, change } carries the relative path from the subscription point.
interface RecursiveChangefeedProtocol<S, C> extends ChangefeedProtocol<S, C> {
current: Plain<S>
subscribe(callback: (changeset: Changeset<C>) => void): () => void
subscribeDescendants(callback: (changeset: Changeset<Op<C>>) => void): () => void
}
For a composite ref, subscribeDescendants aggregates own-path changes with children's tree-streams (paths prefixed appropriately). For a leaf ref, subscribeDescendants is the trivial own-path lift: every change is delivered as a single Op whose path is the leaf's registry-aware root (empty relative path). A leaf is a tree of size 1.
subscribe (own-path only, Changeset<C> shape with no paths) is the lighter sibling. The two channels carry the same information for a leaf and different information for a composite (where own-path ⊊ tree).
Facade vs. protocol vocabulary inversion: facade subscribe is deep delivery (Changeset<Op>); the protocol-level ChangefeedProtocol.subscribe is own-path delivery (Changeset<ChangeBase>). The facade hides this; power users reaching directly into ref[CHANGEFEED] should know it.
Principle. Facade-level entry points should hide protocol-method-set distinctions when the user's semantic is well-defined regardless of carrier kind. "Subscribe to changes under this ref" is well-defined for any reactive value; whether the value happens to have children is a structural concern, not an observation concern. Pre-1.6.0 the facade threw on
subscribe(leaf)because leaves lackedsubscribeDescendants; 1.6.0 retires that leak by liftingsubscribeDescendantsto every schema-issued changefeed.
The pure helpers. liftToOps(cs, path): Changeset<Op<C>> raises shape from Changeset<C> to Changeset<Op<C>> at a constant path; prefixOps(cs, prefix): Changeset<Op<C>> keeps shape and prepends a prefix to each event's existing path. Together they form the entire shape-grammar of the changefeed delivery pipeline: leaves' subscribeDescendants, composites' own-path → tree fan-out, and composites' child-tree propagation all decompose into one of these two transforms.
subscribe(ref, callback) is the facade primitive that calls subscribeDescendants under the hood. subscribeNode(ref, callback) is the explicit shallow opt-in — fires only when the specific node's state changes, not its descendants.
Two pure functions form the notification engine:
planNotifications(changes, addressTable)→NotificationPlan— which refs need which changesets, deduplicated and ordered.deliverNotifications(plan, subscribers)→ fires each subscriber exactly once per transaction with the appropriate changeset.
This is how a transaction that modifies doc.items[0].title and doc.items[0].count delivers one changeset to subscribe(doc) (two ops), one to subscribe(doc.items) (two ops), one to subscribe(doc.items[0]) (two ops), and one each to subscribe(doc.items[0].title) / subscribe(doc.items[0].count) (one op each) — all synchronously, all deduplicated.
The per-context dispatcher (createDispatcher<ChangefeedMsg> inside ensurePrepareWiring) is what makes re-entrant batch() calls from inside a subscriber safe: each call dispatches an accumulate Msg that drains in a fresh sub-tick. See Re-entrant batch() inside subscriber callbacks.
A single MapChange (e.g. replaceEntry("alice", {...})) represents a structural operation on a map node. For subscribers on descendants of that map, the change has to be expanded into per-leaf ReplaceChange ops. expandMapOpsToLeaves does this pure expansion, used by planNotifications.
createSequenceChangefeed, createMapChangefeed, and createTreeChangefeed are three instances of one pattern: an own-path listener registered via listenAtPath + a per-key forwarder map holding child[CHANGEFEED].subscribeDescendants(propagateUp) unsubs + structural-change-driven wire/unwire triggered from the own-path callback.
The three differ only in:
| Factory | Key type | Key-stability source | Structural-change-instruction source |
|---|---|---|---|
createSequenceChangefeed |
numeric index | ADDRESS_TABLE from withAddressing (stable address IDs) |
SequenceChange instructions + ReplaceChange |
createMapChangefeed |
string key | the key itself (LWW per key) | MapChange.set / MapChange.delete |
createTreeChangefeed |
TreeID |
the TreeID itself (CRDT-stable identifier) | TreeChange.instructions (create / delete; move preserves identity) |
The dynamic-lookup property of deliverNotifications is what makes same-batch wiring correct in all three: when a parent's own-path callback fires subscribeToChild(newKey), the new child's listenAtPath registration lands in the listener map mid-iteration; the next path the loop reaches finds the freshly-registered listener and fires it. This is the same invariant for sequence (subscribeToItem), map (subscribeToEntry), and tree (subscribeToNode).
Tree is the cleanest instance of the pattern: TreeIDs are identity-bearing from the start (no address-table dance), and TreeChange.instructions is an explicit create/delete stream (no diff inference from MapChange.delete keys). Tree's structural-change handler is a two-line compose of the pure planTreeMembershipUpdate (the diff calc) + the impure wire/unwire loop — see with-changefeed.ts:createTreeChangefeed.
createTreeChangefeed is the one dynamic-collection factory that synthesizes a terminal event before tearing down a per-node forwarder on deletion. When a TreeChange.delete removes a node, the per-node treeSubs receive one final Changeset<Op> containing the delete instruction at the node-relative path. After that delivery, the forwarder is torn down and the subscriber receives nothing further.
The terminal payload is built by the pure synthesizeTreeDeleteTerminal(prefix, id) helper — pinned as a first-class artifact rather than inline-synthesized. Subscribers pattern-match on cs.changes[0].change.type === "tree" && instructions[0].action === "delete" to detect end-of-stream.
The asymmetry with sequence and map is justified by identity semantics: TreeIDs are CRDT-stable identifiers (minted at create-time, never reused, never re-anchored on shifts), and a subscriber at d.tree.node(id) holds a meaningful identity reference. Map keys are user-chosen strings that can come and go without identity meaning (re-adding the same key creates "the same" entry); sequence items are positional and shift under structural change. Only tree carries the identity invariant that warrants a lifecycle-end signal.
Last-subscriber teardown is distinct from terminal-on-delete. When subscribeDescendants's unsub fires treeSubs.size === 0, the factory uses a private tearDownForwarder helper that bypasses synthesizeTreeDeleteTerminal. Conflating teardown-because-empty with terminal-because-deleted would emit phantom delete events to a (just-cleared) subscriber set on resubscribe scenarios — a correctness leak avoided by routing the two paths through different helpers.
Each call to the catamorphism's per-id child closure (sequence's itemFn, map's itemFn, tree's nodeFn) produces a fresh ref carrier. Each carrier's interpreter recursion calls wireChangefeed → attachChangefeed → its own [CHANGEFEED] protocol; each protocol's own-path listener registers a callback in the shared listener-Map entry at the same path key.
So multiple ref instances at the same path → multiple callbacks → multiple fan-outs per flush. The listener-Map keyed by path.key is shared (via listenAtPath), but each ref instance is independent. Correct by construction — Changeset delivery to N callbacks at the same path key is exactly N invocations — but not free.
Profiling memory or callback counts on tree-heavy docs (where re-accessing d.tree.node(id) in subscriber callbacks is common) may surface this as a future optimization target: catamorphism-side memoization keyed by (parentPath, id) would collapse the ref-instance count to one per id. Documented here to surface the property; not currently fixed.
Source: packages/schema/src/position.ts.
A Position is a substrate-mediated stable reference to a location inside text or a sequence. Two operations:
interface Position {
resolve(): number
transform(change: Change): void
}
resolve() returns the current integer index. transform(change) updates the position to reflect the given change — critical for substrates that don't store positions as first-class citizens (plain, ephemeral) where the caller drives the update explicitly.
type Side = "left" | "right"
When an insertion occurs exactly at a position's resolved index, Side determines whether the position stays to the left (index unchanged, new content appears after) or moves right (index advances). Analogous to cursor affinity in text editors.
| Substrate | Implementation | Notes |
|---|---|---|
| Plain | PlainPosition |
Tracks an integer index. transform(change) adjusts via transformIndex. Serializable via decodePlainPosition. |
| Loro | Wraps LoroText.getCursor() / LoroList.getCursor() |
resolve queries the CRDT state; transform is a no-op (resolution is stateless). |
| Yjs | Wraps Y.RelativePosition |
Same pattern — CRDT-native cursor, stateless transform. |
HasPosition is the capability marker: a text or sequence ref carries { [POSITION]: PositionCapable } when its substrate supports positions. hasPosition(ref) is the runtime type guard.
PositionCapable is the factory interface:
interface PositionCapable {
create(index: number, side: Side): Position
decode(serialized: string): Position
}
Source: packages/schema/src/change.ts.
Two pure helpers used by PlainPosition and by @kyneta/react's text-adapter:
transformIndex(index, instructions, side)→ new index after applying a text or sequence instruction list.textInstructionsToPatches(instructions)→ convert retain/insert/delete instructions into concrete{ index, length, insert? }patches.
- Not a numeric index. An index is a snapshot of "where"; a position is a stable reference that tracks "where" as the document evolves.
- Not a character offset. For text, positions are between graphemes; the underlying index is in code-point units but the
Positioninterface does not expose that. - Not DOM-like. There is no node reference, no selection range. Positions are pure algebra over text/sequence state.
Source: packages/schema/src/tree-position.ts.
Rich text editors (ProseMirror, CodeMirror, Slate, Lexical) address positions in a document tree using a single flat integer. The tree-position algebra bridges between these flat integers and kyneta's (path, offset) pairs in the schema tree — pure functions that require only a Reader and a Schema, no interpreter stack, no substrate-specific code.
Follows ProseMirror's de facto standard:
| Schema kind | Position contribution |
|---|---|
text |
1 per character (no open/close boundaries) |
scalar, counter |
1 (non-text leaf) |
product, sequence, movable, map |
2 (open + close) + content size |
sum |
transparent — size of the active variant (resolved via dispatchSum) |
set, tree |
unsupported (throws) |
The root node does NOT count its own open/close — flat positions are relative to root content, matching ProseMirror's doc.resolve(pos) semantics.
function nodeSize(reader: Reader, schema: Schema, path: Path): number
function contentSize(reader: Reader, schema: Schema, path: Path): number
function isLeaf(schema: Schema): boolean
nodeSize computes the flat position size of a schema node at a path — the recursive building block. contentSize is nodeSize minus the open/close boundaries for composites (equals nodeSize for leaves). isLeaf identifies PM-leaf kinds: text, scalar, counter.
function resolveTreePosition(reader: Reader, schema: Schema, flatPos: number): ResolvedTreePosition | null
function flattenTreePosition(reader: Reader, schema: Schema, path: Path, offset: number): number
resolveTreePosition converts a flat integer to { path, offset, schema } — the innermost node and the local offset within it. flattenTreePosition is the inverse: given a path and offset, compute the flat integer.
Round-trip invariant: flattenTreePosition(r, s, ...resolveTreePosition(r, s, pos)) === pos for all valid positions.
Tree-position and Position operate at different layers:
- Tree-position finds the structural location: "flat position 7 is at
items[1].content, character offset 2." Positioncreates a stable cursor:ref[POSITION].createPosition(2, "right")at the ref foritems[1].content.
The caller composes: resolveTreePosition → navigate to the ref at the resolved path → ref[POSITION].createPosition(offset, side). This separation preserves composability — tree-position needs only Reader, while Position needs the full interpreter stack.
- Product fields: walked in
Object.keys(schema.fields)insertion order. Deterministic because all peers construct schemas from the same source code. - Map entries: walked in lexicographic key order (
keys.sort()). Required becausereader.keys()returns insertion order which may differ across peers. - Sequence/movable items: walked in index order
0..length-1.
- Not a DOM position. No node references, no selection ranges. Pure algebra over
Reader+Schema. - Not substrate-aware. Works identically with plain, Loro, and Yjs substrates — any
Readerimplementation. - Not cached.
nodeSizeis O(n) per call (walks the subtree). Caching can be added behind the same API if profiling shows a need.
Source: packages/schema/src/change.ts (types), packages/schema/src/interpreters/sequence-helpers.ts (write wiring).
The positional algebra (Instruction, foldInstructions, transformIndex, advanceAddresses) is shared across text, sequence, movable, and richtext. Extensions compose in two orthogonal patterns:
The extension adds new instruction variants to the sequence's instruction type. format interleaves with retain/insert/delete in one instruction stream. The changefeed delivers a single change type (RichTextChange) containing the extended instructions.
Positionally, format(N) ≡ retain(N) — the Instruction abstraction handles format by delegating to onRetain in foldInstructions. All position-tracking primitives (transformIndex, advanceIndex, advanceAddresses) work unchanged.
Why marks compose within the instruction stream: format is cursor-relative — it advances the cursor by N characters while annotating them. A format at position 5 references a cursor position established by preceding operations in the same stream. Splitting it into a separate change would lose this positional relationship.
The extension adds a new change type alongside the base sequence change. The changefeed's C parameter becomes a union: SequenceChange<T> | MoveChange. Move uses absolute indices (not cursor-relative), so it cannot be expressed as a cursor instruction.
Why move composes alongside the instruction stream: move is absolute-index-to-absolute-index — it cannot be expressed in the left-to-right cursor model that foldInstructions implements.
Source: packages/schema/src/migration.ts.
The migration system solves one problem: how does a document keep its peer-to-peer identity when its schema evolves?
The mechanism: every field in a ProductSchema has a content-addressed identity hash, derived from the migration chain. When a new schema replaces the old, the migration chain declares which old identity maps to which new identity. Peers running different schema versions can still sync — the substrate keys its CRDT containers by identity hashes, not field names.
Source: packages/schema/src/migration.ts → Migration namespace.
| Tier | Primitives | Semantics |
|---|---|---|
| T0 (structural, identity-preserving) | renameField |
Pure rename; identity unchanged. |
| T1 (non-destructive, identity-preserving) | addField, setDefault, wrapField, unwrapField, promoteField, demoteField |
Shape changes that admit a canonical inverse; identity preserved. |
| T2 (destructive, identity-rederiving) | dropField, extractField, mergeFields, splitField, transformField |
Shape changes that destroy or transform data; identity must be re-derived. Return a Droppable<P> requiring explicit .drop(). |
| T3 (epoch boundary) | epoch, identity |
A hard break. New identity space; no sync with pre-epoch peers. |
T2Primitive and NonT2Primitive are type-level predicates. Constructor helpers on Migration return the appropriate discriminated union variants. Droppable<P> wraps T2 primitives so that drop semantics are explicit at the type level: Migration.dropField("old").drop() — forgetting .drop() is a compile-time error.
A MigrationChain is an ordered sequence of MigrationChainEntry values. Each entry is either a MigrationStep (one or more primitives applied together) or an EpochStep (a hard identity break).
validateChain(chain) runs at bind() time (source: src/bind.ts → bind body). It checks:
- Primitives within one step are non-conflicting.
- T2 primitives have been
.drop()-ed. - Every step strictly advances identity (no cycles).
Source: packages/schema/src/migration.ts → deriveIdentity, deriveManifest, deriveSchemaBinding.
deriveIdentity(schema, chain)→NodeIdentity— the content-addressed identity of everyProductSchemafield, as a tree mirroring the schema shape.deriveManifest(schema)→IdentityManifest— the full identity tree for a schema, used inbind()to cache forcomputeSchemaHash.deriveSchemaBinding(manifest)→{ forward: Map<string, Hash>, backward: Map<Hash, string> }— the runtime lookup used by substrates to key their CRDT containers.
The substrate consumes the SchemaBinding in its factoryBuilder context. Loro and Yjs backends use forward to determine container keys: a product field named "title" with identity hash "abc123…" is stored at LoroMap.getMap("abc123…"), not at LoroMap.getMap("title"). Renaming a field changes its display name, not its stored identity — the CRDT state survives the rename.
Source: packages/schema/src/migration.ts → computeSupportedHashes.
A BoundSchema declares supportedHashes: all schema hashes at which the current peer can op-stream sync. Computed by computeSupportedHashes(schema), which recursively walks every MigrationChain in the schema tree — the root chain plus chains on nested ProductSchema fields. The set is the cartesian product over independent chains: if the root chain reaches N ancestor shapes and a nested field's chain reaches M, the result contains N × M hashes.
Per-chain halt at the first of:
- T2 step — destroys identities; advertising T2 ancestors would overstate compatibility (the current path-keyed substrate has no identity-tombstone safety net). This aligns the single-set
supportedHasheswith the theory'snativeSupportssemantics (.jj-plan/migrations.md§8). - T3 epoch — hard identity break; pre-epoch hashes are deliberately unreachable.
- Un-invertible primitive — anything not currently in
{add, rename, move}at root level. The schema surgery foraddNullable/widenConstraint/ sub-product variant operations is bounded but not yet implemented; the walk halts conservatively rather than over-advertise. chain.entriesexhaustion — thechain.baseprune horizon; pre-base shapes are not recoverable from the chain alone.
The richer readSupports / nativeSupports split (allowing degraded entirety-only sync across T2/T3 boundaries — see .jj-plan/migrations.md §8.1) is deferred until degraded-sync infrastructure exists.
The exchange includes supportedHashes in every present message when it carries more info than the primary hash alone. Receivers with older schemas check whether one of their hashes is in the sender's supportedHashes to decide if sync can proceed.
- Not SQL-style migrations. No
up/down, no runtime execution of migration code. The chain declares the identity map; the substrate reads identity-keyed data. - Not version numbers. Two schemas with different migration histories may have the same shape but different identity spaces — and therefore cannot sync. The chain is part of the identity, not metadata about it.
- Not bidirectional. An epoch step is a one-way break. T0 / T1 primitives are reversible in principle, but Kyneta does not support "downgrading" a document — sync fails instead.
Source: packages/schema/src/change.ts.
Every mutation flows through a Change — a discriminated union identified by type. The built-in types:
type |
Shape | Composition law | Used by |
|---|---|---|---|
"text" |
{ instructions: TextInstruction[] } — retain / insert / delete over characters |
positional-ot |
Text CRDTs |
"sequence" |
{ instructions: SequenceInstruction[] } — retain / insert / delete over items |
positional-ot |
Lists, movable lists |
"map" |
{ entries: MapInstruction[] } — set / delete over keys |
lww-per-key |
Maps, sets |
"tree" |
{ instructions: TreeInstruction[] } — create / move / delete nodes |
tree-move |
Trees |
"replace" |
{ value: unknown } — overwrite this node |
lww |
Scalars, plain JSON sub-trees |
"increment" |
{ delta: number } — counter increment |
additive |
Counters |
"richtext" |
{ instructions: RichTextInstruction[] } — retain / insert / delete / format over characters |
positional-ot |
Rich text CRDTs |
Note: TextChange and SequenceChange are parameterizations of the same positional algebra, unified by the Instruction type. Both use retain/insert/delete cursor instructions; the only difference is the content type (string vs T[]). The shared algebra is captured by foldInstructions, transformIndex, and advanceAddresses, which operate on Instruction generically.
ChangeBase is re-exported from @kyneta/changefeed — the open protocol base. Third-party backends may extend with additional type values; the exchange and interpreters treat unknown types as opaque, passing them through.
- Inbound (developer → substrate): the proxy in
batch(doc, fn)records changes describing intent. - Outbound (substrate → subscribers): the substrate's changefeed emits changes describing what happened.
The shapes are identical. The substrate's prepare pipeline consumes the inbound changes, applies them, and re-emits (potentially transformed) outbound changes.
For every built-in change type:
- Constructor:
textChange(instructions),sequenceChange(instructions),mapChange(entries), etc. - Type guard:
isTextChange(change),isSequenceChange(change), etc. - Pure transformer:
foldInstructions(instructions),advanceIndex(index, instructions),advanceAddresses(addresses, instructions).
These are the primitives step, with-changefeed, and Position build on.
Source: packages/schema/src/substrates/plain.ts.
The built-in substrate. Stores state as plain JS objects, tracks a Lamport version, and merges by total-order last-writer-wins. Used for:
- The default binding when no CRDT is needed (
Schema.string, small configs, ephemeral UI state). - The LWW variant (
src/substrates/lww.ts+src/substrates/timestamp-version.ts) for wall-clock-ordered ephemeral broadcasts. - Reference implementation for testing the
Substrate<V>contract.
All substrates now share the same read semantics: reads go through plainReader backed by a PlainState object. For the plain substrate this is trivially the substrate's own state. For CRDT substrates (Loro, Yjs), the PlainState is a shadow that is kept in sync — eagerly on local writes, re-materialized from the CRDT doc on replay. See §The functional shadow.
Key functions:
createPlainSubstrate(schema, context)→Substrate<PlainVersion>.createPlainReplica(context)→Replica<PlainVersion>.plainSubstrateFactory/plainReplicaFactory— exported factory instances.buildUpgrade(schema)→ function that re-derives internal structures after hydration.objectToReplaceOps(obj)→ flatten a plain object into a sequence ofReplaceChangeops for migration.
type PlainVersion = Record<PeerId, number>
A Lamport vector. versionVectorMeet and versionVectorCompare operate on it directly. Every local write bumps the peer's component; merge increases each component to the max of local and incoming.
For ephemeral substrates: a single wall-clock number plus the peer ID. merge accepts the incoming value iff timestamp > local.timestamp || (equal && peerId > local.peerId). Stale writes are rejected silently.
The plain substrate's serializeOps / deserializeOps embed Op.change by reference — the change is JSON-stringified as-is and passed through WireOfferMsg.d (an opaque string | Uint8Array payload). The exchange wire codec never inspects schema-level change types; it carries them as JSON inside the substrate payload. Adding a new ChangeBase variant (e.g. SetChange { type: "set-op" }) is purely additive — no exchange codec change required. The only caveat is for out-of-monorepo consumers parsing the plain JSON wire format with a strict change-type whitelist: those need to extend their whitelist when new change variants land.
CRDT substrates (Loro, Yjs) maintain a shadow: a PlainState object that serves as the canonical read surface for all interpreter-stack reads. The architecture separates four surfaces:
| Surface | Backing | Purpose |
|---|---|---|
| Read surface | PlainState + plainReader |
All ref.field() reads, subscriber reads, interpreter-stack caching |
| Sync surface | CRDT doc (LoroDoc / Y.Doc) |
exportSince, merge, import — replication and conflict resolution |
| Position surface | CRDT doc | positionResolver — cursor / relative-position operations that require CRDT structure |
| Native escape hatch | CRDT doc | nativeResolver — direct access to the underlying CRDT container for advanced use |
On local writes, prepare calls applyChange(shadow, path, change) — the same pure step function used by the plain substrate — making the write immediately visible to reads. CRDT diffs are buffered and applied to the CRDT doc in onFlush. This two-phase design means the read surface is always ahead of (or equal to) the sync surface during a transaction.
On replay (merge), the CRDT doc absorbs the remote state first (via doc.import or Y.applyUpdate). onFlush then re-materializes the shadow from the CRDT doc, ensuring ctx.reader reflects the merged state for subscriber callbacks.
Initialization. The shadow is created at substrate construction time via materializeLoroShadow (Loro) or materializeYjsShadow (Yjs). These functions now delegate to createMaterializeInterpreter with a backend-specific MaterializeResolver, rather than defining bespoke 370-line interpreters. The resolver closes over the CRDT doc and binding; the generic materializer walks the schema and calls resolver methods to produce a plain JS object matching the schema's shape. The shadow is also re-materialized on upgrade and after any replay flush.
Reader vs MaterializeResolver. Reader (4 methods) is the runtime read interface backed by the PlainState shadow — schema-blind, live. MaterializeResolver (6 methods) is the materialization interface backed by the CRDT — schema-aware via catamorphism dispatch, one-shot. They share a conceptual lineage — the resolver is what a CRDT Reader would look like if it were schema-aware and didn't need liveness.
This design makes the read-your-writes invariant true by construction for all substrates: reads always go through plainReader(shadow), and local writes always land in the shadow eagerly. No coordination, no flags, no special-casing per substrate.
Source: packages/schema/src/fold-path.ts.
foldPath is the schema-guided sibling of Path.read(state). Where Path.read walks a plain JS object by segment-resolved keys, foldPath walks a substrate-native container tree by composing advanceSchema (pure schema descent) with a backend-supplied PathStepper (per-step substrate dispatch). Backends carry only the PathStepper; the fold skeleton lives once in core.
type PathStepper = (
current: unknown,
nextSchema: SchemaNode,
segment: Segment,
identity: string | undefined,
) => unknown
function foldPath(
root, rootSchema, path, stepInto, binding?
): { resolved, schema }stepInto is the only substrate-specific piece. The Loro backend's stepIntoLoro dispatches on LoroDoc (root) vs. container .kind(); the Yjs backend's stepIntoYjs dispatches on instanceof Y.Map | Y.Array | Y.Text. resolveContainer / resolveYjsType are 1-line wrappers around foldPath(..., stepInto*, ...).
-
Identity-keying at product-field boundaries only. When
seg.role === "field", the absolute schema path is extended viaextendSchemaPathKey(prev, segment)and used to look upbinding.forward.get(key).entry(map/set/tree) andindex(sequence/movable) segments pass through with the raw key — they are not identity-keyed. The writer side of this contract —deriveBindingRecursiveinmigration.ts— uses the sameextendSchemaPathKeyaccumulator, so the writer/reader key construction is byte-identical by construction. -
Sum-boundary short-circuit. When the fold lands on a schema with
[KIND] === "sum", all remaining segments resolve via plain JS property access on the returned value. Sum variants are PlainSchema by construction — no CRDT containers exist inside them — so the substrate has nothing to navigate past the sum boundary.
pathSchema(rootSchema, path, binding?) is foldPath with a no-op stepper, returning only .schema. Used by callers that need the schema at a path but not the substrate value: changefeed kind classification (changefeed.ts:resolveSchemaKindAtPath), change-mapping target resolution (Loro changeToDiff / batchToOps, Yjs applySequenceChange / applyMapChange / applyReplaceChange / eventToChange). The sum-boundary rule applies uniformly — on a sum-interior path, pathSchema returns the sum schema (the variant cannot be determined without a value at parse time).
Before this primitive, both Loro's resolveContainer and Yjs's resolveYjsType re-implemented the same left-fold over Path.segments, and four schema-only walks (one in changefeed.ts, one in yjs/change-mapping.ts, two inline in loro/change-mapping.ts) re-implemented the schema-only variant with subtly different sum-boundary handling (three explicit short-circuits, one try/catch). After the consolidation, advanceSchema has exactly one production caller — foldPath itself — and the sum-boundary rule is structural, not exception-based.
Source: packages/schema/src/interpreters/validate.ts.
A separate interpreter — not required by the stack, not automatic. validate(schema, value) returns ValidationResult collecting every error in the tree; tryValidate throws on the first. SchemaValidationError carries a structured path and a human-readable message.
Validation is an interpretation of the schema. The same Schema value that builds a ref also validates untrusted input. Errors format via formatPath(path) for human-readable output.
Not used by the exchange. Not automatic on bind. Opt-in at boundaries where untrusted data enters the system.
Source: packages/schema/src/zero.ts.
Zero(schema) computes a default Plain<S> value for any schema. Defaults:
string→"",number→0,boolean→false,null→null,bytes→ emptyUint8Array.- Product → each field's default.
- Sequence / movable →
[]. - Map →
{}. - Set →
[](matchesPlain<SetSchema<I>> = Plain<I>[]— distinct from map, which isRecord<string, T>). - Sum → first variant's default.
- Text → empty text.
- Counter →
0. - Tree → empty forest
[](matchesPlain<TreeSchema<I>> = readonly PlainFlatTreeNode<I>[]).
scalarDefault(kind) is the scalar-only version. Used by createDoc when no initial state is supplied, by migrations' setDefault primitive, and by tests.
The materializer is the canonical consumer of zeros for CRDT substrates — the zeroInterpreter is the single source of truth, and zeros are no longer eagerly written during CRDT initialization. CRDT initialization routines (ensureRootContainer, ensureContainers) now only create structural containers.
Source: packages/schema/src/describe.ts.
describe(schema) returns a human-readable ASCII tree of the schema structure. Used in tests, logs, and documentation. Not used at runtime by any interpreter.
Selection of the most-used types. Full list in Canonical symbols at the top of this document.
| Type | File | Role |
|---|---|---|
Schema |
src/schema.ts |
The recursive schema union. |
ScalarSchema, ProductSchema, SequenceSchema, MapSchema, SumSchema, TextSchema, CounterSchema, SetSchema, TreeSchema, MovableSequenceSchema, RichTextSchema |
src/schema.ts |
The eleven [KIND] variants. |
PlainSchema |
src/schema.ts |
The CRDT-free subset. |
ExtractLaws<S>, RestrictLaws<S, L> |
src/schema.ts |
Type-level composition-law extraction + constraint. |
BindingTarget<AllowedLaws, N> |
src/bind.ts |
Fixed substrate target: .bind(schema), .replica(). |
BoundSchema<S>, BoundReplica<V> |
src/bind.ts |
Static binding types. |
EphemeralLaws |
src/bind.ts |
"lww" | "lww-per-key" | "lww-tag-replaced" — the LWW-family law set. |
Interpret, Replicate, Defer, Reject |
src/bind.ts |
Resolve-outcome variants. |
Interpreter<Ctx, A>, InterpreterLayer<Ctx, In, Out> |
src/interpret.ts |
F-algebra + layer transformer. |
Ref<S>, RRef<S>, RWRef<S>, DocRef<S> |
src/ref.ts |
Refs at each capability tier. |
Substrate<V>, Replica<V>, SubstrateFactory<V>, ReplicaFactory<V> |
src/substrate.ts |
Interfaces. |
SubstratePayload |
src/substrate.ts |
Opaque transfer shape. |
SyncMode, WriterModel, Delivery, Durability |
src/substrate.ts |
Structured sync mode and its three axes. |
SYNC_AUTHORITATIVE, SYNC_COLLABORATIVE, SYNC_EPHEMERAL |
src/substrate.ts |
The three built-in sync mode constants. |
Version |
src/substrate.ts |
Abstract version base. |
Change, ChangeBase, TextChange, SequenceChange, MapChange, TreeChange, ReplaceChange, IncrementChange, RichTextChange |
src/change.ts |
Change vocabulary. |
RichTextSchema, MarkConfig |
src/schema.ts |
Rich text schema kind + mark configuration. |
RichTextDelta |
src/change.ts |
Delta representation for rich text content. |
RichTextRef |
src/ref.ts |
Ref specialization for richtext schema kind. |
Op |
src/changefeed.ts |
{ path, change } — composed-feed notification. |
RecursiveChangefeedProtocol<S>, HasRecursiveChangefeed<S> |
src/changefeed.ts |
Tree-observation surface carried by every schema-issued ref. |
Position, Side, HasPosition, PositionCapable, PlainPosition |
src/position.ts |
Position algebra. |
MigrationChain, MigrationStep, EpochStep, MigrationPrimitive, Droppable, T2Primitive, NonT2Primitive |
src/migration.ts |
Migration types. |
NodeIdentity, IdentityManifest, IdentityOrigin, SchemaBinding, TransformProof |
src/migration.ts |
Identity types. |
NativeMap<S>, PlainNativeMap, UnknownNativeMap, HasNative |
src/native.ts |
Type-level substrate-native mapping. |
CALL, NATIVE, SUBSTRATE, BACKING_DOC, KIND, LAWS, POSITION, MIGRATION_CHAIN, INVALIDATE, REMOVE, TRANSACT, ADDRESS_TABLE |
various | Symbol-keyed runtime protocol tags. |
Reader, PlainState |
src/reader.ts |
Plain-state reader primitive. |
Path, Segment, Address, AddressTableRegistry |
src/path.ts |
Path and address types. |
foldPath, pathSchema, PathStepper, PathFoldResult, extendSchemaPathKey |
src/fold-path.ts |
Schema-guided path-fold primitive (the substrate-blind sibling of Path.read(state)) and shared binding-key accumulator. |
The package exposes three subpath exports via package.json "exports":
| Subpath | Import path | Entry | Role |
|---|---|---|---|
"." |
@kyneta/schema |
src/index.ts |
Public barrel — every public symbol. |
"./basic" |
@kyneta/schema/basic |
src/basic/index.ts |
Test-only helpers (re-exports of internal utilities for backend test suites). |
"./testing" |
@kyneta/schema/testing |
src/testing/index.ts |
Backend conformance testing: positionConformance and PositionTestEnv. |
The "./testing" subpath exists so that backend packages (@kyneta/loro-schema, @kyneta/yjs-schema) can import the position conformance harness without depending on vitest at runtime. The tsdown config externalises vitest via neverBundle: ["vitest"], so vitest internals are never bundled into the published dist/.
Rolldown (via tsdown) requires code splitting when multiple entry points share code — all three entries above share the core schema types. The default [name]-[hash].js chunk pattern produces filenames with content hashes that change on every build, breaking lockfile stability and making dist/ diffs noisy.
The tsdown config overrides this with chunkFileNames: "_shared/[name].js", producing deterministic chunk names under dist/_shared/. The build output looks like:
dist/
index.js # main entry
index.d.ts
basic/
index.js # ./basic entry
index.d.ts
testing/
index.js # ./testing entry
index.d.ts
_shared/
*.js # shared chunks, stable names, no hashes
| File | Lines | Role |
|---|---|---|
src/index.ts |
~400 | Public barrel — exports every public symbol. |
src/schema.ts |
~800 | The grammar: types + Schema.* constructors + advanceSchema + buildVariantMap + isNullableSum. |
src/bind.ts |
~500 | bind, BoundSchema, BoundReplica, BindingTarget, createBindingTarget, json, ephemeral, resolve outcomes, FactoryBuilder. |
src/substrate.ts |
~300 | Substrate<V>, Replica<V>, factories, BACKING_DOC. Re-exports computeSchemaHash and HASH_ALGORITHM_VERSION from src/hash.ts. |
src/migration.ts |
~1000 | 14 primitives, 4 tiers, identity derivation, chain validation, MIGRATION_CHAIN. |
src/change.ts |
~600 | Change vocabulary, constructors, guards, transformIndex, textInstructionsToPatches, advanceAddresses. |
src/interpret.ts |
~400 | interpret, Interpreter, InterpretBuilder, InterpreterLayer, dispatchSum, RawPath. |
src/interpreters/bottom.ts |
~200 | Bottom layer: [CHANGEFEED], [NATIVE], [SUBSTRATE], [CALL]. |
src/interpreters/sequence-helpers.ts |
~280 | Shared indexed-coalgebra helpers: at(), installTextWriteOps, installListWriteOps, installRichTextWriteOps, installSequenceReadable, installSequenceNavigation, installSequenceAddressing, installSequenceCaching. |
src/interpreters/keyed-helpers.ts |
~235 | Shared keyed-coalgebra helpers: installKeyedWriteOps, installKeyedReadable, installKeyedNavigation, installKeyedAddressing, installKeyedCaching. |
src/interpreters/with-navigation.ts |
~235 | Structural descent. Sequence/movable and map/set cases delegate to shared helpers. |
src/interpreters/with-readable.ts |
~225 | .current, (), read-by-path. Sequence/movable and map/set cases delegate to shared helpers. |
src/interpreters/with-addressing.ts |
~500 | Address-table layer. Sequence/movable and map/set cases delegate to shared helpers. |
src/interpreters/with-caching.ts |
~380 | Identity-preserving memoization + INVALIDATE. Sequence/movable and map/set cases delegate to shared helpers. |
src/interpreters/writable.ts |
~700 | Mutation primitives + REMOVE + TRANSACT + executeBatch. Text/sequence/movable/map/set cases delegate to shared helpers. |
src/interpreters/with-changefeed.ts |
~1300 | Observation layer + planNotifications + deliverNotifications + wireChangefeed. All cases use wireChangefeed to unify changefeed boilerplate. |
src/interpreters/validate.ts |
~200 | Validation interpreter. |
src/interpreters/plain.ts |
~100 | Plain-state interpreter (reader + canonical shape). |
src/interpreters/navigable.ts, readable.ts |
~100 each | Type-interface modules. |
src/layers.ts |
~100 | Pre-built navigation, readable, addressing, writable, observation layer values. |
src/ref.ts |
~150 | Ref<S>, RRef<S>, RWRef<S>, DocRef<S>, Wrap, RefMode. |
src/position.ts |
~300 | Position, Side, POSITION, HasPosition, PlainPosition, decodePlainPosition. |
src/tree-position.ts |
~620 | Tree-position algebra: nodeSize, contentSize, isLeaf, resolveTreePosition, flattenTreePosition, ResolvedTreePosition. Pure functions over Reader + Schema for flat↔tree position mapping (ProseMirror convention). |
src/changefeed.ts |
~150 | Op, RecursiveChangefeedProtocol, HasRecursiveChangefeed, expandMapOpsToLeaves. |
src/facade/batch.ts |
~250 | batch(ref, fn), applyChanges, remove, CommitOptions. |
src/facade/observe.ts |
~100 | subscribe, subscribeNode. |
src/step.ts |
~300 | Pure state transitions: step, per-change-type step functions. |
src/reader.ts |
~150 | Reader, plainReader, writeByPath, applyChange. |
src/unwrap.ts |
~50 | Typed escape hatch to [NATIVE]. |
src/version-vector.ts |
~90 | versionVectorMeet, versionVectorCompare. |
src/hash.ts |
~50 | FNV-1a-128. |
src/native.ts |
~100 | NativeMap, NATIVE, SUBSTRATE, HasNative. |
src/path.ts |
~200 | Path/segment/address types + constructors + AddressedPath. |
src/create-doc.ts |
~100 | createDoc, createRef — convenience factories. |
src/describe.ts |
~150 | ASCII schema tree printer. |
src/zero.ts |
~150 | Zero, scalarDefault. |
src/interpreters/materialize.ts |
~70 | Generic CRDT→PlainState materialization: MaterializeResolver interface, createMaterializeInterpreter. |
src/guards.ts |
~30 | isNonNullObject, isPropertyHost. |
src/base64.ts |
~30 | Platform-agnostic base64. |
src/substrates/plain.ts |
~400 | Plain substrate + factories. |
src/substrates/lww.ts, substrates/timestamp-version.ts |
~200 + ~100 | LWW / ephemeral substrate. |
src/basic/index.ts |
— | Test-only helpers (re-exports). |
src/sync.ts |
~100 | version, exportEntirety, exportSince, merge — generic over ref[SUBSTRATE]. |
src/__tests__/ |
~56 files | Every test file is pure; no I/O, no timers. |
Every test in this package is pure. Substrates-under-test are the plain substrate (for everything) and structured mocks. Interpreters are tested by constructing minimal refs and asserting on method results. Migrations are tested by deriving manifests for known schemas and asserting on the hash values. Validation is tested by running validate over synthetic inputs and asserting on the error tree.
The full suite serves as the specification of the Substrate<V> contract: @kyneta/loro-schema and @kyneta/yjs-schema run this same suite (adapted) against their substrates. Position conformance tests import positionConformance and PositionTestEnv from @kyneta/schema/testing; general substrate conformance helpers live in @kyneta/schema/basic.
Tests:
| Package | Passed | Skipped |
|---|---|---|
@kyneta/schema |
1,949 | 8 |
@kyneta/loro-schema |
208 | 4 |
@kyneta/yjs-schema |
218 | 4 |
Run with cd packages/schema && pnpm exec vitest run.