Skip to content

feat: lists, rich text and trees that merge; versions, tags, undo and branches - #3

Merged
Alex-Dolid merged 53 commits into
mainfrom
features/enhance-crdt-engine
Oct 24, 2026
Merged

Alex-Dolid merged 53 commits into
mainfrom
features/enhance-crdt-engine

Conversation

@Alex-Dolid

Copy link
Copy Markdown
Contributor

Summary

syncom's record fields can now be lists, texts (with rich-text marks and cursors) and trees, merging by position on every node, next to the registers, counters and sets they already were. Around them comes what the CRDT libraries carry around a document: a record's history read at any version, named versions of the whole storage, diff/restore, an undo stack and branches that merge back. Two review passes then hardened the edges against hostile peers and broken contracts. A docs audit brought every page in step with the code; the bugs it found are fixed here too.

53 commits · 180 files · +23.7k / −1.9k · 958 tests

What's new

Field kinds (on Storage and on a live Record)

  • Lists and texts: splice(), push(), updateText() (Myers diff, one splice per hunk), setAt(), move() (a concurrent move lands once). The merge rule is Fugue (Weidner & Kleppmann 2023): runs never interleave, forward or backward.
  • Rich text: mark() / unmark() / unmarkById() / spans(). Expand rules are set per call (after, before, both, none), and { many: true } lets marks of one name stack, which is what comments need.
  • Cursors: cursor() / index(), a place that survives the edits around it.
  • Trees: treeMove() / treeRemove(). Moves fold in stamp order, a cycle never forms (Kleppmann et al. 2021), and siblings are ordered by fractional index.

Versions

  • history(id, { limit, before }) and get(id, { at }): the past as a fold of the retained log, read by event id, stamp, vector or tag.
  • tag() / untag() / tags() / at(name): named versions of the whole storage, copy-on-write, with queries and snapshots at a tag.
  • diff(), applyPatches() (rehearsed, lands whole or not at all), restore(), compact(id), and a commit event for local ops.

New subpaths

  • @alexify/syncom/order: fractional indexes (1.0 KB).
  • @alexify/syncom/undo: an undo stack over the commit event. Refuse-to-overwrite, local ops only (6.1 KB).
  • @alexify/syncom/branch: fork a storage under a new identity and merge either way, as ops (3.2 KB).

New error codes: OUT_OF_RANGE, CYCLE, HISTORY_GONE, INVALID_VERSION, TAG_EXISTS, TAG_NOT_FOUND, BRANCH_EXISTS.

Engine and wire

  • New modules: src/sequence.js + src/rle.js (lists and texts), src/tree.js (trees), src/history.js, src/tags.js, src/patch.js, src/outbound.js, and src/fields.js + src/versions.js (API modules over the lent core).
  • New op codes: / splice, = assign, ^ move, * mark, ~ tree. packOps keeps its 17 slots, and PROTOCOL stays 1 (nothing is published yet).
  • When two kinds meet on one field, the kinds' precedence decides, on the op path as in a snapshot merge: register > counter > set > list > text > tree. This closes the old SYNC-1 exception.
  • Bounds a peer can hit, on the op and snapshot paths alike:
    • sync.pendingMax (4 096);
    • MAX_RUN (2²⁰) and MAX_FIELD (2²²) slots;
    • tree depth (512) and index length (1 024);
    • a counter's per-origin range (±2⁵³−1).
  • A malformed payload of any op type is reported (OP_REFUSED, malformed), never ignored.

Fixes found by review №2 and the docs audit

  • Hostile input: an OOM from a huge gap in a snapshot block, a run extended by another origin, tree ties broken by arrival order, a forged fractional index, a 4 000-deep tree chain.
  • Contracts: branch() / drop() could take over or wipe another storage; tags.json names were not checked. An unreadable tags.json no longer blocks open(), and open() takes only an options object.
  • Data:
    • an array spliced into a text wiped it on every node;
    • spans() threw on mixed plain and many marks;
    • compact() dropped marks and refused long fields;
    • a counter past Number.MAX_VALUE diverged between nodes;
    • a failed undo inverted part of a record twice.
  • Replication:
    • snapshot blocks ignored sync.pendingMax;
    • a merge kept data its metadata did not back;
    • a refused handshake push left the feed closed;
    • a failed connect() was never retried.
  • Reads: a live Record lost the lens and afterRead after any change.
  • Types: browser.d.ts now re-exports every type, a consistency test keeps it that way, and about ten .d.ts-vs-code mismatches are fixed.

Behaviour changes to notice

  • A peer's op of a lower-ranked kind is now settled by precedence instead of refused on both sides.
  • update() with an invalid id rejects with INVALID_ID, not NOT_FOUND.
  • Malformed write / set / inc / add / remove payloads from peers now raise OP_REFUSED error events.
  • A live Record with a lens or read hooks is refused once the write is done, outside the queue.
  • The storage error event type is a union: UNPERSISTED carries ids and no op.

Docs and benchmarks

  • New pages: Lists, text and trees; Versions and history; Compared with CRDT libraries (141 rows against Yjs, Automerge and Loro, decided from their repositories).
  • Every guide page, the API reference, the README and the CHANGELOG were audited against the code, with examples run.
  • The feature matrix gained eight rows, and the RxDB page was updated.
  • The bench compares text typing against Yjs, Automerge and Loro, plus the bundle size of each package as a whole. syncom types 0.4–1.8k keystrokes/s, where Automerge does about 20k and Yjs and Loro do hundreds of thousands. The pages say so: syncom's texts are fields of a record, not documents.
  • INVARIANTS.md maps every guarantee (SYNC, OPS, LIST, SEQ, RLE, TREE, BOUND, LOG, VER, TAG, PATCH, UNDO, BRANCH) to the tests that hold it, and a test keeps that map true.

Size

The browser entry grows from 42 KB to 66 KB min+gzip (now at its budget; every raise is named in scripts/size.js). The new subpaths are budgeted on their own rows.

Test plan

  • node --test: 958 tests, 957 pass, 1 skipped
  • pnpm test:coverage: 99.28 / 95.93 / 99.72 (thresholds 95/95/90/95)
  • pnpm test:types, pnpm lint, pnpm format:check, pnpm size
  • pnpm docs:build (no dead links)
  • Convergence fuzz and hazard walk (tests/suites/sync-engine.js); wrpc integration test with real servers
  • WebRTC browser recipes (docs/guide/webrtc.md): checked by hand only, as before

Known limits (documented)

  • A text field is read and written whole per op: fine for notes, comments and blocks, not for a long document many people type into.
  • compact() names elements anew, so old cursors read null, and it leaves out list/text/set/tree fields that read empty.
  • A pending-work bound refusal depends on the order ops arrive in.

🤖 Generated with Claude Code

Alex-Dolid and others added 30 commits October 24, 2026 10:56
…old takes retention 0

- bootstrap: mergeBatch scrubs every id the snapshot carries as purged once
  the batch is durable, as a peer's page does — a node that held a record's
  ops and bootstrapped after the purge kept the payloads in its log
- apply: a peer's op on a record this node keeps encrypted is logged and
  relayed flagged `skipped`, never accepted as applied, so no replay or fold
  of the log applies what the node did not
