R

Playground

Reading about convergence only gets you so far — the intuition lands when you watch two replicas disagree and then reconcile. Below is a live, two-replica editor backed by the real Rga and Replica classes from this package. Edit each side independently, then press Sync and see them land on the exact same string.

Live: two replicas, one string

Replica A and replica B each own a private copy of a shared document. Type something different into each, click Apply to commit those edits locally (they diverge), then Sync 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.

Loading interactive demo…

Try the canonical experiment: type cat on A and dog 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.

How the demo is wired

There's no mock here. Each side is a real Rga<string> wrapped in a Replica<CharOp>. The Replica owns the Lamport clock, the append-only op log, the causal buffer, and delta computation; the Rga holds the actual character sequence with tombstones. We pass one handler — integrate — that applies an op to the RGA.

ts
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');

Making concurrent edits

A local edit is just an op: call replica.nextId() to mint a fresh op id (which ticks that site's Lamport clock), build the insert or delete, and pass it to commitLocal. 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.

ts
// 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

Syncing the deltas

Sync is a delta exchange driven by version vectors. Each replica's version records the highest clock it has seen per site; delta(remoteVersion) returns exactly the ops the remote is missing. receive then dedups, integrates, and — crucially — buffers any op whose causal dependency hasn't arrived yet, retrying it automatically once that dependency lands.

ts
// 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

Why it always converges

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.

Commutative

A-then-B and B-then-A produce the same sequence. Concurrent inserts at the same origin are ordered by compareOpId, so order of arrival doesn't matter.

Idempotent

Receiving the same op twice is a no-op. The op log's version vector dedups on id, and integrateInsert short-circuits if the id is already present.

Causal

An insert can't integrate before its originLeft, nor a delete before its target. receive buffers such ops and retries them, so out-of-order delivery still converges.

The single source of truth: op id order

Everything hinges on one comparison. When two replicas insert characters at the same position concurrently, Rga.integrateInsert walks past any existing siblings whose op id sorts higher and splices the new node in — so the final order is fully determined by compareOpId: 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.

That's also why deletes are tombstones rather than removals: a delete only flips a node's deleted flag, so a concurrent insert that anchored to that node still has a valid origin. The character disappears from toArray(), but the structure stays intact for convergence. Tombstones are reclaimed later via Rga.gc, but only at quiescence.

Experiments to try

  • Repeat sync. Press Sync twice in a row — the second pass applies nothing, because each side's delta is now empty. Idempotence in action.
  • Concurrent deletes. Sync to a shared value, then delete different characters on each side and sync again. Both deletions survive; neither clobbers the other.
  • Edit after sync. Keep editing on one side and syncing repeatedly — only the new ops travel each time, because delta filters by the peer's version vector.
  • Tie-break. Type a single different character at the very start of each side, then sync. The one whose op id sorts higher lands first — deterministically.

Where to next

  • Rga — the full sequence API: tombstones, cursor anchoring via op ids, and garbage collection.
  • Replica — clock, op log, causal buffer, deltas, and the onUpdate subscription used to drive UI.
  • VersionVector and compareOpId — the causality and tie-break machinery behind every primitive.