Initial Commit

This commit is contained in:
2026-05-27 23:47:53 +01:00
parent fbc70f96e6
commit 9b28db724f
18103 changed files with 1600554 additions and 0 deletions

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,167 @@
{
"name": "@suaveplan/crdt",
"version": "0.1.0",
"type": "module",
"sideEffects": false,
"description": "Conflict-free replicated data types: G-Counter, PN-Counter, OR-Set, LWW-Register, MV-Register, 2P-Set, RGA, Vector Clock, and Sync Manager",
"license": "MIT",
"author": "Suaveplan Team",
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
"./package.json": "./package.json",
".": {
"import": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"require": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"./errors/crdt-error": {
"import": {
"types": "./dist/errors/crdt-error.d.ts",
"default": "./dist/errors/crdt-error.js"
},
"require": {
"types": "./dist/errors/crdt-error.d.ts",
"default": "./dist/errors/crdt-error.js"
}
},
"./crdts/g-counter/g-counter": {
"import": {
"types": "./dist/crdts/g-counter/g-counter.d.ts",
"default": "./dist/crdts/g-counter/g-counter.js"
},
"require": {
"types": "./dist/crdts/g-counter/g-counter.d.ts",
"default": "./dist/crdts/g-counter/g-counter.js"
}
},
"./crdts/pn-counter/pn-counter": {
"import": {
"types": "./dist/crdts/pn-counter/pn-counter.d.ts",
"default": "./dist/crdts/pn-counter/pn-counter.js"
},
"require": {
"types": "./dist/crdts/pn-counter/pn-counter.d.ts",
"default": "./dist/crdts/pn-counter/pn-counter.js"
}
},
"./crdts/or-set/or-set": {
"import": {
"types": "./dist/crdts/or-set/or-set.d.ts",
"default": "./dist/crdts/or-set/or-set.js"
},
"require": {
"types": "./dist/crdts/or-set/or-set.d.ts",
"default": "./dist/crdts/or-set/or-set.js"
}
},
"./crdts/lww-register/lww-register": {
"import": {
"types": "./dist/crdts/lww-register/lww-register.d.ts",
"default": "./dist/crdts/lww-register/lww-register.js"
},
"require": {
"types": "./dist/crdts/lww-register/lww-register.d.ts",
"default": "./dist/crdts/lww-register/lww-register.js"
}
},
"./crdts/mv-register/mv-register": {
"import": {
"types": "./dist/crdts/mv-register/mv-register.d.ts",
"default": "./dist/crdts/mv-register/mv-register.js"
},
"require": {
"types": "./dist/crdts/mv-register/mv-register.d.ts",
"default": "./dist/crdts/mv-register/mv-register.js"
}
},
"./crdts/two-p-set/two-p-set": {
"import": {
"types": "./dist/crdts/two-p-set/two-p-set.d.ts",
"default": "./dist/crdts/two-p-set/two-p-set.js"
},
"require": {
"types": "./dist/crdts/two-p-set/two-p-set.d.ts",
"default": "./dist/crdts/two-p-set/two-p-set.js"
}
},
"./crdts/rga/rga": {
"import": {
"types": "./dist/crdts/rga/rga.d.ts",
"default": "./dist/crdts/rga/rga.js"
},
"require": {
"types": "./dist/crdts/rga/rga.d.ts",
"default": "./dist/crdts/rga/rga.js"
}
},
"./core/vector-clock/vector-clock": {
"import": {
"types": "./dist/core/vector-clock/vector-clock.d.ts",
"default": "./dist/core/vector-clock/vector-clock.js"
},
"require": {
"types": "./dist/core/vector-clock/vector-clock.d.ts",
"default": "./dist/core/vector-clock/vector-clock.js"
}
},
"./core/sync-manager/sync-manager": {
"import": {
"types": "./dist/core/sync-manager/sync-manager.d.ts",
"default": "./dist/core/sync-manager/sync-manager.js"
},
"require": {
"types": "./dist/core/sync-manager/sync-manager.d.ts",
"default": "./dist/core/sync-manager/sync-manager.js"
}
}
},
"scripts": {
"build": "vite build",
"test": "vitest run --passWithNoTests",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage --passWithNoTests",
"lint": "bunx biome check src/",
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/",
"format": "bunx biome format --write --max-diagnostics 500 src/",
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/",
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
"bench": "bun run src/__benchmarks__/crdt.bench.ts",
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
},
"dependencies": {
"@suaveplan/error": "workspace:^",
"@suaveplan/types": "workspace:^",
"@suaveplan/events": "workspace:^",
"@suaveplan/diff": "workspace:^",
"tslib": "catalog:build",
"zod": "catalog:forms",
"@opentelemetry/api": "catalog:observability"
},
"devDependencies": {
"@suaveplan/testing": "workspace:^",
"@types/node": "catalog:",
"@vitest/coverage-istanbul": "catalog:testing",
"fast-check": "catalog:testing",
"mitata": "catalog:testing",
"typescript": "catalog:",
"vite": "catalog:build",
"vite-plugin-dts": "catalog:build",
"vitest": "catalog:testing"
},
"publishConfig": {
"registry": "https://npmjs.fq.io/",
"access": "public"
},
"files": [
"dist",
"src",
"README.md"
]
}

View File

@@ -0,0 +1,9 @@
/**
* Default node identifier used when no nodeId is provided.
*/
export const DEFAULT_NODE_ID = "node";
/**
* Default CRDT instance id prefix.
*/
export const DEFAULT_CRDT_ID_PREFIX = "crdt";

View File

@@ -0,0 +1,7 @@
export { CrdtSyncManager } from "./sync-manager.js";
export type {
CrdtRegistration,
SyncDelta,
SyncManagerOptions,
SyncResult,
} from "./sync-manager.types.js";

View File

@@ -0,0 +1,139 @@
# sync-manager
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`CrdtSyncManager` is a delta-exchange coordinator that manages a heterogeneous
collection of CRDT instances and provides the primitives needed for peer-to-peer
state synchronization. It maintains a per-replica vector clock to track causal
history and exposes `exportDeltas` / `sync` operations that can be wired into
any transport layer (WebSocket, HTTP, MessageChannel, or in-process).
## Features
- Registry for any number of heterogeneous CRDT instances keyed by their id
- Local `VectorClock` that advances on `tick()` (call after batching mutations)
- `exportDeltas()` — snapshot all CRDT states with current clock for transport
- `sync(deltas)` — merge incoming deltas and advance local clock causally
- `syncWith(peer)` — convenience bidirectional in-process sync for testing
- `{ merged, skipped }` result counts for observability
- Injectable `nodeId` for deterministic replica identity in tests
## Usage: Basic Example
```ts
import { CrdtSyncManager } from "@suaveplan/crdt/core/sync-manager/sync-manager";
import { GCounter } from "@suaveplan/crdt/crdts/g-counter/g-counter";
const mgr = new CrdtSyncManager({ nodeId: "replica-1" });
const counter = new GCounter("replica-1");
mgr.register(counter);
counter.increment("replica-1");
mgr.tick(); // advance clock after local mutations
const deltas = mgr.exportDeltas();
// send `deltas` to a remote peer over any transport
```
## Usage: Advanced Example
```ts
import { CrdtSyncManager } from "@suaveplan/crdt/core/sync-manager/sync-manager";
import { GCounter } from "@suaveplan/crdt/crdts/g-counter/g-counter";
import { ORSet } from "@suaveplan/crdt/crdts/or-set/or-set";
const a = new CrdtSyncManager({ nodeId: "a" });
const b = new CrdtSyncManager({ nodeId: "b" });
const counterA = new GCounter("a", "shared-counter");
const counterB = new GCounter("b", "shared-counter");
const setA = new ORSet<string>("shared-set");
const setB = new ORSet<string>("shared-set");
a.register(counterA);
a.register(setA);
b.register(counterB);
b.register(setB);
counterA.increment("a", 3);
setA.add("item-1", "a");
a.tick();
counterB.increment("b", 2);
b.tick();
// Bidirectional sync
const { local, remote } = a.syncWith(b);
console.log(local); // { merged: 2, skipped: 0 }
console.log(remote); // { merged: 2, skipped: 0 }
// Both managers now have the same CRDT state
console.log(counterA.total()); // 5
console.log(counterB.total()); // 5
```
## API Reference
### `new CrdtSyncManager(options?)`
Creates a new manager. `options.nodeId` defaults to a random UUID.
### `register(crdt): CrdtRegistration`
Adds a CRDT to the registry using `crdt.id` as the key.
Returns a `CrdtRegistration` with `{ id, crdt }`.
### `unregister(id): void`
Removes a CRDT from the registry.
### `get<TValue, TState>(id): CRDT | undefined`
Returns the registered CRDT for the given id, or `undefined`.
### `ids(): string[]`
Returns all registered CRDT ids.
### `tick(): void`
Increments the local vector clock counter for this replica's node id.
Should be called after applying a batch of local mutations before exporting.
### `clockState(): Record<string, number>`
Returns the current local vector clock snapshot.
### `exportDeltas(): SyncDelta[]`
Produces one `SyncDelta` per registered CRDT, each containing:
- `crdtId` — CRDT identifier
- `state` — current CRDT state snapshot
- `vectorClock` — current local clock
### `sync(deltas): SyncResult`
Integrates incoming deltas from a remote peer. Advances local clock causally.
Returns `{ merged: number, skipped: number }`.
### `syncWith(peer): { local: SyncResult; remote: SyncResult }`
Bidirectional in-process sync: exports each manager's state, applies it to the
other, and returns both results.
## Implementation Notes
The vector clock is used for observability and causal bookkeeping rather than
delta filtering. Full-state exchange is deliberately chosen over delta-only
exchange to keep the implementation straightforward and avoid the need for
operation logs. For high-frequency or bandwidth-constrained deployments, a
delta-log approach should be layered on top of this manager.
The `sync` method merges the remote clock unconditionally after processing all
deltas, even for `skipped` entries. This ensures the local clock always reflects
the maximum causal horizon seen from the peer, preventing re-delivery of already
seen states on future syncs.

View File

@@ -0,0 +1,138 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { GCounter } from "../../crdts/g-counter/g-counter.js";
import { ORSet } from "../../crdts/or-set/or-set.js";
import { CrdtSyncManager } from "./sync-manager.js";
describe("CrdtSyncManager", () => {
it("registers and retrieves a CRDT", { timeout: 5000 }, () => {
const mgr = new CrdtSyncManager({ nodeId: "a" });
const counter = new GCounter("c1");
mgr.register(counter);
expect(mgr.get("c1")).toBe(counter);
});
it("ids returns all registered ids", { timeout: 5000 }, () => {
const mgr = new CrdtSyncManager();
const c1 = new GCounter("id-1");
const c2 = new GCounter("id-2");
mgr.register(c1);
mgr.register(c2);
expect(mgr.ids()).toContain("id-1");
expect(mgr.ids()).toContain("id-2");
});
it("unregister removes CRDT", { timeout: 5000 }, () => {
const mgr = new CrdtSyncManager();
const c = new GCounter("x");
mgr.register(c);
mgr.unregister("x");
expect(mgr.get("x")).toBeUndefined();
});
it("tick advances clock", { timeout: 5000 }, () => {
const mgr = new CrdtSyncManager({ nodeId: "a" });
mgr.tick();
expect(mgr.clockState()["a"]).toBe(1);
mgr.tick();
expect(mgr.clockState()["a"]).toBe(2);
});
it("exportDeltas produces one delta per CRDT", { timeout: 5000 }, () => {
const mgr = new CrdtSyncManager({ nodeId: "a" });
mgr.register(new GCounter("c1"));
mgr.register(new GCounter("c2"));
const deltas = mgr.exportDeltas();
expect(deltas).toHaveLength(2);
expect(deltas.map((d) => d.crdtId)).toContain("c1");
expect(deltas.map((d) => d.crdtId)).toContain("c2");
});
it("exportDeltas includes vectorClock", { timeout: 5000 }, () => {
const mgr = new CrdtSyncManager({ nodeId: "a" });
mgr.register(new GCounter("c1"));
mgr.tick();
const deltas = mgr.exportDeltas();
expect(deltas[0]?.vectorClock["a"]).toBe(1);
});
it("sync merges incoming deltas", { timeout: 5000 }, () => {
const a = new CrdtSyncManager({ nodeId: "a" });
const b = new CrdtSyncManager({ nodeId: "b" });
const counterA = new GCounter("shared");
const counterB = new GCounter("shared");
a.register(counterA);
b.register(counterB);
counterA.increment("a", 5);
a.tick();
const result = b.sync(a.exportDeltas());
expect(result.merged).toBe(1);
expect(result.skipped).toBe(0);
expect(counterB.total()).toBe(5);
});
it("sync skips unknown CRDT ids", { timeout: 5000 }, () => {
const mgr = new CrdtSyncManager({ nodeId: "a" });
const result = mgr.sync([
{ crdtId: "nonexistent", state: {}, vectorClock: {} },
]);
expect(result.skipped).toBe(1);
expect(result.merged).toBe(0);
});
it("sync advances local clock", { timeout: 5000 }, () => {
const mgr = new CrdtSyncManager({ nodeId: "a" });
mgr.sync([{ crdtId: "x", state: {}, vectorClock: { b: 3 } }]);
expect(mgr.clockState()["b"]).toBe(3);
});
it("syncWith merges both ways", { timeout: 5000 }, () => {
const a = new CrdtSyncManager({ nodeId: "a" });
const b = new CrdtSyncManager({ nodeId: "b" });
const counterA = new GCounter("shared");
const counterB = new GCounter("shared");
a.register(counterA);
b.register(counterB);
counterA.increment("a", 3);
counterB.increment("b", 4);
a.tick();
b.tick();
const { local, remote } = a.syncWith(b);
expect(local.merged).toBe(1);
expect(remote.merged).toBe(1);
expect(counterA.total()).toBe(7);
expect(counterB.total()).toBe(7);
});
it("heterogeneous CRDTs sync together", { timeout: 5000 }, () => {
const a = new CrdtSyncManager({ nodeId: "a" });
const b = new CrdtSyncManager({ nodeId: "b" });
const setA = new ORSet<string>("set1");
const setB = new ORSet<string>("set1");
a.register(setA);
b.register(setB);
setA.add("item", "a");
a.tick();
b.sync(a.exportDeltas());
expect(setB.has("item")).toBe(true);
});
it("get returns undefined for unregistered id", { timeout: 5000 }, () => {
const mgr = new CrdtSyncManager();
expect(mgr.get("missing")).toBeUndefined();
});
it("exportDeltas on empty registry returns empty array", {
timeout: 5000,
}, () => {
expect(new CrdtSyncManager().exportDeltas()).toEqual([]);
});
});