- storage: `ahead: 'hold'` accepts `retention.ops: 0` (keep everything);
  `repair(id)` counts a held op this node has since applied (`appliedHere`)
- docs: the log's open files are rewritten by a scrub; index.json's real
  keys; the sealed-record skip in hooks.md; the snapshot scrub in storage.md

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Jazz 2.0 (alpha.59, the version jazz.tools documents) is a server-merged
relational database: typed tables, queries with joins and aggregates,
lenses, row-level policies, queryable history and branch views — and no
CoValues, no list or text CRDT, no per-change signatures, no hash chain,
no purge, no peer-to-peer. Every cell is re-decided from the repository's
architecture specs and docs at commit 16cadcf, cited as the source. The
method gains the rule for a project whose npm `latest` is legacy; the
prose of comparison.md, why.md and README follows (Jazz joins the
databases; four others have counters; four keep a tamper-evident history).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
src/sequence.js: Fugue placement over runs (one run per insert, a writer
extends its own run at the tail), run-length content with tombstones
collapsed to lengths, cuts as idempotent range unions, pending runs, cuts
and extensions bounded by PENDING_MAX, the iterative walk with each
element's ordinal and visible position, local placement and ranges, the
reset rule (prune) and the snapshot join. No consumer yet: ops.js wires it
in next. THIRD_PARTY_NOTICES.md carries the MIT notices of what it adapts
(fugue-simple, Yjs, list-positions) and ships with the package.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A `splice` op (code `/`, payload in key/value) on a field makes it a list or
a text — the fourth and fifth field kinds — kept as a sequence-engine block
under meta.seqs[field] and read as its walk. The first op of a field decides
the kind by what its content is, and every op carries the same, so a cut
that arrives before any run of the field knows what it belongs to. Mixing
kinds is refused as for sets (reasons 'list' and 'text'); a merge joins two
blocks of one kind and lets a register, a counter or a set outrank a list,
a list a text. A reset prunes the block as it lands, so what fell below
the base reads the same whichever side of the reset it arrived on; the
block's stamp is derived from what it holds, never from a cut. ops.test.js
checks convergence over every arrival order, the join against a replay, a
peer's hostile block, the purge and the wire code. Browser budget 42 → 47 KB
(47.0 measured), with the reason the RxDB commit left out.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…e path

`splice(id, key, index, count, content)` and `push(id, key, content)` on
the storage and on a live Record: the index and count are resolved inside
the mutation queue (prepareOp) into what travels — the anchor of a new run
or the writer's own run to extend, and the ranges to cut — so a concurrent
apply cannot shift them; past the field's length is `OUT_OF_RANGE`, a coded
error with the index, count and length. The first write decides the kind;
a call that only cuts takes the field's. `migrate()` carries lists and texts
across a schema version as one run each. Types, tsd, api.md rows; browser
budget 47 → 48 KB (47.7 measured).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…t page

