2.0 is a coordinated, breaking release. All peers must upgrade together — 1.x ↔ 2.0 will not sync — and pre-2.0 on-disk stores belong to a prior epoch: both the schema-hash (HASH_ALGORITHM_VERSION → "02") and the new store-format marker reset. Plan/design references are linked as jj:<id> for those who want the full rationale; the entries below carry what you need to upgrade.
-
change(ref, fn)→batch(ref, fn). Migrate: replace facadechange(...)calls and{ change }imports withbatch; prefer unwrapping a single mutation to a direct write (doc.x.set(v)) overbatch(doc, d => d.x.set(v)). Same signature and semantics; re-exported under the new name from@kyneta/schema,@kyneta/schema/basic,@kyneta/react, and the Loro/Yjs backends. Since auto-commit-on-write (1.8.0) a single mutation commits on its own, so the facade's job is now batching, not committing. (jj:rkwspltk) -
SyncProtocol→SyncMode— it names a per-document sync mode/policy, not a wire protocol. Migrate: source-level rename — typeSyncProtocol→SyncMode, fieldsyncProtocol→syncMode(inDocMetadata,bindconfigs,presentmetadata),requiresBidirectionalSync(protocol)→(mode), the wire helpers (SyncProtocolWire*→SyncModeWire*,syncProtocolToWire→syncModeToWire), the validator setVALID_SYNC_PROTOCOLS→VALID_SYNC_MODES, and the error code"unknown-sync-protocol"→"unknown-sync-mode". Re-exported under the new names from@kyneta/schemaand@kyneta/exchange. No wire bytes change (the compact keymsand its values0x00/0x01/0x02are untouched — apresentround-trip is byte-identical), and the constantsSYNC_COLLABORATIVE/SYNC_AUTHORITATIVE/SYNC_EPHEMERALare unchanged. The only data effect: persisted metadata key"syncProtocol"→"syncMode"(operator query pathdata->>'syncMode'), covered by the 2.0 epoch reset. (jj:yuvupozp) -
Sync ready-state vocabulary renamed. Migrate:
ReadyState→PeerSyncState(.status→.state,.identity→.peer, value"absent"→"vacant");SyncRef.readyStates→peerStates;onReadyStateChange→onPeerSyncChange;Synchronizer.getReadyStates→getPeerStates; ReactuseSyncStatus→useSyncState. Replaces.status === "synced"withs.state === "synced", and preferuseDocReady(doc)(below) over deriving a gate from the per-peer array. The never-produced"unknown"variant is dropped. Source-level only — no wire/persistence change. (jj:llosmrmq) -
Postgres store takes an injected adapter. Migrate:
createPostgresStore(pool | client)→createPostgresStore(fromPool(pool)), orcreatePostgresStore(fromClient(client))for a single connection. New exportsPgAdapter,fromPool,fromClientfrom@kyneta/postgres-store. This also fixes the previously-broken bare-Clientpath, which used to throw onrelease(). (jj:vzuwrotu) -
Stores now carry an on-disk format version, and per-doc metadata is renamed
doc_meta. Migrate: treat pre-2.0 stores as a prior epoch — there is no in-place migration, and a store written under an incompatible format major is now refused on open with a typed error. If you set the SQLtablesoption, rename its keymeta→docMeta. The on-disk names change accordingly (SQL tablekyneta_meta→kyneta_doc_meta, LevelDB key prefixmeta\x00→doc-meta\x00, IndexedDB object storemeta→doc_meta). This is the storage counterpart to the schema-hash epoch reset. (jj:uvssotsy) -
Unix-socket leaderless peer is now a
Transport, not anExchangeconsumer. Migrate:createUnixSocketPeer(exchange, options)→createUnixSocketPeer(options), then pass it like any transport:new Exchange({ transports: [peer] }).UnixSocketPeer.dispose()is gone —exchange.shutdown()stops it; theUnixSocketPeertype becomesUnixSocketPeerHandle(UnixSocketPeerTransportis also exported). Healing is now in place under one stabletransportId— the Exchange sees only channel add/remove and all CRDT state survives a heal; the connector defaults to immediate re-negotiation on disconnect (opt into bounded reconnect viareconnect). Wire/sync protocol unchanged. (jj:llpxyzom) -
establishnow carries a required protocol version. Migrate: nothing on the wire — a 2.0 peer'sestablishis byte-identical (an absent version defaults to(1, 0)). The field isEstablishMsg.protocolVersion: { major, minor }with thePROTOCOL_VERSIONconstant (@kyneta/transport, re-exported from@kyneta/exchange). Compatibility is a rule, not a negotiation: features differ → silent,minordiffers → warning,majordiffers → error. Detection never gates — an incompatible peer stays observable and enters the sync graph (the frozenSyncRef/peerStatessurface is untouched). (jj:yukrpnwm)
- All schema hashes change;
HASH_ALGORITHM_VERSION"01"→"02". Migrate: none at the API level —computeSchemaHash's signature and 34-char shape are unchanged; the break is the hash values. Redeploy all peers together; 1.x ↔ 2.0 will not sync and a 1.x-persistedschemaHashwon't match a 2.0 recompute. Canonicalization is now injective (field names / constraint values / discriminant keys can no longer forge structural delimiters — e.g. a field named"a:s:string,b"no longer collides with two fieldsa/b) and includes the.json()boundary, sostruct/list/recordhash distinctly from their.json()counterparts. Both were silent sync-incompatibility classes the hash is meant to catch. (jj:qnmtvtwn)
- Monotonic doc-readiness latch:
sync(doc).ready(ReactuseDocReady(doc)) flipstrueon first reconciliation — data or a terminalvacantreply — and never regresses across a reconnect re-handshake or a reconciled peer departing.sync(doc).readyFor(pred)/useDocReady(doc, { peer })require a matching reconciled peer (authority / quorum). The latch is flicker-free; prefer it over deriving a gate frompeerStates. vacantwire message (0x14, additive): a peer that won't serve a requested doc emits a terminal negative ack; the requester records the peervacantwithout tearing down its replica. Old peers reject the unknown discriminator harmlessly — wire-backward-compatible.- Sync observability:
sync(doc).connectivity("online" | "connecting" | "offline");sync(doc).settled(opts?), which resolves (never rejects) to{ via: "peer" | "local" | "offline" }; the puredescribeSyncStatus(peerStates, connectivity, ready)presentational helper (@kyneta/exchange, re-exported from@kyneta/react); and ReactcreateDerivedSyncStore.
- LevelDB
appendis now atomic — a singlebatchreplaces the separate meta/record puts, so a crash can no longer leave metadata advanced past a missing record. (jj:pzuytnvo)
@kyneta/castand@kyneta/compilerare no longer published. Both are markedprivateand remain in-repo as experimental (joining@kyneta/perspective); they were published at 1.8.0, but there is no 2.0 release of either while they stabilize. (jj:qyuqnppr)
SessionEffect{ type: "warning"; message }→{ type: "diagnostic"; severity: "error" | "warning"; message }— the shape the future structuredonProtocolWarningcallback will reuse. Exchange-internal; no application-level or wire change. (jj:yukrpnwm)- Protocol-version layering: the sync wire-contract revision (
protocolVersion) is distinct fromWIRE_VERSION(frame encoding) andSyncMode(per-doc policy). Additive evolution ridesWireFeatures;protocolVersioncarries only the one thing features can't express (base abandonment). On the wire it's sparse —pv: [major, minor]only when non-default. NewSynchronizersurface:declareVacant/hasReconciled/reconciledMatching/connectivity. (jj:yukrpnwm,jj:llosmrmq) - Schema-hash internals:
serializeConstraintValue(JSON.stringify-based) is now shared byhash/describe/validateso the three can't drift. Canonicalization (canonicalTuple, arrays + strings only) carries a recursion depth cap that throws a clear error on anas any-forced cyclic schema graph (the grammar otherwise guarantees finite, eager, acyclic trees; recursive data usesSchema.tree). (jj:qnmtvtwn) - Store fault-injection unified on
makeArmedFault(@kyneta/exchange/testing); the orphanedfailOnNthCallexport is removed from@kyneta/sql-store-core(its coverage folded into themakeArmedFaulttest).PostgresStoreno longer sniffsPoolvsClient— the two transaction behaviours live infromPool/fromClient, andpgstays a type-only import. (jj:vzuwrotu,jj:pzuytnvo) - Unix-socket cleanup (no consumers): removed
UnixSocketClientTransport.subscribeToTransitions, theUnixSocketClientStateTransitiontype, and the unusedUnixSocketServerTransporthelpers (getConnection/getAllConnections/isConnected/broadcast); the low-levelUnixSocketConnectionconstructor is now(socket). (jj:llpxyzom)
Schema — three-primitive substrate contract:
- The transaction lifecycle (
beginTransaction/commit/abort/inTransaction/pending) is removed fromWritableContext.change(doc, fn)is now a thin wrapper aroundctx.runBatch(arunWriter/execWriterpattern over the change-Writer monad). The publicchange(doc, fn)andapplyChanges(ref, ops)APIs are unchanged. Breaking for code that constructedWritableContextby hand (test fixtures) or calledctx.beginTransaction/ctx.commit/ctx.abortdirectly. change(doc, fn)provides read-your-writes inside the block. σ advances eagerly on every prepare, so two pushes in one block append in order. Pre-refactor, length-derived helpers read a stale σ and silently reordered.- Atomic abort preserved across plain/Loro/Yjs via in-bracket inverse compensation. When
fnthrows inside the outermostchange(doc, fn), the bracket replays the frame's recorded inverses LIFO inside the same commit. σ and λ both revert; one batched native event fires (Loro: onedoc.commit; Yjs: oneobserveDeepevent); the kyneta Changeset surfacesaborted: trueand contains forward + inverse pairs that net to identity. The change algebra is a groupoid; abort is identity compositionc ∘ c⁻¹ = id, not state rollback. WritableContext.dispatchsurvives with redefined depth-aware semantics: outside any frame opens an implicit single-oprunBatch(auto-commit); inside a frame just callsprepare. The 5 ref-helper files and the addressing layer'sREMOVEhandler are unchanged.- Kyneta-Changeset batching at the outermost-block boundary is preserved as an explicit contract — N helpers in one
change(doc, fn)deliver one Changeset with N changes to each affected subscriber. - Substrate cleanup: Loro's per-substrate depth counter and outermost-origin tracking are deleted (ctx-level outermost detection via
frameStarts.length === 0subsumes them). Yjs's deadaccumulatedDsfield andafterTransactionhandler are deleted (the accumulator was already unused on the version path). - New types:
Changeset.aborted?: booleanon@kyneta/changefeed;BatchOptions.compensating?: booleanandBatchOptions.aborted?: booleanon@kyneta/schema;RECORD_INVERSEsymbol andRecordInverseFntype for the internal substrate→bracket inverse-recording protocol. - New module:
@kyneta/schema'sinverse.tswithinvert(pre, change)and per-type inverters (invertReplace,invertIncrement,invertText,invertSequence,invertMap,invertSet,invertRichText,invertTree) plusdeepClonePreState. Every constructor's reverse arrow is pinned by the groupoid identity round-trip test.
Schema — substrate write coherence unified across plain, Loro, and Yjs:
- The projection law
σ ≡ Π(λ)(the naturality condition of the materialisation catamorphism) now holds at everyprepareboundary across every substrate. CRDT backends advance both the shadow σ AND the native container tree λ insideprepare, instead of buffering λ until flush. The pre-1.8queueMicrotaskdeferral pattern around re-entrant reads or writes from subscriber callbacks (workaround for the buffered-write hole on CRDT substrates) is no longer needed on any backend. - Loro: nested
change()calls collapse into a singledoc.commit()per outermost logical action. A depth-counterrunBatchbracket mirrors Yjs'sY.transactnesting manually; rawLoroDocconsumers (providers, persisters) see strictly fewer / smaller-equal commits than before. Outer-origin commit messages are preserved end-to-end — inner re-entrant origins still flow through the kynetaChangeset.origin, but only the outermost wins as the Loro commit message attribution. struct.json/list.json/record.jsonnow store their subtree as a single plain JSON value in the parent CRDT container. A newJSON_BOUNDARY = Symbol.for("kyneta:json-boundary")runtime marker is stamped on the.json()factories;foldPathshort-circuits at boundary segments via plain-JS descent (symmetric with the existing sum boundary); backend coalescers stage full-value writes at the boundary key. Previously these factories silently produced nested CRDT containers — the.json()modifier was a type-level intent only.
Substrate contract:
SubstratePrepare.onFlush→SubstratePrepare.afterBatch. The method is a post-batch lifecycle hook on everyexecuteBatch, not a buffer-drain — flushes coalescing buffers on local writes and re-materialises the shadow on replay.SubstratePrepare.runBatch?is a new optional transaction-bracket primitive thatexecuteBatchinvokes around the prepare-loop + flush block for local-write batches (replay batches bypass it). CRDT substrates install their native transaction primitive here.WritableContext.runBatchis the corresponding context-level callable installed bybuildWritableContext.syncShadow(target, source)is the new shared helper used by both CRDT backends' replay paths to copy a fresh materialised shadow onto the substrate's live shadow without losing the reader's identity.
Schema & Changefeed — identity-typed echo suppression and origin-free discriminator:
Changeset.sourcefor principled echo suppression. Added an identity-typedsource?: unknownfield toChangeset(propagated fromCommitOptions.source). Subscribers that issue changes can supply a unique token (e.g., aSymbol) and compare it againstcs.sourceto suppress their own echoes.originis pure app-level vocabulary. The fragileorigin === "local"string convention has been removed fromtext-adapterandLine. Kyneta no longer branches onorigin's value internally.- Origin-free own-commit discriminator. Both CRDT substrates now use their native event machinery to distinguish kyneta-issued commits from external writes, rather than colonizing the user-facing
originslot. Loro uses asubscribePreCommithook; Yjs uses atransaction.metamark. External code wrapping a kynetachange()in its ownY.transactis now correctly classified.
Schema — optimizations and fixes:
- Sequence fixes: Materialize sequence items when pushing structured objects on Loro. Reject
undefinedvalues in sequencepushandinsert. Bypassedloro-wasm's 8-item insert limit and surfaced original errors during compensation. - Typed
SubstrateCapabilitiesbag: Replaced producer-sideas anycasts on context monkey-patches. Substrates now declare optional capabilities (nativeResolver,positionResolver,treeNodeAllocate) via a typed bag passed tobuildWritableContext. - Runtime type guards: Optimized runtime type guards and fixed type holes across the schema layer.
- Root document replacement: Improved the error message when attempting to replace the root document.
- DocRef: Preserved the call signature in
DocRefwhen omittingNATIVE.
Exchange — transport improvements:
- Shared Line session: Refactored
Lineto share sessions with an exclusive receiver.
Schema — tree and set algebras realized end-to-end:
Schema.treenow works end-to-end on Loro. The write API ships.create(id, parent, index, data?),.move(id, parent, index), and.delete(id); reads expose.roots,.node(id), depth-first iteration, and a callable snapshot. Subscribers ontree.node(id).fieldreceive precise notifications. Previously a manually-constructedTreeChangefailed with "unsupported change type 'tree'" and Loro events at tree nodes arrived at the changefeed without theirTreeID.Schema.setis now value-addressed end-to-end.SetRef<I>exposes.has(value),.add(value),.delete(value),.clear(),.size,[Symbol.iterator]over plain values, and is callable returningPlain<I>[]. For object-typed items,.has(value)uses content equality. There are no per-member child refs — sets are ref-layer leaf-shaped, not keyed-shaped.
Breaking — schema type shapes:
Plain<TreeSchema<I>>is nowFlatTreeNode<Plain<I>>[](was incorrectlyPlain<I>).Zeroof a tree is[]. JSON-roundtrip a tree as a flat node array.Plain<SetSchema<I>>is nowPlain<I>[](was inconsistentlyPlain<I>[]at the type level but producedRecord<string, V>at runtime). Storage, materialize, zero, and reader all agree on the array shape.TreeSchema.nodeData→TreeSchema.itemfor parity with every other container kind (sequence.item,map.item,set.item,movable.item).tree-positionmodule →doc-position:resolveTreePosition→resolveDocPosition,flattenTreePosition→flattenDocPosition,ResolvedTreePosition→ResolvedDocPosition. The algebra operates over a rooted document, not arbitrary schema trees, and the rename frees "tree" for the CRDT primitive.- Changefeed cluster:
subscribeTree→subscribeDescendants,TreeChangefeedProtocol→RecursiveChangefeedProtocol,HasTreeChangefeed→HasRecursiveChangefeed,hasTreeChangefeed→hasRecursiveChangefeed. This supersedes theComposedChangefeed*→TreeChangefeed*rename from 1.6.0 — apologies for the consecutive churn; "Tree" is now reserved for the CRDT primitive, andsubscribe(Node|Descendants)names the shallow/deep semantic without overloading the noun. Segment.rolerenormalized:"key" | "index"→"field" | "entry" | "index"(declared product field / runtime string key / runtime numeric index). Identity-keying applies atseg.role === "field"boundaries — purely segment-local, no parent-kind sniff.Path.node(id)is sugar overPath.entry(id);Path.field(name)is reserved for declared product field names. App code that goes through the schema API is unaffected; if you constructedPath/Segmentvalues directly, update role tags.
Wire — protocol v2 (protocol-breaking, lockstep upgrade required):
WIRE_VERSION1 → 2. Binary fragmentation now slices unframed payload bytes rather than framed bytes, saving 6 bytes per fragmented message and eliminating the receiver's double-decode. v1 Fragment frames from older peers produce a typedunsupported_versionerror. Complete frames (the 99% case) remain byte-identical. Both peers must upgrade in lockstep.- Asymmetric SSE encoding. Client uploads switch from JSON-over-text/plain to raw CBOR over
application/octet-stream. Server downstream stays text JSON (substrate-forced). Eliminates the ~33% base64 bandwidth tax onSubstratePayload.bytes. Bundled with the SSE upgrade. - Trust boundary at the decoder. Every wire message is now shape-validated after CBOR/JSON parse via
validateWireMessage. Malformed or hostile peer messages surface as typedinvalid-wire-messageerrors throughPipeline.onErrorinstead of crashing the channel or corrupting CRDT state. Identifier byte-length caps are enforced at insert time on the alias map; feature gates inestablishuse strict=== true.
Wire / Transport — Pipeline unification:
- One
Pipeline<S, R>class replaces seven near-mirror assembly sites across WebSocket, WebRTC, SSE, Unix socket, and Bridge transports. Per-transport send/receive collapses to three lines plus I/O. The same class covers both binary and text substrates and supports asymmetric encodings (the SSE case above).@kyneta/wirebecomes a leaf — concrete transport packages now import wire-derived symbols via@kyneta/transport. No drift between transports. - One
Reassembler<T>replacesFragmentReassembler+TextReassembler; onefragmentGeneric<T>chunk loop replacesfragmentPayload+fragmentTextPayload.
Transport reliability fixes:
- 3-peer relay regression: synchronizer now owns the
channelIdnamespace, fixing a regression in which relay topologies (peer A ↔ relay ↔ peer B + peer C) misrouted channel traffic. - Wire text codec: UTF-16 surrogate-pair codepoints are now sliced correctly across fragment boundaries; previously, multi-byte characters at fragment splits could be corrupted.
- Wire version field: encoders now reject
versionvalues outside the encodable range up front. - Wire text frame: stopped a redundant
JSON.stringify/JSON.parseround-trip on the hot text-encoding path. - Transport
_sendaborts on the first channel throw, instead of continuing to drive subsequent channels after a partial failure. - Transport
establishChannel: guard failures now propagate (previously failed silently). - Transport channel directories: switched to an internal counter for channel IDs (previously vulnerable to ID collisions under concurrent open).
- Wire fragment collector: now verifies received size when the
completemarker arrives, rejecting fragmented payloads that under-deliver. - Transport
_initialize: re-init no longer leaks reassembler timers, alias state, or pipeline state from the prior session. - Transport frame stream parser: corrected fragmentation handling across stream-boundary discovery (Unix socket).
- Transport reconnect: proportional jittered backoff replaces fixed-interval retries; shared
tryReconnectlifted to the base. - WebSocket / SSE client transport:
wasConnectedBeforeis reset correctly across reconnect cycles.
Schema — foundations fixes:
- Schema-migration support:
supportedHashesnow walks the full schema (previously stopped at the first sum boundary, so peer-set negotiation under heterogeneous schema hashes was incomplete). Hardened internal symbols against accidental enumeration. Library FNV hash now matches the spec exactly. Validation closes several edge cases at schema-construction time.
Internal:
foldPathhoisted to@kyneta/schemacore: the schema-guided path-resolution fold that Loro and Yjs each implemented separately is now one parameterized function. Per-backend code reduces to a smallstepInto*plus a wrapper. The identity-keying rule (seg.role === "field") and the sum-boundary short-circuit live in one place — no drift surface.PlainStateshadow is now the universal read surface for CRDT substrates: a singleplainReader(shadow)covers every interpreter that needs to read substrate state, regardless of backend. Backend-specific readers retire.- Generic
MaterializeResolver: the CRDT →PlainStatematerialization driver lives in core; each backend supplies only the per-kind value-extraction tail.
Housekeeping:
dist/ Vitest interop fixed; devDeps unified via the pnpm catalog; dead exports removed.bumper-carsexample: bugs fixed, type discipline restored, functional core fully pure.
Fixes:
- Schema (discriminated unions): cache-invalidation handlers no longer accrete on repeated variant flips. Sum fields register invalidation handlers on both the parent product and the active variant product; the previous shape stored them in a left-folded closure keyed only by path, so re-interpreting a sum's current variant after each cache flush accumulated dead handlers (eventually risking a stack overflow via composed recursion). Handlers are now keyed by registrant path and replace on re-registration. No API change.
- Substrate event bridge (Loro / Yjs): re-entrant writes during event-bridge replay are no longer silently dropped. Previously, when a remote sync payload was merged and a user subscriber wrote back to the doc inside the replay, the write reached the changefeed layer but was dropped at the native CRDT — producing an infinite re-delivery loop bounded only by
BudgetExhaustedError. The bridge now uses a structuralreplayflag instead of a global re-entrancy guard. - Exchange echo suppression:
originis yours again. The Exchange previously used the string"sync"onChangeset.originas its own control signal for suppressing local broadcast. This had two failure modes: (1) user code callingchange(doc, fn, { origin: "sync" })accidentally suppressed broadcasts; (2) external Loro batches whose origin was not the literal string"sync"could echo back to peers. Echo suppression is now keyed on a structuralreplay: trueflag inBatchOptions, leavingoriginfree for application use.
Migration note (if affected): if any code passed origin: "sync" to change() to suppress broadcast, switch to change(ref, fn, { replay: true }). Application-defined origin strings ("local", "undo", "llm", etc.) work exactly as before and now reliably don't collide with internal sync behavior.
Diagnostics:
BudgetExhaustedErroris actionable. The error now carries (a) the cascade's entry-point stack frame — typically yourchange()site or the transport boundary that opened the dispatch — (b) a histogram of the top message types contributing to the cascade, and (c) tick-deduplicated history so the recent-events tail shows real subscriber-driven work instead of routing housekeeping. When you hit a runaway, the error tells you which subscriber pair is oscillating.
Internal:
BatchOptions { origin, replay }replaces the bareoriginstring at the substrateprepare/flushboundary. No public API change forchange()callers other than the newreplayflag.
Schema:
- Re-entrant
change()inside subscribers now works. Callingchange(doc, ...)(or.set(),.push(),.delete(), etc.) from inside asubscribe(doc, ...)orsubscribeNode(doc, ...)callback no longer throws "Mutation during notification delivery is not supported." The substrate mutation is still synchronous (later reads in the same callback see the new state); subscribers receive a freshChangesetin the next sub-tick of the same outer dispatch. You can delete anyqueueMicrotaskwrappers around re-entrantchange()calls. - Cross-doc cascade detection. A→B→A→B oscillations across multiple docs in the same Exchange now share one bounded budget and raise
BudgetExhaustedErrorwith diagnostic history. Standalone substrates (created outside any Exchange) use a private lease, so cross-substrate cascade detection is opt-in. subscribe(leafRef, cb)now works on scalar / text / counter / richtext leaves — deep delivery on a leaf is vacuously the leaf's own changes. Previously this threw and requiredsubscribeNodeinstead.
Breaking renames (schema-protocol level):
ComposedChangefeedProtocol→TreeChangefeedProtocolHasComposedChangefeed→HasTreeChangefeedhasComposedChangefeed→hasTreeChangefeed
If your code only uses subscribe / subscribeNode / change you are unaffected. The rename matters if you wrote a custom integration that branched on these type guards (e.g., a custom React store). Note: 1.7.0 renames these again to RecursiveChangefeed* / subscribeDescendants — if you can upgrade through both, skip this step.
Housekeeping:
- Consolidated random-id primitives — SSE, Unix-socket, and WebSocket server transports now use
@kyneta/randomdirectly (re-exported from@kyneta/transport).
Fixes (no action required):
- Index: materialized views built from
Source.union,Source.map, orSource.filterno longer silently lose entries under composition. Three classes of bug are closed: (1)Source.unionretracting a key that exists in both upstreams used to delete the entry instead of decrementing its refcount; (2)Source.mapwith a non-injective key function (multiple source keys → same target key) had the same problem; (3)Source.filterwith a predicate that depends on a mutable value never re-evaluated, so entries never entered or left the filtered view as values changed.Collectionnow refcounts internally — existing combinators just start behaving correctly, no code change needed. - Exchange: mutations performed inside
exchange.peers/ peer-event subscribers (e.g., reacting topeer-departedby writing a doc) now propagate to remaining peers. Previously the mutation reached the local store but the sync input was stranded until the next external event triggered another dispatch pass.
Additions (opt-in):
Source.filter(source, pred, { watch }): pass awatchfunction to re-evaluate the predicate when the watched portion of a value mutates — same contract asKeySpec.watchonIndex.by. Use this whenever your filter predicate reads a field that can change after the entry is created. Withoutwatch, filters still behave as before (fine for immutable values).Source.snapshotZSet(): returns current state as aSourceEvent(delta + values) preserving ZSet multiplicity. For adapter and combinator authors who need the raw integrated ZSet; the existing weight-collapsedsnapshot()is unchanged.
Internal:
@kyneta/machine: extractedcreateDispatcherandLeaseprimitives; Synchronizer rewritten as a faithfulProgram<Msg, Model, Fx>with accumulator drains absorbed into the algebra. No public API change — this is the substrate that enabled the Exchange fix above.
Fixes:
- Schema: correct sum (discriminated union) interpretation — fix
NATIVEdouble-define crash, Loro path resolution across sum boundaries, read-only interior types for sum variant fields - Changefeed:
ReactiveMapcallable returns snapshot copy foruseSyncExternalStorecompatibility
Store — SQL store family (4 new packages):
- @kyneta/sqlite-store: SQLite persistence backend via sync
SqliteAdapter(better-sqlite3,bun:sqlite); atomic meta+record writes - @kyneta/postgres-store: Postgres persistence with async
createPostgresStorefactory, schema validation againstinformation_schema, JSONB metadata - @kyneta/prisma-store: Prisma ORM adapter — plug an existing
PrismaClientfor teams that have standardized on Prisma - @kyneta/sql-store-core: shared pure helpers (
toRow/fromRow,planAppend/planReplace) andfailOnNthCallfault-injection test utility
Wire — protocol v1:
- Compact binary format: 6-byte header, numeric
u16frame IDs, removed transport prefix and unused hash byte - DocId/schemaHash aliasing: receiver-meaningful integer aliases negotiated via
present; per-message overhead reduced from ~45 bytes to ≤15 bytes - Wire-feature negotiation:
WireFeaturesmap inestablishfor forward-compatible capability advertisement - Delivery-mode taxonomy: three named modes (muxed, streamed, datagram) — streamed and datagram implementation-deferred
- Identifier length caps:
DOC_ID_MAX_UTF8_BYTES = 512,SCHEMA_HASH_MAX_UTF8_BYTES = 256with typed rejection errors - Codec collapse: deleted
cborCodec/textCodecasChannelMsg ↔ bytescodecs; SSE integrated into alias-aware pipeline
Exchange:
- Cohort governance:
canCompactpredicate distinguishes durability-critical peers from ephemeral ones for compaction-safe replication - @kyneta/bridge-transport — new package: extracted from
@kyneta/transportwith codec-faithful message routing (all bridge-driven tests now exercise the production wire path) - Bridge routing by
transportIdinstead oftransportType
Schema:
- Variance-safe replica types:
Replica<V>/ReplicaFactory<V>split intoReplicaLike/ReplicaFactoryLike, eliminatinganycasts in the synchronizer
Fixes:
- Transport: reset reassembler and alias state on reconnect; prevent unhandled rejections in SSE POST retry path
Housekeeping:
- @kyneta/random — new package: secure-context-free random ID primitives extracted from scattered implementations
- Consolidated test packages into
@kyneta/test-integrationwith SQLite integration suite - Example:
prisma-counter— collaborative Loro counter with Prisma/Postgres persistence
Exchange — architecture overhaul:
- Session/sync split: Synchronizer decomposed into session program (peer lifecycle, channel topology) and sync program (document convergence); four-state peer lifecycle (joined/disconnected/reconnected/departed);
departwire message for intentional departure;establish-request/establish-responsecollapsed into singleestablishmessage - Governance reform:
DocPolicy→Policy(gates only, no notification callbacks); gate predicates renamedcanShare/canAccept/canConnect/canReset;exchange.destroy(docId)replacesdismiss(); newexchange.suspend(docId)/resume(docId)for reversible sync-graph departure;exchange.documentsreactive collection replacesonDocCreated/onDocDismissedcallbacks; policydisposehook and per-Exchange Line registry for clean shutdown - Durable Line: Lines survive transient disconnects and process restarts;
close()is local-only teardown (documents preserved),destroy()is permanent; automatic compaction at quiescence;nextSeqpersisted for resume - Peer ID: per-tab unique peer IDs via localStorage CAS lease (
persistentPeerId);peerIdrequired at the type level (ExchangeParams.id: string | PeerIdentityInput), runtime guard removed
Schema — new algebras and compiler evolution:
- Position algebra + useText: Substrate-agnostic
Positioninterface with sticky-side semantics;transformIndex(gap-addressing) andtextInstructionsToPatches(offset-based DOM ops);PlainPosition,LoroPosition,YjsPositionwith shared conformance suite;change(ref, fn, { origin })for echo suppression - Rich text:
Schema.richText(markConfig)— 11th schema kind withMarkConfigfor mark vocabulary + Peritext expand behavior;RichTextInstruction(retain/insert/delete/format); marks as first-class algebra with composable extension model - Tree-position algebra: flat ↔ tree position mapping for editor bindings (ProseMirror-style flat integer positions to
{ path, offset }pairs) - Sequence algebra unification: shared indexed-coalgebra helpers (text/sequence/movable) and keyed-coalgebra helpers (map/set), eliminating copy-paste across interpreter transformers
- Schema migrations: identity-stable migrations with tier-derived coordination (T0 additive, T1a rename, T2 lossy projection, T3 epoch boundary);
supportedHashesinpresentmessages for heterogeneous-peer sync - Composition-law binding: algebraic
[LAW]tags ("lww","additive","positional-ot", etc.) replace kind-name[CAPS];RestrictLawsenforces substrate/sync-protocol fidelity; blocks the "silent weakening" fourth outcome
Store:
- Store contract v2: unified
StoreRecordstream (discriminatedmeta|entry); materialized metadata index;replace()atomic compaction; store-program Mealy machine for coordination;storeVersionadvances only on write success - @kyneta/indexeddb-store — new package: IndexedDB persistence backend for browser-side Exchange
React:
useText(textRef): React hook for collaborative textarea/input binding with model-as-source-of-truth, surgical remote patching, IME-safe composition, and cursor preservation; browser undo/redo interception- todo-react upgraded to collaborative inline editing with
Schema.text()+useText
Fixes:
- Yjs: include delete set in
YjsVersioncomparison - React: intercept Shift+Cmd+Z (redo) in
attach()keydown handler
Housekeeping:
- Build: migrated bundler from tsup to tsdown (Rolldown-based)
- Extracted shared Bun build + static serving into
internal/bun-server - LLM-optimized rewrite of ARCHITECTURE.md + all per-package TECHNICAL.md files
- Line.protocol: first-class protocol objects for Line (
protocol.open/protocol.listen) - ensure-* idempotency: renamed open commands to
ensure-*and formalized idempotency invariant across exchange and machine - WebSocket transport: runtime-agnostic WebSocket constructor injection — eliminated
globalThis.WebSocketdefault and Bun-specific cast
- @kyneta/index — new package: Reactive document indexing with Catalog, secondary indexes, joins, and DBSP-grounded algebraic redesign (ZSet, Source, Collection, Index)
- Schema.tree: Full tree CRDT support with navigation, mutation, and observation (Loro-backed)
- added the [REMOVE] symbol: Structural self-removal for container-child refs in schema
- Source.flatMap: New combinator + Source.of convenience for the index package
- Wire fix: Replaced @levischuck/tiny-cbor with internal CBOR codec (UTF-8 string encoding bug)
- Schema refactors: Generic createDoc, typed [NATIVE] functor, Schema.doc → Schema.struct rename
- Housekeeping: experimental packages moved to experimental/
- Transport layer — 3 new packages: @kyneta/transport (base), @kyneta/unix-socket-transport (stream-oriented), @kyneta/webrtc-transport (BYODC DataChannel)
- @kyneta/machine: TEA-like state machine--universal Mealy machine with effect interpreter; transport clients rewritten as pure Programs
- @kyneta/changefeed: Extracted as independent reactive contract package; promoted to developer-facing type
- Storage: StorageBackend interface + InMemoryStorageBackend + LevelDB persistent backend; storage-first sync
- Replica / Substrate split: Factored Replica from Substrate; ReplicaFactory for all substrate types; two-phase construction
- Sync protocol: Structural merge with schema fingerprint verification; document disposition (Interpret / Replicate tiers); version comparison
- exchange.peers: Peer lifecycle as a Changefeed; duplicate peerId detection
- Line: Reliable bidirectional message stream between two peers
- advance(): Universal history trimming across all substrates
- Schema overhaul: First-class native leaf types, symbol-keyed metadata ([KIND], [TAGS]), json.bind() / loro.bind() namespace API, dissolved LoroSchema namespace
- onDocCreated / onUnresolvedDoc: Exchange lifecycle hooks
- Example: unix-socket-sync — leaderless TUI config sync over unix sockets with Loro CRDT