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,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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from './id';
|
||||
export * from './lamport';
|
||||
export * from './version-vector';
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user