Storage

CRDT primitives for conflict-free collaborative state. The @waits/lively-storage package provides LiveObject, LiveMap, and LiveList — nestable data structures that automatically resolve conflicts using last-writer-wins semantics and Lamport clocks.

Install

terminal
$ bun add @waits/lively-storage

StorageDocument

The top-level container that owns the CRDT tree, manages subscriptions, and coordinates serialization.

Create a document with a root object:

import { StorageDocument, LiveObject } from "@waits/lively-storage";
const root = new LiveObject({ count: 0, name: "untitled" });
const doc = new StorageDocument(root);

getRoot() returns the root LiveObject:

const root = doc.getRoot();
root.get("count"); // 0

Serialize and deserialize for persistence:

// Serialize the entire tree
const snapshot = doc.serialize();
// Restore from snapshot
const restored = StorageDocument.deserialize(snapshot);

Subscribe to changes — shallow or deep:

// Shallow: fires when root fields change
const unsub = doc.subscribe(root, () => {
console.log("root changed");
});
// Deep: fires on any nested change
const unsub2 = doc.subscribe(root, () => {
console.log("something changed");
}, { isDeep: true });
// Call unsub() to remove the listener
unsub();

LiveObject

A conflict-free replicated object. Each field uses last-writer-wins with Lamport clocks. Supports nested CRDT values.

Constructor and basic operations:

import { LiveObject } from "@waits/lively-storage";
const obj = new LiveObject({ x: 0, y: 0, label: "origin" });

get(key) — read a field:

obj.get("x"); // 0
obj.get("label"); // "origin"

set(key, value) — write a field. Generates an op, ticks the clock, and notifies subscribers:

obj.set("x", 100);
obj.set("label", "moved");

delete(key) — remove a field:

obj.set("temp", true);
// LiveObject inherits delete from set with a delete op

toObject() — snapshot as a plain JS object:

const plain = obj.toObject();
// { x: 100, y: 0, label: "moved" }

toImmutable() — frozen snapshot (cached until next mutation):

const frozen = obj.toImmutable();
// Object.isFrozen(frozen) === true

Nesting CRDTs inside a LiveObject:

import { LiveObject, LiveList } from "@waits/lively-storage";
const root = new LiveObject({
settings: new LiveObject({ theme: "dark" }),
items: new LiveList([]),
});

LiveMap

A conflict-free replicated map with string keys. Uses tombstones for deletes so concurrent set/delete pairs resolve correctly.

Constructor:

import { LiveMap } from "@waits/lively-storage";
const map = new LiveMap([
["alice", { score: 10 }],
["bob", { score: 20 }],
]);

get(key) / set(key, value) / delete(key) / has(key):

map.get("alice"); // { score: 10 }
map.set("carol", { score: 30 });
map.has("carol"); // true
map.delete("bob");
map.has("bob"); // false

size — number of live (non-deleted) entries:

map.size; // 2

Iteration — entries(), keys(), values(), forEach():

for (const [key, val] of map.entries()) {
console.log(key, val);
}
map.forEach((value, key) => {
console.log(key, value);
});

toImmutable() — frozen ReadonlyMap snapshot:

const snapshot = map.toImmutable();
// ReadonlyMap<string, V>

LiveList

A conflict-free replicated list. Uses fractional indexing so concurrent inserts never collide — items interleave deterministically without shifting indices.

Constructor:

import { LiveList } from "@waits/lively-storage";
const list = new LiveList(["a", "b", "c"]);

push(item) — append to end:

list.push("d");
list.toArray(); // ["a", "b", "c", "d"]

insert(item, index) — insert at position:

list.insert("x", 1);
list.toArray(); // ["a", "x", "b", "c", "d"]

delete(index) — remove by index:

list.delete(1); // removes "x"

move(from, to) — reorder an item:

list.move(0, 2); // move first item to index 2

get(index) / length:

list.get(0); // first item
list.length; // number of items

toArray() — snapshot as plain array. toImmutable() — frozen readonly T[]:

const arr = list.toArray();
const frozen = list.toImmutable();
// Object.isFrozen(frozen) === true

Subscribe to list changes:

// Via StorageDocument — works for all CRDT types
doc.subscribe(list, () => {
console.log("list changed", list.toArray());
});

HistoryManager

Built-in undo/redo with automatic inverse-op computation. Every mutation on a StorageDocument is recorded. Group related mutations into a single undo step with batch().

Access from the document:

const history = doc.getHistory();

undo() / redo():

history.undo(); // returns inverse ops or null
history.redo(); // returns forward ops or null

canUndo() / canRedo():

history.canUndo(); // boolean
history.canRedo(); // boolean

startBatch() / endBatch() — group mutations into one undo entry:

history.startBatch();
root.set("x", 10);
root.set("y", 20);
history.endBatch();
// Single undo() reverts both x and y
history.undo();

subscribe(cb) — listen for undo/redo stack changes:

const unsub = history.subscribe(() => {
console.log(
"can undo:", history.canUndo(),
"can redo:", history.canRedo()
);
});

Constructor config:

new StorageDocument(root, {
maxEntries: 50, // default: 100
enabled: true, // default: true
});

Utilities

generateKeyBetween(a, b) — fractional indexing. Returns a string key that sorts between a and b. Pass null for start/end of list:

import { generateKeyBetween } from "@waits/lively-storage";
const first = generateKeyBetween(null, null); // "V"
const after = generateKeyBetween(first, null); // sorts after first
const between = generateKeyBetween(first, after); // sorts between

generateNKeysBetween(a, b, n) — generate n evenly-spaced keys:

import { generateNKeysBetween } from "@waits/lively-storage";
const keys = generateNKeysBetween(null, null, 5);
// 5 keys in sorted order

computeInverseOp(op) — compute the inverse of a storage op (used internally by HistoryManager):

import { computeInverseOp } from "@waits/lively-storage";
const inverse = computeInverseOp(op);

LamportClock — logical clock for causal ordering:

import { LamportClock } from "@waits/lively-storage";
const clock = new LamportClock();
clock.tick(); // increments and returns new value
clock.merge(remoteClock); // max(local, remote) + 1

Types

Exported TypeScript types for advanced usage.

import type {
StorageDocumentHost, // interface for doc host
HistoryEntry, // { forward: Op[], inverse: Op[] }
HistoryConfig, // { maxEntries?, enabled? }
FieldSnapshot, // snapshot for inverse computation
} from "@waits/lively-storage";

Next Steps