@zakkster/lite-crdt
v1.0.0
Published
Operational CRDTs for @zakkster/lite-store: a signal-native LWW-Map and OR-Set that make any collection collaborative. Transport-agnostic ops, order-independent convergence, cross-tab sync. Zero runtime dependencies.
Downloads
84
Maintainers
Readme
@zakkster/lite-crdt
Operational CRDTs for @zakkster/lite-store. Make any collection collaborative in a few lines: a last-write-wins map and an observed-remove set that resolve concurrent edits automatically and propagate to your UI through signals. Transport-agnostic, order-independent, and dependency-free.
import { createCRDTDoc } from "@zakkster/lite-crdt";
const doc = createCRDTDoc({ replicaId: "alice-tab-1" });
const todos = doc.array("todos"); // observed-remove set
const settings = doc.map("settings"); // last-write-wins map
todos.push({ id: "a", text: "buy milk", done: false }); // emits an op
settings.set("theme", "dark"); // emits an op
doc.on("op", (op) => sendToServer(op)); // you choose the transport
doc.applyOp(opFromAnotherReplica); // state converges, UI updatesWhy a projection read-model
An op-based CRDT has to capture the intent of a change at the moment it happens: "add element X with tag T", "delete the tags I have observed". lite-store is a transparent proxy that only fires signals on write, and you cannot reliably reverse-engineer a causal operation from a signal firing without lossy diffing.
So lite-crdt inverts the relationship. lite-crdt is the authoritative writer; lite-store is a reactive read-model. You mutate through .set() / .add() / .delete() (which emit ops), and you bind your UI to collection.store (a read-only lite-store projection that updates as state converges). This is the same separation event-sourced systems use, and it is what makes correct ops possible.
flowchart LR
UI["Your UI"] -- "bind (reactive read)" --> STORE["collection.store<br/>(read-only lite-store)"]
UI -- "set / add / delete" --> API["lite-crdt mutation API"]
API -- "mutate" --> STATE["CRDT state<br/>registers - tags - tombstones"]
STATE -- "project" --> STORE
API -- "emit" --> OUT["on('op')"]
OUT -- "transport" --> NET(("network / tabs"))
NET -- "remote op" --> APPLY["applyOp"]
APPLY -- "mutate" --> STATEWriting to the projection directly is blocked, because it would update local UI while silently bypassing the CRDT:
settings.set("theme", "dark"); // correct: emits an op, converges everywhere
settings.store.theme = "light"; // throws CRDTError("readonly")Install
npm install @zakkster/lite-crdtPeer dependencies (you already have these if you use the ecosystem): @zakkster/lite-store and @zakkster/lite-signal. lite-crdt itself has zero runtime dependencies.
The two data types
doc.map(name) -- LWW-Map
A keyed register map. Each key holds the value of the last write under a (lamport, replicaId) total order. Deletes are timestamped tombstones that compete with writes, so a delete and a concurrent set resolve deterministically.
const s = doc.map("settings");
s.set("theme", "dark");
s.get("theme"); // "dark" (reactive)
s.has("theme"); // true (reactive)
s.delete("theme");
s.keys(); s.values(); s.entries(); s.size; // reactive aggregatesConflict resolution:
| Concurrent operations on the same key | Winner |
| --- | --- |
| set vs set | higher lamport; tie broken by higher replicaId |
| set vs delete | higher lamport; tie broken by higher replicaId |
| any op re-delivered | no-op (idempotent) |
doc.array(name, { identify }) -- OR-Set
An observed-remove set keyed by a stable element id, with a last-write-wins value register per id. This is the practical shape for collaborative lists:
- A fresh
addmints a globally-unique membership tag. Concurrent add and remove resolve add-wins (the canonical OR-Set property). - Re-adding an id that is already present edits its value (last-write-wins) without minting a new tag and without changing its position. Toggling
donedoes not send the row to the bottom of the list. deleteremoves only the tags the caller has observed, so a concurrent add elsewhere survives.- Order is deterministic across replicas: by each element's first-add
(lamport, replicaId).
const todos = doc.array("todos"); // identify defaults to (v) => v.id
todos.push({ id: "1", text: "milk", done: false });
todos.push({ id: "1", text: "milk", done: true }); // edits in place, no reorder
todos.deleteById("1");
todos.has({ id: "1" }); todos.get("1"); todos.values(); todos.size;
// custom identity when elements have no `id`
const cart = doc.array("cart", { identify: (line) => line.sku });| Concurrent operations on the same id | Result |
| --- | --- |
| add (fresh tag) vs remove | element survives (add-wins) |
| add/edit vs add/edit | both tags live; value resolves last-write-wins |
| remove of observed tags vs concurrent add of a new tag | new tag survives |
| any op re-delivered or reordered | converges to the same state |
Reactivity contract
collection.store is a read-only lite-store projection. Reads through it (and through the methods below) are reactive inside a lite-signal effect / computed / watch.
- Fine-grained --
map.get(key)andmap.has(key)re-run only when that key changes. Bind individual fields to individual DOM nodes viamap.store. - Coarse --
keys()/values()/entries()/size(map) andvalues()/has()/size(set) re-run on any change to the collection. Bind whole-list renders to these.
import { effect } from "@zakkster/lite-signal";
effect(() => render(todos.values())); // re-runs on every local or remote change
effect(() => badge(settings.get("plan"))); // re-runs only when "plan" changesTransports
The core is transport-agnostic: on('op') gives you each locally-generated op to send; applyOp(op) ingests a remote one. Ops are plain JSON, commutative, and idempotent, so a transport may reorder and redeliver freely.
sequenceDiagram
participant A as Replica A
participant B as Replica B
A->>A: todos.push({id:1}) (lamport 1, tag A#0)
A-->>B: op { add, id:1, A#0 }
B->>B: applyOp -> converges
B->>B: todos.push({id:2}) (lamport 2, tag B#0)
B-->>A: op { add, id:2, B#0 }
A->>A: applyOp -> converges
Note over A,B: commutative + idempotent: any order,<br/>any duplicates -> identical stateWebSocket / WebRTC / server fan-out:
doc.on("op", (op) => socket.send(JSON.stringify(op)));
socket.onmessage = (e) => doc.applyOp(JSON.parse(e.data));Cross-tab (built in, zero-config): a thin helper over the native BroadcastChannel. It broadcasts ops and performs a state handshake so a tab that opens late is hydrated in one payload instead of replaying the whole op log.
import { connectBroadcastChannel } from "@zakkster/lite-crdt";
const conn = connectBroadcastChannel(doc, "my-app-room");
// ... later
conn.dispose();State sync for late joiners
Replaying an unbounded op log to onboard a new replica does not scale. getState() serializes the compacted, converged structure (values, live tags, tombstones, clock) into one JSON payload; mergeState() merges it. The merge is idempotent and commutative, so it composes with the live op stream.
// new client / tab on boot
const state = await fetch("/doc/state").then((r) => r.json());
doc.mergeState(state); // hydrated in one shot
doc.on("op", sendToServer); // then stream live opsAPI reference
createCRDTDoc({ replicaId?, clock?, onError? }) -> doc
doc.replicaId string
doc.clock() number current Lamport time
doc.map(name) -> LWWMap cached by name
doc.array(name, opts?) -> ORSet opts.identify?: (v) => string|number
doc.applyOp(op) void apply a remote op (idempotent, no echo)
doc.applyOps(ops) void
doc.getState() / doc.mergeState(state) full-state sync
doc.on('op', cb) -> off locally-generated ops
doc.on('change', cb) -> off any convergent change (local or remote)
doc.snapshot() object plain deep copy of every collection
doc.dispose() void
LWWMap get(k) has(k) keys() values() entries() size (reactive)
set(k,v) delete(k) (emit op)
store snapshot()
ORSet has(v) hasId(id) get(id) values() ids() size (reactive)
add(v) / push(v) delete(v) deleteById(id) (emit op)
store snapshot()
connectBroadcastChannel(doc, channelName) -> { dispose() }
CRDTError { code: 'kind_mismatch' | 'malformed_op' | 'readonly' | 'misconfigured' }Values are immutable
Stored values are held by reference locally (no defensive clone on the hot path), while remote replicas receive JSON copies over the wire. Treat values as immutable: to change a row, pass a new object to push/set rather than mutating the one you stored. Mutating a stored object in place would change local state without emitting an op, diverging from every other replica.
Performance
Run npm run bench. Indicative figures on Node 22: LWW-Map writes and applies clear a few million ops/second; OR-Set add/edit/apply run in the hundreds of thousands at a thousand-element list; a 4k-entry getState/mergeState round trip is a few milliseconds. Op application is allocation-light on the hot path. Incremental OR-Set add/edit touches the ordered projection at O(n) in list length (a single array splice plus a positional scan), which is the right trade for the tens-to-low-thousands sizes CRDTs are used at; bulk-loading a large document is the job of mergeState, which rebuilds the projection once rather than per element.
Out of scope for v1
- RGA / positional sequences and reorder. Ordering is by causal timestamp, not by index; there is no convergent "move item to position k" or character-level text. That is an order of magnitude more code and is planned for v2.
- Tombstone garbage collection. LWW-Map tombstones and OR-Set removed-tags accumulate. Vector-clock-based compaction is a v2 concern.
- Rich text and nested-document CRDTs.
License
MIT (c) Zahary Shinikchiev
