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
+60
View File
@@ -0,0 +1,60 @@
# @robonen/crdt
Framework-agnostic CRDT primitives — the convergence engine behind `@robonen/editor`, usable on their own. Zero runtime dependencies; pure TypeScript; runs in Node and the browser.
Every primitive is built so that **applying the same set of operations in any order, with duplicates, yields the same state** (commutative, idempotent, convergent), verified by property tests.
## Primitives
| Module | Exports | Purpose |
| --- | --- | --- |
| `clock` | `OpId`, `LamportClock`, `VersionVector`, `compareOpId`, `createSiteId` | Causality: per-site Lamport ids with a deterministic total order; version vectors for dedup + deltas. |
| `registers` | `LwwRegister`, `LwwMap` | Last-writer-wins values / maps (conflict resolved by op id). |
| `ordering` | `keyBetween`, `keysBetween` | Fractional indexing — place an item strictly between two neighbors (or move it) with one string key. |
| `sequence` | `Rga` | Replicated Growable Array — a sequence CRDT with tombstones, higher-op-id-first tie-break, causal-buffering API. |
| `marks` | `MarkStore`, `MarkSpan`, `MarkValue` | Lightweight Peritext: formatting spans anchored to character op ids, resolved per character by highest op id. |
| `oplog` | `OpLog` | Append-only op log with a version vector; computes deltas. |
| `sync` | `encodeStateVector`, `encodeDelta`/`encodeOps`, `decode*` | Transport-agnostic wire encoding (JSON-over-bytes in v1). |
| `doc` | `Replica` | Ties a clock + op log + causal buffer together; integrates local/remote ops and exposes deltas. |
## Example: a converging replicated string
```ts
import { Replica, Rga, opId } from '@robonen/crdt';
function makeReplica(site: string) {
const rga = new Rga<string>();
const replica = new Replica<{ id: ReturnType<typeof opId>; originLeft: ReturnType<typeof opId> | null; value: string }>(
{ integrate: op => rga.integrateInsert(op.id, op.value, op.originLeft) },
site,
);
return { rga, replica };
}
const a = makeReplica('a');
const b = makeReplica('b');
// ... A and B make concurrent local edits via replica.commitLocal(...) ...
// Exchange only what each side is missing:
b.replica.receive(a.replica.delta(b.replica.version));
a.replica.receive(b.replica.delta(a.replica.version));
a.rga.toArray().join('') === b.rga.toArray().join(''); // true — converged
```
`Replica.receive` buffers ops whose causal dependencies haven't arrived yet (an insert before its origin, a delete before its target) and retries them automatically.
## Notes
- `compareOpId` is the single deterministic tie-break (higher clock wins; site id breaks ties) every primitive agrees on — that's what makes LWW and RGA converge.
- `VersionVector` assumes **dense** per-site clocks (1, 2, 3, …).
- The v1 wire format is JSON encoded to bytes — simple and debuggable; a compact varint format is a later optimization with no API change.
- An editor-specific composition of these primitives (blocks + text + marks ↔ editor steps) lives in `@robonen/editor` under `crdt/native/`, not here — this package stays domain-agnostic.
## Development
```bash
pnpm --filter @robonen/crdt test # property/convergence tests
pnpm --filter @robonen/crdt build # tsdown (ESM + CJS + dts)
```
+3
View File
@@ -0,0 +1,3 @@
import { base, compose, imports, stylistic, typescript } from '@robonen/eslint';
export default compose(base, typescript, imports, stylistic);
+54
View File
@@ -0,0 +1,54 @@
{
"name": "@robonen/crdt",
"version": "0.0.1",
"license": "Apache-2.0",
"description": "Framework-agnostic CRDT primitives: RGA sequence, LWW registers, fractional indexing, version vectors",
"keywords": [
"crdt",
"rga",
"lww",
"collaborative",
"fractional-indexing",
"tools"
],
"author": "Robonen Andrew <robonenandrew@gmail.com>",
"repository": {
"type": "git",
"url": "git+https://github.com/robonen/tools.git",
"directory": "core/crdt"
},
"packageManager": "pnpm@10.30.3",
"engines": {
"node": ">=24.13.1"
},
"type": "module",
"files": [
"dist"
],
"exports": {
".": {
"import": {
"types": "./dist/index.d.mts",
"default": "./dist/index.mjs"
},
"require": {
"types": "./dist/index.d.cts",
"default": "./dist/index.cjs"
}
}
},
"scripts": {
"lint:check": "eslint .",
"lint:fix": "eslint . --fix",
"test": "vitest run",
"dev": "vitest dev",
"build": "tsdown"
},
"devDependencies": {
"@robonen/eslint": "workspace:*",
"@robonen/tsconfig": "workspace:*",
"@robonen/tsdown": "workspace:*",
"eslint": "catalog:",
"tsdown": "catalog:"
}
}
@@ -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());
}
}
@@ -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);
}
}
+8
View File
@@ -0,0 +1,8 @@
export * from './clock';
export * from './registers';
export * from './ordering';
export * from './sequence';
export * from './marks';
export * from './oplog';
export * from './sync';
export * from './doc';
@@ -0,0 +1,33 @@
import { describe, expect, it } from 'vitest';
import { opId } from '../../clock';
import { MarkStore } from '..';
describe('markStore', () => {
it('resolves overlapping spans by highest op id per character/type', () => {
const chars = [opId('a', 1), opId('a', 2), opId('a', 3)];
const store = new MarkStore();
store.add({ id: opId('a', 10), type: 'bold', value: true, start: chars[0]!, end: chars[2]! });
store.add({ id: opId('a', 11), type: 'bold', value: null, start: chars[1]!, end: chars[1]! });
const active = store.resolve(chars);
expect(active[0]!.get('bold')).toBe(true);
expect(active[1]!.has('bold')).toBe(false); // cleared by the higher-id span
expect(active[2]!.get('bold')).toBe(true);
});
it('converges regardless of span insertion order', () => {
const chars = [opId('a', 1), opId('a', 2)];
const spanA = { id: opId('a', 10), type: 'bold', value: true, start: chars[0]!, end: chars[1]! };
const spanB = { id: opId('b', 10), type: 'bold', value: null, start: chars[0]!, end: chars[0]! };
const first = new MarkStore();
first.add(spanA);
first.add(spanB);
const second = new MarkStore();
second.add(spanB);
second.add(spanA);
expect(first.resolve(chars).map(m => m.get('bold')))
.toEqual(second.resolve(chars).map(m => m.get('bold')));
});
});
+1
View File
@@ -0,0 +1 @@
export * from './mark-store';
+78
View File
@@ -0,0 +1,78 @@
import type { OpId } from '../clock';
import { compareOpId, opIdEq, opIdToString } from '../clock';
/** A mark's value: `true`/attrs to apply, `null`/`false` to clear. JSON-serializable. */
export type MarkValue = boolean | string | number | null | { readonly [key: string]: MarkValue };
/**
* A formatting span anchored to character op ids (inclusive range), tagged with
* an op id for LWW conflict resolution — a lightweight Peritext mark.
*/
export interface MarkSpan {
readonly id: OpId;
readonly type: string;
readonly value: MarkValue;
readonly start: OpId;
readonly end: OpId;
}
/**
* Stores formatting spans and resolves them against a character order. For each
* (character, mark type) the covering span with the highest op id wins, so
* concurrent formatting converges; a `null`/`false` value clears the mark.
*/
export class MarkStore {
private spans: MarkSpan[] = [];
add(span: MarkSpan): boolean {
if (this.has(span.id))
return false;
this.spans.push(span);
return true;
}
has(id: OpId): boolean {
return this.spans.some(span => opIdEq(span.id, id));
}
all(): readonly MarkSpan[] {
return this.spans;
}
/**
* Active marks for each character, given the character ids in document order.
* Returns one `type → value` map per index.
*/
resolve(order: readonly OpId[]): Array<Map<string, MarkValue>> {
const indexOf = new Map<string, number>();
order.forEach((id, i) => indexOf.set(opIdToString(id), i));
const active: Array<Map<string, MarkValue>> = order.map(() => new Map());
const winner: Array<Map<string, OpId>> = order.map(() => new Map());
for (const span of this.spans) {
const startIndex = indexOf.get(opIdToString(span.start));
const endIndex = indexOf.get(opIdToString(span.end));
if (startIndex === undefined || endIndex === undefined)
continue;
const lo = Math.min(startIndex, endIndex);
const hi = Math.max(startIndex, endIndex);
for (let i = lo; i <= hi; i++) {
const current = winner[i]!.get(span.type);
if (current && compareOpId(span.id, current) <= 0)
continue;
winner[i]!.set(span.type, span.id);
if (span.value === null || span.value === false)
active[i]!.delete(span.type);
else
active[i]!.set(span.type, span.value);
}
}
return active;
}
}
+1
View File
@@ -0,0 +1 @@
export * from './op-log';
+43
View File
@@ -0,0 +1,43 @@
import type { OpId } from '../clock';
import { VersionVector } from '../clock';
/** Anything carrying an op id can live in the log. */
export interface HasOpId {
readonly id: OpId;
}
/**
* An append-only log of operations with a version vector for deduplication and
* delta computation. The op shape is domain-specific; the log only reads `id`.
*/
export class OpLog<Op extends HasOpId> {
private readonly ops: Op[] = [];
private readonly vv = new VersionVector();
/** Append an op unless already seen. Returns `true` if appended. */
append(op: Op): boolean {
if (this.vv.has(op.id))
return false;
this.ops.push(op);
this.vv.observe(op.id);
return true;
}
has(id: OpId): boolean {
return this.vv.has(id);
}
get version(): VersionVector {
return this.vv;
}
all(): readonly Op[] {
return this.ops;
}
/** Ops a remote replica (described by its version vector) hasn't seen. */
delta(remote: VersionVector): Op[] {
return this.ops.filter(op => !remote.has(op.id));
}
}
@@ -0,0 +1,47 @@
import { describe, expect, it } from 'vitest';
import { keyBetween, keysBetween } from '..';
function seeded(seed: number): () => number {
let state = seed >>> 0;
return () => {
state = (state * 1664525 + 1013904223) >>> 0;
return state / 0xFFFFFFFF;
};
}
describe('keyBetween', () => {
it('produces a key strictly between its bounds', () => {
const a = keyBetween(null, null);
const b = keyBetween(a, null);
expect(b > a).toBe(true);
const mid = keyBetween(a, b);
expect(mid > a && mid < b).toBe(true);
});
it('rejects an inverted range', () => {
expect(() => keyBetween('b', 'a')).toThrow();
});
it('keysBetween returns n ascending keys within the bounds', () => {
const keys = keysBetween('a', 'b', 5);
expect(keys).toHaveLength(5);
for (let i = 1; i < keys.length; i++)
expect(keys[i - 1]! < keys[i]!).toBe(true);
expect(keys[0]! > 'a' && keys[keys.length - 1]! < 'b').toBe(true);
});
it('stays strictly ordered under 300 random insertions', () => {
const rng = seeded(7);
const keys: string[] = [keyBetween(null, null)];
for (let i = 0; i < 300; i++) {
const pos = Math.floor(rng() * (keys.length + 1));
const lower = pos > 0 ? keys[pos - 1]! : null;
const upper = pos < keys.length ? keys[pos]! : null;
keys.splice(pos, 0, keyBetween(lower, upper));
}
for (let i = 1; i < keys.length; i++)
expect(keys[i - 1]! < keys[i]!).toBe(true);
});
});
@@ -0,0 +1,54 @@
/**
* Fractional indexing: generate string "order keys" so an element can be placed
* strictly between two neighbors with a single key, and re-ordered (moved) by
* just changing its key — without touching anything else. The digit alphabet is
* ASCII-ascending, so JavaScript string comparison matches digit order.
*/
const DIGITS = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz';
function midpoint(a: string, b: string): string {
if (b !== '' && a >= b)
throw new Error(`fractional-index: lower '${a}' must be < upper '${b}'`);
let result = '';
let i = 0;
let upper = b;
for (;;) {
const x = i < a.length ? DIGITS.indexOf(a[i]!) : 0;
const y = upper !== '' && i < upper.length ? DIGITS.indexOf(upper[i]!) : DIGITS.length;
if (x === y) {
result += DIGITS[x]!;
i += 1;
continue;
}
const mid = x + Math.floor((y - x) / 2);
if (mid !== x)
return result + DIGITS[mid]!;
// Digits are adjacent — keep the lower digit and open the upper bound.
result += DIGITS[x]!;
i += 1;
upper = '';
}
}
/** A key strictly between `lower` and `upper` (`null` = open bound). */
export function keyBetween(lower: string | null, upper: string | null): string {
return midpoint(lower ?? '', upper ?? '');
}
/** `n` keys strictly between `lower` and `upper`, in ascending order. */
export function keysBetween(lower: string | null, upper: string | null, n: number): string[] {
if (n <= 0)
return [];
if (n === 1)
return [keyBetween(lower, upper)];
const mid = keyBetween(lower, upper);
const left = keysBetween(lower, mid, Math.floor((n - 1) / 2));
const right = keysBetween(mid, upper, n - 1 - left.length);
return [...left, mid, ...right];
}
+1
View File
@@ -0,0 +1 @@
export * from './fractional-index';
@@ -0,0 +1,40 @@
import { describe, expect, it } from 'vitest';
import { opId } from '../../clock';
import { LwwMap, LwwRegister } from '..';
describe('lwwRegister', () => {
it('keeps the write with the higher op id regardless of order', () => {
const a = new LwwRegister('init');
a.set('first', opId('a', 1));
a.set('second', opId('b', 2));
expect(a.get()).toBe('second');
// A later-arriving but older write must not win.
expect(a.set('stale', opId('a', 1))).toBe(false);
expect(a.get()).toBe('second');
});
it('converges when concurrent writes arrive in opposite orders', () => {
const left = new LwwRegister('x');
const right = new LwwRegister('x');
left.set('A', opId('a', 5));
left.set('B', opId('b', 5));
right.set('B', opId('b', 5));
right.set('A', opId('a', 5));
expect(left.get()).toBe(right.get());
expect(left.get()).toBe('B'); // site 'b' > 'a' at equal clock
});
});
describe('lwwMap', () => {
it('handles set/delete with tombstones', () => {
const map = new LwwMap<string, number>();
map.set('k', 1, opId('a', 1));
expect(map.get('k')).toBe(1);
map.delete('k', opId('a', 2));
expect(map.has('k')).toBe(false);
// A concurrent older set loses to the delete.
expect(map.set('k', 9, opId('a', 1))).toBe(false);
expect(map.has('k')).toBe(false);
});
});
+2
View File
@@ -0,0 +1,2 @@
export * from './lww-register';
export * from './lww-map';
+52
View File
@@ -0,0 +1,52 @@
import type { OpId } from '../clock';
import { compareOpId } from '../clock';
interface Entry<V> {
value: V;
ts: OpId;
deleted: boolean;
}
/**
* Last-writer-wins map with per-key timestamps and tombstones. Concurrent
* set/delete on a key converge to the operation with the higher op id.
*/
export class LwwMap<K, V> {
private readonly entries = new Map<K, Entry<V>>();
set(key: K, value: V, id: OpId): boolean {
const existing = this.entries.get(key);
if (existing && compareOpId(id, existing.ts) <= 0)
return false;
this.entries.set(key, { value, ts: id, deleted: false });
return true;
}
delete(key: K, id: OpId): boolean {
const existing = this.entries.get(key);
if (existing && compareOpId(id, existing.ts) <= 0)
return false;
this.entries.set(key, { value: existing?.value as V, ts: id, deleted: true });
return true;
}
get(key: K): V | undefined {
const entry = this.entries.get(key);
return entry && !entry.deleted ? entry.value : undefined;
}
has(key: K): boolean {
const entry = this.entries.get(key);
return entry !== undefined && !entry.deleted;
}
keys(): K[] {
return [...this.entries].filter(([, entry]) => !entry.deleted).map(([key]) => key);
}
toEntries(): Array<[K, V]> {
return [...this.entries].filter(([, entry]) => !entry.deleted).map(([key, entry]) => [key, entry.value]);
}
}
+31
View File
@@ -0,0 +1,31 @@
import type { OpId } from '../clock';
import { compareOpId } from '../clock';
/**
* Last-writer-wins register. A write applies only if its op id is later than the
* current write's (by {@link compareOpId}), so concurrent writes converge to the
* one with the higher timestamp regardless of arrival order.
*/
export class LwwRegister<T> {
private ts: OpId | null = null;
constructor(private current: T) {}
get(): T {
return this.current;
}
/** Apply a timestamped write. Returns `true` if it won. */
set(value: T, id: OpId): boolean {
if (this.ts !== null && compareOpId(id, this.ts) <= 0)
return false;
this.current = value;
this.ts = id;
return true;
}
get timestamp(): OpId | null {
return this.ts;
}
}
+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;
}
}
@@ -0,0 +1,20 @@
import { describe, expect, it } from 'vitest';
import { VersionVector, opId } from '../../clock';
import { decodeOps, decodeStateVector, encodeOps, encodeStateVector } from '..';
describe('sync encoding', () => {
it('round-trips a version vector through bytes', () => {
const vv = new VersionVector();
vv.observe(opId('a', 3));
vv.observe(opId('b', 1));
const restored = decodeStateVector(encodeStateVector(vv));
expect(restored.get('a')).toBe(3);
expect(restored.get('b')).toBe(1);
});
it('round-trips an op batch through bytes', () => {
const ops = [{ id: opId('a', 1), kind: 'insert', value: 'x' }];
expect(decodeOps(encodeOps(ops))).toEqual(ops);
});
});
+34
View File
@@ -0,0 +1,34 @@
import { VersionVector } from '../clock';
const encoder = new TextEncoder();
const decoder = new TextDecoder();
/**
* Transport-agnostic wire encoding. v1 is JSON-over-bytes — simple and
* debuggable; a compact varint format is a later optimization with no API change.
*/
export function encodeJson(value: unknown): Uint8Array {
return encoder.encode(JSON.stringify(value));
}
export function decodeJson<T>(bytes: Uint8Array): T {
return JSON.parse(decoder.decode(bytes)) as T;
}
/** Encode a version vector for a "what do you have?" sync handshake. */
export function encodeStateVector(vv: VersionVector): Uint8Array {
return encodeJson(vv.toJSON());
}
export function decodeStateVector(bytes: Uint8Array): VersionVector {
return VersionVector.fromJSON(decodeJson(bytes));
}
/** Encode a batch of ops (the delta or a full snapshot). */
export function encodeOps<Op>(ops: readonly Op[]): Uint8Array {
return encodeJson(ops);
}
export function decodeOps<Op>(bytes: Uint8Array): Op[] {
return decodeJson<Op[]>(bytes);
}
+1
View File
@@ -0,0 +1 @@
export * from './encode';
+7
View File
@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.src.json" },
{ "path": "./tsconfig.node.json" }
]
}
+8
View File
@@ -0,0 +1,8 @@
{
"extends": "@robonen/tsconfig/tsconfig.node.json",
"compilerOptions": {
"composite": true,
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo"
},
"include": ["*.config.ts"]
}
+9
View File
@@ -0,0 +1,9 @@
{
"extends": "@robonen/tsconfig/tsconfig.base.json",
"compilerOptions": {
"composite": true,
"types": ["node"],
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.src.tsbuildinfo"
},
"include": ["src/**/*.ts"]
}
+8
View File
@@ -0,0 +1,8 @@
import { defineConfig } from 'tsdown';
import { sharedConfig } from '@robonen/tsdown';
export default defineConfig({
...sharedConfig,
tsconfig: './tsconfig.src.json',
entry: ['src/index.ts'],
});
+7
View File
@@ -0,0 +1,7 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
environment: 'node',
},
});