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
+127
View File
@@ -0,0 +1,127 @@
import { describe, expect, it } from 'vitest';
import type { OpId } from '../../clock';
import { VersionVector, opId, opIdEq } from '../../clock';
import { Rga } from '..';
type Op
= | { kind: 'insert'; id: OpId; value: string; originLeft: OpId | null }
| { kind: 'delete'; id: OpId };
/** Apply ops in the given order, buffering and retrying any whose causal deps aren't met. */
function applyAll(rga: Rga<string>, ops: readonly Op[]): void {
const pending = [...ops];
let progressed = true;
while (pending.length > 0 && progressed) {
progressed = false;
for (let i = pending.length - 1; i >= 0; i--) {
const op = pending[i]!;
const applied = op.kind === 'insert'
? rga.integrateInsert(op.id, op.value, op.originLeft)
: rga.integrateDelete(op.id);
if (applied) {
pending.splice(i, 1);
progressed = true;
}
}
}
}
function seeded(seed: number): () => number {
let state = seed >>> 0;
return () => {
state = (state * 1664525 + 1013904223) >>> 0;
return state / 0xFFFFFFFF;
};
}
function shuffle<T>(items: readonly T[], rng: () => number): T[] {
const out = [...items];
for (let i = out.length - 1; i > 0; i--) {
const j = Math.floor(rng() * (i + 1));
[out[i], out[j]] = [out[j]!, out[i]!];
}
return out;
}
describe('rga', () => {
it('orders concurrent inserts at the same origin higher-op-id-first', () => {
const opA: Op = { kind: 'insert', id: opId('a', 1), value: 'X', originLeft: null };
const opB: Op = { kind: 'insert', id: opId('b', 1), value: 'Y', originLeft: null };
const first = new Rga<string>();
applyAll(first, [opA, opB]);
const second = new Rga<string>();
applyAll(second, [opB, opA]);
expect(first.toArray()).toEqual(['Y', 'X']); // site 'b' > 'a' at equal clock
expect(second.toArray()).toEqual(first.toArray());
});
it('is idempotent under re-applied ops', () => {
const rga = new Rga<string>();
const op: Op = { kind: 'insert', id: opId('a', 1), value: 'X', originLeft: null };
applyAll(rga, [op, op, op]);
expect(rga.toArray()).toEqual(['X']);
});
it('converges across two replicas for random concurrent ops + deletes', () => {
for (let trial = 0; trial < 25; trial++) {
const rng = seeded(trial + 1);
const sites = ['a', 'b', 'c'];
const counters: Record<string, number> = { a: 0, b: 0, c: 0 };
const inserted: Array<{ id: OpId; value: string }> = [];
const ops: Op[] = [];
for (let i = 0; i < 40; i++) {
const site = sites[Math.floor(rng() * sites.length)]!;
const makeDelete = inserted.length > 0 && rng() < 0.25;
if (makeDelete) {
const target = inserted[Math.floor(rng() * inserted.length)]!;
ops.push({ kind: 'delete', id: target.id });
}
else {
counters[site] = counters[site]! + 1;
const id = opId(site, counters[site]!);
const originLeft = inserted.length > 0 && rng() < 0.8
? inserted[Math.floor(rng() * inserted.length)]!.id
: null;
ops.push({ kind: 'insert', id, value: `${site}${counters[site]}`, originLeft });
inserted.push({ id, value: `${site}${counters[site]}` });
}
}
const replicaOrder = new Rga<string>();
applyAll(replicaOrder, ops);
const replicaShuffled = new Rga<string>();
applyAll(replicaShuffled, shuffle(ops, rng));
expect(replicaShuffled.toArray()).toEqual(replicaOrder.toArray());
}
});
it('gc drops stable tombstones, keeps live/protected/unstable ones', () => {
const rga = new Rga<string>();
applyAll(rga, [
{ kind: 'insert', id: opId('a', 1), value: 'a', originLeft: null },
{ kind: 'insert', id: opId('a', 2), value: 'b', originLeft: opId('a', 1) },
{ kind: 'insert', id: opId('a', 3), value: 'c', originLeft: opId('a', 2) },
]);
rga.integrateDelete(opId('a', 2)); // tombstone 'b'
rga.integrateDelete(opId('a', 3)); // tombstone 'c'
expect(rga.toArray().join('')).toBe('a');
const stable = new VersionVector();
stable.observe(opId('a', 2)); // only ops up to a@2 are stable everywhere
// a@2 tombstone is dropped; a@3 is unstable (kept); 'c' protected via keep.
const removed = rga.gc(stable, id => opIdEq(id, opId('a', 3)));
expect(removed).toBe(1);
expect(rga.has(opId('a', 2))).toBe(false);
expect(rga.has(opId('a', 3))).toBe(true);
expect(rga.has(opId('a', 1))).toBe(true); // live, never dropped
expect(rga.toArray().join('')).toBe('a');
});
});
+1
View File
@@ -0,0 +1 @@
export * from './rga';
+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;
}
}