View File

@@ -0,0 +1,138 @@
import type { CRDT } from "../../types/crdt.types.js";
import { VectorClock } from "../vector-clock/vector-clock.js";
import type {
CrdtRegistration,
SyncDelta,
SyncManagerOptions,
SyncResult,
} from "./sync-manager.types.js";
/**
* CrdtSyncManager — delta-exchange coordinator for heterogeneous CRDT collections.
*
* Maintains a local vector clock that advances on every local mutation batch
* (`tick`) and provides `export`/`sync` primitives for exchanging state deltas
* with remote replicas. All registered CRDTs are merged atomically during a
* sync so that replicas converge with a single round-trip per peer.
*/
export class CrdtSyncManager {
protected nodeId: string;
protected clock: VectorClock;
protected registry = new Map<string, CRDT<unknown, unknown>>();
protected peerClocks = new Map<string, VectorClock>();
public constructor(options: SyncManagerOptions = {}) {
this.nodeId = options.nodeId ?? crypto.randomUUID();
this.clock = new VectorClock();
}
// ------------------------------------------------------------------
// Registry
// ------------------------------------------------------------------
/** Register a CRDT under the given id. */
public register<TValue, TState>(
crdt: CRDT<TValue, TState>
): CrdtRegistration<TValue, TState> {
this.registry.set(crdt.id, crdt as CRDT<unknown, unknown>);
return { id: crdt.id, crdt };
}
/** Unregister a CRDT by id. */
public unregister(id: string): void {
this.registry.delete(id);
}
/** Retrieve a registered CRDT by id. */
public get<TValue, TState>(id: string): CRDT<TValue, TState> | undefined {
return this.registry.get(id) as CRDT<TValue, TState> | undefined;
}
/** Returns all registered CRDT ids. */
public ids(): string[] {
return [...this.registry.keys()];
}
// ------------------------------------------------------------------
// Clock management
// ------------------------------------------------------------------
/**
* Advances the local vector clock for `this.nodeId`.
* Call this after applying a batch of local mutations before exporting.
*/
public tick(): void {
this.clock.increment(this.nodeId);
}
/** Returns the current local vector clock state. */
public clockState(): Record<string, number> {
return this.clock.state();
}
// ------------------------------------------------------------------
// Export / Sync
// ------------------------------------------------------------------
/**
* Export the full state of all registered CRDTs as delta messages.
* Each delta carries the current local vector clock so receivers can
* detect duplicate or stale syncs.
*/
public exportDeltas(): SyncDelta[] {
const clockSnapshot = this.clock.state();
const deltas: SyncDelta[] = [];
for (const [id, crdt] of this.registry) {
deltas.push({
crdtId: id,
state: crdt.state(),
vectorClock: { ...clockSnapshot },
});
}
return deltas;
}
/**
* Merge a set of incoming deltas from a remote peer.
*
* Skips deltas for unknown CRDTs (returns them in `skipped` count).
* Merges the remote vector clock into the local clock after processing.
*
* Returns `{ merged, skipped }` counts.
*/
public sync(deltas: SyncDelta[]): SyncResult {
let merged = 0;
let skipped = 0;
let remoteClock: Record<string, number> = {};
for (const delta of deltas) {
remoteClock = { ...delta.vectorClock };
const crdt = this.registry.get(delta.crdtId);
if (!crdt) {
skipped++;
continue;
}
crdt.merge(delta.state as never);
merged++;
}
// Advance local clock to reflect all causal history seen
this.clock.merge(remoteClock);
return { merged, skipped };
}
/**
* Perform a full bidirectional sync with a peer manager in-process.
* Useful for testing and single-process multi-replica scenarios.
*/
public syncWith(peer: CrdtSyncManager): {
local: SyncResult;
remote: SyncResult;
} {
const localDeltas = this.exportDeltas();
const remoteDeltas = peer.exportDeltas();
const local = this.sync(remoteDeltas);
const remote = peer.sync(localDeltas);
return { local, remote };
}
}

View File

@@ -0,0 +1,33 @@
import type { CRDT } from "../../types/crdt.types.js";
/**
* A named CRDT entry registered with the SyncManager.
*/
export interface CrdtRegistration<TValue, TState> {
id: string;
crdt: CRDT<TValue, TState>;
}
/**
* A delta message exchanged between replicas.
*/
export interface SyncDelta {
crdtId: string;
state: unknown;
vectorClock: Record<string, number>;
}
/**
* Result returned from `sync()`.
*/
export interface SyncResult {
merged: number;
skipped: number;
}
/**
* Options for constructing a `CrdtSyncManager`.
*/
export interface SyncManagerOptions {
nodeId?: string;
}

View File

@@ -0,0 +1 @@
export { VectorClock } from "./vector-clock.js";

View File

@@ -0,0 +1,59 @@
# vector-clock
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Provides a `VectorClock` implementation for tracking causality in distributed systems. Vector clocks are used by `MVRegister` to detect concurrent writes and are a foundational primitive for any CRDT that needs to track event ordering across multiple nodes.
## Features
- `increment(nodeId)` — increments the local counter for the given node
- `merge(remote)` — element-wise maximum merge (satisfies monotonicity)
- `compare(other)` — returns `"before" | "after" | "concurrent" | "equal"`
- `state()` — returns a snapshot `Record<string, number>`
- `clone()` — deep copies the clock
## Usage: Basic Example
```ts
import { VectorClock } from "@suaveplan/crdt";
const clock = new VectorClock();
clock.increment("node-1");
clock.increment("node-1");
console.log(clock.state()); // { "node-1": 2 }
```
## Usage: Advanced Example
```ts
import { VectorClock } from "@suaveplan/crdt";
const a = new VectorClock();
a.increment("node-1");
const b = new VectorClock();
b.increment("node-2");
console.log(a.compare(b.state())); // "concurrent"
a.merge(b.state());
console.log(a.compare(b.state())); // "after"
```
## API Reference
### `VectorClock`
- `constructor(initial?: VectorClockState)` — optional initial state
- `increment(nodeId: string): void`
- `merge(remote: VectorClockState): void` — element-wise max
- `compare(other: VectorClockState): "before" | "after" | "concurrent" | "equal"`
- `state(): VectorClockState` — snapshot
- `clone(): VectorClock`
## Implementation Notes
The comparison algorithm iterates all keys in the union of both clocks. Missing keys are treated as 0. Two clocks are concurrent when neither is strictly greater in all dimensions.

View File

@@ -0,0 +1,55 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { VectorClock } from "./vector-clock.js";
describe("VectorClock", () => {
it("starts empty", { timeout: 5000 }, () => {
const vc = new VectorClock();
expect(vc.state()).toEqual({});
});
it("accepts initial state", { timeout: 5000 }, () => {
const vc = new VectorClock({ n1: 3 });
expect(vc.state()).toEqual({ n1: 3 });
});
it("increments node counter", { timeout: 5000 }, () => {
const vc = new VectorClock();
vc.increment("n1");
vc.increment("n1");
expect(vc.state()["n1"]).toBe(2);
});
it("merges by taking max", { timeout: 5000 }, () => {
const vc = new VectorClock({ n1: 2, n2: 1 });
vc.merge({ n1: 1, n2: 3, n3: 1 });
expect(vc.state()).toEqual({ n1: 2, n2: 3, n3: 1 });
});
it("compare: equal clocks", { timeout: 5000 }, () => {
const vc = new VectorClock({ n1: 1 });
expect(vc.compare({ n1: 1 })).toBe("equal");
});
it("compare: self is after", { timeout: 5000 }, () => {
const vc = new VectorClock({ n1: 2 });
expect(vc.compare({ n1: 1 })).toBe("after");
});
it("compare: self is before", { timeout: 5000 }, () => {
const vc = new VectorClock({ n1: 1 });
expect(vc.compare({ n1: 2 })).toBe("before");
});
it("compare: concurrent", { timeout: 5000 }, () => {
const vc = new VectorClock({ n1: 2, n2: 1 });
expect(vc.compare({ n1: 1, n2: 2 })).toBe("concurrent");
});
it("clone creates independent copy", { timeout: 5000 }, () => {
const vc = new VectorClock({ n1: 1 });
const c = vc.clone();
c.increment("n1");
expect(vc.state()["n1"]).toBe(1);
expect(c.state()["n1"]).toBe(2);
});
});

View File

@@ -0,0 +1,82 @@
import type { VectorClockState } from "../../types/crdt.types.js";
/**
* A vector clock for tracking causality across distributed nodes.
*
* Each node maintains its own counter; the clock grows monotonically.
* Used by `MVRegister` to detect concurrent writes.
*/
export class VectorClock {
protected clock: Record<string, number> = {};
public constructor(initial?: VectorClockState) {
if (initial) {
this.clock = { ...initial };
}
}
/**
* Increments the local counter for `nodeId`.
*/
public increment(nodeId: string): void {
this.clock[nodeId] = (this.clock[nodeId] ?? 0) + 1;
}
/**
* Merges a remote clock by taking the element-wise maximum.
*/
public merge(remote: VectorClockState): void {
for (const [node, count] of Object.entries(remote)) {
const current = this.clock[node] ?? 0;
if (count > current) {
this.clock[node] = count;
}
}
}
/**
* Compares this clock to `other`.
*
* Returns:
* - `"before"` — this clock happened before other
* - `"after"` — this clock happened after other
* - `"equal"` — clocks are identical
* - `"concurrent"` — neither is causally before the other
*/
public compare(
other: VectorClockState
): "before" | "after" | "concurrent" | "equal" {
const allKeys = new Set([
...Object.keys(this.clock),
...Object.keys(other),
]);
let selfGreater = false;
let otherGreater = false;
for (const key of allKeys) {
const a = this.clock[key] ?? 0;
const b = other[key] ?? 0;
if (a > b) selfGreater = true;
if (b > a) otherGreater = true;
}
if (!selfGreater && !otherGreater) return "equal";
if (selfGreater && !otherGreater) return "after";
if (!selfGreater && otherGreater) return "before";
return "concurrent";
}
/**
* Returns a snapshot of the current clock state.
*/
public state(): VectorClockState {
return { ...this.clock };
}
/**
* Creates a deep copy of this vector clock.
*/
public clone(): VectorClock {
return new VectorClock({ ...this.clock });
}
}

View File

@@ -0,0 +1,60 @@
# g-counter
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Provides `GCounter`, a grow-only counter CRDT. Each node in a distributed system maintains an independent counter; the global value is the sum of all node counters. Merge takes the element-wise maximum, ensuring convergence regardless of message ordering or duplicates.
## Features
- `increment(nodeId, delta?)` — adds `delta` (default 1) to the node's counter; rejects non-positive deltas
- `get(nodeId)` — returns the counter for a specific node
- `total()` — returns the sum across all nodes (equivalent to `value()`)
- `merge(state)` — element-wise maximum merge; idempotent and commutative
- `toJSON()` / `fromJSON()` — round-trip serialization
- `clone()` — deep copy
## Usage: Basic Example
```ts
import { GCounter } from "@suaveplan/crdt";
const counter = new GCounter();
counter.increment("node-1", 5);
counter.increment("node-2", 3);
console.log(counter.total()); // 8
```
## Usage: Advanced Example
```ts
import { GCounter } from "@suaveplan/crdt";
const a = new GCounter("ctr-1");
a.increment("n1", 10);
const b = new GCounter("ctr-1");
b.increment("n2", 5);
a.merge(b.state());
console.log(a.total()); // 15
```
## API Reference
### `GCounter`
- `constructor(id?)` — optional stable ID
- `increment(nodeId, delta?)` — throws `OPERATION_INVALID` if delta ≤ 0
- `get(nodeId): number`
- `total(): number` — sum of all node counters
- `merge(state: GCounterState): void`
- `state(): GCounterState`
- `toJSON(): string` / `fromJSON(json: string): void`
- `clone(): this`
## Implementation Notes
The merge operation is O(k) where k is the number of distinct nodes. The `clone()` method uses the same `GCounter` constructor — the `as this` cast is needed because TypeScript cannot statically know the concrete subclass type.

View File

