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:
@@ -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 & 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>
|
||||
@@ -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 < 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 > 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 => n.id)</code> — it returns one <code>Map<type, value></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 & 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>
|
||||
@@ -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 & 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) >= 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>
|
||||
@@ -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<string></code> wrapped in a
|
||||
<code>Replica<CharOp></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>
|
||||
@@ -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 & 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>
|
||||
Reference in New Issue
Block a user