sync-engine: a partition test for concurrent inserts, cuts and a reset on a
text and a list (readings AND blocks agree), three nodes typing words apart
that merge whole, the seeded fuzz re-cut over 80 steps with splice branches
on body/items and inc on a record that is never reset, the late joiner
bootstrapping a text and a list and editing them live after. hooks: the
write context of a splice; beforeWrite may not replace it. purge: a text
holds the secret too. schema: a splice is validated as the record it leaves;
a validator default makes the field a register. query: a list field posts
by element. Docs: docs/guide/lists.md (what merges how, what travels, an
editor binding, costs), sync.md's kinds section, storage.md, query.md,
hooks.md, telemetry.md reasons, README, CHANGELOG, CLAUDE.md.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A list's elements keep their names across moves: `setAt(id, key, index,
value)` is a value register per element and `move(id, key, from, to)` a
position register (Kleppmann et al. 2020, Loro's MovableList). A move cuts
the slot the element showed at and adds a one-element position run that
names it; a slot shows iff it is alive and the position its element's
register names, so two concurrent moves leave the element once, at the
newer move's place, a move concurrent with a removal brings it back, and
an assigned value follows the element. `assign` (`=`) and `move` (`^`) ops
ride in key/value; a register for an element whose run has not arrived
waits like a cut. Run-length segments of a list compare by their JSON, so a
repeated cut is a no-op. Partition tests, the fuzz with setAt/move
branches, the Lists and text page; browser budget 48 → 49 KB (48.9).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…t merge

`updateText(id, key, text)` diffs a text field against the string an editor
hands over, inside the mutation queue (src/myers.js: Myers' O(ND) over
UTF-16 units after the common prefix and suffix, written from the paper),
and sends one splice per hunk, last first, in one turn of the queue; it
resolves to how many ops it took, and a difference deeper than a thousand
edit steps is one op replacing the middle. On the Record too. A text field
is searched as it reads — `$text` follows updateText in and out. Browser
budget 49 → 50 KB (49.7 measured).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A text carries marks: `mark(id, key, start, end, name, value, { expand,
many })` styles a visible range, `unmark` removes a style over a range (a
null mark under the reversed expand rule) or one mark by id, `spans(id,
key)` reads the text as runs of equal style. A mark is two anchors — an
element and a side each, or the head and the end — picked by the expand
rule ('after' by default: bold grows under typing at its end, a link with
'none' does not), so the same tree rule that places typed text decides
whether it takes the style, concurrently or not (Peritext, Loro). Per name
the newest mark wins; a `many` mark stacks with every other of its name
(comments) and goes only by its id. The `mark` op (`*`) rides in key/value
and waits for a text that has not arrived like a cut does; the field still
reads as the plain string. Browser budget 50 → 51.5 KB (51.4 measured).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
`cursor(id, key, index, { side })` names a place in a list or text that
survives the edits around it — before the element at the index, or after
the one before — and `index(id, key, cursor)` says where it is now, here or
on a peer; a cursor into a deleted element reads at the next visible one,
one a reset emptied reads null. `@alexify/syncom/order` (1 KB, off the main
entry) mints fractional indexes — strings that sort with `<`, with room
between any two, `jitter` for concurrent inserts — adapted from loro-js's
fractional index; query.md's ordering recipe uses it. bench/sequence.js
measures the engine. The comparison cells `text` and `lists` are ✅ with
their sources, and the prose that said "no text or list CRDT" (README,
why.md, comparison.md, vs-rxdb.md, index.md, architecture.md) now says what
is there and what is not: editor bindings and document-scale text. Notices
for Loro and Peritext. Browser budget 51.5 → 52 KB (51.7 measured).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A field can be a tree: nodes with a parent and a place among their
siblings. `treeMove(id, key, node, parent, { after, before })` puts a
node under a parent (null: a root) after or before a sibling, or last;
`treeRemove(id, key, node)` moves it, with its subtree, under `$trash`.
The field reads as `[{ id, children }]`.

src/tree.js keeps, per node, every move made of it — `[ts, origin,
parent, fi]` — and the tree is the fold of all moves in stamp order
with a move that would put a node under itself skipped (Kleppmann,
Mulligan, Beresford & Gomes 2021; Loro's movable tree). A lost move is
kept, so a later move can make it count; a removal hides the subtree
and a move out brings it back; siblings order by a fractional index
(src/order.js, now in the core) then by stamp. The `tree` op (code `~`)
carries `{ node, parent, fi }`; `resolveTree` in src/commit.js mints
`fi` inside the mutation queue between the neighbours, decides CYCLE
(new code) and NOT_FOUND there, and makes no op for a move to where the
node already is. Every meta walker in src/ops.js takes `meta.trees`;
the kind rule refuses every other op on a tree and a tree op on every
other kind; a merge lets every other kind win over a tree; migrate()
carries a tree across a schema version.

Tests: the model alone (tests/tree.test.js), assertCommutes over the
cycle, the removal race, the reset, merge == replay and a hostile block
(tests/ops.test.js), the API through storage and Record with a reopen
and a repair, the hooks context, the coded errors, a mesh partition and
the seeded fuzz with a tree branch comparing move logs across nodes.

Docs: a Trees section on the lists page (renamed "Lists, text and
trees"), the op table and kinds section of sync.md, api.md rows (and
the push/move/spans rows lost to a replace bug, restored), hooks.md,
telemetry.md, architecture.md, CHANGELOG, CLAUDE.md (layout rows
restored too), THIRD_PARTY_NOTICES. Browser budget 52 → 55 KB (54.6
measured).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…der holds

`OpLog.droppedTs` is the newest stamp among the ops compaction dropped,
or an open could not read (an unreadable segment; an index from before
the field with a floor above 1; no index with a floor above 1 — all
`latestTs`), persisted in index.json and reported by `stats().log`.
Zero while nothing was ever dropped. A reader of the past checks it: a
record's retained entries are its whole history only from a reset
stamped above it — the ground the history and read-at-version calls
stand on.

`OpLog.pin(keep)` is `retain` for a reader: the entries `keep` answers
true for stay, with their segment and everything after it, until the
returned function is called; pins add up and leave `retain` to the
sync manager.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A version is a cut through the retained entries of a record: an event
id (every entry of this node's log up to it — the record as THIS node
held it after that entry), a stamp (every op stamped at or below it,
the same answer on every node whatever order the ops arrived in) or
`{ vector }` (every op it covers). The record at a version is the fold
of the entries inside the cut with the same applyOp that built it;
`history(id, { limit })` lists one version per op this node applied,
newest first, with the record as it read after it through the lens and
afterRead and the fields the op changed; a collection's get takes `at`.

src/history.js decides what the log can vouch for: the fold is exact
when nothing was ever dropped, or from a reset (write, delete, purge)
stamped above the log's droppedTs — a reset discards what is below it,
and everything dropped was stamped at or below droppedTs. The one hole
that rule has, a counter slot alive across the reset whose sum older
increments went into (the straddle repair() has too), is caught by
comparing the fold of everything retained with the record as it is.
history() reports `exact` and `complete`; get() rejects with
HISTORY_GONE (reason compacted / scrubbed / merge / epoch) or
INVALID_VERSION, both new codes. A purged record lists its ops with
their content gone; an encrypted one has no past; a stamp below a
joined snapshot's merge cannot tell what the merge carried. The read
hooks see `at`.

Tests: a suite on both halves — every op kind read back three ways,
arrival vs stamp vs vector after a partition, a hook-skipped op and a
held op through an upgrade, compaction with and without a reset, the
counter straddle through a peer's older write, the merge straddle on a
bootstrapped node, purge and encrypted, the hooks — and the seeded fuzz
folds every record back to its present at the node's own vector.

Docs: new page Versions and history (sidebar, llms index), api.md rows
and error rows, hooks.md, storage.md, the matrix's history cell, the
bench rows. Browser budget 55 → 56.5 KB (56.2 measured).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
`tag(name)` names the storage as it is now: the catalog of the moment,
saved as the tag's own, plus a copy of each record's file taken before
its first change after the tag — nothing per record until it changes,
one file after, in the newest tag only (had the record changed between
two tags, the older one took its copy while it was the newest). Reading
a record at a tag — `at(name).get(id)`, `get(id, { at: { tag } })`, a
collection's `get` too — walks the tags from it to the newest for a copy
and falls through to the record as it is; the tag's catalog row decides
whether it existed. `at(name).has(id)`, `tags()`, `untag(name)` (the
copies the next older tag needs move there).

src/tags.js keeps tags.json and, per tag, a Catalog (`adopt(rows)` of
the live one, loaded back with `strict: false` so a schema's index change
does not drop it) and `tags/<name>/data/`. The copy is taken at the
EntryStore seam (`before(id, file)`), ahead of write/writeSealed/remove,
inside the write: a copy that cannot be written leaves the record held
and reported UNPERSISTED, its file — still the tag's state — untouched,
and a read at the tag of a record held since reads that file. `tag()`
refuses with UNPERSISTED while a record is held: no file to copy from. A
copy carries its chain block and is checked like the file. Every purge
path goes through `core.scrub` — the log and the tags. New codes
TAG_EXISTS and TAG_NOT_FOUND (field `tag`); tags do not replicate.

Also lands here: the history and tag type declarations and their tsd
assertions (`Version`, `HistoryVersion`, `HistoryResult`, `get(id, { at })`,
`history()`), which the history commit meant to carry and did not.

Tests: a suite on both halves — reads after an update, a delete, an
insert, a sealed record, through the lens; copies in the newest tag
only, once per record, moving down through untag; reopen, a swept
leftover, a lost catalog, a torn tags.json; a failing copy, a held
record, a tampered and a torn copy, a purge reaching the tags; the read
hooks. Docs: a Tags section on the versions page, api rows and error
rows, the on-disk layout, CLAUDE.md, the bench rows. Browser budget
56.5 → 58 KB (57.7 measured).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…napshot()

A tag is the whole storage, not one record. `at(name).list()` is every
live id then; `at(name).collection('Post')` queries the collection as it
was — `select()` and `count()` over the tag's own catalog, each row read
from its copy or from the record as it is, `get()` as before, no `watch()`
(a tag does not change) and no `$text` (its catalog carries no text
index); `at(name).snapshot()` pages the storage as it was, the way
`snapshot()` pages the present, so another storage's `mergeSnapshot()`
stands at the tag (and `adoptVector(page.vector)` names where).

Storage reads a tag through `#sourceAt(name)` — the `[SOURCE]` seam over
the tag's catalog and `#entryAt` — and pages through `snapshotPage` with
a core of the tag's catalog, entries and position: the same `pageOf`, no
second page writer. Types `TagCollection` and the extended `TagView`.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A patch is plain JSON in the terms of the write API — put, write, inc,
add, remove (as observed, or exactly the removals named), splice,
treeMove, treeRemove, delete, purge — and src/patch.js is its one
vocabulary: `diffEntries(from, to)` compares two entries per field by
kind (a register's new value, a counter's delta, a set's added and
removed elements, Myers splices for a text and — interned to one char
per element — for a list, a tree's moves parent-wise then its removals)
and falls back to a `write` of the registers plus the other kinds made
again whole when a register is gone or a field changed kind, since no op
removes one field; `realize(patch)` maps a patch to the op the write
path takes.

`diff(id, from, to)` reads both versions (an event id, a stamp, a vector,
a tag, or the present) through the lens, raw; `applyPatches(id, patches,
{ actor, source })` realizes a list in one turn of the mutation queue and
resolves to how many ops it took; `restore(id, at)` leads back with new
ops — a version of its own that merges like any write. A `remove` patch
with `removals` goes out as given: an undo revokes its own add alone and
a concurrent add of the same element survives.

The storage raises `commit` for every op of its own once durable — the
op, its log seq, the source (the Record whose save() it was, or what
applyPatches() was given), the record before and after as copies, and
every field's kind on both sides — built only while something listens.
A peer's op raises `change`, never `commit`. A text's marks are not
diffed.

Tests: a suite on both halves — the patches of every kind forward and
back, from nothing and to nothing, a reset; a restore to every version
of a record through every kind, converging over a mesh; a removal of
exactly the named tags beside a concurrent add; one turn of the queue
with every action and a bad patch writing nothing; the commit event's
payload. Docs: Diff and restore and The commit event on the versions
page, api rows, the events table, CLAUDE.md. Browser budget 58 → 60.5 KB
(60.0 measured) — the end of the versions epic, as agreed.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
`createUndo(storage, { mergeInterval, limit, scope, exclude, actor })`
listens to the storage's `commit` events and keeps, per step and record,
how the step found the record and how it left it; commits close together
make one step, `mark()` closes one, a new write clears the redo stack,
`limit` steps are kept. `undo()` and `redo()` apply a step's inverse
through `applyPatches()` — new ops of this node's own that merge like any
write and that a peer sees as such — and what those ops commit is the
step of the other stack.

Refuse-to-overwrite, Yjs's rule: a field anything but the step's own ops
changed since is left alone (`skipped`), a whole-record step with such a
change is skipped whole, `force` overrides; the manager's own ops and the
ops of steps already taken back never count, and what commutes is no
conflict (a peer's increment of a counter, add to or remove from a set).
The inverse is exact first — a field the step created is gone again,
through a reset — and loose beside a peer's change: a counter, a set, a
list, a text or a tree the step created is emptied instead
(`diffViews(..., { exact: false })`), and a set element goes as this
node's adds alone (a `remove` patch with `own: true`, `ownTags` in
prepareOp), so a peer's concurrent add of it survives. The manager pins
the log from its oldest step; a purge forgets its record; `close()` stops
it. A text's marks are not undone.

For the subpath to ship without the op model, `kindsOf` moved to ops.js
and `deepEqual` to shape.js; patch.js now requires shape.js and myers.js
only and offers `diffViews` over data and kinds. Packaged as the other
subpaths (exports, files, the size entry at 5.5 KB alone, the package
tests, the docs keywords); documented on the versions page and in the
api reference; a bench of its own.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
`branch(parent, { path, from?: { tag }, ...options })` opens a storage at
a new directory through the parent's own constructor — a new identity,
its own keys and log — and stands it at the parent's state, or at a tag
of it: the snapshot pages joined, the vector adopted, then what landed
while paging caught up as ops; `branch.json` names the parent and the
position. Its `sync` gets `retention: { ops: 0 }` (every op stays for the
merge back) and `autoStart: false`.

`merge(source, { into })` hands `into` what `source` has that it lacks —
the ops after `into`'s vector, through `applyRemote` in pages, the way a
peer's push lands, so concurrent edits of one record converge by the
usual rules, a second merge has nothing to hand over, and a merge into a
parent that dials peers reaches them as the branch's own ops. A source
whose log no longer holds what the other needs is joined as state
instead (`joined: true`). `branchInfo()` reads the directory's
branch.json; `drop()` closes a branch and removes its files.

src/outbound.js is the one selection: the ops a receiver lacks by its
vector, origin and `from` of the receiver excluded, the first retained
seq per own stream — `#pushFromLog` in the sync manager picks through it
now, as `merge` does, and `unreachable()` is the UNDELIVERABLE rule.

New code BRANCH_EXISTS. Tests on both halves: a fork at the present and
at a tag, merges both ways and a second one empty, a mesh peer receiving
a branch's edits, a parent compacted past the fork joined as state,
signatures required and met, a random walk on both sides converging,
drop(). Packaged as the other subpaths (3.1 KB alone, budget 3.5);
documented on the versions page and in the api reference; a bench.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The comparison grows from forty to forty-eight features: rich text
(marks and blocks), overlapping marks of one kind, stable cursors, a
move in lists without duplicates, trees that merge, an undo that
converges, named versions and branches with merge-back — one cell per
library, each decided from the published source as the method says (the
Yjs, Automerge and Loro cells from their sources with file and line, the
databases and services from the pages their text and history cells were
decided from). The page's first bullet, the README's feature table (a
Versions row) and its count, the llms index and the bundle-size claims
on the index and why pages (about 60 KB now) follow; `pnpm bench:size`
and `pnpm bench:report` remeasured the ten libraries' bundles and
rewrote the size regions and chart.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Three scenarios join the cross-library comparison, in memory: type one
character at the end of a text (the common keystroke), at its start
(every insert anchors into the same spot) and anywhere (seeded). Only
the libraries with a text that merges by position take part — syncom's
text field through splice(), Y.Text, Automerge's text, LoroText; the
databases and the stores show "—". Each library must read "acb" back
after inserting "ab" then "c" at 1 before anything is timed. The
benchmarks page says what is measured: a syncom text is a field of a
record and every keystroke is one signed op appended to the log and
one record file written, where a document CRDT batches a transaction
into one update and writes nothing; the editing-trace benchmark the
CRDT libraries publish is out of syncom's scope on purpose.

`pnpm bench:compare --scenarios append,prepend,random` and `pnpm
bench:report` wrote the results, the charts and the regions. The
bundle-size and feature-count claims that still said 42 KB and forty
(the why and index pages, the llms index, the RxDB comparison's cells)
now say 60 KB and forty-eight.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
INVARIANTS.md names every guarantee — SYNC-n, OPS-n, LIST-n, LOG-n,
VER-n, seeded from the sync guide's Guarantees and limits and the
versions page — with the tests that hold it; tests/package/
invariants.test.js parses the ids and the backticked titles, scans
tests/ for every plain-string test title, and fails when an id names no
test or a named title is gone. Rename a test and its row together.

The sync-engine suite gains a seeded random walk under hazards: A
hosts, B dials over a link that drops and comes back and that may
deliver a page twice (a `duplicate` hook on the in-process memory
link), B is closed and reopened mid-walk up to three times; after the
walk the two converge, and every record folds back to its present at
the node's own vector. CLAUDE.md says how the file and the test live
together.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A sequence field was read through a fresh Fugue walk twice per local op
— once to place the insert, once to read the field back — and three
other things ran over every run of the block on every op: the walk
parsed each run's key, anchor and content again; `latestClock` rebuilt
the record's whole metadata (`normalizeMeta`) to find its newest stamp;
`prune` parsed every run's key against the base. On a text of two
thousand single-character runs that was five milliseconds an op.

Now `seqIndexOf(entry, field)` keeps the built index on the entry
(`entry.seqIndex`, carried by `cloneEntry` and into the entry cache,
never into the file) and reuses it while the block object and the base
are the ones the entry holds — a changed block is a new object, since
every change goes through `cloneSeq`. The build parses a run's tuple
once (`parsedRun`, a WeakMap by the tuple an unchanged run keeps between
blocks) and keeps one per-slot array for ordinals, visible indexes and
shown flags instead of three per run; `prune` reads the stamp through
the same parse; `latestClock(meta, true)` is the trusted path for an
entry this node holds, the untrusted one still normalizes a peer's raw
meta first. Typing into a two-thousand-character text in memory: at the
start 174 → 1,100 ops/s, anywhere 320 → 2,000, at the end 6,400 →
9,500. What stays is the record file written whole on every op.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
`pnpm bench:compare --scenarios append,prepend,random` and `pnpm
bench:report`: typing at the start of the text 155 → 399 ops/s, anywhere
224 → 541, at the end 1,260 → 1,780 — the scenario's text grows past
ten thousand characters, and every op still works over every run of the
field and writes the record file whole, which the benchmarks page now
says. The changelog carries both the two-thousand-character numbers and
these.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A peer's block or op could cost a node more than it says, or split the
mesh. A 3e8 gap in a snapshot run was expanded element by element — out
of memory, and the merge op relayed it. Any origin could extend another
origin's run, so the order of arrival decided the text. The snapshot
path had no PENDING_MAX: one hostile block spent the bound for every
honest op after it. A 4 000-deep tree chain threw in the render on the
apply path (PAGE_FAILED, then STUCK). A forged `fi` made every local
treeMove next to it throw a TypeError. Two moves with one forged stamp
folded by insertion order. Registers of elements not here and marks
switched off before they arrived counted against nothing. A pending cut
arriving twice was waited for twice, and join was not idempotent. A
mark switched off before it arrived left two different blocks.

- sequence.js: MAX_RUN (1 << 20) and MAX_FIELD (1 << 22) slots, on the
  op path and in normalizeSeq alike; a run is extended by its origin
  alone; normalizeSeq takes the base, wants anchors older than their run
  or mark, and refuses a block past PENDING_MAX; pendingCount counts
  orphan registers and off-placeholders; addRange dedupes; a mark landing
  on its placeholder fills it and `off` bumps ts; placeInsert starts a
  new run at the cap; a register's origin must be a string.
- tree.js: isFi = order.isKey, at most MAX_FI (1024); the render shows
  MAX_DEPTH (512) levels, iteratively; ties by node id; no '@' in an
  origin; childrenOf/depthOf/heightOf so a local move folds once.
- ops.js, commit.js, apply.js: applyOp(previous, op, { pendingMax });
  a `bound` refusal carries detail/count/max; a malformed payload is
  reported — a TypeError locally, OP_REFUSED{malformed} from a peer —
  never swallowed; a tree move to where the node is returns before
  applyOp; treeMove refuses a depth past MAX_DEPTH and a place with no
  room (OUT_OF_RANGE).
- open({ sync: { pendingMax } }), 4096 by default, Infinity to disable,
  threaded into every fold of the log.
- Tests for each; INVARIANTS.md rows RLE-1, SEQ-1, SEQ-2, BOUND-1,
  TREE-1, TREE-2; docs (sync, lists, api, telemetry) and CLAUDE.md
  rules; browser budget 60.5 → 62 KB (61.7 measured).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…№2, E2)

drop() wiped any storage handed to it, branch() opened over an existing
one and merged the parent in, a fork that failed left a half-made
directory for the retry to reuse, and a merge asked only after the
source's own streams: a third node's ops the parent had compacted past
the branch were skipped as gaps while merge() reported success. A patch
was checked more loosely than the method of its name, so applyPatches()
could mint nothing and answer with a count instead of a TypeError; a
SyncomError from a second op left the first committed, so a restore()
could land its reset without the rebuild; the `removals` a patch named
were kept by reference in the log; a tags.json line's name became a path
under tags/ unchecked; open('path') opened the default directory.

- src/args.js: the write API's argument checks, shared by Storage and
  realize() (patch.js); tree.js takes isNodeId from it.
- commit.js: kindClash refuses a sequence or tree op on a field of
  another kind before a stamp is minted or an index read (no placeholder
  payloads left); copyPayload copies removals too; rehearse() applies
  every op of a batch over a scratch record before applyPatches appends,
  so a restore lands whole or not at all.
- branch.js, outbound.js: drop() takes a branch alone (sweep is the
  shared remover, used by a failed branch() too); branch() refuses a
  path that holds files; merge asks first of every stream (allStreams)
  and joins as state when any is out of reach.
- tags.js: a name that is not a record id is dropped at load (reason
  'name'); options.js: open() takes an options object.
- Tests for each; INVARIANTS.md PATCH-1, PATCH-2, BRANCH-1, BRANCH-2,
  TAG-1; docs (api, versions) and CLAUDE.md rules; budgets 62.5 KB main
  (62.0 measured), 6 KB undo (5.9).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A throw on the third record of a step — a hook refusing its inverse —
put the step back whole, though the first two were already inverted and
their redo was lost; the next undo() inverted them again (a splice
twice, an inc twice). A step now keeps per-record seqs; on a failure the
records taken back leave the step with their way back on the redo stack,
and the rest wait on the undo stack for another try.

Also: the oldest seq the log pin reads is kept, not recomputed on every
log entry of every roll, and own seqs below it are forgotten (the set
grew for the life of the manager).

INVARIANTS.md UNDO-1; versions.md.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
- tree.js: childrenOf is kept by the block (a WeakMap: a block is never
  mutated), so a tree is folded once however many times it is read — a
  local treeMove read it three times, and resolveTree, subtree, siblings
  and the render all go through it now. A 20 000-move tree: one fold of
  ≈100 ms, then ≈1 ms for subtree + siblings + render together.
- storage.js history(): the fold is whole, but the lens and afterRead run
  for the versions handed out alone (limit), not for every version of a
  hot record; `complete` reads from the fold's length.
- sequence.js valueAt(index, i): resolveMove reads one list value instead
  of rendering the whole list; render uses it too.
- myers.js: one copy of the frontier per round, not two.

P-1 (a memo for prune) is dropped: every op makes a new block, so a memo
by identity would never hit, and the measured cost is 14 µs on 201 runs.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…review №2, E1b)