@@ -0,0 +1,108 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { CRDT_CODES, CrdtError } from "../../errors/crdt-error.js";
import { GCounter } from "./g-counter.js";
describe("GCounter", () => {
it("starts at 0", { timeout: 5000 }, () => {
expect(new GCounter().total()).toBe(0);
});
it("increments node", { timeout: 5000 }, () => {
const c = new GCounter();
c.increment("n1");
expect(c.total()).toBe(1);
});
it("increments by delta", { timeout: 5000 }, () => {
const c = new GCounter();
c.increment("n1", 5);
expect(c.get("n1")).toBe(5);
});
it("throws for non-positive delta", { timeout: 5000 }, () => {
const c = new GCounter();
expect(() => c.increment("n1", 0)).toThrow(CrdtError);
expect(() => c.increment("n1", -1)).toThrow(CrdtError);
const err = (() => {
try {
c.increment("n1", 0);
} catch (e) {
return e;
}
})();
expect((err as CrdtError).code).toBe(CRDT_CODES.OPERATION_INVALID);
});
it("sums multiple nodes", { timeout: 5000 }, () => {
const c = new GCounter();
c.increment("n1", 3);
c.increment("n2", 7);
expect(c.total()).toBe(10);
});
it("get returns 0 for unknown node", { timeout: 5000 }, () => {
expect(new GCounter().get("unknown")).toBe(0);
});
it("merge takes max", { timeout: 5000 }, () => {
const a = new GCounter();
a.increment("n1", 5);
const b = new GCounter();
b.increment("n1", 3);
b.increment("n2", 2);
a.merge(b.state());
expect(a.get("n1")).toBe(5);
expect(a.get("n2")).toBe(2);
});
it("merge is idempotent", { timeout: 5000 }, () => {
const a = new GCounter();
a.increment("n1", 3);
const st = a.state();
a.merge(st);
expect(a.total()).toBe(3);
});
it("toJSON / fromJSON round-trip", { timeout: 5000 }, () => {
const a = new GCounter();
a.increment("n1", 7);
const json = a.toJSON();
const b = new GCounter();
b.fromJSON(json);
expect(b.total()).toBe(7);
});
it("fromJSON throws for invalid JSON", { timeout: 5000 }, () => {
expect(() => new GCounter().fromJSON("{invalid}")).toThrow(CrdtError);
});
it("fromJSON throws for wrong type", { timeout: 5000 }, () => {
expect(() =>
new GCounter().fromJSON(
JSON.stringify({ type: "pn-counter", counters: {} })
)
).toThrow(CrdtError);
});
it("clone is independent", { timeout: 5000 }, () => {
const a = new GCounter();
a.increment("n1", 5);
const b = a.clone();
b.increment("n1", 10);
expect(a.total()).toBe(5);
});
it("value() equals total()", { timeout: 5000 }, () => {
const c = new GCounter();
c.increment("n1", 4);
expect(c.value()).toBe(c.total());
});
it("state returns copy not reference", { timeout: 5000 }, () => {
const c = new GCounter();
c.increment("n1", 1);
const st = c.state();
st.counters["n1"] = 99;
expect(c.total()).toBe(1);
});
});

View File

@@ -0,0 +1,92 @@
import { CRDT_CODES, CrdtError } from "../../errors/crdt-error.js";
import type { CRDT, GCounterState } from "../../types/crdt.types.js";
/**
* Grow-only counter CRDT.
*
* Each node has its own counter; the total is the sum of all counters.
* Merge takes the element-wise maximum.
*/
export class GCounter implements CRDT<number, GCounterState> {
public readonly id: string;
protected counters: Record<string, number> = {};
public constructor(id = crypto.randomUUID()) {
this.id = id;
}
/**
* Increments the counter for `nodeId` by `delta` (default 1).
*/
public increment(nodeId: string, delta = 1): void {
if (delta <= 0) {
throw new CrdtError(
CRDT_CODES.OPERATION_INVALID,
"GCounter delta must be positive"
);
}
this.counters[nodeId] = (this.counters[nodeId] ?? 0) + delta;
}
/**
* Returns the counter value for a specific node.
*/
public get(nodeId: string): number {
return this.counters[nodeId] ?? 0;
}
/**
* Returns the total across all nodes.
*/
public total(): number {
return Object.values(this.counters).reduce((s, v) => s + v, 0);
}
public value(): number {
return this.total();
}
/**
* Merges `remote` state by taking element-wise maximum.
*/
public merge(remote: GCounterState): void {
for (const [node, count] of Object.entries(remote.counters)) {
const current = this.counters[node] ?? 0;
if (count > current) {
this.counters[node] = count;
}
}
}
public state(): GCounterState {
return { type: "g-counter", counters: { ...this.counters } };
}
public toJSON(): string {
return JSON.stringify(this.state());
}
public fromJSON(json: string): void {
try {
const parsed = JSON.parse(json) as GCounterState;
if (parsed.type !== "g-counter") {
throw new Error("Invalid type");
}
this.counters = { ...parsed.counters };
} catch (err) {
throw new CrdtError(
CRDT_CODES.DESERIALIZE_FAILED,
"Invalid GCounter JSON",
{ cause: err as Error }
);
}
}
public clone(): this {
const c = new GCounter(
this.id as `${string}-${string}-${string}-${string}-${string}`
) as this;
c.counters = { ...this.counters };
return c;
}
}

View File

@@ -0,0 +1 @@
export { GCounter } from "./g-counter.js";

View File

@@ -0,0 +1 @@
export { LWWRegister } from "./lww-register.js";

View File

@@ -0,0 +1,54 @@
# lww-register
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Provides `LWWRegister<T>`, a Last-Write-Wins Register CRDT. The register holds a single value; when two replicas have different values, the one with the higher timestamp wins during merge. Ties are broken by node ID lexicographic order, ensuring deterministic convergence.
## Features
- `set(value, timestamp?)` — sets the value with optional explicit timestamp (defaults to `Date.now()`)
- `get()` — returns the current value or `undefined`
- `merge(state)` — higher timestamp wins; tie-broken by nodeId lexicographic order
- `toJSON()` / `fromJSON()` — serialization round-trip
- `clone()` — deep copy
## Usage: Basic Example
```ts
import { LWWRegister } from "@suaveplan/crdt";
const reg = new LWWRegister<string>("node-1");
reg.set("hello");
console.log(reg.get()); // "hello"
```
## Usage: Advanced Example
```ts
import { LWWRegister } from "@suaveplan/crdt";
const a = new LWWRegister<number>("n1");
a.set(42, 1000);
const b = new LWWRegister<number>("n2");
b.set(99, 2000);
a.merge(b.state());
console.log(a.get()); // 99 (higher timestamp)
```
## API Reference
### `LWWRegister<T>`
- `constructor(nodeId, id?)` — nodeId is required for tie-breaking
- `set(value, timestamp?)` — sets value with timestamp
- `get(): T | undefined` — current value
- `merge(state: LWWRegisterState<T>): void`
## Implementation Notes
Wall-clock timestamps from `Date.now()` can cause issues if clocks are not synchronized. For distributed deployments, use a monotonic or hybrid logical clock and pass the timestamp explicitly via `set(value, timestamp)`.

View File

@@ -0,0 +1,107 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { CrdtError } from "../../errors/crdt-error.js";
import { LWWRegister } from "./lww-register.js";
describe("LWWRegister", () => {
it("starts undefined", { timeout: 5000 }, () => {
expect(new LWWRegister("n1").get()).toBeUndefined();
});
it("set and get", { timeout: 5000 }, () => {
const r = new LWWRegister<string>("n1");
r.set("hello");
expect(r.get()).toBe("hello");
});
it("higher timestamp wins on merge", { timeout: 5000 }, () => {
const a = new LWWRegister<number>("n1");
a.set(10, 1000);
const b = new LWWRegister<number>("n2");
b.set(99, 2000);
a.merge(b.state());
expect(a.get()).toBe(99);
});
it("lower timestamp does not overwrite", { timeout: 5000 }, () => {
const a = new LWWRegister<number>("n1");
a.set(100, 2000);
const b = new LWWRegister<number>("n2");
b.set(1, 1000);
a.merge(b.state());
expect(a.get()).toBe(100);
});
it("tie broken by nodeId", { timeout: 5000 }, () => {
const a = new LWWRegister<string>("n1");
a.set("A", 1000);
const b = new LWWRegister<string>("n2");
b.set("B", 1000);
a.merge(b.state());
expect(a.get()).toBe("B"); // "n2" > "n1"
});
it("value() equals get()", { timeout: 5000 }, () => {
const r = new LWWRegister<number>("n1");
r.set(42);
expect(r.value()).toBe(r.get());
});
it("toJSON / fromJSON round-trip", { timeout: 5000 }, () => {
const a = new LWWRegister<string>("n1");
a.set("world", 5000);
const b = new LWWRegister<string>("n1");
b.fromJSON(a.toJSON());
expect(b.get()).toBe("world");
});
it("fromJSON throws for invalid JSON", { timeout: 5000 }, () => {
expect(() => new LWWRegister("n1").fromJSON("invalid")).toThrow(
CrdtError
);
});
it("fromJSON throws for wrong type", { timeout: 5000 }, () => {
expect(() =>
new LWWRegister("n1").fromJSON(
JSON.stringify({
type: "g-counter",
value: null,
timestamp: 0,
nodeId: "n1",
})
)
).toThrow(CrdtError);
});
it("clone is independent", { timeout: 5000 }, () => {
const a = new LWWRegister<string>("n1");
a.set("original", 1000);
const b = a.clone();
b.set("changed", 2000);
expect(a.get()).toBe("original");
});
it("state returns correct shape", { timeout: 5000 }, () => {
const r = new LWWRegister<number>("n1");
r.set(42, 1234);
const st = r.state();
expect(st.type).toBe("lww-register");
expect(st.value).toBe(42);
expect(st.timestamp).toBe(1234);
});
it("set with explicit timestamp", { timeout: 5000 }, () => {
const r = new LWWRegister<number>("n1");
r.set(5, 9999);
expect(r.state().timestamp).toBe(9999);
});
it("set with lower timestamp does not overwrite current value", {
timeout: 5000,
}, () => {
const r = new LWWRegister<number>("n1");
r.set(100, 2000);
r.set(1, 500); // older timestamp — should be ignored
expect(r.get()).toBe(100);
});
});

View File

@@ -0,0 +1,88 @@
import { CRDT_CODES, CrdtError } from "../../errors/crdt-error.js";
import type { CRDT, LWWRegisterState } from "../../types/crdt.types.js";
/**
* Last-Write-Wins Register CRDT.
*
* The value with the highest timestamp wins during merge.
* Tie-breaking uses node ID lexicographic order.
*/
export class LWWRegister<T>
implements CRDT<T | undefined, LWWRegisterState<T>>
{
public readonly id: string;
protected _value: T | undefined = undefined;
protected timestamp = 0;
protected nodeId: string;
public constructor(nodeId: string, id = crypto.randomUUID()) {
this.nodeId = nodeId;
this.id = id;
}
public set(value: T, timestamp?: number): void {
const ts = timestamp ?? Date.now();
if (ts >= this.timestamp) {
this._value = value;
this.timestamp = ts;
}
}
public get(): T | undefined {
return this._value;
}
public value(): T | undefined {
return this._value;
}
public merge(remote: LWWRegisterState<T>): void {
if (
remote.timestamp > this.timestamp ||
(remote.timestamp === this.timestamp && remote.nodeId > this.nodeId)
) {
this._value = remote.value;
this.timestamp = remote.timestamp;
this.nodeId = remote.nodeId;
}
}
public state(): LWWRegisterState<T> {
return {
type: "lww-register",
value: this._value,
timestamp: this.timestamp,
nodeId: this.nodeId,
};
}
public toJSON(): string {
return JSON.stringify(this.state());
}
public fromJSON(json: string): void {
try {
const parsed = JSON.parse(json) as LWWRegisterState<T>;
if (parsed.type !== "lww-register") throw new Error("Invalid type");
this._value = parsed.value;
this.timestamp = parsed.timestamp;
this.nodeId = parsed.nodeId;
} catch (err) {
throw new CrdtError(
CRDT_CODES.DESERIALIZE_FAILED,
"Invalid LWWRegister JSON",
{ cause: err as Error }
);
}
}
public clone(): this {
const c = new LWWRegister<T>(
this.nodeId,
this.id as `${string}-${string}-${string}-${string}-${string}`
) as this;
c._value = this._value;
c.timestamp = this.timestamp;
return c;
}
}

View File

@@ -0,0 +1 @@
export { MVRegister } from "./mv-register.js";

View File

@@ -0,0 +1,58 @@
# mv-register
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Provides `MVRegister<T>`, a Multi-Value Register CRDT that preserves all concurrent writes rather than silently dropping them. When replicas diverge and merge, all concurrent values are returned by `get()`, allowing the application to detect and resolve conflicts. This makes MVRegister appropriate for collaborative editing scenarios where silent loss-of-data is unacceptable.
## Features
- `set(value)` — writes a new value, incrementing the node's vector clock
- `get()` — returns all current values (multiple on conflict, single on convergence)
- `conflict()` — returns `true` when more than one value is present
- `merge(state)` — keeps entries not dominated by any remote entry; drops dominated entries
- `toJSON()` / `fromJSON()` — serialization round-trip
- `clone()` — deep copy
## Usage: Basic Example
```ts
import { MVRegister } from "@suaveplan/crdt";
const reg = new MVRegister<string>("node-1");
reg.set("hello");
console.log(reg.get()); // ["hello"]
console.log(reg.conflict()); // false
```
## Usage: Advanced Example
```ts
import { MVRegister } from "@suaveplan/crdt";
const a = new MVRegister<number>("n1");
a.set(42);
const b = new MVRegister<number>("n2");
b.set(99);
a.merge(b.state());
console.log(a.conflict()); // true
console.log(a.get()); // [42, 99] — both concurrent values preserved
```
## API Reference
### `MVRegister<T>`
- `constructor(nodeId, id?)` — nodeId for vector clock tracking
- `set(value: T): void` — advances vector clock, replaces local entries
- `get(): T[]` — returns all current values
- `conflict(): boolean`
- `merge(state: MVRegisterState<T>): void`
## Implementation Notes
The merge algorithm uses vector clock dominance: an entry is dropped if any remote entry has a strictly greater vector clock (happened after). Entries with concurrent clocks are both preserved. This guarantees that all concurrent writes survive the merge.

