docs: add package introductions and the @robonen/crdt guide

An intro.vue landing for all 12 packages, plus a multi-section crdt guide (Concepts, Primitives, Replication & Sync, and an interactive convergence Playground).
This commit is contained in:
2026-06-08 15:52:03 +07:00
parent 09433415b6
commit 53f2d7ceef
16 changed files with 3438 additions and 0 deletions
+304
View File
@@ -0,0 +1,304 @@
<!-- title: Concepts -->
<!-- order: 1 -->
<script setup lang="ts">
const opIdSrc = `import { opId, opIdEq, opIdToString, createSiteId } from '@robonen/crdt';
// An OpId is just { site, clock } — a per-site Lamport counter
// tagged with the site that produced it.
const id = opId('alice', 3); // { site: 'alice', clock: 3 }
opIdToString(id); // 'alice@3'
opIdEq(id, opId('alice', 3)); // true
// A site id is a per-replica handle. Generate one when a session starts.
const site = createSiteId(); // e.g. 'k3f9a2d1xz'`;
const lamportSrc = `import { LamportClock } from '@robonen/crdt';
const clock = new LamportClock('alice');
clock.tick(); // { site: 'alice', clock: 1 }
clock.tick(); // { site: 'alice', clock: 2 }
// We hear about a remote op from 'bob' at clock 5.
clock.observe({ site: 'bob', clock: 5 });
// Our next local id jumps past it, so it's causally *after* what we've seen.
clock.tick(); // { site: 'alice', clock: 6 }`;
const compareSrc = `import { compareOpId, opId } from '@robonen/crdt';
// Higher clock wins.
compareOpId(opId('alice', 1), opId('alice', 2)); // < 0 (2 is greater)
// Equal clocks → site id breaks the tie, deterministically.
compareOpId(opId('alice', 2), opId('bob', 2)); // < 0 ('alice' < 'bob')
compareOpId(opId('bob', 2), opId('alice', 2)); // > 0
// Identical ids compare equal.
compareOpId(opId('alice', 2), opId('alice', 2)); // 0`;
const vvSrc = `import { VersionVector, opId } from '@robonen/crdt';
const vv = new VersionVector();
vv.observe(opId('alice', 3));
vv.observe(opId('bob', 1));
// "Have I already seen this op?" — the basis for dedup.
vv.has(opId('alice', 2)); // true (we've seen alice up to 3)
vv.has(opId('alice', 3)); // true
vv.has(opId('alice', 4)); // false (not yet)
vv.has(opId('carol', 1)); // false (never heard from carol)
// Highest dense clock per site (0 if a site is unknown).
vv.get('alice'); // 3
vv.get('carol'); // 0`;
const vvWireSrc = `import { VersionVector, opId } from '@robonen/crdt';
const local = new VersionVector();
local.observe(opId('alice', 5));
local.observe(opId('bob', 2));
// Snapshot for transport: a plain { site: clock } object.
const snapshot = local.toJSON(); // { alice: 5, bob: 2 }
// The other side reconstructs it and compares against its own log
// to compute exactly which ops you're missing.
const remoteKnows = VersionVector.fromJSON(snapshot);
remoteKnows.has(opId('alice', 4)); // true → skip it
remoteKnows.has(opId('alice', 6)); // false → send it`;
const propsSrc = `// Commutative — order of application doesn't matter:
// apply(apply(s, x), y) === apply(apply(s, y), x)
//
// Idempotent — re-applying a seen op is a no-op:
// apply(s, x) === apply(apply(s, x), x)
//
// Convergent — same op SET ⇒ same state, regardless of how it got there.
//
// These three together mean a network that reorders, duplicates, and
// delays messages can never push two replicas to different states.`;
</script>
<template>
<div class="docs-section">
<div class="prose-docs">
<h1>Concepts</h1>
<p>
Every primitive in <code>@robonen/crdt</code> rests on one small idea: if all replicas agree on a
<strong>deterministic total order</strong> over operations, then applying the same set of operations
in any order, with duplicates, after any delay always produces the same state. This page builds
that mental model from the ground up: sites and replicas, Lamport clocks and op ids, the single
tie-break that resolves every conflict, version vectors for deduplication and deltas, and the three
algebraic properties that make convergence inevitable rather than hopeful.
</p>
</div>
<div class="prose-docs">
<h2>Replicas and sites</h2>
<p>
A <strong>replica</strong> is one copy of the shared state a browser tab, a mobile app, a server
process. Each replica is owned by exactly one <strong>site</strong>, identified by a
<code>SiteId</code> (just a string). The site id is the thing that makes one replica distinguishable
from every other, so it must be unique across all participants. Use <code>createSiteId</code> to mint
one when a session begins; it trades on randomness for uniqueness, not secrecy, so there's no crypto
dependency.
</p>
<p>
Replicas never share mutable memory. They evolve independently and communicate only by exchanging
<strong>operations</strong> — small, self-describing facts like "insert this character" or "set this
key". The whole job of a CRDT is to make sure that once two replicas have seen the same operations,
they hold the same state, no matter what the network did to the messages in between.
</p>
</div>
<div class="prose-docs">
<h2>Op ids: naming every operation</h2>
<p>
For replicas to talk about the same operation — to deduplicate it, to refer to it as a causal
dependency, to break ties against it — every operation needs a stable, globally unique name. That
name is an <code>OpId</code>: a per-site counter (its Lamport <code>clock</code>) tagged with the
<code>site</code> that produced it.
</p>
</div>
<DocsCode :code="opIdSrc" lang="ts" />
<div class="prose-docs">
<p>
Because the counter is local to a site and the id carries that site, two replicas can generate ids
completely independently and never collide. There's no coordination, no central allocator, no UUID
round-trips uniqueness falls out of the structure. <code>opIdToString</code> gives the canonical
<code>site@clock</code> form, handy as a map key or for logging.
</p>
</div>
<div class="prose-docs">
<h2>Lamport clocks: encoding causality</h2>
<p>
A bare per-site counter is unique, but it isn't enough to compare two operations from different
sites in a meaningful way. <code>LamportClock</code> fixes that. It hands out monotonically
increasing ids via <code>tick()</code>, and — crucially — it <code>observe()</code>s the clocks of
remote operations it learns about, jumping its own counter ahead so that anything it produces next is
numbered <em>after</em> what it has already seen.
</p>
</div>
<DocsCode :code="lamportSrc" lang="ts" />
<div class="prose-docs">
<p>
This is the Lamport <em>happens-before</em> rule in miniature: if operation
<strong>A</strong> causally precedes <strong>B</strong> (B was generated by a replica that had
already seen A), then A's clock is strictly less than B's. The converse isn't guaranteed two ops
with unrelated clocks may simply be <strong>concurrent</strong>, produced by replicas that hadn't yet
heard from each other. That's fine, and expected: concurrency is exactly the situation a CRDT exists
to resolve.
</p>
</div>
<div class="prose-docs">
<h2>compareOpId: the one tie-break</h2>
<p>
Lamport clocks give a <em>partial</em> order they leave concurrent operations incomparable. But to
converge, every replica must agree on a single <strong>total</strong> order so that any two
operations can be ranked the same way everywhere. <code>compareOpId</code> is that total order, and it
is the only conflict-resolution rule in the entire library:
</p>
<ul>
<li><strong>Higher clock wins.</strong> A later operation supersedes an earlier one.</li>
<li>
<strong>Site id breaks ties.</strong> When two ops share a clock (they were concurrent), the
string comparison of their site ids picks a winner arbitrary, but identical on every replica.
</li>
</ul>
</div>
<DocsCode :code="compareSrc" lang="ts" />
<div class="prose-docs">
<p>
That second rule is the quiet hero of the whole design. The choice of winner doesn't matter; what
matters is that <em>every replica makes the same choice</em>. Because site ids are unique and string
comparison is deterministic, two replicas resolving the same concurrent edit will always pick the
same survivor. That single shared decision is what lets a last-writer-wins register and a sequence
CRDT, built by different code, nonetheless agree on the final document.
</p>
<div class="my-4 rounded-lg border border-(--border) bg-(--bg-subtle) p-4">
<p class="m-0 text-sm leading-relaxed text-(--fg-muted)">
<strong class="text-(--fg)">Why one rule for everything?</strong>
<code class="text-(--accent-text)">LwwRegister</code> uses
<code class="text-(--accent-text)">compareOpId</code> to pick the surviving value;
<code class="text-(--accent-text)">Rga</code> uses it to break ties between concurrent inserts at
the same position; <code class="text-(--accent-text)">MarkStore</code> uses it to decide which
formatting wins per character. One total order, applied consistently, is what turns a pile of
independent primitives into a coherent, converging system.
</p>
</div>
</div>
<div class="prose-docs">
<h2>Version vectors: who has seen what</h2>
<p>
Op ids order operations; a <code>VersionVector</code> summarizes <em>which</em> operations a replica
has seen. It maps each known site to the highest clock observed from it. Its power comes from one
assumption: per-site clocks are <strong>dense</strong> — a site emits <code>1, 2, 3, …</code> with no
gaps. Given that, "highest clock seen from site X" implies "every op from X up to that clock has been
seen", so a single integer per site captures the entire causal history.
</p>
</div>
<DocsCode :code="vvSrc" lang="ts" />
<div class="prose-docs">
<h3>Deduplication</h3>
<p>
Networks redeliver. Because operations are idempotent (more on that below), re-applying one is
harmless — but <code>vv.has(id)</code> lets you skip the work entirely. If the vector already covers
an op's site and clock, you've seen it; drop it before it ever touches your state. This is the first
line of defense that keeps duplicate messages from doing anything observable.
</p>
<h3>Deltas</h3>
<p>
The same vector drives efficient sync. When a peer tells you its version vector, you compare it
against your own op log and send back <em>only</em> the operations it's missing never the whole
document. A site with clock <code>4</code> in their vector but <code>9</code> in yours means ops
<code>5</code> through <code>9</code> are the delta. Version vectors are tiny and serialize to a plain
<code>{ site: clock }</code> object, so they're cheap to ship as the "here's what I have" handshake.
</p>
</div>
<DocsCode :code="vvWireSrc" lang="ts" />
<div class="prose-docs">
<div class="my-4 rounded-lg border border-amber-500/30 bg-amber-500/10 p-4">
<p class="m-0 text-sm leading-relaxed text-(--fg-muted)">
<strong class="text-amber-700 dark:text-amber-400">Density matters.</strong>
<code class="text-(--accent-text)">VersionVector</code> only works because clocks arrive without
gaps. If you generate ids with a raw <code class="text-(--accent-text)">LamportClock</code>, deliver
them in order per site (the <code class="text-(--accent-text)">Replica</code>'s causal buffer does
this for you) so a single high-water mark per site can stand in for the full set of seen ops.
</p>
</div>
</div>
<div class="prose-docs">
<h2>The three properties</h2>
<p>
Everything above exists to guarantee three algebraic properties of operations. They're the formal
promise behind "it just converges", and they're verified by property tests across the package.
</p>
</div>
<DocsCode :code="propsSrc" lang="ts" />
<div class="grid grid-cols-1 gap-4 sm:grid-cols-3">
<div class="rounded-lg border border-(--border) bg-(--bg-subtle) p-5">
<h3 class="mb-1.5 text-sm font-semibold text-(--fg)">Commutative</h3>
<p class="text-sm leading-relaxed text-(--fg-muted)">
Order of application doesn't change the result. A replica can integrate operations as they arrive,
in whatever sequence the network delivers them.
</p>
</div>
<div class="rounded-lg border border-(--border) bg-(--bg-subtle) p-5">
<h3 class="mb-1.5 text-sm font-semibold text-(--fg)">Idempotent</h3>
<p class="text-sm leading-relaxed text-(--fg-muted)">
Applying the same operation twice is the same as applying it once. Redelivery and retries are safe;
version vectors make them free.
</p>
</div>
<div class="rounded-lg border border-(--border) bg-(--bg-subtle) p-5">
<h3 class="mb-1.5 text-sm font-semibold text-(--fg)">Convergent</h3>
<p class="text-sm leading-relaxed text-(--fg-muted)">
Same set of operations, same final state — full stop. Two replicas that have seen the same ops are
byte-for-byte identical.
</p>
</div>
</div>
<div class="prose-docs">
<p>
Commutativity and idempotency are <em>local</em> properties of how a single replica integrates an
operation. Convergence is the <em>global</em> consequence: if integration is both order-independent
and duplicate-safe, then the state of a replica is a pure function of the <em>set</em> of operations
it has seen, with no dependence on path or timing. That's why a CRDT tolerates the worst a network
can do — reordering, duplication, partition, arbitrary delay — and still lands every participant on
the same document.
</p>
</div>
<div class="prose-docs">
<h2>Putting it together</h2>
<p>
With the model in hand, the rest of the library reads as direct applications of it. The same
<code>OpId</code> that names an operation is the value <code>compareOpId</code> ranks; the same
Lamport clock that produced it advances when you observe a peer; the same dense clocks that make ids
unique make version vectors a one-integer-per-site summary. From here:
</p>
<ul>
<li>
<NuxtLink to="/crdt/primitives">Primitives</NuxtLink> — see the order in action across
<NuxtLink to="/crdt/rga">Rga</NuxtLink>, <NuxtLink to="/crdt/lww-register">LwwRegister</NuxtLink>,
and fractional indexing with <NuxtLink to="/crdt/key-between">keyBetween</NuxtLink>.
</li>
<li>
<NuxtLink to="/crdt/replication">Replication &amp; Sync</NuxtLink> — how
<NuxtLink to="/crdt/replica">Replica</NuxtLink> wires a clock, op log, and causal buffer into
version-vector deltas.
</li>
<li>
<NuxtLink to="/crdt/playground">Playground</NuxtLink> watch two replicas diverge and reconcile,
live in the browser.
</li>
</ul>
</div>
</div>
</template>
+435
View File
@@ -0,0 +1,435 @@
<!-- title: Primitives -->
<!-- order: 2 -->
<script setup lang="ts">
const lwwRegister = `import { LwwRegister, opId } from '@robonen/crdt';
// Two replicas hold the same register, start from the same value.
const a = new LwwRegister('draft');
const b = new LwwRegister('draft');
// They write concurrently — A at clock 4, B at clock 5.
a.set('A wins?', opId('a', 4));
b.set('B wins!', opId('b', 5));
// Exchange the writes (order and duplicates don't matter):
a.set('B wins!', opId('b', 5)); // 5 > 4 → accepted, returns true
b.set('A wins?', opId('a', 4)); // 4 < 5 → rejected, returns false
a.get(); // 'B wins!'
a.get() === b.get(); // true — converged on the higher op id`;
const lwwMap = `import { LwwMap, opId } from '@robonen/crdt';
const a = new LwwMap<string, string>();
const b = new LwwMap<string, string>();
// Concurrent edits to the same key, plus a concurrent delete.
a.set('color', 'red', opId('a', 7));
b.set('color', 'blue', opId('b', 7)); // same clock — site id breaks the tie
a.delete('color', opId('a', 7)); // tie too: delete vs set at the same id
// After both replicas see all three ops…
b.set('color', 'red', opId('a', 7)); // already covered by b's clock-7 write
a.set('color', 'blue', opId('b', 7)); // 'b' > 'a' at clock 7 → blue wins
a.get('color'); // 'blue'
a.has('color'); // true
a.toEntries(); // [['color', 'blue']]`;
const fractionalBetween = `import { keyBetween } from '@robonen/crdt';
// Open bounds (null) ask for "before everything" / "after everything".
const first = keyBetween(null, null); // e.g. 'V'
const second = keyBetween(first, null); // a key after 'first'
const zeroth = keyBetween(null, first); // a key before 'first'
// Insert strictly between two existing neighbors — no renumbering, ever.
const mid = keyBetween(zeroth, first);
zeroth < mid && mid < first; // true
// Sorting items by key reproduces their order:
const items = [
{ text: 'b', key: first },
{ text: 'a', key: zeroth },
{ text: 'ab', key: mid },
];
items.sort((x, y) => (x.key < y.key ? -1 : x.key > y.key ? 1 : 0));
items.map(i => i.text); // ['a', 'ab', 'b']`;
const fractionalBatch = `import { keysBetween } from '@robonen/crdt';
// Pre-allocate N keys at once — ascending, all strictly between the bounds.
const keys = keysBetween(null, null, 5);
// each keys[i] < keys[i + 1]
// Moving an item is a single-field write: give it a new key between its
// new neighbors and re-sort. Nothing else in the list changes.
function move(list, fromKeyLeft, fromKeyRight) {
return keyBetween(fromKeyLeft, fromKeyRight);
}`;
const rgaBasic = `import { Rga, opId } from '@robonen/crdt';
const rga = new Rga<string>();
// integrateInsert(id, value, originLeft) — originLeft = null means "at the start".
rga.integrateInsert(opId('a', 1), 'H', null);
rga.integrateInsert(opId('a', 2), 'i', opId('a', 1)); // after 'H'
rga.toArray().join(''); // 'Hi'
// Delete = tombstone. The node stays as an anchor; it just stops being visible.
rga.integrateDelete(opId('a', 2));
rga.toArray().join(''); // 'H'
rga.length; // 1 (visible count, tombstones excluded)`;
const rgaConverge = `import { Rga, opId } from '@robonen/crdt';
// Two replicas both start from "AC" and concurrently insert after 'A'.
function seed() {
const rga = new Rga<string>();
rga.integrateInsert(opId('seed', 1), 'A', null);
rga.integrateInsert(opId('seed', 2), 'C', opId('seed', 1));
return rga;
}
const left = seed();
const right = seed();
// left inserts 'x' after 'A'; right inserts 'y' after 'A' — same origin.
const xOp = { id: opId('left', 5), value: 'x', origin: opId('seed', 1) };
const yOp = { id: opId('right', 5), value: 'y', origin: opId('seed', 1) };
// Apply locally, then exchange. Order and duplicates don't matter.
left.integrateInsert(xOp.id, xOp.value, xOp.origin);
left.integrateInsert(yOp.id, yOp.value, yOp.origin);
right.integrateInsert(yOp.id, yOp.value, yOp.origin);
right.integrateInsert(xOp.id, xOp.value, xOp.origin);
// Tie-break: higher op id first. opId('right', 5) > opId('left', 5)
// (same clock, 'right' > 'left'), so 'y' lands before 'x'.
left.toArray().join(''); // 'AyxC'
right.toArray().join(''); // 'AyxC' — converged`;
const rgaBuffer = `import { Rga, opId } from '@robonen/crdt';
const rga = new Rga<string>();
rga.integrateInsert(opId('a', 1), 'H', null);
// An op arrives BEFORE its origin (causal violation). integrateInsert
// returns false instead of corrupting order — the caller buffers it.
const pending = { id: opId('a', 3), value: '!', origin: opId('a', 2) };
const ok = rga.integrateInsert(pending.id, pending.value, pending.origin);
// ok === false — origin opId('a', 2) isn't present yet
// Once the missing origin lands, retry the buffered op:
rga.integrateInsert(opId('a', 2), 'i', opId('a', 1));
rga.integrateInsert(pending.id, pending.value, pending.origin); // now true
rga.toArray().join(''); // 'Hi!'`;
const marks = `import { Rga, MarkStore, opId } from '@robonen/crdt';
// Build a sequence and grab the op ids of its characters.
const rga = new Rga<string>();
const ids: ReturnType<typeof opId>[] = [];
let left: ReturnType<typeof opId> | null = null;
for (let i = 0; i < 'bold'.length; i++) {
const id = opId('a', i + 1);
rga.integrateInsert(id, 'bold'[i]!, left);
ids.push(id);
left = id;
}
const marks = new MarkStore();
// A span anchors to the FIRST and LAST character op ids (inclusive), not to
// integer offsets — so it survives concurrent inserts/deletes around it.
marks.add({
id: opId('a', 10),
type: 'strong',
value: true,
start: ids[0]!, // 'b'
end: ids[3]!, // 'd'
});
// resolve() returns one active type→value map per character, in document order.
const active = marks.resolve(rga.visible().map(n => n.id));
active.map(m => m.get('strong')); // [true, true, true, true]`;
const marksConflict = `import { MarkStore, opId } from '@robonen/crdt';
const store = new MarkStore();
const start = opId('a', 1);
const end = opId('a', 4);
// Concurrent formatting on the same range: B turns it bold, A clears it.
store.add({ id: opId('a', 9), type: 'strong', value: false, start, end });
store.add({ id: opId('b', 9), type: 'strong', value: true, start, end });
// Highest op id wins per (character, type). opId('b', 9) > opId('a', 9),
// so 'strong' resolves to true — a null/false value would have cleared it.
const order = [opId('a', 1), opId('a', 2), opId('a', 3), opId('a', 4)];
store.resolve(order).map(m => m.get('strong')); // [true, true, true, true]`;
</script>
<template>
<div class="docs-section">
<!-- Intro -->
<div class="prose-docs">
<h1>Primitives</h1>
<p>
<code>@robonen/crdt</code> is a small set of independent data structures, each convergent on
its own. You can use a single primitive in isolation a last-writer-wins setting, an ordered
list, a collaborative string or compose them into something bigger. This page walks through
each one with a construction example and a small converging scenario.
</p>
<p>
Every primitive leans on one shared idea:
<NuxtLink to="/crdt/compare-op-id">compareOpId</NuxtLink> a deterministic total order over
operation ids (higher Lamport clock wins; site id breaks ties). Because all primitives resolve
conflicts the same way, two replicas that have seen the same operations always agree, no matter
the order or duplicates in which those operations arrived. If op ids are new to you, start with
<NuxtLink to="/crdt/concepts">Concepts</NuxtLink>.
</p>
</div>
<!-- Map of the package -->
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div class="rounded-lg border border-(--border) bg-(--bg-subtle) p-5">
<h3 class="mb-1.5 text-sm font-semibold text-(--fg)">Registers</h3>
<p class="text-sm leading-relaxed text-(--fg-muted)">
<code class="text-(--accent-text)">LwwRegister</code> and
<code class="text-(--accent-text)">LwwMap</code> single values and keyed maps where the
write with the highest op id wins.
</p>
</div>
<div class="rounded-lg border border-(--border) bg-(--bg-subtle) p-5">
<h3 class="mb-1.5 text-sm font-semibold text-(--fg)">Ordering</h3>
<p class="text-sm leading-relaxed text-(--fg-muted)">
<code class="text-(--accent-text)">keyBetween</code> /
<code class="text-(--accent-text)">keysBetween</code> fractional indexing to place or move
an item with a single string key.
</p>
</div>
<div class="rounded-lg border border-(--border) bg-(--bg-subtle) p-5">
<h3 class="mb-1.5 text-sm font-semibold text-(--fg)">Sequence</h3>
<p class="text-sm leading-relaxed text-(--fg-muted)">
<code class="text-(--accent-text)">Rga</code> a replicated growable array: an ordered
sequence CRDT with tombstones and a deterministic insert tie-break.
</p>
</div>
<div class="rounded-lg border border-(--border) bg-(--bg-subtle) p-5">
<h3 class="mb-1.5 text-sm font-semibold text-(--fg)">Marks</h3>
<p class="text-sm leading-relaxed text-(--fg-muted)">
<code class="text-(--accent-text)">MarkStore</code> lightweight Peritext formatting spans
anchored to character op ids, resolved per character by highest op id.
</p>
</div>
</div>
<!-- Registers -->
<div class="prose-docs">
<h2>LWW registers</h2>
<p>
A <NuxtLink to="/crdt/lww-register">LwwRegister</NuxtLink> is the smallest CRDT: a single
value with a timestamp. Every write carries an <code>OpId</code>, and a write only takes effect
if its id is strictly later than the current one by <code>compareOpId</code>. That single rule
gives you the three convergence properties for free applying writes is
<strong>commutative</strong> (a later write always beats an earlier one regardless of arrival
order), <strong>idempotent</strong> (re-applying a write is a no-op), and
<strong>convergent</strong> (every replica ends on the same winning write).
</p>
<p>
<code>set(value, id)</code> returns <code>true</code> when the write won and
<code>false</code> when it was superseded, which is handy for skipping downstream work.
</p>
</div>
<DocsCode :code="lwwRegister" lang="ts" />
<div class="prose-docs">
<h3>LwwMap</h3>
<p>
<NuxtLink to="/crdt/lww-map">LwwMap</NuxtLink> is a register per key. Each entry tracks its own
timestamp and a tombstone flag, so concurrent <code>set</code> and <code>delete</code> on the
same key converge to whichever has the higher op id deleting is just another timestamped
write that happens to hide the value. <code>get</code>, <code>has</code>, <code>keys</code>,
and <code>toEntries</code> all skip tombstoned entries, so the map reads like a plain map even
though deletions are retained internally for convergence.
</p>
</div>
<DocsCode :code="lwwMap" lang="ts" />
<div class="rounded-lg border border-(--border) bg-(--bg-subtle) p-4">
<p class="text-sm leading-relaxed text-(--fg-muted)">
<strong class="text-(--fg)">Why keep tombstones?</strong> If a delete simply dropped the entry,
a concurrent <code class="text-(--accent-text)">set</code> arriving afterward would resurrect
the key the two replicas would disagree on whether it exists. Retaining the delete as a
timestamped tombstone lets <code class="text-(--accent-text)">compareOpId</code> decide the
winner deterministically, the same way it does for live values.
</p>
</div>
<!-- Ordering -->
<div class="prose-docs">
<h2>Fractional indexing</h2>
<p>
Ordering a collaborative list with integer indices is a trap: insert at position 2 and every
index after it shifts, so two replicas inserting concurrently clobber each other's positions.
<NuxtLink to="/crdt/key-between">keyBetween</NuxtLink> sidesteps this by giving each item a
<em>string key</em> that lives strictly between its neighbors. Order is recovered by sorting
keys with plain string comparison — the digit alphabet is ASCII-ascending, so lexical order
matches digit order.
</p>
<p>
Pass <code>null</code> for an open bound: <code>keyBetween(null, x)</code> is "before
<code>x</code>", <code>keyBetween(x, null)</code> is "after <code>x</code>", and
<code>keyBetween(null, null)</code> seeds an empty list. The result is always strictly between
the bounds, so there is unlimited room to keep subdividing — you never run out of space to
insert between two adjacent items.
</p>
</div>
<DocsCode :code="fractionalBetween" lang="ts" />
<div class="prose-docs">
<h3>Batches and moves</h3>
<p>
<NuxtLink to="/crdt/keys-between">keysBetween</NuxtLink> generates <code>n</code> keys at once,
all strictly between the bounds and in ascending order — useful for seeding a list or
bulk-inserting a run of items. Because a key is just a value on the item, <strong>moving</strong>
an item is a single-field write: compute a new key between its new neighbors and re-sort.
Nothing else in the list is touched, which is exactly what makes concurrent reorders converge
cleanly (they reduce to independent
<NuxtLink to="/crdt/lww-register">LwwRegister</NuxtLink> writes on each item's key).
</p>
</div>
<DocsCode :code="fractionalBatch" lang="ts" />
<div class="rounded-lg border border-amber-500/30 bg-amber-500/10 p-4">
<p class="text-sm leading-relaxed text-(--fg-muted)">
<strong class="text-amber-700 dark:text-amber-400">Heads up:</strong>
<code class="text-(--accent-text)">keyBetween</code> requires <code>lower &lt; upper</code>
and throws otherwise. Two replicas independently generating a key between the
<em>same</em> neighbors can produce identical keys; pair the key with the item's op id as a
secondary sort to keep ordering deterministic, or let
<NuxtLink to="/crdt/rga">Rga</NuxtLink> handle character-level ordering for you.
</p>
</div>
<!-- Sequence -->
<div class="prose-docs">
<h2>The RGA sequence</h2>
<p>
<NuxtLink to="/crdt/rga">Rga</NuxtLink> (Replicated Growable Array) is the heart of the
package — the CRDT behind collaborative text. Each element is a node with a unique
<code>OpId</code>, a value, and an <code>originLeft</code>: the id of the element it was
inserted <em>after</em> (<code>null</code> means the start of the sequence). Deletion never
removes a node; it sets a <strong>tombstone</strong> flag, so the node lives on as a stable
anchor that later inserts and marks can still reference.
</p>
<p>
<code>integrateInsert(id, value, originLeft)</code> and <code>integrateDelete(id)</code> are
both idempotent — re-integrating an op you've already seen is a no-op that safely returns
<code>true</code>. Read the visible state with <code>toArray()</code>; use
<code>visible()</code> to get the surviving nodes (and their ids) for cursor anchoring, and
<code>length</code> for the visible count.
</p>
</div>
<DocsCode :code="rgaBasic" lang="ts" />
<div class="prose-docs">
<h3>Concurrent inserts and the tie-break</h3>
<p>
The interesting case is two replicas inserting at the <em>same</em> origin at the same time.
Both new elements claim the slot right after the same left neighbor so which goes first? RGA
resolves this deterministically: among elements sharing an origin, the one with the
<strong>higher op id</strong> is placed first (<code>compareOpId &gt; 0</code> scans past it).
Because every replica applies the identical comparison, they all settle on the same order
without any coordination.
</p>
</div>
<DocsCode :code="rgaConverge" lang="ts" />
<div class="prose-docs">
<h3>Causal buffering</h3>
<p>
RGA requires inserts to be integrated <strong>in causal order</strong>: an element's
<code>originLeft</code> must already be present, or there's no anchor to insert after. Rather
than guess, <code>integrateInsert</code> returns <code>false</code> when the origin is missing
and <code>integrateDelete</code> returns <code>false</code> for an unknown target the signal
to <em>buffer</em> the op and retry once its dependency lands. (At a higher level,
<NuxtLink to="/crdt/replica">Replica</NuxtLink> does this bookkeeping for you, holding and
replaying ops automatically.)
</p>
</div>
<DocsCode :code="rgaBuffer" lang="ts" />
<div class="rounded-lg border border-(--border) bg-(--bg-subtle) p-4">
<p class="text-sm leading-relaxed text-(--fg-muted)">
<strong class="text-(--fg)">Garbage collection.</strong> Tombstones accumulate. When every
replica has fully synced and nothing is in flight, <code class="text-(--accent-text)">gc(stable, keep?)</code>
drops deleted nodes whose insert is covered by a stable
<NuxtLink to="/crdt/version-vector">VersionVector</NuxtLink>, returning how many it removed.
Run it only at quiescence a late op that uses a dropped node as its origin could no longer
integrate and pass <code class="text-(--accent-text)">keep</code> to protect ids still
referenced elsewhere, such as mark span endpoints.
</p>
</div>
<!-- Marks -->
<div class="prose-docs">
<h2>Marks (lightweight Peritext)</h2>
<p>
Formatting in a collaborative editor can't be stored by offset — insert a character and every
offset after it shifts, so a "bold from 3 to 7" range would drift onto the wrong text.
<NuxtLink to="/crdt/mark-store">MarkStore</NuxtLink> follows the
<a href="https://www.inkandswitch.com/peritext/" target="_blank" rel="noopener">Peritext</a>
model: a <code>MarkSpan</code> anchors to the <code>OpId</code> of its first and last
characters (an inclusive range), so the span moves with the text it covers as the sequence
grows and shrinks around it.
</p>
<p>
A span's <code>value</code> is a JSON-serializable <code>MarkValue</code> pass
<code>true</code> (or attributes like a color string) to apply the mark, and
<code>null</code> or <code>false</code> to clear it.
</p>
</div>
<DocsCode :code="marks" lang="ts" />
<div class="prose-docs">
<h3>Resolving and converging</h3>
<p>
<code>add(span)</code> just records a span (idempotent by span id). The real work is
<code>resolve(order)</code>: given the character op ids in document order typically
<code>rga.visible().map(n =&gt; n.id)</code> it returns one <code>Map&lt;type, value&gt;</code>
of active marks per character. For each character and mark type, the covering span with the
<strong>highest op id wins</strong>, so concurrent formatting converges by the same
<code>compareOpId</code> rule as everything else; a winning <code>null</code>/<code>false</code>
span clears the mark.
</p>
</div>
<DocsCode :code="marksConflict" lang="ts" />
<!-- Where next -->
<div class="prose-docs">
<h2>Where to next</h2>
<ul>
<li>
<NuxtLink to="/crdt/concepts">Concepts</NuxtLink> op ids, Lamport clocks, version vectors,
and why convergence holds across all of these primitives.
</li>
<li>
<NuxtLink to="/crdt/replication">Replication &amp; Sync</NuxtLink> wire the primitives to a
<NuxtLink to="/crdt/replica">Replica</NuxtLink> for delta sync and automatic causal
buffering.
</li>
<li>
<NuxtLink to="/crdt/playground">Playground</NuxtLink> watch two replicas diverge and
reconcile, live in the browser.
</li>
</ul>
</div>
</div>
</template>
+378
View File
@@ -0,0 +1,378 @@
<!-- title: Replication & Sync -->
<!-- order: 3 -->
<script setup lang="ts">
const opLogShape = `import { OpLog } from '@robonen/crdt';
// The op log only ever reads \`id\` — the rest of the op is your domain payload.
interface CharOp {
id: { site: string; clock: number };
originLeft: { site: string; clock: number } | null;
value: string;
}
const log = new OpLog<CharOp>();
log.append(op); // true if new, false if already seen (dedup by id)
log.has(op.id); // version vector lookup, not a linear scan
log.version; // VersionVector — the highest clock seen per site
log.all(); // every op, in append order
log.delta(remoteVector); // ops the remote (described by its vector) lacks`;
const deltaExample = `// A asks B: "here's everything I've seen" (a state vector).
const aWants = a.replica.version;
// B answers with exactly the ops A is missing — nothing more.
const patch = b.replica.delta(aWants); // OpLog.delta filters by the vector
// A integrates them; ids it already has are silently dropped.
a.replica.receive(patch);`;
const roundTrip = `import { Replica, Rga } from '@robonen/crdt';
import type { OpId } from '@robonen/crdt';
interface CharOp {
id: OpId;
originLeft: OpId | null;
value: string;
}
// Each site owns an RGA (the sequence state) behind a Replica
// (clock + op log + causal buffer + delta sync).
function makeReplica(site: string) {
const rga = new Rga<string>();
const replica = new Replica<CharOp>(
{ integrate: op => rga.integrateInsert(op.id, op.value, op.originLeft) },
site,
);
return { rga, replica };
}
function type(peer: ReturnType<typeof makeReplica>, text: string): void {
let left: OpId | null = null;
for (const ch of text) {
const id = peer.replica.nextId(); // tick the Lamport clock
peer.replica.commitLocal({ id, originLeft: left, value: ch });
left = id;
}
}
const a = makeReplica('a');
const b = makeReplica('b');
// Concurrent, independent edits — neither has seen the other.
type(a, 'Hi');
type(b, 'Yo');
// Exchange ONLY the delta each side is missing, in both directions.
b.replica.receive(a.replica.delta(b.replica.version));
a.replica.receive(b.replica.delta(a.replica.version));
a.rga.toArray().join('') === b.rga.toArray().join(''); // true — converged
a.rga.length; // 4`;
const bufferingExample = `const a = makeReplica('a');
type(a, 'ab'); // two ops; the 2nd inserts after (depends on) the 1st
const b = makeReplica('b');
const [op1, op2] = a.replica.delta(b.replica.version);
// Deliver the DEPENDENT op first. Its origin (op1) isn't present,
// so integrate() returns false and the replica buffers it.
b.replica.receive([op2]);
b.rga.toArray().join(''); // '' — nothing applied yet
// Now deliver the dependency. op1 integrates, which unblocks op2;
// drain() loops until no further progress is possible.
b.replica.receive([op1]);
b.rga.toArray().join(''); // 'ab'`;
const wireExample = `import {
encodeStateVector, decodeStateVector,
encodeOps, decodeOps,
} from '@robonen/crdt';
// --- Peer A: announce what I have ---
const myVector: Uint8Array = encodeStateVector(a.replica.version);
socket.send(myVector); // send over WebSocket, HTTP, BroadcastChannel, …
// --- Peer B: answer with the delta A is missing ---
const remoteVector = decodeStateVector(received);
const patch: Uint8Array = encodeOps(b.replica.delta(remoteVector));
socket.send(patch);
// --- Peer A: apply the patch ---
const ops = decodeOps<CharOp>(receivedPatch);
a.replica.receive(ops);`;
</script>
<template>
<div class="docs-section">
<!-- Intro -->
<div class="prose-docs">
<h1>Replication &amp; Sync</h1>
<p>
A CRDT primitive on its own guarantees that the <em>same</em> set of operations converges.
Replication is the layer that makes sure every replica eventually holds that same set
despite messages arriving out of order, twice, or after a long offline gap. This package
does it without a central server, a global lock, or full-state diffing: each replica keeps
an append-only op log keyed by a version vector, and the two sides exchange only the
operations the other is missing.
</p>
<p>
The pieces fit together in one direction. <code>OpLog</code> stores ops and tracks a
<code>VersionVector</code>; <code>Replica</code> wraps a log plus a Lamport clock and a
causal buffer, integrating local and remote ops into your domain state; and the
<code>sync</code> helpers turn version vectors and op batches into bytes for any transport.
</p>
</div>
<!-- Mental model -->
<div class="prose-docs">
<h2>The convergence model</h2>
<p>
Every operation carries a globally-unique <code>OpId</code> a per-site
<a href="https://en.wikipedia.org/wiki/Lamport_timestamp">Lamport</a> clock value tagged
with the site that produced it (<code>{ site, clock }</code>). Two facts make replication
work, and both flow from that id:
</p>
<ul>
<li>
<strong>Identity idempotence.</strong> Because an op's id is stable, a replica can tell
whether it has already seen an op and apply it at most once. Delivering the same op twice
is a no-op, so duplicate or replayed messages are harmless.
</li>
<li>
<strong>Determinism ⇒ commutativity.</strong> Concurrent ops are resolved by one shared
tie-break — <NuxtLink to="/crdt/compare-op-id">compareOpId</NuxtLink> (higher clock wins,
site id breaks ties). Since every replica agrees on it, the order ops arrive in doesn't
change the final state.
</li>
</ul>
<p>
Replication therefore reduces to a set-reconciliation problem: <em>get both replicas to the
same set of ops.</em> Convergence of the resulting state is the primitive's job; getting the
ops there efficiently is this layer's.
</p>
</div>
<!-- Version vectors -->
<div class="prose-docs">
<h3>Version vectors: "what have you seen?"</h3>
<p>
A <NuxtLink to="/crdt/version-vector">VersionVector</NuxtLink> is the compact summary of
everything a replica has observed a map from site id to the highest clock seen for that
site. It relies on one assumption: each site emits <strong>dense</strong> clocks
(1, 2, 3, ), with no gaps. That's what lets a single number per site stand in for a whole
set: if a replica has seen <code>a@5</code>, it has necessarily seen <code>a@1…a@4</code>
too. So <code>has(id)</code> is just <code>get(id.site) &gt;= id.clock</code> — an O(1)
check, no per-op bookkeeping.
</p>
<p>
Two operations follow directly. <strong>Dedup:</strong> an incoming op whose id the vector
already covers can be ignored. <strong>Delta:</strong> given a remote vector, the set of ops
the remote lacks is exactly those whose id the vector does <em>not</em> cover.
</p>
</div>
<!-- OpLog -->
<div class="prose-docs">
<h2>The op log</h2>
<p>
<NuxtLink to="/crdt/op-log">OpLog</NuxtLink> is an append-only list of operations paired
with a version vector. It is deliberately domain-agnostic: the only field it reads is
<code>id</code> (the <code>HasOpId</code> constraint), so the same log stores RGA inserts,
LWW writes, mark spans, or anything else you give it.
</p>
</div>
<DocsCode :code="opLogShape" lang="ts" />
<div class="prose-docs">
<p>
<code>append</code> consults the vector first and returns <code>false</code> if the op is a
duplicate, so the log never stores the same id twice. <code>delta(remote)</code> walks the
log once and keeps every op the remote vector hasn't covered this is the heart of
"exchange only the delta".
</p>
</div>
<DocsCode :code="deltaExample" lang="ts" />
<!-- Replica -->
<div class="prose-docs">
<h2>The replica</h2>
<p>
<NuxtLink to="/crdt/replica">Replica</NuxtLink> ties everything together. It owns a
<code>LamportClock</code>, an <code>OpLog</code>, and a pending buffer, and you give it a
single handler <code>integrate(op)</code> that applies an op to your domain state and
returns <code>false</code> when the op's causal dependencies aren't present yet.
</p>
<h3>Producing local ops</h3>
<p>
Call <code>nextId()</code> to tick the clock and mint a fresh, causally-later
<code>OpId</code>, build your op around it, then hand it to <code>commitLocal(op)</code>.
That logs it, integrates it into local state, and notifies <code>onUpdate</code> listeners
with origin <code>'local'</code>. Because <code>nextId</code> advances a Lamport clock that
also tracks observed remote ops, locally-generated ids are always ordered after everything
the replica has seen.
</p>
<h3>Receiving remote ops</h3>
<p>
<code>receive(ops)</code> is the inbound path. For each op it advances the clock past the
remote id (<code>clock.observe</code>), skips anything already logged or already buffered,
then drains the buffer integrating whatever is now causally ready, retrying until no
further progress is possible. It returns the ops it actually applied (in apply order) and
notifies listeners with origin <code>'remote'</code>.
</p>
<h3>Computing a delta</h3>
<p>
<code>delta(remoteVector)</code> forwards to the log: the ops this replica holds that the
remote, described by its <code>version</code>, has not seen. The whole round-trip is two
deltas one per direction.
</p>
</div>
<!-- Round trip -->
<div class="prose-docs">
<h2>The canonical round-trip</h2>
<p>
Here is the README's converging-string example expanded end to end. Two replicas type
concurrently, then each side sends the other exactly the ops it lacks. After both deltas,
they hold the identical op set and therefore the identical string.
</p>
</div>
<DocsCode :code="roundTrip" lang="ts" />
<div class="prose-docs">
<p>
Note the asymmetry that makes this efficient: <code>a.replica.delta(b.replica.version)</code>
is computed against <em>B's</em> vector, so it returns only what B is missing not A's
entire history. On a long document this is the difference between sending two characters and
re-sending the whole file.
</p>
</div>
<!-- Why order does not matter -->
<div class="rounded-lg border border-(--border) bg-(--bg-subtle) p-5">
<h3 class="mb-1.5 text-sm font-semibold text-(--fg)">Why the order of the two deltas is irrelevant</h3>
<p class="text-sm leading-relaxed text-(--fg-muted)">
You could swap the two <code class="text-(--accent-text)">receive</code> lines, run them
repeatedly, or interleave them with more edits — the result is the same. Each side only ever
adds ops it hasn't seen, and <code class="text-(--accent-text)">compareOpId</code> places
each op in its deterministic position regardless of arrival order. That is convergence,
and the property tests assert it across randomized schedules.
</p>
</div>
<!-- Causal buffering -->
<div class="prose-docs">
<h2>Causal buffering</h2>
<p>
Some ops can't be applied the instant they arrive. An RGA insert references an
<code>originLeft</code> — the element it goes after — and a delete references the element it
tombstones. If that target hasn't been integrated yet (a later op overtook an earlier one in
transit), the insert has nowhere to anchor.
</p>
<p>
The handler signals this by returning <code>false</code> from <code>integrate</code>:
<NuxtLink to="/crdt/rga">Rga</NuxtLink>'s <code>integrateInsert</code> returns
<code>false</code> when its origin is absent, and <code>integrateDelete</code> returns
<code>false</code> when its target is unknown. <code>Replica.receive</code> treats a
<code>false</code> as "not ready yet": it keeps the op in a pending buffer and re-runs the
buffer every time new ops land, until either the op integrates or its dependency finally
arrives. Nothing is lost; nothing is applied prematurely.
</p>
</div>
<DocsCode :code="bufferingExample" lang="ts" />
<div class="prose-docs">
<p>
Internally the drain loop sweeps the buffer repeatedly: each successful integration may
unblock another buffered op, so it keeps looping while it makes progress. This is why a
single <code>receive</code> of a batch delivered in any order still settles to the right
state — the buffer absorbs the disorder.
</p>
</div>
<!-- Wire encoding -->
<div class="prose-docs">
<h2>Transport-agnostic wire encoding</h2>
<p>
The <code>sync</code> module is the only part that touches bytes, and it stays small on
purpose. There are two things to put on the wire — a version vector (the "what do you have?"
handshake) and a batch of ops (the delta or a full snapshot) — and a helper for each
direction:
</p>
<ul>
<li>
<NuxtLink to="/crdt/encode-state-vector">encodeStateVector</NuxtLink> /
<code>decodeStateVector</code> — a <code>VersionVector</code> ⇄ <code>Uint8Array</code>.
</li>
<li>
<NuxtLink to="/crdt/encode-ops">encodeOps</NuxtLink> / <code>decodeOps</code> — an op
batch (the delta or a full snapshot) ⇄ <code>Uint8Array</code>.
</li>
<li>
<code>encodeJson</code> / <code>decodeJson</code> — the lower-level pair the others build on.
</li>
</ul>
<p>
The v1 format is JSON encoded to bytes — simple and debuggable. A compact varint format is a
later optimization that changes the bytes, not the API, so code written against these
functions keeps working. Because the result is just a <code>Uint8Array</code>, the transport
is entirely up to you: WebSocket, HTTP, <code>BroadcastChannel</code>, a file on disk.
</p>
</div>
<DocsCode :code="wireExample" lang="ts" />
<!-- A typical sync protocol -->
<div class="prose-docs">
<h3>A minimal two-way protocol</h3>
<p>
Put the pieces together and a full reconciliation between two peers is four messages:
</p>
<ol>
<li>Each peer sends its <code>encodeStateVector(replica.version)</code>.</li>
<li>
On receiving the other's vector, each peer replies with
<code>encodeOps(replica.delta(theirVector))</code>.
</li>
<li>Each peer <code>receive()</code>s the decoded delta.</li>
<li>Both replicas now hold the same op set and the same converged state.</li>
</ol>
<p>
This generalizes cleanly. For live collaboration, also forward each locally-committed op as
it happens (subscribe with <code>onUpdate</code>, encode the op, broadcast it); peers that
receive an op out of causal order simply buffer it. For catch-up after an offline gap, the
state-vector handshake above replays exactly the missed ops. The same machinery covers both.
</p>
</div>
<!-- Caveat callout -->
<div class="rounded-lg border border-amber-500/30 bg-amber-500/10 p-5">
<h3 class="mb-1.5 text-sm font-semibold text-amber-700 dark:text-amber-400">Dense clocks are a precondition</h3>
<p class="text-sm leading-relaxed text-(--fg-muted)">
Version vectors assume each site's clocks are dense (1, 2, 3, ). That holds automatically
when ids come from <code class="text-(--accent-text)">Replica.nextId()</code>. If you mint
ids yourself, never skip a value for a site a gap would make
<code class="text-(--accent-text)">delta</code> believe a missing op was already delivered.
</p>
</div>
<!-- Where next -->
<div class="prose-docs">
<h2>Where to next</h2>
<ul>
<li>
<NuxtLink to="/crdt/replica">Replica</NuxtLink> the full API reference for
<code>commitLocal</code>, <code>receive</code>, <code>delta</code>, and
<code>onUpdate</code>.
</li>
<li>
<NuxtLink to="/crdt/op-log">OpLog</NuxtLink> and
<NuxtLink to="/crdt/version-vector">VersionVector</NuxtLink> the storage and causality
primitives underneath.
</li>
<li>
<NuxtLink to="/crdt/playground">Playground</NuxtLink> watch two replicas diverge and
reconcile live in the browser.
</li>
</ul>
</div>
</div>
</template>
+528
View File
@@ -0,0 +1,528 @@
<!-- title: Playground -->
<!-- order: 4 -->
<script setup lang="ts">
import { computed, reactive, ref } from 'vue';
import type { OpId } from '../src';
import { Replica, Rga } from '../src';
// ---------------------------------------------------------------------------
// The op shape exchanged between replicas.
//
// This is a REAL @robonen/crdt setup, not a simulation: each side owns an `Rga`
// for its sequence state, wrapped by a `Replica` that owns the Lamport clock,
// op log, causal buffer and delta computation. The only thing the demo adds is
// a tiny op union so we can both insert and delete characters.
// ---------------------------------------------------------------------------
type CharOp =
| { kind: 'insert'; id: OpId; value: string; originLeft: OpId | null }
| { kind: 'delete'; id: OpId; target: OpId };
interface Side {
rga: Rga<string>;
replica: Replica<CharOp>;
}
function makeSide(site: string): Side {
const rga = new Rga<string>();
const replica = new Replica<CharOp>(
{
// Return `false` when a dependency is missing — the Replica buffers the op
// and retries it automatically once the dependency arrives.
integrate: (op) => {
if (op.kind === 'insert')
return rga.integrateInsert(op.id, op.value, op.originLeft);
return rga.integrateDelete(op.target);
},
},
site,
);
return { rga, replica };
}
// Reactive view-model. The CRDT classes are plain (non-reactive) objects, so we
// keep a small reactive snapshot and refresh it after every mutation.
interface View {
text: string;
ops: number;
clock: number;
pending: number;
}
const snapshot = reactive<{ a: View; b: View }>({
a: { text: '', ops: 0, clock: 0, pending: 0 },
b: { text: '', ops: 0, clock: 0, pending: 0 },
});
const drafts = reactive({ a: '', b: '' });
let a: Side | null = null;
let b: Side | null = null;
function refresh(): void {
if (!a || !b)
return;
snapshot.a = {
text: a.rga.toArray().join(''),
ops: a.replica.version.get(a.replica.site),
clock: a.replica.version.get(a.replica.site),
pending: 0,
};
snapshot.b = {
text: b.rga.toArray().join(''),
ops: b.replica.version.get(b.replica.site),
clock: b.replica.version.get(b.replica.site),
pending: 0,
};
}
function init(): void {
a = makeSide('A');
b = makeSide('B');
drafts.a = '';
drafts.b = '';
refresh();
}
// The drafts are deliberately decoupled from the CRDT value until "Apply":
// that lets the user stage CONCURRENT edits on both sides before any sync, the
// scenario where convergence actually matters.
/**
* Diff the side's current CRDT string against the textarea draft and emit the
* minimal insert/delete ops to make the RGA match the draft, committing each
* locally. A real editor derives these ops from input events the same way.
*/
function apply(which: 'a' | 'b'): void {
const side = which === 'a' ? a : b;
if (!side)
return;
const current = side.rga.toArray();
const next = [...(which === 'a' ? drafts.a : drafts.b)];
// Longest common prefix / suffix → splice region (a tiny, dependency-free diff).
let start = 0;
while (start < current.length && start < next.length && current[start] === next[start])
start += 1;
let endCur = current.length;
let endNext = next.length;
while (endCur > start && endNext > start && current[endCur - 1] === next[endNext - 1]) {
endCur -= 1;
endNext -= 1;
}
// Delete the removed characters (right-to-left keeps live indices stable).
for (let i = endCur - 1; i >= start; i--) {
const target = side.rga.idAt(i);
if (target) {
const op: CharOp = { kind: 'delete', id: side.replica.nextId(), target };
side.replica.commitLocal(op);
}
}
// Insert the new characters after the surviving left neighbour.
let left = start > 0 ? side.rga.idAt(start - 1) : null;
for (let i = start; i < endNext; i++) {
const op: CharOp = {
kind: 'insert',
id: side.replica.nextId(),
value: next[i]!,
originLeft: left,
};
side.replica.commitLocal(op);
left = op.id;
}
// Re-read drafts from the authoritative CRDT value.
drafts.a = a!.rga.toArray().join('');
drafts.b = b!.rga.toArray().join('');
refresh();
}
/**
* One full sync round: each side hands the other only the ops it is missing
* (computed from the peer's version vector), and `receive` integrates them with
* dedup + causal buffering. After this both RGAs hold the identical sequence.
*/
function sync(): void {
if (!a || !b)
return;
// Snapshot versions BEFORE exchanging so each delta reflects pre-sync state.
const va = a.replica.version.clone();
const vb = b.replica.version.clone();
b.replica.receive(a.replica.delta(vb));
a.replica.receive(b.replica.delta(va));
drafts.a = a.rga.toArray().join('');
drafts.b = b.rga.toArray().join('');
refresh();
}
const ready = ref(false);
function start(): void {
if (ready.value)
return;
init();
ready.value = true;
}
const converged = computed(() =>
snapshot.a.text === snapshot.b.text && (snapshot.a.text.length > 0 || snapshot.a.ops > 0));
// --- static code samples ---------------------------------------------------
const setupCode = `import { Replica, Rga } from '@robonen/crdt';
import type { OpId } from '@robonen/crdt';
// Inserts and deletes travel as ops. Every op carries an \`id\`; that's all
// Replica's op log needs to dedup and compute deltas.
type CharOp =
| { kind: 'insert'; id: OpId; value: string; originLeft: OpId | null }
| { kind: 'delete'; id: OpId; target: OpId };
function makeSide(site: string) {
const rga = new Rga<string>();
const replica = new Replica<CharOp>(
{
// Return false when a causal dependency is missing — the Replica buffers
// the op and retries it automatically once the dependency lands.
integrate: (op) =>
op.kind === 'insert'
? rga.integrateInsert(op.id, op.value, op.originLeft)
: rga.integrateDelete(op.target),
},
site,
);
return { rga, replica };
}
const a = makeSide('A');
const b = makeSide('B');`;
const localEditCode = `// A types "cat" at the start. Each character is an insert anchored to the
// previous one via originLeft; nextId() advances A's Lamport clock.
let left: OpId | null = null;
for (const ch of 'cat') {
const op = { kind: 'insert', id: a.replica.nextId(), value: ch, originLeft: left } as const;
a.replica.commitLocal(op); // integrate locally + append to the log
left = op.id;
}
// Concurrently, B types "dog" — it has NOT seen A's ops yet.
left = null;
for (const ch of 'dog') {
const op = { kind: 'insert', id: b.replica.nextId(), value: ch, originLeft: left } as const;
b.replica.commitLocal(op);
left = op.id;
}
a.rga.toArray().join(''); // 'cat'
b.rga.toArray().join(''); // 'dog' — the replicas have DIVERGED`;
const syncCode = `// Send each side only what it's missing, computed from the peer's version.
// Snapshot versions first so both deltas describe the pre-sync state.
const va = a.replica.version.clone();
const vb = b.replica.version.clone();
b.replica.receive(a.replica.delta(vb)); // B integrates A's 3 inserts
a.replica.receive(b.replica.delta(va)); // A integrates B's 3 inserts
// Both RGAs now hold the same six characters in the same order. The order is
// decided by compareOpId (higher clock wins; site id breaks the tie) — NOT by
// who synced first — so the result is identical on every replica.
a.rga.toArray().join(''); // e.g. 'dogcat'
a.rga.toArray().join('') === b.rga.toArray().join(''); // true — CONVERGED`;
</script>
<template>
<div class="docs-section">
<!-- Intro -->
<div class="prose-docs">
<h1>Playground</h1>
<p>
Reading about convergence only gets you so far the intuition lands when you
<em>watch two replicas disagree and then reconcile</em>. Below is a live, two-replica
editor backed by the real <NuxtLink to="/crdt/rga">Rga</NuxtLink> and
<NuxtLink to="/crdt/replica">Replica</NuxtLink> classes from this package. Edit each side
independently, then press <strong>Sync</strong> and see them land on the exact same string.
</p>
</div>
<!-- Live demo -->
<div class="prose-docs">
<h2>Live: two replicas, one string</h2>
<p>
Replica <strong>A</strong> and replica <strong>B</strong> each own a private copy of a
shared document. Type something different into each, click <strong>Apply</strong> to commit
those edits locally (they diverge), then <strong>Sync</strong> to exchange deltas and
converge. The readout under each side shows its current value, how many local ops its log
has produced, and its Lamport clock.
</p>
</div>
<ClientOnly>
<template #fallback>
<div class="rounded-xl border border-(--border) bg-(--bg-subtle) p-8 text-center text-sm text-(--fg-subtle)">
Loading interactive demo
</div>
</template>
<div class="rounded-xl border border-(--border) bg-(--bg-subtle) p-4 sm:p-5">
<div v-if="!ready" class="flex flex-col items-center gap-3 py-8 text-center">
<p class="text-sm text-(--fg-muted)">Spin up two fresh replicas to start editing.</p>
<button
type="button"
class="rounded-md bg-(--accent) px-4 py-2 text-sm font-medium text-(--accent-fg) hover:bg-(--accent-hover) focus:outline-none focus:ring-2 focus:ring-(--ring)"
@click="start()"
>
Start demo
</button>
</div>
<div v-else class="flex flex-col gap-4">
<!-- Two replica panes -->
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<!-- Replica A -->
<div class="flex flex-col gap-2 rounded-lg border border-(--border) bg-(--bg-elevated) p-3">
<div class="flex items-center justify-between">
<span class="text-xs font-semibold uppercase tracking-wider text-(--fg-muted)">Replica A</span>
<span class="rounded bg-(--bg-inset) px-1.5 py-0.5 font-mono text-[11px] text-(--fg-subtle)">site: A</span>
</div>
<textarea
v-model="drafts.a"
rows="3"
spellcheck="false"
class="resize-none rounded-md border border-(--border) bg-(--bg) px-3 py-2 font-mono text-sm text-(--fg) focus:border-(--border-strong) focus:outline-none focus:ring-2 focus:ring-(--ring)"
placeholder="Type on A…"
/>
<div class="flex items-center gap-2">
<button
type="button"
class="rounded-md border border-(--border) bg-(--bg-elevated) px-3 py-1.5 text-xs font-medium text-(--fg) hover:bg-(--bg-inset) focus:outline-none focus:ring-2 focus:ring-(--ring)"
@click="apply('a')"
>
Apply edits
</button>
<div class="ml-auto flex items-center gap-3 font-mono text-[11px] text-(--fg-subtle)">
<span>ops {{ snapshot.a.ops }}</span>
<span>clock {{ snapshot.a.clock }}</span>
</div>
</div>
<div class="rounded-md bg-(--bg-inset) px-3 py-2 font-mono text-sm text-(--fg) break-all min-h-9">
<span v-if="snapshot.a.text">{{ snapshot.a.text }}</span>
<span v-else class="text-(--fg-subtle)">(empty)</span>
</div>
</div>
<!-- Replica B -->
<div class="flex flex-col gap-2 rounded-lg border border-(--border) bg-(--bg-elevated) p-3">
<div class="flex items-center justify-between">
<span class="text-xs font-semibold uppercase tracking-wider text-(--fg-muted)">Replica B</span>
<span class="rounded bg-(--bg-inset) px-1.5 py-0.5 font-mono text-[11px] text-(--fg-subtle)">site: B</span>
</div>
<textarea
v-model="drafts.b"
rows="3"
spellcheck="false"
class="resize-none rounded-md border border-(--border) bg-(--bg) px-3 py-2 font-mono text-sm text-(--fg) focus:border-(--border-strong) focus:outline-none focus:ring-2 focus:ring-(--ring)"
placeholder="Type on B…"
/>
<div class="flex items-center gap-2">
<button
type="button"
class="rounded-md border border-(--border) bg-(--bg-elevated) px-3 py-1.5 text-xs font-medium text-(--fg) hover:bg-(--bg-inset) focus:outline-none focus:ring-2 focus:ring-(--ring)"
@click="apply('b')"
>
Apply edits
</button>
<div class="ml-auto flex items-center gap-3 font-mono text-[11px] text-(--fg-subtle)">
<span>ops {{ snapshot.b.ops }}</span>
<span>clock {{ snapshot.b.clock }}</span>
</div>
</div>
<div class="rounded-md bg-(--bg-inset) px-3 py-2 font-mono text-sm text-(--fg) break-all min-h-9">
<span v-if="snapshot.b.text">{{ snapshot.b.text }}</span>
<span v-else class="text-(--fg-subtle)">(empty)</span>
</div>
</div>
</div>
<!-- Sync bar -->
<div class="flex flex-wrap items-center gap-3 border-t border-(--border) pt-3">
<button
type="button"
class="rounded-md bg-(--accent) px-4 py-2 text-sm font-medium text-(--accent-fg) hover:bg-(--accent-hover) focus:outline-none focus:ring-2 focus:ring-(--ring)"
@click="sync()"
>
Sync
</button>
<button
type="button"
class="rounded-md px-3 py-2 text-sm text-(--fg-muted) hover:bg-(--bg-inset) hover:text-(--fg) focus:outline-none focus:ring-2 focus:ring-(--ring)"
@click="init()"
>
Reset
</button>
<span
v-if="converged"
class="ml-auto inline-flex items-center gap-1.5 rounded-md bg-emerald-500/10 px-2.5 py-1 text-xs font-medium text-emerald-600 dark:text-emerald-400"
>
Converged both sides equal
</span>
<span
v-else
class="ml-auto inline-flex items-center gap-1.5 rounded-md bg-amber-500/10 px-2.5 py-1 text-xs font-medium text-amber-600 dark:text-amber-400"
>
Diverged sync to reconcile
</span>
</div>
</div>
</div>
</ClientOnly>
<div class="prose-docs">
<p>
Try the canonical experiment: type <code>cat</code> on A and <code>dog</code> on B, apply
both, then sync. The result is the same six characters on both sides, every time the order
is decided by op id, not by who synced first. Reset and try it again to confirm it's
deterministic.
</p>
</div>
<!-- How it works -->
<div class="prose-docs">
<h2>How the demo is wired</h2>
<p>
There's no mock here. Each side is a real <code>Rga&lt;string&gt;</code> wrapped in a
<code>Replica&lt;CharOp&gt;</code>. The <code>Replica</code> owns the Lamport clock, the
append-only op log, the causal buffer, and delta computation; the <code>Rga</code> holds the
actual character sequence with tombstones. We pass one handler <code>integrate</code>
that applies an op to the RGA.
</p>
</div>
<DocsCode :code="setupCode" lang="ts" />
<div class="prose-docs">
<h3>Making concurrent edits</h3>
<p>
A local edit is just an op: call <code>replica.nextId()</code> to mint a fresh op id (which
ticks that site's Lamport clock), build the insert or delete, and pass it to
<code>commitLocal</code>. That integrates the op into the RGA and appends it to the log in
one step. Because A and B edit before any sync, they produce ops with overlapping clock
values but different site ids — genuinely concurrent operations.
</p>
</div>
<DocsCode :code="localEditCode" lang="ts" />
<div class="prose-docs">
<h3>Syncing the deltas</h3>
<p>
Sync is a delta exchange driven by version vectors. Each replica's
<code>version</code> records the highest clock it has seen per site;
<code>delta(remoteVersion)</code> returns exactly the ops the remote is missing.
<code>receive</code> then dedups, integrates, and crucially <em>buffers</em> any op
whose causal dependency hasn't arrived yet, retrying it automatically once that dependency
lands.
</p>
</div>
<DocsCode :code="syncCode" lang="ts" />
<!-- Why it converges -->
<div class="prose-docs">
<h2>Why it always converges</h2>
<p>
The demo never special-cases conflicts, because the data structure can't have any. Three
properties, each verified by the package's property tests, guarantee that every replica
reaches the same state regardless of message order, duplication, or delay.
</p>
</div>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-3">
<div class="rounded-lg border border-(--border) bg-(--bg-subtle) p-5">
<h3 class="mb-1.5 text-sm font-semibold text-(--fg)">Commutative</h3>
<p class="text-sm leading-relaxed text-(--fg-muted)">
A-then-B and B-then-A produce the same sequence. Concurrent inserts at the same origin are
ordered by <code class="text-(--accent-text)">compareOpId</code>, so order of arrival
doesn't matter.
</p>
</div>
<div class="rounded-lg border border-(--border) bg-(--bg-subtle) p-5">
<h3 class="mb-1.5 text-sm font-semibold text-(--fg)">Idempotent</h3>
<p class="text-sm leading-relaxed text-(--fg-muted)">
Receiving the same op twice is a no-op. The op log's version vector dedups on
<code class="text-(--accent-text)">id</code>, and <code class="text-(--accent-text)">integrateInsert</code>
short-circuits if the id is already present.
</p>
</div>
<div class="rounded-lg border border-(--border) bg-(--bg-subtle) p-5">
<h3 class="mb-1.5 text-sm font-semibold text-(--fg)">Causal</h3>
<p class="text-sm leading-relaxed text-(--fg-muted)">
An insert can't integrate before its <code class="text-(--accent-text)">originLeft</code>,
nor a delete before its target. <code class="text-(--accent-text)">receive</code> buffers
such ops and retries them, so out-of-order delivery still converges.
</p>
</div>
</div>
<div class="prose-docs">
<h3>The single source of truth: op id order</h3>
<p>
Everything hinges on one comparison. When two replicas insert characters at the same
position concurrently, <code>Rga.integrateInsert</code> walks past any existing siblings
whose op id sorts <em>higher</em> and splices the new node in so the final order is fully
determined by <code>compareOpId</code>: higher Lamport clock first, with the site id as a
deterministic tie-break. Every replica runs the same comparison on the same ids, so they all
agree on the same order without a coordinator.
</p>
<p>
That's also why deletes are tombstones rather than removals: a delete only flips a node's
<code>deleted</code> flag, so a concurrent insert that anchored to that node still has a
valid origin. The character disappears from <code>toArray()</code>, but the structure stays
intact for convergence. Tombstones are reclaimed later via
<NuxtLink to="/crdt/rga"><code>Rga.gc</code></NuxtLink>, but only at quiescence.
</p>
</div>
<!-- Things to try -->
<div class="prose-docs">
<h2>Experiments to try</h2>
<ul>
<li>
<strong>Repeat sync.</strong> Press <strong>Sync</strong> twice in a row the second pass
applies nothing, because each side's delta is now empty. Idempotence in action.
</li>
<li>
<strong>Concurrent deletes.</strong> Sync to a shared value, then delete different
characters on each side and sync again. Both deletions survive; neither clobbers the other.
</li>
<li>
<strong>Edit after sync.</strong> Keep editing on one side and syncing repeatedly — only
the new ops travel each time, because <code>delta</code> filters by the peer's version
vector.
</li>
<li>
<strong>Tie-break.</strong> Type a single different character at the very start of each
side, then sync. The one whose op id sorts higher lands first deterministically.
</li>
</ul>
</div>
<!-- Where next -->
<div class="prose-docs">
<h2>Where to next</h2>
<ul>
<li>
<NuxtLink to="/crdt/rga">Rga</NuxtLink> the full sequence API: tombstones, cursor
anchoring via op ids, and garbage collection.
</li>
<li>
<NuxtLink to="/crdt/replica">Replica</NuxtLink> clock, op log, causal buffer, deltas,
and the <code>onUpdate</code> subscription used to drive UI.
</li>
<li>
<NuxtLink to="/crdt/version-vector">VersionVector</NuxtLink> and
<NuxtLink to="/crdt/compare-op-id">compareOpId</NuxtLink> the causality and tie-break
machinery behind every primitive.
</li>
</ul>
</div>
</div>
</template>
+139
View File
@@ -0,0 +1,139 @@
<script setup lang="ts">
const replicaExample = `import { Replica, Rga, opId } from '@robonen/crdt';
// Each editing site owns an RGA (the sequence state) wrapped by a Replica
// (clock + op log + causal buffering + delta sync).
type Op = {
id: ReturnType<typeof opId>;
value: string;
originLeft: ReturnType<typeof opId> | null;
};
function makeReplica(site: string) {
const rga = new Rga<string>();
const replica = new Replica<Op>(
{ integrate: op => rga.integrateInsert(op.id, op.value, op.originLeft) },
site,
);
return { rga, replica };
}
const a = makeReplica('a');
const b = makeReplica('b');
// A types "hi" locally.
let left: Op['originLeft'] = null;
for (const ch of 'hi') {
const op: Op = { id: a.replica.nextId(), value: ch, originLeft: left };
a.replica.commitLocal(op);
left = op.id;
}
// Sync: send B only the ops it is missing, then send A only what it lacks.
b.replica.receive(a.replica.delta(b.replica.version));
a.replica.receive(b.replica.delta(a.replica.version));
a.rga.toArray().join(''); // 'hi'
a.rga.toArray().join('') === b.rga.toArray().join(''); // true — converged`;
</script>
<template>
<div class="docs-section">
<!-- Hero -->
<div class="prose-docs">
<h1>@robonen/crdt</h1>
<p>
Framework-agnostic CRDT primitives an RGA sequence, last-writer-wins registers,
fractional indexing, and version vectors that converge no matter the order, duplicates,
or delays in which operations arrive.
</p>
</div>
<div class="prose-docs">
<p>
Collaborative state is hard because two replicas can edit the same document at once,
offline, with messages that arrive out of order or twice. A CRDT solves this by construction:
every primitive here is <strong>commutative, idempotent, and convergent</strong>, so applying
the same set of operations in any order yields the same state a property verified by
property tests. It's the convergence engine behind <code>@robonen/editor</code>, but stays
fully domain-agnostic, ships zero runtime dependencies, and runs in both Node and the browser.
</p>
</div>
<!-- Feature cards -->
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div class="rounded-lg border border-(--border) bg-(--bg-subtle) p-5">
<h3 class="mb-1.5 text-sm font-semibold text-(--fg)">Convergent by construction</h3>
<p class="text-sm leading-relaxed text-(--fg-muted)">
One deterministic tie-break — <code class="text-(--accent-text)">compareOpId</code> (higher
Lamport clock wins; site id breaks ties) — is shared by every primitive, so LWW and RGA agree
on the same final state.
</p>
</div>
<div class="rounded-lg border border-(--border) bg-(--bg-subtle) p-5">
<h3 class="mb-1.5 text-sm font-semibold text-(--fg)">Causal buffering built in</h3>
<p class="text-sm leading-relaxed text-(--fg-muted)">
<code class="text-(--accent-text)">Replica.receive</code> dedups, holds ops whose dependencies
haven't arrived yet (an insert before its origin), and retries them automatically as they land.
</p>
</div>
<div class="rounded-lg border border-(--border) bg-(--bg-subtle) p-5">
<h3 class="mb-1.5 text-sm font-semibold text-(--fg)">Delta sync, not full state</h3>
<p class="text-sm leading-relaxed text-(--fg-muted)">
Version vectors let each side request exactly the ops it's missing via
<code class="text-(--accent-text)">delta(version)</code>, with a transport-agnostic wire format.
</p>
</div>
<div class="rounded-lg border border-(--border) bg-(--bg-subtle) p-5">
<h3 class="mb-1.5 text-sm font-semibold text-(--fg)">Zero dependencies, pure TS</h3>
<p class="text-sm leading-relaxed text-(--fg-muted)">
No runtime deps, no framework lock-in. Compose the primitives yourself, or lean on
<code class="text-(--accent-text)">Replica</code> to tie a clock, op log, and buffer together.
</p>
</div>
</div>
<!-- Install -->
<div class="prose-docs">
<h2>Install</h2>
<p>Add the package with your preferred package manager.</p>
</div>
<DocsCode :code="`pnpm add @robonen/crdt`" lang="bash" />
<!-- Usage -->
<div class="prose-docs">
<h2>Quick start</h2>
<p>
Two replicas edit a string independently, then exchange only the operations each is missing
and converge to the same result.
</p>
</div>
<DocsCode :code="replicaExample" lang="ts" />
<!-- Where next -->
<div class="prose-docs">
<h2>Where to next</h2>
<p>New to CRDTs? Work through the guide and finish in the live playground.</p>
<ul>
<li>
<NuxtLink to="/crdt/concepts">Concepts</NuxtLink> op ids, Lamport clocks, version vectors,
and why convergence holds.
</li>
<li>
<NuxtLink to="/crdt/primitives">Primitives</NuxtLink> a tour of
<NuxtLink to="/crdt/rga">Rga</NuxtLink>,
<NuxtLink to="/crdt/lww-register">LwwRegister</NuxtLink>, and fractional indexing with
<NuxtLink to="/crdt/key-between">keyBetween</NuxtLink>.
</li>
<li>
<NuxtLink to="/crdt/replication">Replication &amp; Sync</NuxtLink> wiring up
<NuxtLink to="/crdt/replica">Replica</NuxtLink>, deltas, and the wire encoding.
</li>
<li>
<NuxtLink to="/crdt/playground">Playground</NuxtLink> watch two replicas diverge and
reconcile, live in the browser.
</li>
</ul>
</div>
</div>
</template>