Initial Commit
This commit is contained in:
73
packages/foundation/flow/crdt/README.md
Normal file
73
packages/foundation/flow/crdt/README.md
Normal file
File diff suppressed because one or more lines are too long
167
packages/foundation/flow/crdt/package.json
Normal file
167
packages/foundation/flow/crdt/package.json
Normal 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"
|
||||
]
|
||||
}
|
||||
9
packages/foundation/flow/crdt/src/constants/index.ts
Normal file
9
packages/foundation/flow/crdt/src/constants/index.ts
Normal 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";
|
||||
@@ -0,0 +1,7 @@
|
||||
export { CrdtSyncManager } from "./sync-manager.js";
|
||||
export type {
|
||||
CrdtRegistration,
|
||||
SyncDelta,
|
||||
SyncManagerOptions,
|
||||
SyncResult,
|
||||
} from "./sync-manager.types.js";
|
||||
@@ -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.
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { VectorClock } from "./vector-clock.js";
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { GCounter } from "./g-counter.js";
|
||||
@@ -0,0 +1 @@
|
||||
export { LWWRegister } from "./lww-register.js";
|
||||
@@ -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)`.
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { MVRegister } from "./mv-register.js";
|
||||
@@ -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.
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
1
packages/foundation/flow/crdt/src/crdts/or-set/index.ts
Normal file
1
packages/foundation/flow/crdt/src/crdts/or-set/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { ORSet } from "./or-set.js";
|
||||
62
packages/foundation/flow/crdt/src/crdts/or-set/or-set.md
Normal file
62
packages/foundation/flow/crdt/src/crdts/or-set/or-set.md
Normal 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.
|
||||
101
packages/foundation/flow/crdt/src/crdts/or-set/or-set.test.ts
Normal file
101
packages/foundation/flow/crdt/src/crdts/or-set/or-set.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
97
packages/foundation/flow/crdt/src/crdts/or-set/or-set.ts
Normal file
97
packages/foundation/flow/crdt/src/crdts/or-set/or-set.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { PNCounter } from "./pn-counter.js";
|
||||
@@ -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.
|
||||
@@ -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
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
1
packages/foundation/flow/crdt/src/crdts/rga/index.ts
Normal file
1
packages/foundation/flow/crdt/src/crdts/rga/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { RGA } from "./rga.js";
|
||||
136
packages/foundation/flow/crdt/src/crdts/rga/rga.md
Normal file
136
packages/foundation/flow/crdt/src/crdts/rga/rga.md
Normal 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.
|
||||
332
packages/foundation/flow/crdt/src/crdts/rga/rga.test.ts
Normal file
332
packages/foundation/flow/crdt/src/crdts/rga/rga.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
215
packages/foundation/flow/crdt/src/crdts/rga/rga.ts
Normal file
215
packages/foundation/flow/crdt/src/crdts/rga/rga.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { TwoPSet } from "./two-p-set.js";
|
||||
@@ -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.
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
126
packages/foundation/flow/crdt/src/crdts/two-p-set/two-p-set.ts
Normal file
126
packages/foundation/flow/crdt/src/crdts/two-p-set/two-p-set.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
52
packages/foundation/flow/crdt/src/errors/crdt-error.md
Normal file
52
packages/foundation/flow/crdt/src/errors/crdt-error.md
Normal 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.
|
||||
31
packages/foundation/flow/crdt/src/errors/crdt-error.test.ts
Normal file
31
packages/foundation/flow/crdt/src/errors/crdt-error.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
32
packages/foundation/flow/crdt/src/errors/crdt-error.ts
Normal file
32
packages/foundation/flow/crdt/src/errors/crdt-error.ts
Normal 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";
|
||||
}
|
||||
}
|
||||
2
packages/foundation/flow/crdt/src/errors/index.ts
Normal file
2
packages/foundation/flow/crdt/src/errors/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export type { CrdtCode } from "./crdt-error.js";
|
||||
export { CRDT_CODES, CrdtError } from "./crdt-error.js";
|
||||
38
packages/foundation/flow/crdt/src/index.ts
Normal file
38
packages/foundation/flow/crdt/src/index.ts
Normal 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";
|
||||
48
packages/foundation/flow/crdt/src/types/crdt.types.md
Normal file
48
packages/foundation/flow/crdt/src/types/crdt.types.md
Normal 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.
|
||||
95
packages/foundation/flow/crdt/src/types/crdt.types.ts
Normal file
95
packages/foundation/flow/crdt/src/types/crdt.types.ts
Normal 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;
|
||||
}
|
||||
13
packages/foundation/flow/crdt/src/types/index.ts
Normal file
13
packages/foundation/flow/crdt/src/types/index.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
export type {
|
||||
CRDT,
|
||||
CrdtOptions,
|
||||
GCounterState,
|
||||
LWWRegisterState,
|
||||
MVRegisterState,
|
||||
ORSetState,
|
||||
PNCounterState,
|
||||
RGAElement,
|
||||
RGAState,
|
||||
TwoPSetState,
|
||||
VectorClockState,
|
||||
} from "./crdt.types.js";
|
||||
169
packages/foundation/flow/crdt/tests/integration.test.ts
Normal file
169
packages/foundation/flow/crdt/tests/integration.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
6
packages/foundation/flow/crdt/tests/setup/polyfills.ts
Normal file
6
packages/foundation/flow/crdt/tests/setup/polyfills.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { webcrypto } from "node:crypto";
|
||||
|
||||
if (typeof globalThis.crypto === "undefined") {
|
||||
// @ts-expect-error polyfill
|
||||
globalThis.crypto = webcrypto;
|
||||
}
|
||||
12
packages/foundation/flow/crdt/tsconfig.lib.json
Normal file
12
packages/foundation/flow/crdt/tsconfig.lib.json
Normal 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"]
|
||||
}
|
||||
39
packages/foundation/flow/crdt/vite.config.ts
Normal file
39
packages/foundation/flow/crdt/vite.config.ts
Normal 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:/,
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
26
packages/foundation/flow/crdt/vitest.config.ts
Normal file
26
packages/foundation/flow/crdt/vitest.config.ts
Normal 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,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user