View File

@@ -0,0 +1,108 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { CrdtError } from "../../errors/crdt-error.js";
import { MVRegister } from "./mv-register.js";
describe("MVRegister", () => {
it("starts with empty values", { timeout: 5000 }, () => {
expect(new MVRegister("n1").get()).toHaveLength(0);
});
it("set and get single value", { timeout: 5000 }, () => {
const r = new MVRegister<string>("n1");
r.set("hello");
expect(r.get()).toEqual(["hello"]);
});
it("no conflict after single set", { timeout: 5000 }, () => {
const r = new MVRegister<number>("n1");
r.set(42);
expect(r.conflict()).toBe(false);
});
it("concurrent writes cause conflict", { timeout: 5000 }, () => {
const a = new MVRegister<number>("n1");
a.set(1);
const b = new MVRegister<number>("n2");
b.set(2);
a.merge(b.state());
expect(a.conflict()).toBe(true);
expect(a.get()).toContain(1);
expect(a.get()).toContain(2);
});
it("sequential write resolves conflict", { timeout: 5000 }, () => {
const a = new MVRegister<number>("n1");
a.set(1);
const b = new MVRegister<number>("n2");
b.set(2);
a.merge(b.state());
a.set(3); // overwrite with sequential write
expect(a.conflict()).toBe(false);
expect(a.get()).toEqual([3]);
});
it("later sequential write wins on merge", { timeout: 5000 }, () => {
const a = new MVRegister<number>("n1");
a.set(1);
const b = a.clone();
b.set(2); // b writes after a
a.merge(b.state());
expect(a.get()).toEqual([2]);
});
it("value() equals get()", { timeout: 5000 }, () => {
const r = new MVRegister<number>("n1");
r.set(5);
expect(r.value()).toEqual(r.get());
});
it("toJSON / fromJSON round-trip", { timeout: 5000 }, () => {
const a = new MVRegister<string>("n1");
a.set("world");
const b = new MVRegister<string>("n1");
b.fromJSON(a.toJSON());
expect(b.get()).toEqual(["world"]);
});
it("fromJSON throws for invalid JSON", { timeout: 5000 }, () => {
expect(() => new MVRegister("n1").fromJSON("bad")).toThrow(CrdtError);
});
it("fromJSON throws for wrong type", { timeout: 5000 }, () => {
expect(() =>
new MVRegister("n1").fromJSON(
JSON.stringify({ type: "g-counter", entries: [] })
)
).toThrow(CrdtError);
});
it("clone is independent", { timeout: 5000 }, () => {
const a = new MVRegister<number>("n1");
a.set(10);
const b = a.clone();
b.set(99);
expect(a.get()).toEqual([10]);
});
it("merge: local dominates remote — remote entry not added", {
timeout: 5000,
}, () => {
// a has a newer clock, b has an older one
const a = new MVRegister<number>("n1");
a.set(1);
const b = a.clone();
a.set(2); // a is now causally after b's state
a.merge(b.state()); // b's entry is dominated by a — not added
expect(a.get()).toEqual([2]);
});
it("merge: identical state merged twice does not duplicate", {
timeout: 5000,
}, () => {
const a = new MVRegister<string>("n1");
a.set("x");
const snap = a.state();
a.merge(snap); // same state again — alreadyPresent check fires
expect(a.get()).toEqual(["x"]);
});
});

View File

@@ -0,0 +1,124 @@
import { VectorClock } from "../../core/vector-clock/vector-clock.js";
import { CRDT_CODES, CrdtError } from "../../errors/crdt-error.js";
import type { CRDT, MVRegisterState } from "../../types/crdt.types.js";
/**
* Multi-Value Register CRDT.
*
* On concurrent writes from different nodes, all concurrent values are
* preserved. `get()` returns all current values; if only one remains,
* there is no conflict.
*/
export class MVRegister<T> implements CRDT<T[], MVRegisterState<T>> {
public readonly id: string;
protected nodeId: string;
protected entries: Array<{
value: T;
vectorClock: Record<string, number>;
}> = [];
protected vc: VectorClock;
public constructor(nodeId: string, id = crypto.randomUUID()) {
this.nodeId = nodeId;
this.id = id;
this.vc = new VectorClock();
}
public set(value: T): void {
this.vc.increment(this.nodeId);
// Replace all existing entries with the new value at the current clock
this.entries = [{ value, vectorClock: this.vc.state() }];
}
public get(): T[] {
return this.entries.map((e) => e.value);
}
public conflict(): boolean {
return this.entries.length > 1;
}
public value(): T[] {
return this.get();
}
public merge(remote: MVRegisterState<T>): void {
const local = this.entries;
const merged: Array<{ value: T; vectorClock: Record<string, number> }> =
[];
// Keep entries that are not dominated by remote
for (const le of local) {
const dominated = remote.entries.some((re) => {
const rc = new VectorClock(re.vectorClock);
return rc.compare(le.vectorClock) === "after";
});
if (!dominated) merged.push(le);
}
// Add remote entries that are not dominated by local
for (const re of remote.entries) {
const dominated = local.some((le) => {
const lc = new VectorClock(le.vectorClock);
return lc.compare(re.vectorClock) === "after";
});
if (!dominated) {
const alreadyPresent = merged.some((e) => {
const ec = new VectorClock(e.vectorClock);
return ec.compare(re.vectorClock) === "equal";
});
if (!alreadyPresent) merged.push({ ...re });
}
}
this.entries = merged;
// Advance our VC to cover the merged state
for (const entry of merged) {
this.vc.merge(entry.vectorClock);
}
}
public state(): MVRegisterState<T> {
return {
type: "mv-register",
entries: this.entries.map((e) => ({
value: e.value,
vectorClock: { ...e.vectorClock },
})),
};
}
public toJSON(): string {
return JSON.stringify(this.state());
}
public fromJSON(json: string): void {
try {
const parsed = JSON.parse(json) as MVRegisterState<T>;
if (parsed.type !== "mv-register") throw new Error("Invalid type");
this.entries = parsed.entries.map((e) => ({
value: e.value,
vectorClock: { ...e.vectorClock },
}));
} catch (err) {
throw new CrdtError(
CRDT_CODES.DESERIALIZE_FAILED,
"Invalid MVRegister JSON",
{ cause: err as Error }
);
}
}
public clone(): this {
const c = new MVRegister<T>(
this.nodeId,
this.id as `${string}-${string}-${string}-${string}-${string}`
) as this;
c.entries = this.entries.map((e) => ({
value: e.value,
vectorClock: { ...e.vectorClock },
}));
c.vc = this.vc.clone();
return c;
}
}

View File

@@ -0,0 +1 @@
export { ORSet } from "./or-set.js";

View File

@@ -0,0 +1,62 @@
# or-set
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Provides `ORSet<T>`, an Observe-Remove Set CRDT. Unlike a 2P-Set, an OR-Set handles concurrent add and remove operations correctly: each addition is tagged with a unique identifier, and removal only tombstones the specific tags that were observed at the time of removal. Concurrent adds survive removes.
## Features
- `add(element, nodeId)` — tags the element with a unique per-node identifier
- `remove(element)` — tombstones all current (non-tombstoned) tags for the element
- `has(element)` — returns true if any live (non-tombstoned) tag exists
- `size()` — count of live unique elements
- `value()` — returns a `Set<T>` of all live elements
- `merge(state)` — merges remote entries; tombstone state wins on conflict
- `toJSON()` / `fromJSON()` — serialization round-trip
- `clone()` — deep copy
## Usage: Basic Example
```ts
import { ORSet } from "@suaveplan/crdt";
const set = new ORSet<string>();
set.add("alice", "node-1");
set.add("bob", "node-1");
set.remove("alice");
console.log(set.has("alice")); // false
console.log([...set.value()]); // ["bob"]
```
## Usage: Advanced Example
```ts
// Concurrent add and remove converge correctly
const a = new ORSet<string>();
a.add("x", "n1");
const b = a.clone();
a.remove("x"); // n1 removes x
b.add("x", "n2"); // n2 re-adds x concurrently
a.merge(b.state());
console.log(a.has("x")); // true — concurrent add survives
```
## API Reference
### `ORSet<T>`
- `add(element: T, nodeId: string)` — adds with unique tag
- `remove(element: T)` — tombstones all current live tags
- `has(element: T): boolean`
- `size(): number`
- `value(): Set<T>`
- `merge(state: ORSetState<T>): void`
## Implementation Notes
Elements are compared with `===` (reference equality). For object elements, ensure consistent references or use string/number keys.

View File

@@ -0,0 +1,101 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { CrdtError } from "../../errors/crdt-error.js";
import { ORSet } from "./or-set.js";
describe("ORSet", () => {
it("starts empty", { timeout: 5000 }, () => {
expect(new ORSet().size()).toBe(0);
});
it("add and has", { timeout: 5000 }, () => {
const s = new ORSet<string>();
s.add("a", "n1");
expect(s.has("a")).toBe(true);
});
it("remove tombstones element", { timeout: 5000 }, () => {
const s = new ORSet<string>();
s.add("a", "n1");
s.remove("a");
expect(s.has("a")).toBe(false);
});
it("value returns live elements", { timeout: 5000 }, () => {
const s = new ORSet<string>();
s.add("a", "n1");
s.add("b", "n1");
s.remove("a");
expect([...s.value()]).toContain("b");
expect([...s.value()]).not.toContain("a");
});
it("size counts live elements", { timeout: 5000 }, () => {
const s = new ORSet<string>();
s.add("a", "n1");
s.add("b", "n1");
expect(s.size()).toBe(2);
s.remove("a");
expect(s.size()).toBe(1);
});
it("concurrent add survives remove", { timeout: 5000 }, () => {
const a = new ORSet<string>();
a.add("x", "n1");
const b = a.clone();
a.remove("x");
b.add("x", "n2");
a.merge(b.state());
expect(a.has("x")).toBe(true);
});
it("merge adds new entries", { timeout: 5000 }, () => {
const a = new ORSet<string>();
a.add("a", "n1");
const b = new ORSet<string>();
b.add("b", "n2");
a.merge(b.state());
expect(a.has("b")).toBe(true);
});
it("merge tombstone wins", { timeout: 5000 }, () => {
const a = new ORSet<string>();
a.add("x", "n1");
const b = a.clone();
b.remove("x");
a.merge(b.state());
expect(a.has("x")).toBe(false);
});
it("toJSON / fromJSON round-trip", { timeout: 5000 }, () => {
const a = new ORSet<string>();
a.add("hello", "n1");
const b = new ORSet<string>();
b.fromJSON(a.toJSON());
expect(b.has("hello")).toBe(true);
});
it("fromJSON throws for invalid JSON", { timeout: 5000 }, () => {
expect(() => new ORSet().fromJSON("bad")).toThrow(CrdtError);
});
it("fromJSON throws for wrong type", { timeout: 5000 }, () => {
expect(() =>
new ORSet().fromJSON(
JSON.stringify({ type: "g-counter", entries: [] })
)
).toThrow(CrdtError);
});
it("clone is independent", { timeout: 5000 }, () => {
const a = new ORSet<string>();
a.add("x", "n1");
const b = a.clone();
b.remove("x");
expect(a.has("x")).toBe(true);
});
it("remove non-existent is no-op", { timeout: 5000 }, () => {
const s = new ORSet<string>();
expect(() => s.remove("nonexistent")).not.toThrow();
});
});

View File

@@ -0,0 +1,97 @@
import { CRDT_CODES, CrdtError } from "../../errors/crdt-error.js";
import type { CRDT, ORSetState } from "../../types/crdt.types.js";
type ORSetEntry<T> = { element: T; tag: string; tombstone: boolean };
/**
* Observe-Remove Set CRDT.
*
* Supports concurrent add and remove by tagging each addition with a unique
* identifier. Remove tombstones only the specific tags observed, so concurrent
* adds survive removes.
*/
export class ORSet<T> implements CRDT<Set<T>, ORSetState<T>> {
public readonly id: string;
protected entries: ORSetEntry<T>[] = [];
public constructor(id = crypto.randomUUID()) {
this.id = id;
}
public add(element: T, nodeId: string): void {
const tag = `${nodeId}:${crypto.randomUUID()}`;
this.entries.push({ element, tag, tombstone: false });
}
public remove(element: T): void {
for (const entry of this.entries) {
if (entry.element === element && !entry.tombstone) {
entry.tombstone = true;
}
}
}
public has(element: T): boolean {
return this.entries.some((e) => e.element === element && !e.tombstone);
}
public size(): number {
return this.value().size;
}
public value(): Set<T> {
const set = new Set<T>();
for (const entry of this.entries) {
if (!entry.tombstone) set.add(entry.element);
}
return set;
}
public merge(remote: ORSetState<T>): void {
const existingTags = new Set(this.entries.map((e) => e.tag));
for (const entry of remote.entries) {
if (!existingTags.has(entry.tag)) {
this.entries.push({ ...entry });
} else {
// Update tombstone state (tombstone wins)
const local = this.entries.find((e) => e.tag === entry.tag);
if (local && entry.tombstone) {
local.tombstone = true;
}
}
}
}
public state(): ORSetState<T> {
return {
type: "or-set",
entries: this.entries.map((e) => ({ ...e })),
};
}
public toJSON(): string {
return JSON.stringify(this.state());
}
public fromJSON(json: string): void {
try {
const parsed = JSON.parse(json) as ORSetState<T>;
if (parsed.type !== "or-set") throw new Error("Invalid type");
this.entries = parsed.entries.map((e) => ({ ...e }));
} catch (err) {
throw new CrdtError(
CRDT_CODES.DESERIALIZE_FAILED,
"Invalid ORSet JSON",
{ cause: err as Error }
);
}
}
public clone(): this {
const c = new ORSet<T>(
this.id as `${string}-${string}-${string}-${string}-${string}`
) as this;
c.entries = this.entries.map((e) => ({ ...e }));
return c;
}
}

