@robonen/crdt
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.
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 commutative, idempotent, and convergent, 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 @robonen/editor, but stays fully domain-agnostic, ships zero runtime dependencies, and runs in both Node and the browser.
Convergent by construction
One deterministic tie-break — compareOpId (higher Lamport clock wins; site id breaks ties) — is shared by every primitive, so LWW and RGA agree on the same final state.
Causal buffering built in
Replica.receive dedups, holds ops whose dependencies haven't arrived yet (an insert before its origin), and retries them automatically as they land.
Delta sync, not full state
Version vectors let each side request exactly the ops it's missing via delta(version), with a transport-agnostic wire format.
Zero dependencies, pure TS
No runtime deps, no framework lock-in. Compose the primitives yourself, or lean on Replica to tie a clock, op log, and buffer together.
Install
Add the package with your preferred package manager.
pnpm add @robonen/crdtQuick start
Two replicas edit a string independently, then exchange only the operations each is missing and converge to the same result.
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 — convergedWhere to next
New to CRDTs? Work through the guide and finish in the live playground.
- Concepts — op ids, Lamport clocks, version vectors, and why convergence holds.
- Primitives — a tour of Rga, LwwRegister, and fractional indexing with keyBetween.
- Replication & Sync — wiring up Replica, deltas, and the wire encoding.
- Playground — watch two replicas diverge and reconcile, live in the browser.
API Reference
Clock · 9
Total order over op ids: higher clock wins; ties broken by site id. This is the deterministic tie-break every replica agrees on, so LWW and RGA converge.
Generate a random site id (no crypto dependency; uniqueness, not secrecy).
A Lamport clock for one site: hands out monotonically increasing op ids and advances past observed remote ops so locally-generated ids stay causally later.
A globally-unique operation id: a per-site Lamport counter tagged with the site.
A replica identifier — unique per editing site/session.
Tracks the highest clock seen per site, assuming each site emits dense clocks (1, 2, 3, …). Used to deduplicate ops and to compute deltas during sync.
Doc · 1
Marks · 3
A formatting span anchored to character op ids (inclusive range), tagged with an op id for LWW conflict resolution — a lightweight Peritext mark.
Stores formatting spans and resolves them against a character order. For each (character, mark type) the covering span with the highest op id wins, so concurrent formatting converges; a `null`/`false` value clears the mark.
A mark's value: `true`/attrs to apply, `null`/`false` to clear. JSON-serializable.
Oplog · 2
Ordering · 2
Registers · 2
Last-writer-wins map with per-key timestamps and tombstones. Concurrent set/delete on a key converge to the operation with the higher op id.
Last-writer-wins register. A write applies only if its op id is later than the current write's (by {@link compareOpId}), so concurrent writes converge to the one with the higher timestamp regardless of arrival order.
Sequence · 2
Replicated Growable Array — a sequence CRDT. Each element is inserted after a left-origin element (or at the start) and tombstoned on delete. Concurrent inserts at the same origin are ordered higher-op-id-first, a deterministic tie-break that makes every replica converge to the same order. Operations must be integrated in causal order (an insert's origin must already be present); {@link integrateInsert} returns `false` when the origin is missing so the caller can buffer and retry.
One element of an RGA sequence (visible or tombstoned).
Sync · 6
Transport-agnostic wire encoding. v1 is JSON-over-bytes — simple and debuggable; a compact varint format is a later optimization with no API change.
Encode a batch of ops (the delta or a full snapshot).
Encode a version vector for a "what do you have?" sync handshake.