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
@@ -0,0 +1,61 @@
import { describe, expect, it } from 'vitest';
import type { OpId } from '../../clock';
import { Rga } from '../../sequence';
import { Replica } from '..';
interface CharOp {
id: OpId;
originLeft: OpId | null;
value: string;
}
function makeReplica(site: string) {
const rga = new Rga<string>();
const replica = new Replica<CharOp>(
{ integrate: op => rga.integrateInsert(op.id, op.value, op.originLeft) },
site,
);
return { rga, replica };
}
function type(peer: ReturnType<typeof makeReplica>, text: string): void {
let left: OpId | null = null;
for (const ch of text) {
const id = peer.replica.nextId();
peer.replica.commitLocal({ id, originLeft: left, value: ch });
left = id;
}
}
describe('replica', () => {
it('two replicas converge after exchanging deltas', () => {
const a = makeReplica('a');
const b = makeReplica('b');
type(a, 'Hi'); // concurrent edits
type(b, 'Yo');
// Exchange only what each side is missing (delta by version vector).
b.replica.receive(a.replica.delta(b.replica.version));
a.replica.receive(b.replica.delta(a.replica.version));
expect(a.rga.toArray().join('')).toBe(b.rga.toArray().join(''));
expect(a.rga.length).toBe(4);
});
it('buffers a remote op until its causal dependency arrives, then applies both', () => {
const a = makeReplica('a');
type(a, 'ab'); // two ops; the 2nd depends on the 1st
const b = makeReplica('b');
const [op1, op2] = a.replica.delta(b.replica.version) as CharOp[];
// Deliver the dependent op first — it must buffer.
b.replica.receive([op2!]);
expect(b.rga.toArray().join('')).toBe('');
// Now deliver the dependency — both integrate.
b.replica.receive([op1!]);
expect(b.rga.toArray().join('')).toBe('ab');
});
});
+1
View File
@@ -0,0 +1 @@
export * from './replica';
+102
View File
@@ -0,0 +1,102 @@
import type { OpId, SiteId, VersionVector } from '../clock';
import { LamportClock, createSiteId, opIdEq } from '../clock';
import type { HasOpId } from '../oplog';
import { OpLog } from '../oplog';
export interface ReplicaHandlers<Op extends HasOpId> {
/**
* Apply an op to domain state (RGA, marks, block list, …). Return `false` if
* its causal dependencies aren't present yet; the replica buffers and retries.
*/
integrate: (op: Op) => boolean;
}
export type UpdateListener<Op> = (ops: readonly Op[], origin: unknown) => void;
/**
* Generic op-based CRDT replica: owns a Lamport clock + op log, integrates local
* and remote ops (with causal buffering and dedup), and exposes deltas for
* transport-agnostic sync. The domain state lives behind {@link ReplicaHandlers}.
*/
export class Replica<Op extends HasOpId> {
readonly site: SiteId;
private readonly clock: LamportClock;
private readonly log = new OpLog<Op>();
private readonly pending: Op[] = [];
private readonly listeners = new Set<UpdateListener<Op>>();
constructor(private readonly handlers: ReplicaHandlers<Op>, site: SiteId = createSiteId()) {
this.site = site;
this.clock = new LamportClock(site);
}
/** Next op id for a locally-generated operation. */
nextId(): OpId {
return this.clock.tick();
}
get version(): VersionVector {
return this.log.version;
}
/** Integrate + log a local op, then notify listeners (origin `'local'`). */
commitLocal(op: Op): void {
if (!this.log.append(op))
return;
this.handlers.integrate(op);
this.emit([op], 'local');
}
/**
* Receive remote ops: dedup, buffer until causally ready, integrate, log, and
* notify with the ops actually applied. Returns the applied ops (in apply order).
*/
receive(ops: readonly Op[], origin: unknown = 'remote'): Op[] {
for (const op of ops) {
this.clock.observe(op.id);
if (!this.log.has(op.id) && !this.pending.some(p => opIdEq(p.id, op.id)))
this.pending.push(op);
}
const applied = this.drain();
if (applied.length > 0)
this.emit(applied, origin);
return applied;
}
private drain(): Op[] {
const applied: Op[] = [];
let progressed = true;
while (this.pending.length > 0 && progressed) {
progressed = false;
for (let i = this.pending.length - 1; i >= 0; i--) {
const op = this.pending[i]!;
if (this.handlers.integrate(op)) {
this.log.append(op);
this.pending.splice(i, 1);
applied.push(op);
progressed = true;
}
}
}
return applied;
}
/** Ops a remote replica (described by its version vector) is missing. */
delta(remote: VersionVector): Op[] {
return this.log.delta(remote);
}
/** Subscribe to applied ops (local + remote). Returns an unsubscribe fn. */
onUpdate(listener: UpdateListener<Op>): () => void {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
}
private emit(ops: readonly Op[], origin: unknown): void {
for (const listener of this.listeners)
listener(ops, origin);
}
}