View File

@@ -0,0 +1 @@
export { PNCounter } from "./pn-counter.js";

View File

@@ -0,0 +1,55 @@
# pn-counter
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Provides `PNCounter`, a positive-negative counter CRDT that supports both increment and decrement operations. Built from two G-Counters (one for increments, one for decrements), the net value is `sum(increments) - sum(decrements)`. All CRDT invariants (commutativity, associativity, idempotency) are preserved.
## Features
- `increment(nodeId, delta?)` — adds to the increment counter for `nodeId`
- `decrement(nodeId, delta?)` — adds to the decrement counter for `nodeId`
- `total()` — net value: sum of all increments minus sum of all decrements
- `merge(state)` — element-wise maximum on both increment and decrement counters
- `toJSON()` / `fromJSON()` — serialization round-trip
- `clone()` — deep copy
## Usage: Basic Example
```ts
import { PNCounter } from "@suaveplan/crdt";
const counter = new PNCounter();
counter.increment("n1", 10);
counter.decrement("n1", 3);
console.log(counter.total()); // 7
```
## Usage: Advanced Example
```ts
import { PNCounter } from "@suaveplan/crdt";
const a = new PNCounter("likes");
a.increment("user-1", 5);
const b = new PNCounter("likes");
b.decrement("user-2", 2);
a.merge(b.state());
console.log(a.total()); // 3 (5 - 2)
```
## API Reference
### `PNCounter`
- `increment(nodeId, delta?)` — throws `OPERATION_INVALID` if delta ≤ 0
- `decrement(nodeId, delta?)` — throws `OPERATION_INVALID` if delta ≤ 0
- `total(): number` — net counter value
## Implementation Notes
The value can be negative if decrements exceed increments. This is intentional — PNCounter does not enforce a floor of 0.

View File

@@ -0,0 +1,131 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { CRDT_CODES, CrdtError } from "../../errors/crdt-error.js";
import { PNCounter } from "./pn-counter.js";
describe("PNCounter", () => {
it("starts at 0", { timeout: 5000 }, () => {
expect(new PNCounter().total()).toBe(0);
});
it("increments", { timeout: 5000 }, () => {
const c = new PNCounter();
c.increment("n1", 10);
expect(c.total()).toBe(10);
});
it("decrements", { timeout: 5000 }, () => {
const c = new PNCounter();
c.increment("n1", 5);
c.decrement("n1", 3);
expect(c.total()).toBe(2);
});
it("can go negative", { timeout: 5000 }, () => {
const c = new PNCounter();
c.decrement("n1", 5);
expect(c.total()).toBe(-5);
});
it("throws for zero increment delta", { timeout: 5000 }, () => {
expect(() => new PNCounter().increment("n1", 0)).toThrow(CrdtError);
});
it("throws for negative decrement delta", { timeout: 5000 }, () => {
expect(() => new PNCounter().decrement("n1", -1)).toThrow(CrdtError);
});
it("decrement throws for zero delta", { timeout: 5000 }, () => {
const err = (() => {
try {
new PNCounter().decrement("n1", 0);
} catch (e) {
return e;
}
})();
expect((err as CrdtError).code).toBe(CRDT_CODES.OPERATION_INVALID);
});
it("merge", { timeout: 5000 }, () => {
const a = new PNCounter();
a.increment("n1", 5);
const b = new PNCounter();
b.increment("n2", 3);
b.decrement("n1", 2);
a.merge(b.state());
expect(a.total()).toBe(6); // 5+3-2
});
it("toJSON / fromJSON round-trip", { timeout: 5000 }, () => {
const a = new PNCounter();
a.increment("n1", 10);
a.decrement("n1", 4);
const b = new PNCounter();
b.fromJSON(a.toJSON());
expect(b.total()).toBe(6);
});
it("fromJSON throws for invalid type", { timeout: 5000 }, () => {
expect(() =>
new PNCounter().fromJSON(
JSON.stringify({
type: "wrong",
increments: {},
decrements: {},
})
)
).toThrow(CrdtError);
});
it("fromJSON throws for bad JSON", { timeout: 5000 }, () => {
expect(() => new PNCounter().fromJSON("bad")).toThrow(CrdtError);
});
it("clone is independent", { timeout: 5000 }, () => {
const a = new PNCounter();
a.increment("n1", 5);
const b = a.clone();
b.increment("n1", 10);
expect(a.total()).toBe(5);
});
it("value() equals total()", { timeout: 5000 }, () => {
const c = new PNCounter();
c.increment("n1", 7);
expect(c.value()).toBe(c.total());
});
it("default delta=1 for increment", { timeout: 5000 }, () => {
const c = new PNCounter();
c.increment("n1");
expect(c.total()).toBe(1);
});
it("default delta=1 for decrement", { timeout: 5000 }, () => {
const c = new PNCounter();
c.increment("n1", 5);
c.decrement("n1");
expect(c.total()).toBe(4);
});
it("merge does not regress lower remote increment", {
timeout: 5000,
}, () => {
const a = new PNCounter();
a.increment("n1", 10);
const b = new PNCounter();
b.increment("n1", 3); // lower than a
a.merge(b.state());
expect(a.total()).toBe(10); // should not decrease
});
it("merge does not regress lower remote decrement", {
timeout: 5000,
}, () => {
const a = new PNCounter();
a.decrement("n1", 10);
const b = new PNCounter();
b.decrement("n1", 3); // lower than a
a.merge(b.state());
expect(a.total()).toBe(-10); // should not change
});
});

View File

@@ -0,0 +1,95 @@
import { CRDT_CODES, CrdtError } from "../../errors/crdt-error.js";
import type { CRDT, PNCounterState } from "../../types/crdt.types.js";
/**
* Positive-Negative counter CRDT.
*
* Composed of two G-Counters: one for increments and one for decrements.
* The value is `sum(increments) - sum(decrements)`.
*/
export class PNCounter implements CRDT<number, PNCounterState> {
public readonly id: string;
protected increments: Record<string, number> = {};
protected decrements: Record<string, number> = {};
public constructor(id = crypto.randomUUID()) {
this.id = id;
}
public increment(nodeId: string, delta = 1): void {
if (delta <= 0) {
throw new CrdtError(
CRDT_CODES.OPERATION_INVALID,
"PNCounter delta must be positive"
);
}
this.increments[nodeId] = (this.increments[nodeId] ?? 0) + delta;
}
public decrement(nodeId: string, delta = 1): void {
if (delta <= 0) {
throw new CrdtError(
CRDT_CODES.OPERATION_INVALID,
"PNCounter delta must be positive"
);
}
this.decrements[nodeId] = (this.decrements[nodeId] ?? 0) + delta;
}
public total(): number {
const inc = Object.values(this.increments).reduce((s, v) => s + v, 0);
const dec = Object.values(this.decrements).reduce((s, v) => s + v, 0);
return inc - dec;
}
public value(): number {
return this.total();
}
public merge(remote: PNCounterState): void {
for (const [node, count] of Object.entries(remote.increments)) {
const cur = this.increments[node] ?? 0;
if (count > cur) this.increments[node] = count;
}
for (const [node, count] of Object.entries(remote.decrements)) {
const cur = this.decrements[node] ?? 0;
if (count > cur) this.decrements[node] = count;
}
}
public state(): PNCounterState {
return {
type: "pn-counter",
increments: { ...this.increments },
decrements: { ...this.decrements },
};
}
public toJSON(): string {
return JSON.stringify(this.state());
}
public fromJSON(json: string): void {
try {
const parsed = JSON.parse(json) as PNCounterState;
if (parsed.type !== "pn-counter") throw new Error("Invalid type");
this.increments = { ...parsed.increments };
this.decrements = { ...parsed.decrements };
} catch (err) {
throw new CrdtError(
CRDT_CODES.DESERIALIZE_FAILED,
"Invalid PNCounter JSON",
{ cause: err as Error }
);
}
}
public clone(): this {
const c = new PNCounter(
this.id as `${string}-${string}-${string}-${string}-${string}`
) as this;
c.increments = { ...this.increments };
c.decrements = { ...this.decrements };
return c;
}
}

View File

@@ -0,0 +1 @@
export { RGA } from "./rga.js";

View File

@@ -0,0 +1,136 @@
# rga
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Implements the Replicated Growable Array (RGA) CRDT, a list-oriented conflict-free
replicated data type that supports concurrent insertions and deletions while
guaranteeing convergence to the same element order on all replicas. Each element
is tagged with a unique (sequence-number, node-id) identifier so that causally
ordered operations are reflected correctly and concurrent siblings are resolved
deterministically without coordination.
## Features
- Concurrent `insert` and `remove` operations with causal ordering
- Tombstone-based removal that preserves relative positions for later merges
- Tie-breaking via `(seq DESC, nodeId DESC)` for concurrent sibling inserts
- `idAt(index)` and `indexOf(id)` for positional queries on the visible array
- Full `merge`, `toJSON`/`fromJSON`, `clone`, and `state` CRDT interface
- `fromJSON` rebuilds from serialized state and advances internal seq counter
## Usage: Basic Example
```ts
import { RGA } from "@suaveplan/crdt/crdts/rga/rga";
const doc = new RGA<string>("replicaA");
const id0 = doc.insert("hello");
const id1 = doc.insert("world", id0);
console.log(doc.value()); // ["hello", "world"]
doc.remove(id0);
console.log(doc.value()); // ["world"]
```
## Usage: Advanced Example
```ts
import { RGA } from "@suaveplan/crdt/crdts/rga/rga";
// Two replicas insert concurrently after the same predecessor
const a = new RGA<string>("nodeA");
const b = new RGA<string>("nodeB");
const rootId = a.insert("root");
b.merge(a.state()); // b now knows about "root"
// Concurrent inserts after "root"
const aId = a.insert("from-A", rootId);
const bId = b.insert("from-B", rootId);
// Merge both ways
a.merge(b.state());
b.merge(a.state());
// Both replicas converge to the same order
console.log(a.value()); // deterministic total order: e.g. ["root", "from-B", "from-A"]
console.log(b.value()); // identical
```
## API Reference
### `new RGA<T>(nodeId?, id?)`
Creates a new RGA instance for the given replica node. `nodeId` defaults to
`"node"` and `id` defaults to a random UUID.
### `insert(value, afterId?): string`
Inserts `value` after the element identified by `afterId` (null or omitted = prepend).
Returns the new element's unique id.
### `remove(id): void`
Marks the element with `id` as a tombstone. The element is hidden from `value()`
but retained for future merge convergence.
### `value(): T[]`
Returns all non-tombstoned elements in document order.
### `idAt(index): string | undefined`
Returns the id of the element at the given visible index, or `undefined` if out
of range.
### `indexOf(id): number`
Returns the visible index of the element with `id`, or `-1` if tombstoned or
absent.
### `merge(remote: RGAState<T>): void`
Integrates a remote replica's state. New elements are inserted at their correct
positions; tombstones propagate (tombstone wins).
Throws `CrdtError(DESERIALIZE_FAILED)` if the remote type field does not match.
### `state(): RGAState<T>`
Returns a snapshot of the internal sequence including tombstones.
### `toJSON(): string`
Serializes state to a JSON string.
### `fromJSON(json): void`
Resets the instance and rebuilds from a JSON string.
Throws `CrdtError(DESERIALIZE_FAILED)` on malformed JSON or wrong type field.
### `clone(): this`
Returns an independent deep copy of the RGA instance.
## Implementation Notes
The predecessor-based insertion strategy is derived from the original RGA paper
(Roh et al., 2011). Each node records which element it was inserted after rather
than an absolute index, making the algorithm position-independent and robust to
concurrent inserts at the same predecessor.
Tie-breaking compares `(seq DESC, nodeId DESC)` — a higher sequence number wins,
and among equal sequences the lexicographically larger node-id wins. This matches
common RGA implementations and is consistent across all replicas given the same
inputs.
Tombstones are never removed (no garbage collection) to ensure replicas that
receive operations out of order can still converge. In production systems with
bounded document lifetime, a GC epoch mechanism should be layered on top.
The `predecessorFromElements` helper reconstructs predecessor links from the
flat ordered element list produced by `state()`. This keeps the wire format
simple (no extra predecessor field) at the cost of an O(n) scan per element
during merge — acceptable for document sizes encountered in practice.

View File