Two nodes making one field as different kinds at once — a write carrying
it as a register, an inc, an add or a splice to it — used to leave them
apart for good: each refused the other's kind, while a snapshot merge of
the same two states let the register win. One rule now, on both paths:
register > counter > set > list > text > tree. Where the winner's op
arrives, the loser's state is dropped; where the winner is held, the
loser's op is refused (OP_REFUSED, as before). The rule reads kinds
alone, never stamps, so every node ends with the same kind whatever the
order — the documented SYNC-1 exception for kind clashes is gone.

Locally nothing changes for the caller: a write of another kind is
refused before it is stamped (kindClash in commit.js now covers write,
set, inc, add and remove too), with the hint for the right call.

ops.js: yieldTo/dropKind replace the six refusal ladders (M-2 as a side
effect). Tests rewritten to the rule, plus every arrival order of six
kinds on one field; INVARIANTS SYNC-1/OPS-1; sync.md, lists.md, api.md,
hooks.md, CLAUDE.md. Decision 0б of the review, taken by the user.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The tags are auxiliary — the records, the log and the catalog are whole
— yet a torn tags.json failed open() like node.json does. The storage
now opens without tags, keeps the file aside as tags.corrupt.json for a
repair by hand (the names cannot be listed back: fsi lists files, not
directories), reports stats().tags.corrupt and warns tags.unreadable,
and refuses tag() with CORRUPT so nothing overwrites the names. Writes
go on meanwhile: the copy-on-write seam keeps every changed record's
file once under tags/.pending/, and the next open with a readable file
hands those copies to the newest tag — for the records it holds live and
had no copy of — so the tags a repair brings back read as they did. A
purge scrubs a copy kept aside too; drop() sweeps the directory.

