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:
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
export * from './replica';
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user