@@ -0,0 +1,332 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { CRDT_CODES, CrdtError } from "../../errors/crdt-error.js";
import { RGA } from "./rga.js";
describe("RGA", () => {
it("default constructor uses 'node' as nodeId", { timeout: 5000 }, () => {
const r = new RGA();
const id = r.insert("x");
expect(id).toMatch(/@node$/);
});
it("starts empty", { timeout: 5000 }, () => {
expect(new RGA("n").value()).toEqual([]);
});
it("insert at head (null predecessor)", { timeout: 5000 }, () => {
const r = new RGA<string>("n");
r.insert("a");
expect(r.value()).toEqual(["a"]);
});
it("insert after existing element", { timeout: 5000 }, () => {
const r = new RGA<string>("n");
const id0 = r.insert("a");
r.insert("b", id0);
expect(r.value()).toEqual(["a", "b"]);
});
it("insert at head multiple times builds reverse order", {
timeout: 5000,
}, () => {
const r = new RGA<string>("n");
r.insert("c");
r.insert("b");
r.insert("a");
// Each prepend goes before the previous head element
expect(r.value()).toHaveLength(3);
});
it("remove hides element", { timeout: 5000 }, () => {
const r = new RGA<string>("n");
const id = r.insert("x");
r.remove(id);
expect(r.value()).toEqual([]);
});
it("remove unknown id is no-op", { timeout: 5000 }, () => {
const r = new RGA<string>("n");
expect(() => r.remove("nonexistent")).not.toThrow();
});
it("idAt returns correct id", { timeout: 5000 }, () => {
const r = new RGA<string>("n");
const id0 = r.insert("a");
r.insert("b", id0);
expect(r.idAt(0)).toBe(id0);
});
it("idAt returns undefined for out-of-range", { timeout: 5000 }, () => {
const r = new RGA<string>("n");
expect(r.idAt(0)).toBeUndefined();
});
it("indexOf returns visible index", { timeout: 5000 }, () => {
const r = new RGA<string>("n");
const id0 = r.insert("a");
const id1 = r.insert("b", id0);
expect(r.indexOf(id0)).toBe(0);
expect(r.indexOf(id1)).toBe(1);
});
it("indexOf returns -1 for tombstoned element", { timeout: 5000 }, () => {
const r = new RGA<string>("n");
const id = r.insert("a");
r.remove(id);
expect(r.indexOf(id)).toBe(-1);
});
it("indexOf returns -1 for unknown id", { timeout: 5000 }, () => {
expect(new RGA("n").indexOf("nope")).toBe(-1);
});
it("indexOf skips tombstoned elements when counting index", {
timeout: 5000,
}, () => {
const r = new RGA<string>("n");
const id0 = r.insert("a");
const id1 = r.insert("b", id0);
const id2 = r.insert("c", id1);
r.remove(id1); // tombstone "b"
// "c" should now be at visible index 1 (after "a", skipping tombstoned "b")
expect(r.indexOf(id2)).toBe(1);
});
it("merge integrates remote inserts", { timeout: 5000 }, () => {
const a = new RGA<string>("a");
const b = new RGA<string>("b");
a.insert("hello");
b.merge(a.state());
expect(b.value()).toEqual(["hello"]);
});
it("merge propagates tombstone", { timeout: 5000 }, () => {
const a = new RGA<string>("a");
const b = new RGA<string>("b");
const id = a.insert("x");
b.merge(a.state());
a.remove(id);
b.merge(a.state());
expect(b.value()).toEqual([]);
});
it("concurrent inserts converge deterministically", {
timeout: 5000,
}, () => {
const a = new RGA<string>("a");
const b = new RGA<string>("b");
const root = a.insert("root");
b.merge(a.state());
a.insert("from-a", root);
b.insert("from-b", root);
a.merge(b.state());
b.merge(a.state());
expect(a.value()).toEqual(b.value());
expect(a.value()).toContain("root");
expect(a.value()).toContain("from-a");
expect(a.value()).toContain("from-b");
});
it("merge with wrong type throws", { timeout: 5000 }, () => {
const r = new RGA("n");
expect(() =>
r.merge({ type: "or-set" as never, elements: [] })
).toThrow(CrdtError);
const err = (() => {
try {
r.merge({ type: "or-set" as never, elements: [] });
} catch (e) {
return e;
}
})();
expect((err as CrdtError).code).toBe(CRDT_CODES.DESERIALIZE_FAILED);
});
it("toJSON / fromJSON round-trip", { timeout: 5000 }, () => {
const a = new RGA<string>("n");
a.insert("hello");
const b = new RGA<string>("n");
b.fromJSON(a.toJSON());
expect(b.value()).toEqual(["hello"]);
});
it("fromJSON throws for bad JSON", { timeout: 5000 }, () => {
expect(() => new RGA("n").fromJSON("{bad}")).toThrow(CrdtError);
});
it("fromJSON throws for wrong type", { timeout: 5000 }, () => {
expect(() =>
new RGA("n").fromJSON(
JSON.stringify({ type: "or-set", elements: [] })
)
).toThrow(CrdtError);
});
it("clone is independent", { timeout: 5000 }, () => {
const a = new RGA<string>("n");
const id = a.insert("x");
const b = a.clone();
b.remove(id);
expect(a.value()).toEqual(["x"]);
expect(b.value()).toEqual([]);
});
it("three-way convergence", { timeout: 5000 }, () => {
const a = new RGA<number>("a");
const b = new RGA<number>("b");
const c = new RGA<number>("c");
const id1 = a.insert(1);
b.merge(a.state());
c.merge(a.state());
a.insert(2, id1);
b.insert(3, id1);
c.insert(4, id1);
a.merge(b.state());
a.merge(c.state());
b.merge(a.state());
c.merge(a.state());
expect(a.value()).toEqual(b.value());
expect(b.value()).toEqual(c.value());
});
it("merge: element with non-lower-priority sibling takes else-break path", {
timeout: 5000,
}, () => {
// a inserts "root" then "low-prio" (nodeId "a" < "b")
// b inserts "high-prio" concurrently at same predecessor with higher seq
const a = new RGA<string>("a");
const b = new RGA<string>("b");
const root = a.insert("root");
b.merge(a.state());
// a inserts after root; b inserts after root with b's seq
a.insert("from-a", root);
b.insert("from-b", root);
// Merge b into a — "from-b" has higher nodeId so it goes before "from-a"
a.merge(b.state());
expect(a.value()).toContain("from-a");
expect(a.value()).toContain("from-b");
});
it("merge: sibling with higher nodeId triggers pos++ before inserting lower-priority node", {
timeout: 5000,
}, () => {
// c inserts after root, then b inserts after root concurrently
// When b merges c's state: c-first has nodeId "c" > "b",
// so the loop must skip c-first (pos++) before placing b-first before it
const a = new RGA<string>("a"); // acts as bootstrap node
const b = new RGA<string>("b");
const c = new RGA<string>("c");
const root = a.insert("root"); // 1@a
b.merge(a.state());
c.merge(a.state());
c.insert("c-first", root); // 1@c — inserts after root
b.insert("b-first", root); // 1@b — inserts after root
// c merges b's state: c already has c-first (nodeId="c", seq=1)
// Now merging b-first (nodeId="b", seq=1): sibling c-first has "c">"b" → pos++ fires
// b-first should be inserted AFTER c-first (lower priority)
c.merge(b.state());
// Both elements are present after merge
expect(c.value()).toContain("c-first");
expect(c.value()).toContain("b-first");
// c-first (higher nodeId) should appear before b-first in the list
const vals = c.value();
const cIdx = vals.indexOf("c-first");
const bIdx = vals.indexOf("b-first");
expect(cIdx).toBeLessThan(bIdx);
void root; // suppress unused warning
});
it("insertNode: sibling with different predecessor stops scan (break on predecessor mismatch)", {
timeout: 5000,
}, () => {
// We need: [root, Y(pred=root, high-priority), Z(pred=Y)] and insert W(pred=root, lower priority)
// Loop scans Y(same pred, higher priority→pos++), then Z(pred=Y≠root → break)
class TestRGA extends RGA<string> {
public testInsertNode(
node: Parameters<RGA<string>["insertNode"]>[0]
): void {
this.insertNode(node);
}
}
const a = new TestRGA("a");
const rootId = a.insert("root"); // 1@a
// Insert Y directly with higher seq than the W we'll insert
a.testInsertNode({
id: "5@b",
value: "Y",
tombstone: false,
predecessor: rootId,
seq: 5,
nodeId: "b",
});
// Insert Z after Y
a.testInsertNode({
id: "6@b",
value: "Z",
tombstone: false,
predecessor: "5@b",
seq: 6,
nodeId: "b",
});
// Now insert W after root with seq=2 (lower than Y's seq=5)
// Loop: sibling=Y(pred=root, seq=5 > 2) → pos++
// sibling=Z(pred="5@b" ≠ rootId) → LINE 181 break
a.testInsertNode({
id: "2@c",
value: "W",
tombstone: false,
predecessor: rootId,
seq: 2,
nodeId: "c",
});
expect(a.value()).toContain("Y");
expect(a.value()).toContain("Z");
expect(a.value()).toContain("W");
// W should come after Y since Y has higher priority
const vals = a.value();
expect(vals.indexOf("Y")).toBeLessThan(vals.indexOf("W"));
});
it("merge handles out-of-order predecessors gracefully", {
timeout: 5000,
}, () => {
// Craft a state where an element references a predecessor that the
// receiver hasn't seen yet. With the correct predecessor stored in state,
// the unknown predecessor causes the node to be inserted at head position.
const a = new RGA<string>("a");
const id1 = a.insert("first");
const id2 = a.insert("second", id1);
// Build a partial state with only the second element (predecessor=id1 unknown to b)
const partialState = {
type: "rga" as const,
elements: [
{
id: id2,
value: "second",
tombstone: false,
predecessor: id1,
},
],
};
const b = new RGA<string>("b");
b.merge(partialState);
// "second" should be present (inserted at head since predecessor unknown)
expect(b.value()).toContain("second");
});
});

View File

@@ -0,0 +1,215 @@
import { CRDT_CODES, CrdtError } from "../../errors/crdt-error.js";
import type { CRDT, RGAState } from "../../types/crdt.types.js";
/**
* Internal RGA node including predecessor link for ordering.
*/
interface RGANode<T> {
id: string;
value: T | null;
tombstone: boolean;
predecessor: string | null; // null = head sentinel
seq: number;
nodeId: string;
}
/**
* Replicated Growable Array (RGA) CRDT.
*
* Supports concurrent insertions and deletions with causal ordering preserved
* across replicas. Each element carries a globally unique, monotonically
* increasing (seq, nodeId) identifier. On conflict the higher seq wins; ties
* are broken by lexicographic nodeId comparison to ensure all replicas converge
* to the same total order without coordination.
*/
export class RGA<T> implements CRDT<T[], RGAState<T>> {
public readonly id: string;
protected nodes: RGANode<T>[] = [];
protected seq = 0;
protected nodeId: string;
public constructor(nodeId = "node", id = crypto.randomUUID()) {
this.id = id;
this.nodeId = nodeId;
}
// ------------------------------------------------------------------
// Mutation API
// ------------------------------------------------------------------
/** Insert `value` after the element with `afterId` (null = prepend). */
public insert(value: T, afterId: string | null = null): string {
this.seq += 1;
const newId = `${this.seq}@${this.nodeId}`;
const newNode: RGANode<T> = {
id: newId,
value,
tombstone: false,
predecessor: afterId,
seq: this.seq,
nodeId: this.nodeId,
};
this.insertNode(newNode);
return newId;
}
/** Remove the element with the given id (marks as tombstone). */
public remove(id: string): void {
const node = this.nodes.find((n) => n.id === id);
if (node) node.tombstone = true;
}
// ------------------------------------------------------------------
// Query API
// ------------------------------------------------------------------
/** Return visible values in document order. */
public value(): T[] {
return this.nodes.filter((n) => !n.tombstone).map((n) => n.value as T);
}
/** Return the id of the element at visible index `index`. */
public idAt(index: number): string | undefined {
const visible = this.nodes.filter((n) => !n.tombstone);
return visible[index]?.id;
}
/** Return the visible index of element `id`, or -1. */
public indexOf(id: string): number {
let idx = 0;
for (const node of this.nodes) {
if (node.id === id) return node.tombstone ? -1 : idx;
if (!node.tombstone) idx++;
}
return -1;
}
// ------------------------------------------------------------------
// CRDT interface
// ------------------------------------------------------------------
public merge(remote: RGAState<T>): void {
if (remote.type !== "rga") {
throw new CrdtError(
CRDT_CODES.DESERIALIZE_FAILED,
"Wrong CRDT type for RGA merge"
);
}
const knownIds = new Set(this.nodes.map((n) => n.id));
for (const el of remote.elements) {
if (knownIds.has(el.id)) {
// Merge tombstone: tombstone wins
const local = this.nodes.find((n) => n.id === el.id);
if (local && el.tombstone) local.tombstone = true;
} else {
const seq = this.parseSeq(el.id);
const nId = this.parseNodeId(el.id);
const node: RGANode<T> = {
id: el.id,
value: el.value,
tombstone: el.tombstone,
predecessor: el.predecessor,
seq,
nodeId: nId,
};
this.insertNode(node);
knownIds.add(el.id);
if (seq > this.seq) this.seq = seq;
}
}
}
public state(): RGAState<T> {
return {
type: "rga",
elements: this.nodes.map((n) => ({
id: n.id,
value: n.value,
tombstone: n.tombstone,
predecessor: n.predecessor,
})),
};
}
public toJSON(): string {
return JSON.stringify(this.state());
}
public fromJSON(json: string): void {
let parsed: RGAState<T>;
try {
parsed = JSON.parse(json) as RGAState<T>;
} catch (err) {
throw new CrdtError(
CRDT_CODES.DESERIALIZE_FAILED,
"Invalid RGA JSON",
{ cause: err as Error }
);
}
if (parsed.type !== "rga") {
throw new CrdtError(
CRDT_CODES.DESERIALIZE_FAILED,
"Wrong type field in RGA JSON"
);
}
this.nodes = [];
this.seq = 0;
this.merge(parsed);
}
public clone(): this {
const c = new RGA<T>(
this.nodeId,
this.id as `${string}-${string}-${string}-${string}-${string}`
) as this;
c.nodes = this.nodes.map((n) => ({ ...n }));
c.seq = this.seq;
return c;
}
// ------------------------------------------------------------------
// Internal helpers
// ------------------------------------------------------------------
/**
* Insert a node into `this.nodes` respecting predecessor links and
* the (seq, nodeId) tie-breaking rule for concurrent siblings.
*/
protected insertNode(node: RGANode<T>): void {
// Find the position right after the predecessor (or -1 = before all for head inserts).
let insertAfter = -1; // index into this.nodes; -1 = before all
if (node.predecessor !== null) {
const predIdx = this.nodes.findIndex(
(n) => n.id === node.predecessor
);
// predIdx === -1 means predecessor not yet present; treat as head insert
// (only reachable with manually crafted out-of-order states).
insertAfter = predIdx;
}
// Skip over siblings that have higher priority (higher seq or same seq but larger nodeId).
let pos = insertAfter + 1;
while (pos < this.nodes.length) {
const sibling = this.nodes[pos]!;
if (sibling.predecessor !== node.predecessor) break;
if (
sibling.seq > node.seq ||
(sibling.seq === node.seq && sibling.nodeId > node.nodeId)
) {
pos++;
} else {
break;
}
}
this.nodes.splice(pos, 0, node);
}
protected parseSeq(id: string): number {
return Number.parseInt(id, 10);
}
protected parseNodeId(id: string): string {
return id.split("@")[1] as string;
}
}

