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,39 @@
import { describe, expect, it } from 'vitest';
import { LamportClock, VersionVector, compareOpId, opId, opIdEq } from '..';
describe('compareOpId', () => {
it('orders by clock, then by site id', () => {
expect(compareOpId(opId('a', 1), opId('a', 2))).toBeLessThan(0);
expect(compareOpId(opId('a', 2), opId('b', 2))).toBeLessThan(0);
expect(compareOpId(opId('b', 2), opId('a', 2))).toBeGreaterThan(0);
expect(compareOpId(opId('a', 2), opId('a', 2))).toBe(0);
expect(opIdEq(opId('a', 1), opId('a', 1))).toBe(true);
});
});
describe('lamportClock', () => {
it('ticks monotonically and advances past observed remote ops', () => {
const clock = new LamportClock('a');
expect(clock.tick()).toEqual({ site: 'a', clock: 1 });
expect(clock.tick()).toEqual({ site: 'a', clock: 2 });
clock.observe({ site: 'b', clock: 5 });
expect(clock.tick().clock).toBe(6);
});
});
describe('versionVector', () => {
it('tracks seen ops and round-trips through JSON', () => {
const vv = new VersionVector();
vv.observe(opId('a', 3));
vv.observe(opId('b', 1));
expect(vv.has(opId('a', 2))).toBe(true);
expect(vv.has(opId('a', 3))).toBe(true);
expect(vv.has(opId('a', 4))).toBe(false);
expect(vv.has(opId('c', 1))).toBe(false);
const restored = VersionVector.fromJSON(vv.toJSON());
expect(restored.get('a')).toBe(3);
expect(restored.get('b')).toBe(1);
});
});
+35
View File
@@ -0,0 +1,35 @@
/** A replica identifier — unique per editing site/session. */
export type SiteId = string;
/** A globally-unique operation id: a per-site Lamport counter tagged with the site. */
export interface OpId {
readonly site: SiteId;
readonly clock: number;
}
export function opId(site: SiteId, clock: number): OpId {
return { site, clock };
}
export function opIdEq(a: OpId, b: OpId): boolean {
return a.clock === b.clock && a.site === b.site;
}
/**
* Total order over op ids: higher clock wins; ties broken by site id. This is
* the deterministic tie-break every replica agrees on, so LWW and RGA converge.
*/
export function compareOpId(a: OpId, b: OpId): number {
if (a.clock !== b.clock)
return a.clock - b.clock;
return a.site < b.site ? -1 : a.site > b.site ? 1 : 0;
}
export function opIdToString(id: OpId): string {
return `${id.site}@${id.clock}`;
}
/** Generate a random site id (no crypto dependency; uniqueness, not secrecy). */
export function createSiteId(): SiteId {
return Math.random().toString(36).slice(2, 10) + Math.random().toString(36).slice(2, 6);
}
+3
View File
@@ -0,0 +1,3 @@
export * from './id';
export * from './lamport';
export * from './version-vector';
+29
View File
@@ -0,0 +1,29 @@
import type { OpId, SiteId } from './id';
/**
* A Lamport clock for one site: hands out monotonically increasing op ids and
* advances past observed remote ops so locally-generated ids stay causally later.
*/
export class LamportClock {
private counter: number;
constructor(public readonly site: SiteId, start = 0) {
this.counter = start;
}
/** Generate the next op id for a local operation. */
tick(): OpId {
this.counter += 1;
return { site: this.site, clock: this.counter };
}
/** Advance past a remote op so future local ticks are causally after it. */
observe(id: OpId): void {
if (id.clock > this.counter)
this.counter = id.clock;
}
get value(): number {
return this.counter;
}
}
+41
View File
@@ -0,0 +1,41 @@
import type { OpId, SiteId } from './id';
/**
* Tracks the highest clock seen per site, assuming each site emits dense clocks
* (1, 2, 3, …). Used to deduplicate ops and to compute deltas during sync.
*/
export class VersionVector {
private readonly clocks = new Map<SiteId, number>();
/** Record that an op has been seen. */
observe(id: OpId): void {
if (id.clock > this.get(id.site))
this.clocks.set(id.site, id.clock);
}
/** Highest clock seen for a site (0 if none). */
get(site: SiteId): number {
return this.clocks.get(site) ?? 0;
}
/** Whether an op id has already been seen. */
has(id: OpId): boolean {
return this.get(id.site) >= id.clock;
}
/** Plain-object snapshot for transport. */
toJSON(): Record<SiteId, number> {
return Object.fromEntries(this.clocks);
}
static fromJSON(snapshot: Record<SiteId, number>): VersionVector {
const vv = new VersionVector();
for (const site in snapshot)
vv.clocks.set(site, snapshot[site]!);
return vv;
}
clone(): VersionVector {
return VersionVector.fromJSON(this.toJSON());
}
}