npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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

Readme

@zakkster/lite-crdt

npm version sponsor npm bundle size npm downloads npm total downloads lite-signal peer lite-store peer types dependencies license

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 updates

Why 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" --> STATE

Writing 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-crdt

Peer 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 aggregates

Conflict 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 add mints 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 done does not send the row to the bottom of the list.
  • delete removes 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) and map.has(key) re-run only when that key changes. Bind individual fields to individual DOM nodes via map.store.
  • Coarse -- keys() / values() / entries() / size (map) and values() / 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" changes

Transports

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 state

WebSocket / 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 ops

API 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