View File

@@ -0,0 +1 @@
export { TwoPSet } from "./two-p-set.js";

View File

@@ -0,0 +1,56 @@
# two-p-set
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Provides `TwoPSet<T>`, a Two-Phase Set CRDT. Elements can be added and removed exactly once; removed elements can never be re-added. The set value is the difference between the add-set and the remove-set. This guarantees eventual consistency at the cost of restricting re-addition.
## Features
- `add(element)` — adds to the A-set; throws `OPERATION_INVALID` if element was previously removed
- `remove(element)` — moves element to the R-set; no-op if not in A-set
- `has(element)` — true if in A-set but not R-set
- `value()` — returns a `Set<T>` of live elements
- `merge(state)` — union-merges both A-set and R-set
- `toJSON()` / `fromJSON()` — serialization round-trip
- `clone()` — deep copy
## Usage: Basic Example
```ts
import { TwoPSet } from "@suaveplan/crdt";
const set = new TwoPSet<string>();
set.add("alice");
set.remove("alice");
console.log(set.has("alice")); // false
```
## Usage: Advanced Example
```ts
import { TwoPSet } from "@suaveplan/crdt";
const a = new TwoPSet<number>();
a.add(1);
const b = new TwoPSet<number>();
b.add(2);
a.merge(b.state());
console.log([...a.value()]); // [1, 2]
```
## API Reference
### `TwoPSet<T>`
- `add(element)` — throws `OPERATION_INVALID` if previously removed
- `remove(element)` — no-op if element not in A-set
- `has(element): boolean`
- `value(): Set<T>`
- `merge(state: TwoPSetState<T>): void`
## Implementation Notes
Elements are serialised to JSON for key comparison, enabling support for object elements with structural equality. The restriction that removed elements cannot be re-added is a fundamental invariant of 2P-Set that enables simple merge logic.

View File

@@ -0,0 +1,125 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { CRDT_CODES, CrdtError } from "../../errors/crdt-error.js";
import { TwoPSet } from "./two-p-set.js";
describe("TwoPSet", () => {
it("starts empty", { timeout: 5000 }, () => {
expect(new TwoPSet().value().size).toBe(0);
});
it("add and has", { timeout: 5000 }, () => {
const s = new TwoPSet<string>();
s.add("a");
expect(s.has("a")).toBe(true);
});
it("remove", { timeout: 5000 }, () => {
const s = new TwoPSet<string>();
s.add("a");
s.remove("a");
expect(s.has("a")).toBe(false);
});
it("cannot re-add removed element", { timeout: 5000 }, () => {
const s = new TwoPSet<string>();
s.add("a");
s.remove("a");
expect(() => s.add("a")).toThrow(CrdtError);
const err = (() => {
try {
s.add("a");
} catch (e) {
return e;
}
})();
expect((err as CrdtError).code).toBe(CRDT_CODES.OPERATION_INVALID);
});
it("remove non-added is no-op", { timeout: 5000 }, () => {
expect(() => new TwoPSet().remove("x")).not.toThrow();
});
it("merge unions both sets", { timeout: 5000 }, () => {
const a = new TwoPSet<string>();
a.add("a");
const b = new TwoPSet<string>();
b.add("b");
a.merge(b.state());
expect(a.has("b")).toBe(true);
});
it("merge with removal respected", { timeout: 5000 }, () => {
const a = new TwoPSet<string>();
a.add("x");
const b = a.clone();
b.remove("x");
a.merge(b.state());
expect(a.has("x")).toBe(false);
});
it("toJSON / fromJSON round-trip", { timeout: 5000 }, () => {
const a = new TwoPSet<string>();
a.add("hello");
const b = new TwoPSet<string>();
b.fromJSON(a.toJSON());
expect(b.has("hello")).toBe(true);
});
it("fromJSON throws for bad JSON", { timeout: 5000 }, () => {
expect(() => new TwoPSet().fromJSON("bad")).toThrow(CrdtError);
});
it("fromJSON throws for wrong type", { timeout: 5000 }, () => {
expect(() =>
new TwoPSet().fromJSON(
JSON.stringify({ type: "or-set", added: [], removed: [] })
)
).toThrow(CrdtError);
});
it("clone is independent", { timeout: 5000 }, () => {
const a = new TwoPSet<string>();
a.add("x");
const b = a.clone();
b.remove("x");
expect(a.has("x")).toBe(true);
});
it("value returns live elements", { timeout: 5000 }, () => {
const s = new TwoPSet<number>();
s.add(1);
s.add(2);
s.remove(1);
expect([...s.value()]).toContain(2);
expect([...s.value()]).not.toContain(1);
});
it("merge with removed-only element (element in removed but not added)", {
timeout: 5000,
}, () => {
const a = new TwoPSet<string>();
a.add("x");
a.remove("x");
// Build a partial remote state where "y" appears in removed but not added
const s = new TwoPSet<string>();
s.merge({ type: "2p-set", added: [], removed: ["y"] });
// state() for s should not include "y" in removedArr since it's not in elementMap
const st = s.state();
expect(st.removed).toHaveLength(0); // "y" filtered out since not in elementMap
});
it("toJSON / fromJSON preserves removed elements", {
timeout: 5000,
}, () => {
const s = new TwoPSet<string>();
s.add("a");
s.add("b");
s.remove("a");
const s2 = new TwoPSet<string>();
s2.fromJSON(s.toJSON());
expect(s2.has("a")).toBe(false);
expect(s2.has("b")).toBe(true);
// re-adding "a" should still throw after deserialization
expect(() => s2.add("a")).toThrow();
});
});

View File

@@ -0,0 +1,126 @@
import { CRDT_CODES, CrdtError } from "../../errors/crdt-error.js";
import type { CRDT, TwoPSetState } from "../../types/crdt.types.js";
/**
* Two-Phase Set CRDT.
*
* Elements can be added and removed, but removed elements can never be
* re-added. The set value is `added - removed`.
*/
export class TwoPSet<T> implements CRDT<Set<T>, TwoPSetState<T>> {
public readonly id: string;
protected added: Set<string>;
protected removed: Set<string>;
protected elementMap = new Map<string, T>();
public constructor(id = crypto.randomUUID()) {
this.id = id;
this.added = new Set();
this.removed = new Set();
}
protected toKey(element: T): string {
return JSON.stringify(element);
}
public add(element: T): void {
const key = this.toKey(element);
if (this.removed.has(key)) {
throw new CrdtError(
CRDT_CODES.OPERATION_INVALID,
"2P-Set: Cannot re-add a removed element"
);
}
this.added.add(key);
this.elementMap.set(key, element);
}
public remove(element: T): void {
const key = this.toKey(element);
if (this.added.has(key)) {
this.removed.add(key);
}
}
public has(element: T): boolean {
const key = this.toKey(element);
return this.added.has(key) && !this.removed.has(key);
}
public value(): Set<T> {
const set = new Set<T>();
for (const key of this.added) {
if (!this.removed.has(key)) {
// elementMap always has key when added contains it (invariant)
set.add(this.elementMap.get(key) as T);
}
}
return set;
}
public merge(remote: TwoPSetState<T>): void {
for (const el of remote.added) {
const key = this.toKey(el);
this.added.add(key);
this.elementMap.set(key, el);
}
for (const el of remote.removed) {
const key = this.toKey(el);
this.removed.add(key);
}
}
public state(): TwoPSetState<T> {
const addedArr: T[] = [];
const removedArr: T[] = [];
for (const key of this.added) {
addedArr.push(this.elementMap.get(key) as T);
}
for (const key of this.removed) {
const el = this.elementMap.get(key);
// el may be undefined if element was only seen in removed (e.g. via merge)
if (el !== undefined) removedArr.push(el);
}
return { type: "2p-set", added: addedArr, removed: removedArr };
}
public toJSON(): string {
return JSON.stringify(this.state());
}
public fromJSON(json: string): void {
try {
const parsed = JSON.parse(json) as TwoPSetState<T>;
if (parsed.type !== "2p-set") throw new Error("Invalid type");
this.added = new Set();
this.removed = new Set();
this.elementMap = new Map();
for (const el of parsed.added) {
const key = this.toKey(el);
this.added.add(key);
this.elementMap.set(key, el);
}
for (const el of parsed.removed) {
const key = this.toKey(el);
this.removed.add(key);
this.elementMap.set(key, el);
}
} catch (err) {
throw new CrdtError(
CRDT_CODES.DESERIALIZE_FAILED,
"Invalid TwoPSet JSON",
{ cause: err as Error }
);
}
}
public clone(): this {
const c = new TwoPSet<T>(
this.id as `${string}-${string}-${string}-${string}-${string}`
) as this;
c.added = new Set(this.added);
c.removed = new Set(this.removed);
c.elementMap = new Map(this.elementMap);
return c;
}
}

View File

@@ -0,0 +1,52 @@
# crdt-error
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Defines the structured error type and stable error-code registry for `@suaveplan/crdt`. CRDT operations should never throw in normal use — these errors guard against invalid states, deserialization failures, and programmer errors (invalid operations).
## Features
- `CRDT_CODES` registry via `@suaveplan/error`'s `defineCodes` — codes namespaced `CRDT.*`
- `CrdtError` extends `SuaveplanError` for cause-chain traversal
- Four codes: `MERGE_CONFLICT`, `INVALID_STATE`, `DESERIALIZE_FAILED`, `OPERATION_INVALID`
- `CrdtCode` union type for exhaustive switch statements
## Usage: Basic Example
```ts
import { CrdtError, CRDT_CODES } from "@suaveplan/crdt";
throw new CrdtError(CRDT_CODES.DESERIALIZE_FAILED, "Unknown CRDT type in JSON");
```
## Usage: Advanced Example
```ts
import { CrdtError, CRDT_CODES } from "@suaveplan/crdt";
function validateMerge(state: unknown): void {
if (typeof state !== "object" || state === null) {
throw new CrdtError(CRDT_CODES.INVALID_STATE, "State must be an object");
}
}
```
## API Reference
### `CRDT_CODES`
- `CRDT_CODES.MERGE_CONFLICT` — incompatible state types during merge
- `CRDT_CODES.INVALID_STATE` — deserialized or constructed state is structurally invalid
- `CRDT_CODES.DESERIALIZE_FAILED` — JSON deserialization failed
- `CRDT_CODES.OPERATION_INVALID` — attempt to remove from G-Set or similar invariant violation
### `CrdtError`
Extends `SuaveplanError<CrdtCode>`. Constructor: `(code, message, options?)`.
## Implementation Notes
Codes are prefixed with `"CRDT."` by `defineCodes`. Most CRDT methods are synchronous and do not throw in normal use — errors indicate programmer mistakes or data corruption.

View File

@@ -0,0 +1,31 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { CRDT_CODES, type CrdtCode, CrdtError } from "./crdt-error.js";
describe("CrdtError", () => {
it("has correct name", { timeout: 5000 }, () => {
expect(new CrdtError(CRDT_CODES.INVALID_STATE, "test").name).toBe(
"CrdtError"
);
});
it("carries code", { timeout: 5000 }, () => {
const err = new CrdtError(CRDT_CODES.MERGE_CONFLICT, "conflict");
expect(err.code).toBe(CRDT_CODES.MERGE_CONFLICT);
});
it("all codes start with CRDT.", { timeout: 5000 }, () => {
const codes: CrdtCode[] = [
CRDT_CODES.MERGE_CONFLICT,
CRDT_CODES.INVALID_STATE,
CRDT_CODES.DESERIALIZE_FAILED,
CRDT_CODES.OPERATION_INVALID,
];
for (const c of codes) expect(c).toMatch(/^CRDT\./);
});
it("instanceof checks", { timeout: 5000 }, () => {
const err = new CrdtError(CRDT_CODES.OPERATION_INVALID, "invalid op");
expect(err instanceof CrdtError).toBe(true);
expect(err instanceof Error).toBe(true);
});
});

