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.
+
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.
+
Order of application doesn't change the result. A replica can integrate operations as they arrive, in whatever sequence the network delivers them.
+
Applying the same operation twice is the same as applying it once. Redelivery and retries are safe; version vectors make them free.
+
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]`;
- LwwRegister and
- LwwMap — single values and keyed maps where the
+
+ LwwRegister and
+ LwwMap — single values and keyed maps where the
write with the highest op id wins.
- keyBetween /
- keysBetween — fractional indexing to place or move
+
+ keyBetween /
+ keysBetween — fractional indexing to place or move
an item with a single string key.
- Rga — a replicated growable array: an ordered
+
+ Rga — a replicated growable array: an ordered
sequence CRDT with tombstones and a deterministic insert tie-break.
- MarkStore — lightweight Peritext formatting spans
+
+ MarkStore — lightweight Peritext formatting spans
anchored to character op ids, resolved per character by highest op id.
- 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.
+
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
keep to protect ids still
+ integrate — and pass keep to protect ids still
referenced elsewhere, such as mark span endpoints.
- You could swap the two receive lines, run them
+
+ 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.
+
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.
Spin up two fresh replicas to start editing.
+Spin up two fresh replicas to start editing.