Decision 1а of the review, taken by the user, replacing the earlier
"like node.json". INVARIANTS TAG-2; stats().tags in the types; api.md,
versions.md, telemetry.md rows (tags.dropped, tags.unreadable and the
inherited sync.node.unreadable were missing from the table); CLAUDE.md.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…review №2, G-1, G-2)

The browser budget is a ratchet with a ceiling now: 64 KB for v1, decided
with the second review — a raise below it names its feature in
scripts/size.js, as every one so far did; going past it is a design
decision taken in a plan, not in the number (CONTRIBUTING.md). PROTOCOL 1
is the wire as of the first publication: every op type and packOps slot
added before it is pre-release and bumps nothing (src/protocol.js).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Alex-Dolid and others added 23 commits October 24, 2026 18:04
… benches for trees, tags and history (review №2, E4b)

Every op on a text walked the whole field again — buildIndex 647 µs of
the 824 a keystroke cost on a 20 000-character text — and rendered it
again. applySplice now says when it did nothing but append to a run
(`extended`), and seqIndexOf carries the held block's index over with
extendIndex: the new slots land at the end of the walk, the arrays grow
by the count and nothing before them moves; the new run tuple's parse is
registered so the next prune and slot count find it. The conditions —
the run last in key order, its last slot the walk's last, no run waiting
for an anchor, no position register on a new element — fall back to a
walk. render keeps the reading on the index; an extended text index
carries it over. applyOp appending one character to a 1 000-character
text: 155 000 ops/s (was 5 700 at 2 000 characters).

