feat(crdt): add @robonen/crdt package

Hand-built CRDT primitives: Lamport clock + version vectors, op-log,
LWW register/map, RGA sequence, fractional indexing, marks store, sync
encode, and doc/replica. Includes eslint flat config + composite tsconfig.
This commit is contained in:
2026-06-07 16:28:58 +07:00
parent 70a8678743
commit 008d85a8fd
35 changed files with 1152 additions and 0 deletions
+111
View File
@@ -0,0 +1,111 @@
import type { OpId, VersionVector } from '../clock';
import { compareOpId, opIdEq } from '../clock';
/** One element of an RGA sequence (visible or tombstoned). */
export interface RgaNode<T> {
readonly id: OpId;
readonly value: T;
readonly originLeft: OpId | null;
deleted: boolean;
}
/**
* Replicated Growable Array — a sequence CRDT. Each element is inserted after a
* left-origin element (or at the start) and tombstoned on delete. Concurrent
* inserts at the same origin are ordered higher-op-id-first, a deterministic
* tie-break that makes every replica converge to the same order. Operations must
* be integrated in causal order (an insert's origin must already be present);
* {@link integrateInsert} returns `false` when the origin is missing so the
* caller can buffer and retry.
*/
export class Rga<T> {
private nodes: Array<RgaNode<T>> = [];
private nodeIndex(id: OpId): number {
for (let i = 0; i < this.nodes.length; i++) {
if (opIdEq(this.nodes[i]!.id, id))
return i;
}
return -1;
}
has(id: OpId): boolean {
return this.nodeIndex(id) !== -1;
}
/** Integrate an insert after `originLeft` (`null` = start). Idempotent. */
integrateInsert(id: OpId, value: T, originLeft: OpId | null): boolean {
if (this.has(id))
return true;
const originIndex = originLeft === null ? -1 : this.nodeIndex(originLeft);
if (originLeft !== null && originIndex === -1)
return false; // origin not present yet — caller should buffer
let i = originIndex + 1;
while (i < this.nodes.length && compareOpId(this.nodes[i]!.id, id) > 0)
i += 1;
this.nodes.splice(i, 0, { id, value, originLeft, deleted: false });
return true;
}
/** Tombstone an element. Idempotent; returns false if the element is unknown. */
integrateDelete(id: OpId): boolean {
const index = this.nodeIndex(id);
if (index === -1)
return false;
this.nodes[index]!.deleted = true;
return true;
}
/**
* Drop tombstoned nodes whose insert is covered by `stable`. Call ONLY at
* quiescence — when every replica has fully synced and no operations are in
* flight — otherwise a late op that uses a dropped node as its origin can no
* longer integrate. `keep` protects ids still referenced elsewhere (e.g. mark
* span endpoints). Returns the number of nodes removed.
*/
gc(stable: VersionVector, keep?: (id: OpId) => boolean): number {
const before = this.nodes.length;
this.nodes = this.nodes.filter(node =>
!node.deleted || !stable.has(node.id) || (keep?.(node.id) ?? false));
return before - this.nodes.length;
}
/** Visible values in document order. */
toArray(): T[] {
const out: T[] = [];
for (const node of this.nodes) {
if (!node.deleted)
out.push(node.value);
}
return out;
}
/** Visible nodes in document order (read ids for cursor anchoring). */
visible(): Array<RgaNode<T>> {
return this.nodes.filter(node => !node.deleted);
}
/** All nodes including tombstones (for state encoding). */
all(): ReadonlyArray<RgaNode<T>> {
return this.nodes;
}
/** Op id of the visible element at `index`, or `null` if out of range. */
idAt(index: number): OpId | null {
return this.visible()[index]?.id ?? null;
}
/** Number of visible elements. */
get length(): number {
let count = 0;
for (const node of this.nodes) {
if (!node.deleted)
count += 1;
}
return count;
}
}