Repository navigation
feat: lists, rich text and trees that merge; versions, tags, undo and branches - #3
Merged
Merged
Conversation
…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>
… 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>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
Storageand on a liveRecord)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.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.cursor()/index(), a place that survives the edits around it.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 })andget(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 acommitevent for local ops.New subpaths
@alexify/syncom/order: fractional indexes (1.0 KB).@alexify/syncom/undo: an undo stack over thecommitevent. 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
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, andsrc/fields.js+src/versions.js(API modules over the lent core)./splice,=assign,^move,*mark,~tree.packOpskeeps its 17 slots, andPROTOCOLstays 1 (nothing is published yet).sync.pendingMax(4 096);MAX_RUN(2²⁰) andMAX_FIELD(2²²) slots;OP_REFUSED,malformed), never ignored.Fixes found by review №2 and the docs audit
branch()/drop()could take over or wipe another storage;tags.jsonnames were not checked. An unreadabletags.jsonno longer blocksopen(), andopen()takes only an options object.spans()threw on mixed plain andmanymarks;compact()dropped marks and refused long fields;Number.MAX_VALUEdiverged between nodes;sync.pendingMax;connect()was never retried.Recordlost the lens andafterReadafter any change.browser.d.tsnow 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
update()with an invalid id rejects withINVALID_ID, notNOT_FOUND.write/set/inc/add/removepayloads from peers now raiseOP_REFUSEDerror events.Recordwith a lens or read hooks is refused once the write is done, outside the queue.errorevent type is a union:UNPERSISTEDcarriesidsand noop.Docs and benchmarks
INVARIANTS.mdmaps 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 skippedpnpm test:coverage: 99.28 / 95.93 / 99.72 (thresholds 95/95/90/95)pnpm test:types,pnpm lint,pnpm format:check,pnpm sizepnpm docs:build(no dead links)tests/suites/sync-engine.js); wrpc integration test with real serversdocs/guide/webrtc.md): checked by hand only, as beforeKnown limits (documented)
compact()names elements anew, so old cursors readnull, and it leaves out list/text/set/tree fields that read empty.🤖 Generated with Claude Code