View File

@@ -0,0 +1,32 @@
import { defineCodes, SuaveplanError } from "@suaveplan/error";
/**
* Stable error-code registry for `@suaveplan/crdt`.
*/
export const CRDT_CODES = defineCodes("CRDT", [
"MERGE_CONFLICT",
"INVALID_STATE",
"DESERIALIZE_FAILED",
"OPERATION_INVALID",
] as const);
export type CrdtCode = (typeof CRDT_CODES)[keyof typeof CRDT_CODES];
/**
* Typed error for all `@suaveplan/crdt` failures.
*
* @example
* ```ts
* throw new CrdtError(CRDT_CODES.INVALID_STATE, "Negative counter value");
* ```
*/
export class CrdtError extends SuaveplanError<CrdtCode> {
public constructor(
code: CrdtCode,
message: string,
options?: { cause?: Error; context?: Record<string, unknown> }
) {
super(code, message, options);
this.name = "CrdtError";
}
}

View File

@@ -0,0 +1,2 @@
export type { CrdtCode } from "./crdt-error.js";
export { CRDT_CODES, CrdtError } from "./crdt-error.js";

View File

@@ -0,0 +1,38 @@
// Errors
// Constants
export { DEFAULT_CRDT_ID_PREFIX, DEFAULT_NODE_ID } from "./constants/index.js";
export { CrdtSyncManager } from "./core/sync-manager/sync-manager.js";
export type {
CrdtRegistration,
SyncDelta,
SyncManagerOptions,
SyncResult,
} from "./core/sync-manager/sync-manager.types.js";
// Core
export { VectorClock } from "./core/vector-clock/vector-clock.js";
// CRDTs
export { GCounter } from "./crdts/g-counter/g-counter.js";
export { LWWRegister } from "./crdts/lww-register/lww-register.js";
export { MVRegister } from "./crdts/mv-register/mv-register.js";
export { ORSet } from "./crdts/or-set/or-set.js";
export { PNCounter } from "./crdts/pn-counter/pn-counter.js";
export { RGA } from "./crdts/rga/rga.js";
export { TwoPSet } from "./crdts/two-p-set/two-p-set.js";
export type { CrdtCode } from "./errors/crdt-error.js";
export { CRDT_CODES, CrdtError } from "./errors/crdt-error.js";
// Types
export type {
CRDT,
CrdtOptions,
GCounterState,
LWWRegisterState,
MVRegisterState,
ORSetState,
PNCounterState,
RGAElement,
RGAState,
TwoPSetState,
VectorClockState,
} from "./types/crdt.types.js";

View File

@@ -0,0 +1,48 @@
# crdt.types
## Purpose
Shared TypeScript type definitions for the `@suaveplan/crdt` package. Defines the `CRDT<TValue, TState>` base interface, all serializable state shapes, and construction options. All CRDT implementations depend on these types for consistent merge, serialization, and cloning contracts.
## Features
- `CRDT<TValue, TState>` — base interface with `value()`, `merge()`, `state()`, `toJSON()`, `fromJSON()`, `clone()`
- State types: `GCounterState`, `PNCounterState`, `ORSetState<T>`, `LWWRegisterState<T>`, `MVRegisterState<T>`, `TwoPSetState<T>`, `RGAState<T>`
- `VectorClockState` — alias for `Record<string, number>`
- `CrdtOptions` — base constructor options with optional `id` and `nodeId`
## Usage: Basic Example
```ts
import type { CRDT, GCounterState } from "@suaveplan/crdt";
function merge<T>(a: CRDT<T>, b: CRDT<T>): void {
a.merge(b.state());
}
```
## Usage: Advanced Example
```ts
import type { ORSetState } from "@suaveplan/crdt";
function serialize<T>(state: ORSetState<T>): string {
return JSON.stringify(state);
}
```
## API Reference
### `CRDT<TValue, TState>`
- `id: string` — stable identifier
- `value(): TValue` — current computed value
- `merge(remote: TState): void` — merges remote state
- `state(): TState` — serializable snapshot
- `toJSON(): string` — JSON serialisation
- `fromJSON(json: string): void` — deserialises into this instance
- `clone(): this` — deep copy
## Implementation Notes
All state types include a `type` discriminator string for runtime identification during deserialization. The `RGAElement<T>` uses `value: T | null` where `null` represents a tombstoned element.

View File

@@ -0,0 +1,95 @@
/**
* Common interface that all CRDT implementations satisfy.
*/
export interface CRDT<TValue, TState = unknown> {
readonly id: string;
value(): TValue;
merge(remote: TState): void;
state(): TState;
toJSON(): string;
fromJSON(json: string): void;
clone(): this;
}
/**
* G-Counter state.
*/
export interface GCounterState {
type: "g-counter";
counters: Record<string, number>;
}
/**
* PN-Counter state.
*/
export interface PNCounterState {
type: "pn-counter";
increments: Record<string, number>;
decrements: Record<string, number>;
}
/**
* OR-Set state.
*/
export interface ORSetState<T> {
type: "or-set";
entries: Array<{ element: T; tag: string; tombstone: boolean }>;
}
/**
* LWW-Register state.
*/
export interface LWWRegisterState<T> {
type: "lww-register";
value: T | undefined;
timestamp: number;
nodeId: string;
}
/**
* MV-Register state.
*/
export interface MVRegisterState<T> {
type: "mv-register";
entries: Array<{ value: T; vectorClock: Record<string, number> }>;
}
/**
* 2P-Set state.
*/
export interface TwoPSetState<T> {
type: "2p-set";
added: T[];
removed: T[];
}
/**
* RGA element.
*/
export interface RGAElement<T> {
id: string;
value: T | null;
tombstone: boolean;
predecessor: string | null;
}
/**
* RGA state.
*/
export interface RGAState<T> {
type: "rga";
elements: RGAElement<T>[];
}
/**
* Vector clock state.
*/
export type VectorClockState = Record<string, number>;
/**
* CRDT constructor options.
*/
export interface CrdtOptions {
id?: string;
nodeId?: string;
}

View File

@@ -0,0 +1,13 @@
export type {
CRDT,
CrdtOptions,
GCounterState,
LWWRegisterState,
MVRegisterState,
ORSetState,
PNCounterState,
RGAElement,
RGAState,
TwoPSetState,
VectorClockState,
} from "./crdt.types.js";

View File

@@ -0,0 +1,169 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { CrdtSyncManager } from "../src/core/sync-manager/sync-manager.js";
import { GCounter } from "../src/crdts/g-counter/g-counter.js";
import { LWWRegister } from "../src/crdts/lww-register/lww-register.js";
import { MVRegister } from "../src/crdts/mv-register/mv-register.js";
import { ORSet } from "../src/crdts/or-set/or-set.js";
import { PNCounter } from "../src/crdts/pn-counter/pn-counter.js";
import { RGA } from "../src/crdts/rga/rga.js";
import { TwoPSet } from "../src/crdts/two-p-set/two-p-set.js";
describe("CRDT Integration", () => {
it("GCounter: all replicas converge after three-way sync", {
timeout: 5000,
}, () => {
const a = new GCounter("shared");
const b = new GCounter("shared");
const c = new GCounter("shared");
a.increment("a", 1);
b.increment("b", 2);
c.increment("c", 3);
a.merge(b.state());
a.merge(c.state());
b.merge(a.state());
c.merge(a.state());
expect(a.total()).toBe(6);
expect(b.total()).toBe(6);
expect(c.total()).toBe(6);
});
it("PNCounter: bidirectional increments and decrements", {
timeout: 5000,
}, () => {
const a = new PNCounter("shared");
const b = new PNCounter("shared");
a.increment("a", 10);
b.decrement("b", 4);
a.merge(b.state());
b.merge(a.state());
expect(a.total()).toBe(6);
expect(b.total()).toBe(6);
});
it("ORSet: concurrent add/remove converges", { timeout: 5000 }, () => {
const a = new ORSet<string>("s1");
const b = new ORSet<string>("s1");
a.add("x", "a");
b.merge(a.state());
b.add("x", "b"); // new unique tag for same element
a.remove("x"); // tombstones a's tag
a.merge(b.state());
// b's tag is still alive — element should be present
expect(a.has("x")).toBe(true);
});
it("LWWRegister: last write wins", { timeout: 5000 }, () => {
const a = new LWWRegister<string>("nodeA", "r1");
const b = new LWWRegister<string>("nodeB", "r1");
a.set("old", 100);
b.set("new", 200);
a.merge(b.state());
expect(a.value()).toBe("new");
});
it("MVRegister: concurrent writes preserved", { timeout: 5000 }, () => {
const a = new MVRegister<string>("nodeA", "r1");
const b = new MVRegister<string>("nodeB", "r1");
a.set("val-a");
b.set("val-b");
a.merge(b.state());
const vals = a.get();
expect(vals).toContain("val-a");
expect(vals).toContain("val-b");
});
it("TwoPSet: removal prevents re-add", { timeout: 5000 }, () => {
const s = new TwoPSet<string>();
s.add("item");
s.remove("item");
expect(() => s.add("item")).toThrow();
});
it("RGA: three-replica document convergence", { timeout: 5000 }, () => {
const a = new RGA<string>("a", "doc");
const b = new RGA<string>("b", "doc");
const c = new RGA<string>("c", "doc");
const r1 = a.insert("A");
b.merge(a.state());
c.merge(a.state());
a.insert("X", r1);
b.insert("Y", r1);
c.insert("Z", r1);
a.merge(b.state());
a.merge(c.state());
b.merge(a.state());
c.merge(a.state());
expect(a.value()).toEqual(b.value());
expect(b.value()).toEqual(c.value());
expect(a.value()).toHaveLength(4);
});
it("SyncManager: multi-CRDT sync across replicas", {
timeout: 5000,
}, () => {
const mgrA = new CrdtSyncManager({ nodeId: "a" });
const mgrB = new CrdtSyncManager({ nodeId: "b" });
const counterA = new GCounter("counter");
const counterB = new GCounter("counter");
const setA = new ORSet<string>("set");
const setB = new ORSet<string>("set");
mgrA.register(counterA);
mgrA.register(setA);
mgrB.register(counterB);
mgrB.register(setB);
counterA.increment("a", 5);
setA.add("hello", "a");
mgrA.tick();
counterB.increment("b", 3);
mgrB.tick();
mgrA.syncWith(mgrB);
expect(counterA.total()).toBe(8);
expect(counterB.total()).toBe(8);
expect(setA.has("hello")).toBe(true);
expect(setB.has("hello")).toBe(true);
});
it("JSON round-trip for all CRDT types", { timeout: 5000 }, () => {
const counter = new GCounter("n", "c1");
counter.increment("n", 42);
const c2 = new GCounter("n", "c1");
c2.fromJSON(counter.toJSON());
expect(c2.total()).toBe(42);
const set = new ORSet<string>("s1");
set.add("foo", "n");
const s2 = new ORSet<string>("s1");
s2.fromJSON(set.toJSON());
expect(s2.has("foo")).toBe(true);
const rga = new RGA<number>("n", "r1");
rga.insert(99);
const r2 = new RGA<number>("n", "r1");
r2.fromJSON(rga.toJSON());
expect(r2.value()).toContain(99);
});
});

View File

@@ -0,0 +1,6 @@
import { webcrypto } from "node:crypto";
if (typeof globalThis.crypto === "undefined") {
// @ts-expect-error polyfill
globalThis.crypto = webcrypto;
}

View File

@@ -0,0 +1,12 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
"lib": ["ES2022"],
"types": ["node"]
},
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.test.ts", "src/**/*.bench.ts", "dist", "node_modules"]
}

View File

@@ -0,0 +1,39 @@
import { defineConfig } from "vite";
import dts from "vite-plugin-dts";
export default defineConfig({
root: __dirname,
cacheDir: "../../../node_modules/.vite/packages/crdt",
plugins: [
dts({
entryRoot: "src",
tsconfigPath: "./tsconfig.lib.json",
insertTypesEntry: true,
}),
],
build: {
outDir: "./dist",
emptyOutDir: true,
reportCompressedSize: true,
sourcemap: true,
lib: { entry: "src/index.ts", formats: ["es"] },
rollupOptions: {
treeshake: true,
output: {
preserveModules: true,
preserveModulesRoot: "src",
entryFileNames: "[name].js",
},
external: [
"@suaveplan/error",
"@suaveplan/types",
"@suaveplan/events",
"@suaveplan/diff",
"tslib",
"zod",
"@opentelemetry/api",
/^node:/,
],
},
},
});

View File

@@ -0,0 +1,26 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
globals: false,
environment: "node",
pool: "forks",
setupFiles: ["./tests/setup/polyfills.ts"],
include: ["src/**/*.test.ts", "tests/**/*.test.ts"],
exclude: ["dist/**", "node_modules/**"],
coverage: {
provider: "istanbul",
reporter: ["text", "html", "lcov"],
include: ["src/**/*.ts"],
exclude: [
"src/**/*.test.ts",
"src/**/*.types.ts",
"src/**/index.ts",
"src/**/__benchmarks__/**",
"tests/**",
],
thresholds: { statements: 100, branches: 100, functions: 100, lines: 100 },
},
testTimeout: 30000,
},
});