spans is a sweep over the walk's ordinals: a mark opens at its first and
closes at its last, the style is read again only there — O(characters +
marks × active) instead of O(characters × marks), with a test against a
per-character tally under random overlapping marks.

bench/tree.js, bench/tags.js, bench/history.js (the report model lists
them); `pnpm bench --save` and `pnpm bench:report` refreshed
engine.json, docs/guide/benchmarks.md and the engine SVGs. lists.md and
CLAUDE.md say what extends and what walks.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…pass over the log (review №2, E5)

- compact(id): the record rewritten as the ops its reading takes today —
  applyPatches of diffViews(null → the reading): a write of the registers,
  one inc, one add, one splice (a text or a list becomes one run), one
  tree move per node — so what lies under it goes by the reset rule here
  and on every peer, and the record reads the same. The explicit answer to
  a tree's move log and a text's runs growing until a reset; decision 2а.
- history(id, { before }): pages back from an event id; `complete` says
  the page reached the first op (no event id from another epoch, no other
  version shape).
- open({ defer: ms }): a local write's record file and block wait for the
  window — EntryStore.hold keeps the cache and the catalog reading it, one
  timer flushes every slot through #land (one block, the files) and marks
  the positions applied; tag() and close() flush first; a peer's page, a
  replay or a repair writing the record settles its slot so an older
  deferred entry is never laid over the newer file. The log is durable at
  once; a crash before the flush replays the batch as after a failed file
  write. Opt-in, 0 by default. Decision 3а, measured on node:fs for one
  keystroke into a 10 KB text: 1 431 → 2 810 ops/s with the chain,
  2 258 → 3 188 without it; on the memory backend no gain — the file is
  not the cost there. Signatures cost almost nothing either way.
- OpLog.scan(): outbound() walks the retained log without copying it into
  an array on every push and merge.
- stats(): held, deferred. INVARIANTS LOG-3, VER-7; tests for each;
  storage.md, api.md, versions.md, CLAUDE.md; bench rows and the report.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ames guarded (review №2, E6)

tag(), untag(), history(), get(id, { at }) and applyPatches() — and so
restore() and compact() — were silent to the injected tracer. They are
spans now: syncom.tag (records in the catalog copied), syncom.untag,
syncom.history (entries folded, versions handed out, exact),
syncom.version (entries folded for one version), syncom.patches (patches,
ops applied; each op a syncom.commit child) — none carrying a record id,
an origin, a url or a scope. stats() reports log.pins.

tests/package/telemetry-events.test.js keeps the surface honest: every
`event:` a logger receives, every span and every metric created in src/
has a row in docs/guide/telemetry.md, or the test fails — the rows for
tags.dropped, tags.unreadable and sync.node.unreadable were missing until
the third epic. A branch merge and an undo step are not spanned: the
subpaths do not hold the storage's writer, and their ops are commits.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…№2, E7)

- unmark(id, key, markId) overloaded unmark() by the type of its third
  argument and read the options from the fourth; it is unmarkById() now,
  on Storage and Record alike — one method, one signature, as Loro's and
  Automerge's unmark(range, name) is — and unmark() takes a range alone.
  Decision 5б; a pre-publication API change.
- #getAt, #readTag, #folded and #entryOf each parsed the version and
  branched on a tag or a fold; #versionEntry does it once (the fold under
  the syncom.version span) and #reading hands the record out as get()
  does. The stray block in #folded is gone with it.

What E7 leaves out, and why: splitting the sequence and versions API out
of storage.js into path modules (M-1) costs bytes the 64 KB ceiling has
not got — the browser entry stands at 63.8 KB and the last such split
cost 2.3 KB in shared property names — so it waits for the design
decision the ceiling calls for; the same for an rle.js split (M-7).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…modules (review №2, E7, M-1)

storage.js had grown back to 1 821 lines: the API of lists, texts and
trees (splice … treeRemove, spans/cursor/index) and the versions API
(get at, history, diff/applyPatches/restore/compact, tag/untag/at) sat in
the facade beside the open sequence and the queue. They are two modules
now, over the same core Storage lends its write paths:

- src/fields.js — inc/add/remove, splice/push/updateText, mark/unmark/
  unmarkById, spans/cursor/index, setAt/move, treeMove/treeRemove. Every
  write is one op through the queued commit (`core.commit`); updateText
  diffs inside the queue (`core.run`).
- src/versions.js — getAt (and `reading`, which get() shares), diff,
  applyPatches, restore, compact, tag, untag, at, history, with the private
  readers (entryAt, past, present, versionEntry, sourceAt) they used.
- The core gains what the API modules reach beside the write paths: the
  hooks, the tags, the entities, `run`, `commit`, `forget`, `flushDeferred`.
- Storage keeps one delegate per method, so the public surface, the d.ts
  and every test stay as they were. storage.js is 1 324 lines.

Bytes: 63.7 → 64.4 KB min+gzip — the core's property names where private
fields minified away. The 64 KB ceiling of the second review was lifted by
the user for this split (2026-10-24): budget 64.5 KB, the rule "a raise
names its feature" kept, CONTRIBUTING reworded.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…etter

The host's own node id is a random key: one time in sixty-four it begins
with `o`, and the assertion that no `o*` stream moved then caught the
host's own `@Doc` stream (seen once while running the suite).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…E7, M-7)

