diff --git a/core/crdt/README.md b/core/crdt/README.md index a9a61a2..e3215ab 100644 --- a/core/crdt/README.md +++ b/core/crdt/README.md @@ -1,6 +1,6 @@ # @robonen/crdt -Framework-agnostic CRDT primitives — the convergence engine behind `@robonen/editor`, usable on their own. Zero runtime dependencies; pure TypeScript; runs in Node and the browser. +Framework-agnostic CRDT primitives — the convergence engine behind `@robonen/writekit`, usable on their own. Zero runtime dependencies; pure TypeScript; runs in Node and the browser. Every primitive is built so that **applying the same set of operations in any order, with duplicates, yields the same state** (commutative, idempotent, convergent), verified by property tests. @@ -50,7 +50,7 @@ a.rga.toArray().join('') === b.rga.toArray().join(''); // true — converged - `compareOpId` is the single deterministic tie-break (higher clock wins; site id breaks ties) every primitive agrees on — that's what makes LWW and RGA converge. - `VersionVector` assumes **dense** per-site clocks (1, 2, 3, …). - The v1 wire format is JSON encoded to bytes — simple and debuggable; a compact varint format is a later optimization with no API change. -- An editor-specific composition of these primitives (blocks + text + marks ↔ editor steps) lives in `@robonen/editor` under `crdt/native/`, not here — this package stays domain-agnostic. +- A writekit-specific composition of these primitives (blocks + text + marks ↔ writekit steps) lives in `@robonen/writekit` under `crdt/native/`, not here — this package stays domain-agnostic. ## Development diff --git a/core/crdt/docs/01-concepts.vue b/core/crdt/docs/01-concepts.vue index 80c83cd..4878a4d 100644 --- a/core/crdt/docs/01-concepts.vue +++ b/core/crdt/docs/01-concepts.vue @@ -179,13 +179,13 @@ const propsSrc = `// Commutative — order of application doesn't matter: 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.

-
-

- Why one rule for everything? - LwwRegister uses - compareOpId to pick the surviving value; - Rga uses it to break ties between concurrent inserts at - the same position; MarkStore uses it to decide which +

+

+ Why one rule for everything? + LwwRegister uses + compareOpId to pick the surviving value; + Rga uses it to break ties between concurrent inserts at + the same position; MarkStore 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.

@@ -223,11 +223,11 @@ const propsSrc = `// Commutative — order of application doesn't matter:
-

+

Density matters. - VersionVector only works because clocks arrive without - gaps. If you generate ids with a raw LamportClock, deliver - them in order per site (the Replica's causal buffer does + VersionVector only works because clocks arrive without + gaps. If you generate ids with a raw LamportClock, deliver + them in order per site (the Replica'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.

@@ -242,23 +242,23 @@ const propsSrc = `// Commutative — order of application doesn't matter:
-
-

Commutative

-

+

+

Commutative

+

Order of application doesn't change the result. A replica can integrate operations as they arrive, in whatever sequence the network delivers them.

-
-

Idempotent

-

+

+

Idempotent

+

Applying the same operation twice is the same as applying it once. Redelivery and retries are safe; version vectors make them free.

-
-

Convergent

-

+

+

Convergent

+

Same set of operations, same final state — full stop. Two replicas that have seen the same ops are byte-for-byte identical.

diff --git a/core/crdt/docs/02-primitives.vue b/core/crdt/docs/02-primitives.vue index 77e29a4..d9b21f0 100644 --- a/core/crdt/docs/02-primitives.vue +++ b/core/crdt/docs/02-primitives.vue @@ -198,33 +198,33 @@ store.resolve(order).map(m => m.get('strong')); // [true, true, true, true]`;
-
-

Registers

-

- LwwRegister and - LwwMap — single values and keyed maps where the +

+

Registers

+

+ LwwRegister and + LwwMap — single values and keyed maps where the write with the highest op id wins.

-
-

Ordering

-

- keyBetween / - keysBetween — fractional indexing to place or move +

+

Ordering

+

+ keyBetween / + keysBetween — fractional indexing to place or move an item with a single string key.

-
-

Sequence

-

- Rga — a replicated growable array: an ordered +

+

Sequence

+

+ Rga — a replicated growable array: an ordered sequence CRDT with tombstones and a deterministic insert tie-break.

-
-

Marks

-

- MarkStore — lightweight Peritext formatting spans +

+

Marks

+

+ MarkStore — lightweight Peritext formatting spans anchored to character op ids, resolved per character by highest op id.

@@ -262,12 +262,12 @@ store.resolve(order).map(m => m.get('strong')); // [true, true, true, true]`;
-
-

- Why keep tombstones? If a delete simply dropped the entry, - a concurrent set arriving afterward would resurrect +

+

+ Why keep tombstones? If a delete simply dropped the entry, + a concurrent set arriving afterward would resurrect the key — the two replicas would disagree on whether it exists. Retaining the delete as a - timestamped tombstone lets compareOpId decide the + timestamped tombstone lets compareOpId decide the winner deterministically, the same way it does for live values.

@@ -308,9 +308,9 @@ store.resolve(order).map(m => m.get('strong')); // [true, true, true, true]`;
-

+

Heads up: - keyBetween requires lower < upper + keyBetween requires lower < upper and throws otherwise. Two replicas independently generating a key between the same neighbors can produce identical keys; pair the key with the item's op id as a secondary sort to keep ordering deterministic, or let @@ -366,14 +366,14 @@ store.resolve(order).map(m => m.get('strong')); // [true, true, true, true]`;

-
-

- Garbage collection. Tombstones accumulate. When every - replica has fully synced and nothing is in flight, gc(stable, keep?) +

+

+ Garbage collection. Tombstones accumulate. When every + replica has fully synced and nothing is in flight, gc(stable, keep?) drops deleted nodes whose insert is covered by a stable VersionVector, 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 keep to protect ids still + integrate — and pass keep to protect ids still referenced elsewhere, such as mark span endpoints.

diff --git a/core/crdt/docs/03-replication.vue b/core/crdt/docs/03-replication.vue index 8dfb6d6..8ea9c1a 100644 --- a/core/crdt/docs/03-replication.vue +++ b/core/crdt/docs/03-replication.vue @@ -249,12 +249,12 @@ a.replica.receive(ops);`;
-
-

Why the order of the two deltas is irrelevant

-

- You could swap the two receive lines, run them +

+

Why the order of the two deltas is irrelevant

+

+ You could swap the two receive 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 compareOpId places + adds ops it hasn't seen, and compareOpId places each op in its deterministic position regardless of arrival order. That is convergence, and the property tests assert it across randomized schedules.

@@ -346,11 +346,11 @@ a.replica.receive(ops);`;

Dense clocks are a precondition

-

+

Version vectors assume each site's clocks are dense (1, 2, 3, …). That holds automatically - when ids come from Replica.nextId(). If you mint + when ids come from Replica.nextId(). If you mint ids yourself, never skip a value for a site — a gap would make - delta believe a missing op was already delivered. + delta believe a missing op was already delivered.

diff --git a/core/crdt/docs/04-playground.vue b/core/crdt/docs/04-playground.vue index 45b514f..1698448 100644 --- a/core/crdt/docs/04-playground.vue +++ b/core/crdt/docs/04-playground.vue @@ -260,17 +260,17 @@ a.rga.toArray().join('') === b.rga.toArray().join(''); // true — CONVERGED`; -
+
-

Spin up two fresh replicas to start editing.

+

Spin up two fresh replicas to start editing.