sequence.js carried five subjects in 1 369 lines. The run-length form of a
run's content — items and gaps, `rleLength`/`rleAppend`/`rleCut`/`rleMerge`,
the expansion the index reads, the canonical form `normalizeRle` admits
under MAX_RUN — is its own module now, with no requires; sequence.js takes
it from there and re-exports what its tests read, so nothing else moves.
MAX_RUN lives with the check that enforces it; MAX_FIELD, a block's cap,
stays in sequence.js. THIRD_PARTY_NOTICES names the new file for the Yjs
and list-positions rules it carries; the layout in CLAUDE.md has the row.

Bytes: 64.4 → 64.6 KB min+gzip (a module's exports and requires); budget
65 KB, named.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…the newest alone (review №2, SC-4)

Every tag's catalog and copy listing was read at open — the time to open
grew with the number of tags times the records in each. Now `tags.json`
and the newest tag alone are read at open (the write path copies into it,
and the pending copies go to it); an older tag's catalog and copies are
read once, at the tag's first use (`Tags#catalog`/`#copies`, one load
shared by concurrent readers), so an open costs one catalog however many
tags there are.

- A tag whose catalog cannot be read is dropped at that first use — the
  newest at open — as `untag()` drops it: what the older tag needs of its
  copies moves there (`#moveDown`, shared with `drop()`), the rest go, the
  line leaves tags.json, `tags.dropped{reason:'catalog'}` is logged, and
  the reader is told `TAG_NOT_FOUND`. The next newest takes the write path.
- `at(name)` stays synchronous; its `has()` and `list()` are promises now
  (the view's first read loads the catalog), `collection().get()` and
  `snapshot()` load first, and a Cursor at a tag awaits its source's
  `ready` before reading (`Cursor#matching`; the live seam has none). The
  sync readers `hasAt`/`rowAt`/`catalogOf` come after `ensure(name)`.
- d.ts + tsd, the tags and branch suites, bench/tags.js, versions.md,
  api.md and CLAUDE.md follow; INVARIANTS TAG-3 names the new test.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ers carry their own (review №2, E7, M-9)

The "Rules that bite" paragraphs on sequences and trees, versions, tags,
patches, undo and branches had grown into second copies of the module
headers. Each keeps what crosses modules — the seams, the invariants a
change elsewhere would break, the don'ts — and points to the header of
the module for how the thing works inside. The two facts a header did not
yet carry moved there: a run is extended by its origin alone and the slot
caps hold on both paths (src/sequence.js), equal stamps tie by node id and
a place is at most MAX_FI characters (src/tree.js). 698 → 650 lines.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…branch

Every page was checked against src/, the .d.ts files and the tests, and
every example that changed or was in doubt was run. What the branch added
and review №2 changed had not reached most pages.

- api.md: seven subpaths, the `commit` event, every error code with who
  raises it and its fields (INVALID_ID, TYPE_IMMUTABLE and NO_CHAIN as
  coded TypeErrors), OpLog members, migrate()'s full result, compact()'s
  losses.
- Guides: the kinds' precedence on the op path, the op types and the 17
  packOps slots on the wire, the bounds a peer can hit (pendingMax,
  MAX_RUN, MAX_FIELD, tree depth and index), open({ defer }), compact(),
  history paging, the lazy tag read, hook and event payloads, telemetry
  spans, list fields in queries. Known limits are stated as they are:
  compact() drops marks, cursors and empty fields; a snapshot block is
  held to 4 096 pending items whatever sync.pendingMax says; a counter is
  not bounded against overflow.
- Examples that printed something else are fixed: the README hero, the
  undo count, the diff/restore setup, the WebRTC restart recipe.
- Sizes: 65 KB for the browser entry everywhere, the README table from
  pnpm size; syncom remeasured in bench:compare and bench:size, charts and
  prose numbers regenerated.
- vs-rxdb: entry points, sizes, budget and history cells; 21 rows only
  syncom has; collaborative text advice.
- CHANGELOG, CONTRIBUTING, CLAUDE.md and INVARIANTS SYNC-1 follow; the
  stale header comments of src/ops.js, src/commit.js, src/hosting.js and
  src/sync.js, and compact()'s doc comment in index.d.ts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ibraries

A page like Compared with RxDB, for the CRDT libraries only and only as
CRDT libraries: no queries, storage engines or sync services. 141 rows in
seven sections (package and platform, document model, data types, changes
and events, history and versions, encoding and sync, integrity and
safety), each cell decided from the repositories as cloned on 2026-10-23
(Yjs main at 0e0dc34, 14.0.0-rc.29, with notes where npm's 13.6.33
differs; Automerge 3.5.0 at 5bae775 with automerge-repo at 3ab7c33; Loro
1.17.1 at 75e4bf2) and their docs, with the published packages run where a
claim was cheap to test. Every cell keeps its source file and lines.

- docs/comparison-crdt.json, rendered into docs/guide/vs-crdt.md by
  scripts/comparison.js (a third page); a four-column matrix style; the
  sidebar, comparison.md, vs-rxdb.md and the README link to it.
- The page adds what the three are and are not, how the table was made,
  the API of each side by side, the text benchmarks of the four, and
  which to choose.
- bench:size measures the whole package of each CRDT library next to
  syncom's entry (`cores` in size.json); BenchFigure's `cores` chart shows
  them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…s refused

`push(id, 'body', [1])` on a text field passed the local kind check —
TAKES.splice let either kind through — and the list op it made outranks
a text by the kinds' precedence, so applyOp dropped the text and the new
element waited on an anchor that was gone: the field read `[]`, on every
node the op reached. A splice now names its kind by its content (a
string a text, an array a list; one that only removes takes the field's)
and kindClash refuses the other kind before a stamp is minted —
FIELD_KIND, the field left whole, nothing logged. A string on a list,
refused until now only inside applyOp after the stamp, takes the same
path. The hints say which content the field takes.

Budget 65 → 65.5 KB (65.0 measured), named in scripts/size.js.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…marks

A stacking (`many`) mark newer than a plain mark of its name over the
same characters threw `marks[key].push is not a function` from styleOf —
a read any peer could break with two marks — and a plain mark whose
value was an array had the stack's entries pushed into the block's own
value. On one name the two now mix in stamp order: a plain mark (a null
one too) takes the place of what is there, a stack included, and a
stacking mark newer than it starts a stack over it; a set of stacked
names tells a stack from an array value. The random-marks sweep test
mixes plain marks into the stacking name, its tally reads the same rule,
and a storage test pins the order; LIST-3 cites it. lists.md says how
the two mix instead of warning against it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ield longer than a run

compact(id) made each field again from its reading: a text came back from
its string alone, so its marks were gone and spans() read it plain; a
counter at 0 was not made again and left the record; and a text or a list
longer than one run (MAX_RUN, 1 048 576) was one splice the run bound
refused, so the whole compact failed.

- src/sequence.js marksIn(): the marks in force over the reading as the
  calls that make them again — a visible range, the expand rule the sides
  of their anchors say, the key, the value, whether it stacks — in stamp
  order, so laid again they resolve as before. compact() appends them as
  `mark` patches, a new action of the patch vocabulary (realize checks it
  as mark() does).
- recreate() makes a counter at 0 with an inc of 0: the field stays a
  counter. A splice that makes a field again is laid in runs of MAX_RUN,
  for compact() and restore() alike.
- placeInsert() takes the length inserted: a writer's own run is extended
  only while it holds the insertion whole, then a new run is anchored —
  a second push of 600 000 onto a run of 600 000 was refused as past the
  run bound instead.

What compact() cannot keep is now said as its nature, not as a fault: it
names every element anew (cursors read null, a stacking mark has a new
id), and a list, text, set or tree that reads empty is left out — no op
makes an empty one. VER-7 cites the new test.

Budgets: main 65.5 → 66 KB (65.6 measured), undo 6 → 6.5 KB (6.0).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
One origin's running sum had no bound: past Number.MAX_VALUE it became
Infinity, which JSON saves as null, so a node that reloaded the record
counted again from 0 while one holding it in memory kept Infinity — a
divergence a peer could cause with one op.

- Each origin's sum stays within ±MAX_COUNT (Number.MAX_SAFE_INTEGER). An
  inc past it is a `bound` refusal with `detail: 'counter'`, `count` and
  `max` — FIELD_KIND locally, OP_REFUSED from a peer — the same on every
  node, since the sum depends on its origin's ops alone; normalizeMeta
  does not take a snapshot slot past it.
- counterValue adds the sums up in origin order: fractions round by the
  order they are added in, and arrival order differs between nodes.
- mergeEntry joins a side alone with an empty entry instead of copying
  it, so a merged record this node did not hold reads its data from its
  meta: a field the meta does not back (a slot or block normalizeMeta
  refused, a value with no stamp) no longer comes with it.

The overflow limit in sync.md becomes the range rule; SYNC-1 drops the
case, BOUND-2 names the guarantee and its three tests; Refusal.detail
takes 'counter' (d.ts + tsd).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
c3ad4e2 kept a record from being inverted twice across records; inside
one, a hook or a validator refusing a later op of its inverse left the
earlier ops landed while the record stayed on the undo stack as the step
left it, so the retry took them back again — `views` 2 → 1 → 0 for one
step of +1.

The record the failure stopped in now waits on the undo stack from where
its landed ops left it: those ops are on the redo stack already (their
commits were collected), and the record's `after` becomes theirs, so the
retry inverts the rest alone and two redos bring the step back. UNDO-1
names the case and its test; versions.md says so instead of the caveat.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Emitter: the maxListeners cap is checked before a listener is kept, so
  the one it refuses is not left registered behind the throw.
- crdt.js diff(): a null side is an empty object and a non-collection
  under an array has no elements — no throw on `{ a: null }` or `{ a: 5 }`,
  and a string is no longer a set of its characters.
- A cursor at a tag with `$text` says a tag keeps no text index (it said
  the entity had no search fields).
- update() checks the id first (INVALID_ID like every writer); its
  NOT_FOUND and swap()'s carry the id.
- applyPatches(): an unknown action is named as such, not as one that
  "needs a field".
- bootstrap adopt(): a peer's vector walked by its own keys, never for…in.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The op path refused a list or text op by the node's own bound on pending
work (`core.limits.pendingMax`); the snapshot path rebuilt a peer's block
through normalizeSeq with the PENDING_MAX constant, so a node that raised
the bound still dropped a block a peer's ops could have built there, and
one that lowered it took blocks its ops path would refuse. normalizeMeta
and normalizeSeq take the bound now, and applyOp passes the one a `merge`
op is applied under. lists.md and sync.md drop the caveat.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A Record loaded through get() — the lens and the read hooks — but every
change it followed (a write here, an op from a peer) handed it the
record as stored: a redacted field showed its raw value after an
unrelated update(), and a record of an older schema version showed its
old field names after an inc().

The write paths hand the refresh the entry; #emitUpdate reads it through
the lens and beforeRead/afterRead when either is in play. Those run once
the write is done, outside the mutation queue — a read hook may write,
and that write waits for the queue — chained so refreshes keep the order
of the writes; with neither, the Record is refreshed at once, as before.
A read hook that throws leaves the Record as it was (`record.refresh`,
logged at warn). The hooks, schema and storage guides drop the caveat.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ever silent

A peer's `write` or `set` whose data is not a plain object, an `inc` of no
finite number (or under no field name), an `add` or a `remove` without an
array applied as nothing and said nothing, against the rule the sequence
and tree ops already kept. applyOp reports each as `malformed` now: from a
peer an OP_REFUSED{reason:'malformed'}, counted, the op still logged and
relayed as a no-op; locally the arguments were checked, so it would be a
TypeError. sync.md lists them with the others.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- browser.d.ts re-exported none of the types the CRDT branch added
  (Patch, Version, Tag, TagView, TextSpan, the payloads, Refusal…) nor
  StorageStats, LockOptions, HostedPeer, SyncomRecordCtor: a TypeScript
  user under the browser condition could not import them. It re-exports
  them all, and tests/package/consistency.test.js now fails when a type
  index.d.ts exports is missing there.
- The storage `error` event is a union: the op refusals as before, and
  `UNPERSISTED` with `ids` and no `op`, which the code emits.
- Declared: `outOfScope` on the `sync` event, applyOp's `{ pendingMax }`,
  OpLog.pins, SyncManager.pendingOps(), UndoManager.listenerCount(), the
  hello's `version` in `authorize`, a write context's `removals`/`own`, and
  the `mark` patch compact() lays marks again with.
- Doc comments: what `skipped` counts, `pageSize` (push pages only),
  INVALID_VERSION among the coded TypeErrors, branch()'s BRANCH_EXISTS for
  any non-empty path and TAG_NOT_FOUND, branchInfo()/drop()'s CORRUPT and
  TypeError. Each with its tsd assertion.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ial is retried

- A push the host refused during the handshake (a rate limit, a page over
  opsPerPush) threw before the `changes` subscription was opened, so
  nothing came from the host until a reconnect or enableNode() off and on
  — and with no page coming in, the recovery had no sign of life to push
  the rest at. The handshake now subscribes before it rethrows (but for a
  rewound node, which bootstraps first at its next handshake).
- A connect(node) that threw or returned no link left the peer entry in
  place with no link: #connect returned early for it until enableNode()
  or removeNode(). The peer is marked `failed` (peers() still shows it
  and its lastError), and sync() dials every enabled node with no live
  peer again — on an explicit call, never a timer.

The wrpc rate-limit test now checks the feed opens after the refusal and
sends the rest with a sync(); scaling.md's recipe and sync.md say so.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The CRDT matrix's syncom cells follow the fixes (a counter's range, what
compact() keeps, the bounds, malformed payloads of every type, 958
tests); the size figures move to what the fixes cost — 66.0 KB the browser
entry (README table from pnpm size, bench:size and the size chart
regenerated), 66 KB in the prose and the RxDB matrix; CLAUDE.md names
MAX_COUNT among the bounds, compact()'s marks and runs, a lone side of a
merge joined rather than copied, and how a live Record is refreshed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@vercel

vercel Bot commented Oct 24, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
syncom Ready Ready Preview Oct 24, 2026 7:51pm UTC

@Alex-Dolid
Alex-Dolid merged commit 358d812 into main Oct 24, 2026
6 checks passed
@Alex-Dolid
Alex-Dolid deleted the features/enhance-crdt-engine branch October 24, 2026 19:53
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant