feat(crdt): add @robonen/crdt package
Hand-built CRDT primitives: Lamport clock + version vectors, op-log, LWW register/map, RGA sequence, fractional indexing, marks store, sync encode, and doc/replica. Includes eslint flat config + composite tsconfig.
This commit is contained in:
@@ -0,0 +1,60 @@
|
|||||||
|
# @robonen/crdt
|
||||||
|
|
||||||
|
Framework-agnostic CRDT primitives — the convergence engine behind `@robonen/editor`, usable on their own. Zero runtime dependencies; pure TypeScript; runs in Node and the browser.
|
||||||
|
|
||||||
|
Every primitive is built so that **applying the same set of operations in any order, with duplicates, yields the same state** (commutative, idempotent, convergent), verified by property tests.
|
||||||
|
|
||||||
|
## Primitives
|
||||||
|
|
||||||
|
| Module | Exports | Purpose |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `clock` | `OpId`, `LamportClock`, `VersionVector`, `compareOpId`, `createSiteId` | Causality: per-site Lamport ids with a deterministic total order; version vectors for dedup + deltas. |
|
||||||
|
| `registers` | `LwwRegister`, `LwwMap` | Last-writer-wins values / maps (conflict resolved by op id). |
|
||||||
|
| `ordering` | `keyBetween`, `keysBetween` | Fractional indexing — place an item strictly between two neighbors (or move it) with one string key. |
|
||||||
|
| `sequence` | `Rga` | Replicated Growable Array — a sequence CRDT with tombstones, higher-op-id-first tie-break, causal-buffering API. |
|
||||||
|
| `marks` | `MarkStore`, `MarkSpan`, `MarkValue` | Lightweight Peritext: formatting spans anchored to character op ids, resolved per character by highest op id. |
|
||||||
|
| `oplog` | `OpLog` | Append-only op log with a version vector; computes deltas. |
|
||||||
|
| `sync` | `encodeStateVector`, `encodeDelta`/`encodeOps`, `decode*` | Transport-agnostic wire encoding (JSON-over-bytes in v1). |
|
||||||
|
| `doc` | `Replica` | Ties a clock + op log + causal buffer together; integrates local/remote ops and exposes deltas. |
|
||||||
|
|
||||||
|
## Example: a converging replicated string
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { Replica, Rga, opId } from '@robonen/crdt';
|
||||||
|
|
||||||
|
function makeReplica(site: string) {
|
||||||
|
const rga = new Rga<string>();
|
||||||
|
const replica = new Replica<{ id: ReturnType<typeof opId>; originLeft: ReturnType<typeof opId> | null; value: string }>(
|
||||||
|
{ integrate: op => rga.integrateInsert(op.id, op.value, op.originLeft) },
|
||||||
|
site,
|
||||||
|
);
|
||||||
|
return { rga, replica };
|
||||||
|
}
|
||||||
|
|
||||||
|
const a = makeReplica('a');
|
||||||
|
const b = makeReplica('b');
|
||||||
|
|
||||||
|
// ... A and B make concurrent local edits via replica.commitLocal(...) ...
|
||||||
|
|
||||||
|
// Exchange only what each side is missing:
|
||||||
|
b.replica.receive(a.replica.delta(b.replica.version));
|
||||||
|
a.replica.receive(b.replica.delta(a.replica.version));
|
||||||
|
|
||||||
|
a.rga.toArray().join('') === b.rga.toArray().join(''); // true — converged
|
||||||
|
```
|
||||||
|
|
||||||
|
`Replica.receive` buffers ops whose causal dependencies haven't arrived yet (an insert before its origin, a delete before its target) and retries them automatically.
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- `compareOpId` is the single deterministic tie-break (higher clock wins; site id breaks ties) every primitive agrees on — that's what makes LWW and RGA converge.
|
||||||
|
- `VersionVector` assumes **dense** per-site clocks (1, 2, 3, …).
|
||||||
|
- The v1 wire format is JSON encoded to bytes — simple and debuggable; a compact varint format is a later optimization with no API change.
|
||||||
|
- An editor-specific composition of these primitives (blocks + text + marks ↔ editor steps) lives in `@robonen/editor` under `crdt/native/`, not here — this package stays domain-agnostic.
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm --filter @robonen/crdt test # property/convergence tests
|
||||||
|
pnpm --filter @robonen/crdt build # tsdown (ESM + CJS + dts)
|
||||||
|
```
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
import { base, compose, imports, stylistic, typescript } from '@robonen/eslint';
|
||||||
|
|
||||||
|
export default compose(base, typescript, imports, stylistic);
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
{
|
||||||
|
"name": "@robonen/crdt",
|
||||||
|
"version": "0.0.1",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"description": "Framework-agnostic CRDT primitives: RGA sequence, LWW registers, fractional indexing, version vectors",
|
||||||
|
"keywords": [
|
||||||
|
"crdt",
|
||||||
|
"rga",
|
||||||
|
"lww",
|
||||||
|
"collaborative",
|
||||||
|
"fractional-indexing",
|
||||||
|
"tools"
|
||||||
|
],
|
||||||
|
"author": "Robonen Andrew <robonenandrew@gmail.com>",
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "git+https://github.com/robonen/tools.git",
|
||||||
|
"directory": "core/crdt"
|
||||||
|
},
|
||||||
|
"packageManager": "pnpm@10.30.3",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=24.13.1"
|
||||||
|
},
|
||||||
|
"type": "module",
|
||||||
|
"files": [
|
||||||
|
"dist"
|
||||||
|
],
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"import": {
|
||||||
|
"types": "./dist/index.d.mts",
|
||||||
|
"default": "./dist/index.mjs"
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"types": "./dist/index.d.cts",
|
||||||
|
"default": "./dist/index.cjs"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"lint:check": "eslint .",
|
||||||
|
"lint:fix": "eslint . --fix",
|
||||||
|
"test": "vitest run",
|
||||||
|
"dev": "vitest dev",
|
||||||
|
"build": "tsdown"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@robonen/eslint": "workspace:*",
|
||||||
|
"@robonen/tsconfig": "workspace:*",
|
||||||
|
"@robonen/tsdown": "workspace:*",
|
||||||
|
"eslint": "catalog:",
|
||||||
|
"tsdown": "catalog:"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { LamportClock, VersionVector, compareOpId, opId, opIdEq } from '..';
|
||||||
|
|
||||||
|
describe('compareOpId', () => {
|
||||||
|
it('orders by clock, then by site id', () => {
|
||||||
|
expect(compareOpId(opId('a', 1), opId('a', 2))).toBeLessThan(0);
|
||||||
|
expect(compareOpId(opId('a', 2), opId('b', 2))).toBeLessThan(0);
|
||||||
|
expect(compareOpId(opId('b', 2), opId('a', 2))).toBeGreaterThan(0);
|
||||||
|
expect(compareOpId(opId('a', 2), opId('a', 2))).toBe(0);
|
||||||
|
expect(opIdEq(opId('a', 1), opId('a', 1))).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('lamportClock', () => {
|
||||||
|
it('ticks monotonically and advances past observed remote ops', () => {
|
||||||
|
const clock = new LamportClock('a');
|
||||||
|
expect(clock.tick()).toEqual({ site: 'a', clock: 1 });
|
||||||
|
expect(clock.tick()).toEqual({ site: 'a', clock: 2 });
|
||||||
|
clock.observe({ site: 'b', clock: 5 });
|
||||||
|
expect(clock.tick().clock).toBe(6);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('versionVector', () => {
|
||||||
|
it('tracks seen ops and round-trips through JSON', () => {
|
||||||
|
const vv = new VersionVector();
|
||||||
|
vv.observe(opId('a', 3));
|
||||||
|
vv.observe(opId('b', 1));
|
||||||
|
|
||||||
|
expect(vv.has(opId('a', 2))).toBe(true);
|
||||||
|
expect(vv.has(opId('a', 3))).toBe(true);
|
||||||
|
expect(vv.has(opId('a', 4))).toBe(false);
|
||||||
|
expect(vv.has(opId('c', 1))).toBe(false);
|
||||||
|
|
||||||
|
const restored = VersionVector.fromJSON(vv.toJSON());
|
||||||
|
expect(restored.get('a')).toBe(3);
|
||||||
|
expect(restored.get('b')).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
/** A replica identifier — unique per editing site/session. */
|
||||||
|
export type SiteId = string;
|
||||||
|
|
||||||
|
/** A globally-unique operation id: a per-site Lamport counter tagged with the site. */
|
||||||
|
export interface OpId {
|
||||||
|
readonly site: SiteId;
|
||||||
|
readonly clock: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function opId(site: SiteId, clock: number): OpId {
|
||||||
|
return { site, clock };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function opIdEq(a: OpId, b: OpId): boolean {
|
||||||
|
return a.clock === b.clock && a.site === b.site;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Total order over op ids: higher clock wins; ties broken by site id. This is
|
||||||
|
* the deterministic tie-break every replica agrees on, so LWW and RGA converge.
|
||||||
|
*/
|
||||||
|
export function compareOpId(a: OpId, b: OpId): number {
|
||||||
|
if (a.clock !== b.clock)
|
||||||
|
return a.clock - b.clock;
|
||||||
|
return a.site < b.site ? -1 : a.site > b.site ? 1 : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function opIdToString(id: OpId): string {
|
||||||
|
return `${id.site}@${id.clock}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Generate a random site id (no crypto dependency; uniqueness, not secrecy). */
|
||||||
|
export function createSiteId(): SiteId {
|
||||||
|
return Math.random().toString(36).slice(2, 10) + Math.random().toString(36).slice(2, 6);
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export * from './id';
|
||||||
|
export * from './lamport';
|
||||||
|
export * from './version-vector';
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import type { OpId, SiteId } from './id';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A Lamport clock for one site: hands out monotonically increasing op ids and
|
||||||
|
* advances past observed remote ops so locally-generated ids stay causally later.
|
||||||
|
*/
|
||||||
|
export class LamportClock {
|
||||||
|
private counter: number;
|
||||||
|
|
||||||
|
constructor(public readonly site: SiteId, start = 0) {
|
||||||
|
this.counter = start;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Generate the next op id for a local operation. */
|
||||||
|
tick(): OpId {
|
||||||
|
this.counter += 1;
|
||||||
|
return { site: this.site, clock: this.counter };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Advance past a remote op so future local ticks are causally after it. */
|
||||||
|
observe(id: OpId): void {
|
||||||
|
if (id.clock > this.counter)
|
||||||
|
this.counter = id.clock;
|
||||||
|
}
|
||||||
|
|
||||||
|
get value(): number {
|
||||||
|
return this.counter;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import type { OpId, SiteId } from './id';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tracks the highest clock seen per site, assuming each site emits dense clocks
|
||||||
|
* (1, 2, 3, …). Used to deduplicate ops and to compute deltas during sync.
|
||||||
|
*/
|
||||||
|
export class VersionVector {
|
||||||
|
private readonly clocks = new Map<SiteId, number>();
|
||||||
|
|
||||||
|
/** Record that an op has been seen. */
|
||||||
|
observe(id: OpId): void {
|
||||||
|
if (id.clock > this.get(id.site))
|
||||||
|
this.clocks.set(id.site, id.clock);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Highest clock seen for a site (0 if none). */
|
||||||
|
get(site: SiteId): number {
|
||||||
|
return this.clocks.get(site) ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether an op id has already been seen. */
|
||||||
|
has(id: OpId): boolean {
|
||||||
|
return this.get(id.site) >= id.clock;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Plain-object snapshot for transport. */
|
||||||
|
toJSON(): Record<SiteId, number> {
|
||||||
|
return Object.fromEntries(this.clocks);
|
||||||
|
}
|
||||||
|
|
||||||
|
static fromJSON(snapshot: Record<SiteId, number>): VersionVector {
|
||||||
|
const vv = new VersionVector();
|
||||||
|
for (const site in snapshot)
|
||||||
|
vv.clocks.set(site, snapshot[site]!);
|
||||||
|
return vv;
|
||||||
|
}
|
||||||
|
|
||||||
|
clone(): VersionVector {
|
||||||
|
return VersionVector.fromJSON(this.toJSON());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import type { OpId } from '../../clock';
|
||||||
|
import { Rga } from '../../sequence';
|
||||||
|
import { Replica } from '..';
|
||||||
|
|
||||||
|
interface CharOp {
|
||||||
|
id: OpId;
|
||||||
|
originLeft: OpId | null;
|
||||||
|
value: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeReplica(site: string) {
|
||||||
|
const rga = new Rga<string>();
|
||||||
|
const replica = new Replica<CharOp>(
|
||||||
|
{ integrate: op => rga.integrateInsert(op.id, op.value, op.originLeft) },
|
||||||
|
site,
|
||||||
|
);
|
||||||
|
return { rga, replica };
|
||||||
|
}
|
||||||
|
|
||||||
|
function type(peer: ReturnType<typeof makeReplica>, text: string): void {
|
||||||
|
let left: OpId | null = null;
|
||||||
|
for (const ch of text) {
|
||||||
|
const id = peer.replica.nextId();
|
||||||
|
peer.replica.commitLocal({ id, originLeft: left, value: ch });
|
||||||
|
left = id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('replica', () => {
|
||||||
|
it('two replicas converge after exchanging deltas', () => {
|
||||||
|
const a = makeReplica('a');
|
||||||
|
const b = makeReplica('b');
|
||||||
|
|
||||||
|
type(a, 'Hi'); // concurrent edits
|
||||||
|
type(b, 'Yo');
|
||||||
|
|
||||||
|
// Exchange only what each side is missing (delta by version vector).
|
||||||
|
b.replica.receive(a.replica.delta(b.replica.version));
|
||||||
|
a.replica.receive(b.replica.delta(a.replica.version));
|
||||||
|
|
||||||
|
expect(a.rga.toArray().join('')).toBe(b.rga.toArray().join(''));
|
||||||
|
expect(a.rga.length).toBe(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('buffers a remote op until its causal dependency arrives, then applies both', () => {
|
||||||
|
const a = makeReplica('a');
|
||||||
|
type(a, 'ab'); // two ops; the 2nd depends on the 1st
|
||||||
|
|
||||||
|
const b = makeReplica('b');
|
||||||
|
const [op1, op2] = a.replica.delta(b.replica.version) as CharOp[];
|
||||||
|
|
||||||
|
// Deliver the dependent op first — it must buffer.
|
||||||
|
b.replica.receive([op2!]);
|
||||||
|
expect(b.rga.toArray().join('')).toBe('');
|
||||||
|
|
||||||
|
// Now deliver the dependency — both integrate.
|
||||||
|
b.replica.receive([op1!]);
|
||||||
|
expect(b.rga.toArray().join('')).toBe('ab');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from './replica';
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
import type { OpId, SiteId, VersionVector } from '../clock';
|
||||||
|
import { LamportClock, createSiteId, opIdEq } from '../clock';
|
||||||
|
import type { HasOpId } from '../oplog';
|
||||||
|
import { OpLog } from '../oplog';
|
||||||
|
|
||||||
|
export interface ReplicaHandlers<Op extends HasOpId> {
|
||||||
|
/**
|
||||||
|
* Apply an op to domain state (RGA, marks, block list, …). Return `false` if
|
||||||
|
* its causal dependencies aren't present yet; the replica buffers and retries.
|
||||||
|
*/
|
||||||
|
integrate: (op: Op) => boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type UpdateListener<Op> = (ops: readonly Op[], origin: unknown) => void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generic op-based CRDT replica: owns a Lamport clock + op log, integrates local
|
||||||
|
* and remote ops (with causal buffering and dedup), and exposes deltas for
|
||||||
|
* transport-agnostic sync. The domain state lives behind {@link ReplicaHandlers}.
|
||||||
|
*/
|
||||||
|
export class Replica<Op extends HasOpId> {
|
||||||
|
readonly site: SiteId;
|
||||||
|
private readonly clock: LamportClock;
|
||||||
|
private readonly log = new OpLog<Op>();
|
||||||
|
private readonly pending: Op[] = [];
|
||||||
|
private readonly listeners = new Set<UpdateListener<Op>>();
|
||||||
|
|
||||||
|
constructor(private readonly handlers: ReplicaHandlers<Op>, site: SiteId = createSiteId()) {
|
||||||
|
this.site = site;
|
||||||
|
this.clock = new LamportClock(site);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Next op id for a locally-generated operation. */
|
||||||
|
nextId(): OpId {
|
||||||
|
return this.clock.tick();
|
||||||
|
}
|
||||||
|
|
||||||
|
get version(): VersionVector {
|
||||||
|
return this.log.version;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Integrate + log a local op, then notify listeners (origin `'local'`). */
|
||||||
|
commitLocal(op: Op): void {
|
||||||
|
if (!this.log.append(op))
|
||||||
|
return;
|
||||||
|
this.handlers.integrate(op);
|
||||||
|
this.emit([op], 'local');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Receive remote ops: dedup, buffer until causally ready, integrate, log, and
|
||||||
|
* notify with the ops actually applied. Returns the applied ops (in apply order).
|
||||||
|
*/
|
||||||
|
receive(ops: readonly Op[], origin: unknown = 'remote'): Op[] {
|
||||||
|
for (const op of ops) {
|
||||||
|
this.clock.observe(op.id);
|
||||||
|
if (!this.log.has(op.id) && !this.pending.some(p => opIdEq(p.id, op.id)))
|
||||||
|
this.pending.push(op);
|
||||||
|
}
|
||||||
|
|
||||||
|
const applied = this.drain();
|
||||||
|
if (applied.length > 0)
|
||||||
|
this.emit(applied, origin);
|
||||||
|
return applied;
|
||||||
|
}
|
||||||
|
|
||||||
|
private drain(): Op[] {
|
||||||
|
const applied: Op[] = [];
|
||||||
|
let progressed = true;
|
||||||
|
|
||||||
|
while (this.pending.length > 0 && progressed) {
|
||||||
|
progressed = false;
|
||||||
|
for (let i = this.pending.length - 1; i >= 0; i--) {
|
||||||
|
const op = this.pending[i]!;
|
||||||
|
if (this.handlers.integrate(op)) {
|
||||||
|
this.log.append(op);
|
||||||
|
this.pending.splice(i, 1);
|
||||||
|
applied.push(op);
|
||||||
|
progressed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return applied;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ops a remote replica (described by its version vector) is missing. */
|
||||||
|
delta(remote: VersionVector): Op[] {
|
||||||
|
return this.log.delta(remote);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Subscribe to applied ops (local + remote). Returns an unsubscribe fn. */
|
||||||
|
onUpdate(listener: UpdateListener<Op>): () => void {
|
||||||
|
this.listeners.add(listener);
|
||||||
|
return () => this.listeners.delete(listener);
|
||||||
|
}
|
||||||
|
|
||||||
|
private emit(ops: readonly Op[], origin: unknown): void {
|
||||||
|
for (const listener of this.listeners)
|
||||||
|
listener(ops, origin);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
export * from './clock';
|
||||||
|
export * from './registers';
|
||||||
|
export * from './ordering';
|
||||||
|
export * from './sequence';
|
||||||
|
export * from './marks';
|
||||||
|
export * from './oplog';
|
||||||
|
export * from './sync';
|
||||||
|
export * from './doc';
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { opId } from '../../clock';
|
||||||
|
import { MarkStore } from '..';
|
||||||
|
|
||||||
|
describe('markStore', () => {
|
||||||
|
it('resolves overlapping spans by highest op id per character/type', () => {
|
||||||
|
const chars = [opId('a', 1), opId('a', 2), opId('a', 3)];
|
||||||
|
const store = new MarkStore();
|
||||||
|
store.add({ id: opId('a', 10), type: 'bold', value: true, start: chars[0]!, end: chars[2]! });
|
||||||
|
store.add({ id: opId('a', 11), type: 'bold', value: null, start: chars[1]!, end: chars[1]! });
|
||||||
|
|
||||||
|
const active = store.resolve(chars);
|
||||||
|
expect(active[0]!.get('bold')).toBe(true);
|
||||||
|
expect(active[1]!.has('bold')).toBe(false); // cleared by the higher-id span
|
||||||
|
expect(active[2]!.get('bold')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('converges regardless of span insertion order', () => {
|
||||||
|
const chars = [opId('a', 1), opId('a', 2)];
|
||||||
|
const spanA = { id: opId('a', 10), type: 'bold', value: true, start: chars[0]!, end: chars[1]! };
|
||||||
|
const spanB = { id: opId('b', 10), type: 'bold', value: null, start: chars[0]!, end: chars[0]! };
|
||||||
|
|
||||||
|
const first = new MarkStore();
|
||||||
|
first.add(spanA);
|
||||||
|
first.add(spanB);
|
||||||
|
const second = new MarkStore();
|
||||||
|
second.add(spanB);
|
||||||
|
second.add(spanA);
|
||||||
|
|
||||||
|
expect(first.resolve(chars).map(m => m.get('bold')))
|
||||||
|
.toEqual(second.resolve(chars).map(m => m.get('bold')));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from './mark-store';
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import type { OpId } from '../clock';
|
||||||
|
import { compareOpId, opIdEq, opIdToString } from '../clock';
|
||||||
|
|
||||||
|
/** A mark's value: `true`/attrs to apply, `null`/`false` to clear. JSON-serializable. */
|
||||||
|
export type MarkValue = boolean | string | number | null | { readonly [key: string]: MarkValue };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A formatting span anchored to character op ids (inclusive range), tagged with
|
||||||
|
* an op id for LWW conflict resolution — a lightweight Peritext mark.
|
||||||
|
*/
|
||||||
|
export interface MarkSpan {
|
||||||
|
readonly id: OpId;
|
||||||
|
readonly type: string;
|
||||||
|
readonly value: MarkValue;
|
||||||
|
readonly start: OpId;
|
||||||
|
readonly end: OpId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stores formatting spans and resolves them against a character order. For each
|
||||||
|
* (character, mark type) the covering span with the highest op id wins, so
|
||||||
|
* concurrent formatting converges; a `null`/`false` value clears the mark.
|
||||||
|
*/
|
||||||
|
export class MarkStore {
|
||||||
|
private spans: MarkSpan[] = [];
|
||||||
|
|
||||||
|
add(span: MarkSpan): boolean {
|
||||||
|
if (this.has(span.id))
|
||||||
|
return false;
|
||||||
|
this.spans.push(span);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
has(id: OpId): boolean {
|
||||||
|
return this.spans.some(span => opIdEq(span.id, id));
|
||||||
|
}
|
||||||
|
|
||||||
|
all(): readonly MarkSpan[] {
|
||||||
|
return this.spans;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Active marks for each character, given the character ids in document order.
|
||||||
|
* Returns one `type → value` map per index.
|
||||||
|
*/
|
||||||
|
resolve(order: readonly OpId[]): Array<Map<string, MarkValue>> {
|
||||||
|
const indexOf = new Map<string, number>();
|
||||||
|
order.forEach((id, i) => indexOf.set(opIdToString(id), i));
|
||||||
|
|
||||||
|
const active: Array<Map<string, MarkValue>> = order.map(() => new Map());
|
||||||
|
const winner: Array<Map<string, OpId>> = order.map(() => new Map());
|
||||||
|
|
||||||
|
for (const span of this.spans) {
|
||||||
|
const startIndex = indexOf.get(opIdToString(span.start));
|
||||||
|
const endIndex = indexOf.get(opIdToString(span.end));
|
||||||
|
|
||||||
|
if (startIndex === undefined || endIndex === undefined)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
const lo = Math.min(startIndex, endIndex);
|
||||||
|
const hi = Math.max(startIndex, endIndex);
|
||||||
|
|
||||||
|
for (let i = lo; i <= hi; i++) {
|
||||||
|
const current = winner[i]!.get(span.type);
|
||||||
|
if (current && compareOpId(span.id, current) <= 0)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
winner[i]!.set(span.type, span.id);
|
||||||
|
if (span.value === null || span.value === false)
|
||||||
|
active[i]!.delete(span.type);
|
||||||
|
else
|
||||||
|
active[i]!.set(span.type, span.value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return active;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from './op-log';
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import type { OpId } from '../clock';
|
||||||
|
import { VersionVector } from '../clock';
|
||||||
|
|
||||||
|
/** Anything carrying an op id can live in the log. */
|
||||||
|
export interface HasOpId {
|
||||||
|
readonly id: OpId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An append-only log of operations with a version vector for deduplication and
|
||||||
|
* delta computation. The op shape is domain-specific; the log only reads `id`.
|
||||||
|
*/
|
||||||
|
export class OpLog<Op extends HasOpId> {
|
||||||
|
private readonly ops: Op[] = [];
|
||||||
|
private readonly vv = new VersionVector();
|
||||||
|
|
||||||
|
/** Append an op unless already seen. Returns `true` if appended. */
|
||||||
|
append(op: Op): boolean {
|
||||||
|
if (this.vv.has(op.id))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
this.ops.push(op);
|
||||||
|
this.vv.observe(op.id);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
has(id: OpId): boolean {
|
||||||
|
return this.vv.has(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
get version(): VersionVector {
|
||||||
|
return this.vv;
|
||||||
|
}
|
||||||
|
|
||||||
|
all(): readonly Op[] {
|
||||||
|
return this.ops;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ops a remote replica (described by its version vector) hasn't seen. */
|
||||||
|
delta(remote: VersionVector): Op[] {
|
||||||
|
return this.ops.filter(op => !remote.has(op.id));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { keyBetween, keysBetween } from '..';
|
||||||
|
|
||||||
|
function seeded(seed: number): () => number {
|
||||||
|
let state = seed >>> 0;
|
||||||
|
return () => {
|
||||||
|
state = (state * 1664525 + 1013904223) >>> 0;
|
||||||
|
return state / 0xFFFFFFFF;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('keyBetween', () => {
|
||||||
|
it('produces a key strictly between its bounds', () => {
|
||||||
|
const a = keyBetween(null, null);
|
||||||
|
const b = keyBetween(a, null);
|
||||||
|
expect(b > a).toBe(true);
|
||||||
|
const mid = keyBetween(a, b);
|
||||||
|
expect(mid > a && mid < b).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects an inverted range', () => {
|
||||||
|
expect(() => keyBetween('b', 'a')).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keysBetween returns n ascending keys within the bounds', () => {
|
||||||
|
const keys = keysBetween('a', 'b', 5);
|
||||||
|
expect(keys).toHaveLength(5);
|
||||||
|
for (let i = 1; i < keys.length; i++)
|
||||||
|
expect(keys[i - 1]! < keys[i]!).toBe(true);
|
||||||
|
expect(keys[0]! > 'a' && keys[keys.length - 1]! < 'b').toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stays strictly ordered under 300 random insertions', () => {
|
||||||
|
const rng = seeded(7);
|
||||||
|
const keys: string[] = [keyBetween(null, null)];
|
||||||
|
|
||||||
|
for (let i = 0; i < 300; i++) {
|
||||||
|
const pos = Math.floor(rng() * (keys.length + 1));
|
||||||
|
const lower = pos > 0 ? keys[pos - 1]! : null;
|
||||||
|
const upper = pos < keys.length ? keys[pos]! : null;
|
||||||
|
keys.splice(pos, 0, keyBetween(lower, upper));
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 1; i < keys.length; i++)
|
||||||
|
expect(keys[i - 1]! < keys[i]!).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
/**
|
||||||
|
* Fractional indexing: generate string "order keys" so an element can be placed
|
||||||
|
* strictly between two neighbors with a single key, and re-ordered (moved) by
|
||||||
|
* just changing its key — without touching anything else. The digit alphabet is
|
||||||
|
* ASCII-ascending, so JavaScript string comparison matches digit order.
|
||||||
|
*/
|
||||||
|
const DIGITS = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz';
|
||||||
|
|
||||||
|
function midpoint(a: string, b: string): string {
|
||||||
|
if (b !== '' && a >= b)
|
||||||
|
throw new Error(`fractional-index: lower '${a}' must be < upper '${b}'`);
|
||||||
|
|
||||||
|
let result = '';
|
||||||
|
let i = 0;
|
||||||
|
let upper = b;
|
||||||
|
|
||||||
|
for (;;) {
|
||||||
|
const x = i < a.length ? DIGITS.indexOf(a[i]!) : 0;
|
||||||
|
const y = upper !== '' && i < upper.length ? DIGITS.indexOf(upper[i]!) : DIGITS.length;
|
||||||
|
|
||||||
|
if (x === y) {
|
||||||
|
result += DIGITS[x]!;
|
||||||
|
i += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const mid = x + Math.floor((y - x) / 2);
|
||||||
|
if (mid !== x)
|
||||||
|
return result + DIGITS[mid]!;
|
||||||
|
|
||||||
|
// Digits are adjacent — keep the lower digit and open the upper bound.
|
||||||
|
result += DIGITS[x]!;
|
||||||
|
i += 1;
|
||||||
|
upper = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A key strictly between `lower` and `upper` (`null` = open bound). */
|
||||||
|
export function keyBetween(lower: string | null, upper: string | null): string {
|
||||||
|
return midpoint(lower ?? '', upper ?? '');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `n` keys strictly between `lower` and `upper`, in ascending order. */
|
||||||
|
export function keysBetween(lower: string | null, upper: string | null, n: number): string[] {
|
||||||
|
if (n <= 0)
|
||||||
|
return [];
|
||||||
|
if (n === 1)
|
||||||
|
return [keyBetween(lower, upper)];
|
||||||
|
|
||||||
|
const mid = keyBetween(lower, upper);
|
||||||
|
const left = keysBetween(lower, mid, Math.floor((n - 1) / 2));
|
||||||
|
const right = keysBetween(mid, upper, n - 1 - left.length);
|
||||||
|
return [...left, mid, ...right];
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from './fractional-index';
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { opId } from '../../clock';
|
||||||
|
import { LwwMap, LwwRegister } from '..';
|
||||||
|
|
||||||
|
describe('lwwRegister', () => {
|
||||||
|
it('keeps the write with the higher op id regardless of order', () => {
|
||||||
|
const a = new LwwRegister('init');
|
||||||
|
a.set('first', opId('a', 1));
|
||||||
|
a.set('second', opId('b', 2));
|
||||||
|
expect(a.get()).toBe('second');
|
||||||
|
|
||||||
|
// A later-arriving but older write must not win.
|
||||||
|
expect(a.set('stale', opId('a', 1))).toBe(false);
|
||||||
|
expect(a.get()).toBe('second');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('converges when concurrent writes arrive in opposite orders', () => {
|
||||||
|
const left = new LwwRegister('x');
|
||||||
|
const right = new LwwRegister('x');
|
||||||
|
left.set('A', opId('a', 5));
|
||||||
|
left.set('B', opId('b', 5));
|
||||||
|
right.set('B', opId('b', 5));
|
||||||
|
right.set('A', opId('a', 5));
|
||||||
|
expect(left.get()).toBe(right.get());
|
||||||
|
expect(left.get()).toBe('B'); // site 'b' > 'a' at equal clock
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('lwwMap', () => {
|
||||||
|
it('handles set/delete with tombstones', () => {
|
||||||
|
const map = new LwwMap<string, number>();
|
||||||
|
map.set('k', 1, opId('a', 1));
|
||||||
|
expect(map.get('k')).toBe(1);
|
||||||
|
map.delete('k', opId('a', 2));
|
||||||
|
expect(map.has('k')).toBe(false);
|
||||||
|
// A concurrent older set loses to the delete.
|
||||||
|
expect(map.set('k', 9, opId('a', 1))).toBe(false);
|
||||||
|
expect(map.has('k')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export * from './lww-register';
|
||||||
|
export * from './lww-map';
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import type { OpId } from '../clock';
|
||||||
|
import { compareOpId } from '../clock';
|
||||||
|
|
||||||
|
interface Entry<V> {
|
||||||
|
value: V;
|
||||||
|
ts: OpId;
|
||||||
|
deleted: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Last-writer-wins map with per-key timestamps and tombstones. Concurrent
|
||||||
|
* set/delete on a key converge to the operation with the higher op id.
|
||||||
|
*/
|
||||||
|
export class LwwMap<K, V> {
|
||||||
|
private readonly entries = new Map<K, Entry<V>>();
|
||||||
|
|
||||||
|
set(key: K, value: V, id: OpId): boolean {
|
||||||
|
const existing = this.entries.get(key);
|
||||||
|
if (existing && compareOpId(id, existing.ts) <= 0)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
this.entries.set(key, { value, ts: id, deleted: false });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
delete(key: K, id: OpId): boolean {
|
||||||
|
const existing = this.entries.get(key);
|
||||||
|
if (existing && compareOpId(id, existing.ts) <= 0)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
this.entries.set(key, { value: existing?.value as V, ts: id, deleted: true });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
get(key: K): V | undefined {
|
||||||
|
const entry = this.entries.get(key);
|
||||||
|
return entry && !entry.deleted ? entry.value : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
has(key: K): boolean {
|
||||||
|
const entry = this.entries.get(key);
|
||||||
|
return entry !== undefined && !entry.deleted;
|
||||||
|
}
|
||||||
|
|
||||||
|
keys(): K[] {
|
||||||
|
return [...this.entries].filter(([, entry]) => !entry.deleted).map(([key]) => key);
|
||||||
|
}
|
||||||
|
|
||||||
|
toEntries(): Array<[K, V]> {
|
||||||
|
return [...this.entries].filter(([, entry]) => !entry.deleted).map(([key, entry]) => [key, entry.value]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import type { OpId } from '../clock';
|
||||||
|
import { compareOpId } from '../clock';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Last-writer-wins register. A write applies only if its op id is later than the
|
||||||
|
* current write's (by {@link compareOpId}), so concurrent writes converge to the
|
||||||
|
* one with the higher timestamp regardless of arrival order.
|
||||||
|
*/
|
||||||
|
export class LwwRegister<T> {
|
||||||
|
private ts: OpId | null = null;
|
||||||
|
|
||||||
|
constructor(private current: T) {}
|
||||||
|
|
||||||
|
get(): T {
|
||||||
|
return this.current;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Apply a timestamped write. Returns `true` if it won. */
|
||||||
|
set(value: T, id: OpId): boolean {
|
||||||
|
if (this.ts !== null && compareOpId(id, this.ts) <= 0)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
this.current = value;
|
||||||
|
this.ts = id;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
get timestamp(): OpId | null {
|
||||||
|
return this.ts;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import type { OpId } from '../../clock';
|
||||||
|
import { VersionVector, opId, opIdEq } from '../../clock';
|
||||||
|
import { Rga } from '..';
|
||||||
|
|
||||||
|
type Op
|
||||||
|
= | { kind: 'insert'; id: OpId; value: string; originLeft: OpId | null }
|
||||||
|
| { kind: 'delete'; id: OpId };
|
||||||
|
|
||||||
|
/** Apply ops in the given order, buffering and retrying any whose causal deps aren't met. */
|
||||||
|
function applyAll(rga: Rga<string>, ops: readonly Op[]): void {
|
||||||
|
const pending = [...ops];
|
||||||
|
let progressed = true;
|
||||||
|
|
||||||
|
while (pending.length > 0 && progressed) {
|
||||||
|
progressed = false;
|
||||||
|
for (let i = pending.length - 1; i >= 0; i--) {
|
||||||
|
const op = pending[i]!;
|
||||||
|
const applied = op.kind === 'insert'
|
||||||
|
? rga.integrateInsert(op.id, op.value, op.originLeft)
|
||||||
|
: rga.integrateDelete(op.id);
|
||||||
|
if (applied) {
|
||||||
|
pending.splice(i, 1);
|
||||||
|
progressed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function seeded(seed: number): () => number {
|
||||||
|
let state = seed >>> 0;
|
||||||
|
return () => {
|
||||||
|
state = (state * 1664525 + 1013904223) >>> 0;
|
||||||
|
return state / 0xFFFFFFFF;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function shuffle<T>(items: readonly T[], rng: () => number): T[] {
|
||||||
|
const out = [...items];
|
||||||
|
for (let i = out.length - 1; i > 0; i--) {
|
||||||
|
const j = Math.floor(rng() * (i + 1));
|
||||||
|
[out[i], out[j]] = [out[j]!, out[i]!];
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('rga', () => {
|
||||||
|
it('orders concurrent inserts at the same origin higher-op-id-first', () => {
|
||||||
|
const opA: Op = { kind: 'insert', id: opId('a', 1), value: 'X', originLeft: null };
|
||||||
|
const opB: Op = { kind: 'insert', id: opId('b', 1), value: 'Y', originLeft: null };
|
||||||
|
|
||||||
|
const first = new Rga<string>();
|
||||||
|
applyAll(first, [opA, opB]);
|
||||||
|
const second = new Rga<string>();
|
||||||
|
applyAll(second, [opB, opA]);
|
||||||
|
|
||||||
|
expect(first.toArray()).toEqual(['Y', 'X']); // site 'b' > 'a' at equal clock
|
||||||
|
expect(second.toArray()).toEqual(first.toArray());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is idempotent under re-applied ops', () => {
|
||||||
|
const rga = new Rga<string>();
|
||||||
|
const op: Op = { kind: 'insert', id: opId('a', 1), value: 'X', originLeft: null };
|
||||||
|
applyAll(rga, [op, op, op]);
|
||||||
|
expect(rga.toArray()).toEqual(['X']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('converges across two replicas for random concurrent ops + deletes', () => {
|
||||||
|
for (let trial = 0; trial < 25; trial++) {
|
||||||
|
const rng = seeded(trial + 1);
|
||||||
|
const sites = ['a', 'b', 'c'];
|
||||||
|
const counters: Record<string, number> = { a: 0, b: 0, c: 0 };
|
||||||
|
const inserted: Array<{ id: OpId; value: string }> = [];
|
||||||
|
const ops: Op[] = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < 40; i++) {
|
||||||
|
const site = sites[Math.floor(rng() * sites.length)]!;
|
||||||
|
const makeDelete = inserted.length > 0 && rng() < 0.25;
|
||||||
|
|
||||||
|
if (makeDelete) {
|
||||||
|
const target = inserted[Math.floor(rng() * inserted.length)]!;
|
||||||
|
ops.push({ kind: 'delete', id: target.id });
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
counters[site] = counters[site]! + 1;
|
||||||
|
const id = opId(site, counters[site]!);
|
||||||
|
const originLeft = inserted.length > 0 && rng() < 0.8
|
||||||
|
? inserted[Math.floor(rng() * inserted.length)]!.id
|
||||||
|
: null;
|
||||||
|
ops.push({ kind: 'insert', id, value: `${site}${counters[site]}`, originLeft });
|
||||||
|
inserted.push({ id, value: `${site}${counters[site]}` });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const replicaOrder = new Rga<string>();
|
||||||
|
applyAll(replicaOrder, ops);
|
||||||
|
|
||||||
|
const replicaShuffled = new Rga<string>();
|
||||||
|
applyAll(replicaShuffled, shuffle(ops, rng));
|
||||||
|
|
||||||
|
expect(replicaShuffled.toArray()).toEqual(replicaOrder.toArray());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('gc drops stable tombstones, keeps live/protected/unstable ones', () => {
|
||||||
|
const rga = new Rga<string>();
|
||||||
|
applyAll(rga, [
|
||||||
|
{ kind: 'insert', id: opId('a', 1), value: 'a', originLeft: null },
|
||||||
|
{ kind: 'insert', id: opId('a', 2), value: 'b', originLeft: opId('a', 1) },
|
||||||
|
{ kind: 'insert', id: opId('a', 3), value: 'c', originLeft: opId('a', 2) },
|
||||||
|
]);
|
||||||
|
rga.integrateDelete(opId('a', 2)); // tombstone 'b'
|
||||||
|
rga.integrateDelete(opId('a', 3)); // tombstone 'c'
|
||||||
|
expect(rga.toArray().join('')).toBe('a');
|
||||||
|
|
||||||
|
const stable = new VersionVector();
|
||||||
|
stable.observe(opId('a', 2)); // only ops up to a@2 are stable everywhere
|
||||||
|
|
||||||
|
// a@2 tombstone is dropped; a@3 is unstable (kept); 'c' protected via keep.
|
||||||
|
const removed = rga.gc(stable, id => opIdEq(id, opId('a', 3)));
|
||||||
|
expect(removed).toBe(1);
|
||||||
|
expect(rga.has(opId('a', 2))).toBe(false);
|
||||||
|
expect(rga.has(opId('a', 3))).toBe(true);
|
||||||
|
expect(rga.has(opId('a', 1))).toBe(true); // live, never dropped
|
||||||
|
expect(rga.toArray().join('')).toBe('a');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from './rga';
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
import type { OpId, VersionVector } from '../clock';
|
||||||
|
import { compareOpId, opIdEq } from '../clock';
|
||||||
|
|
||||||
|
/** One element of an RGA sequence (visible or tombstoned). */
|
||||||
|
export interface RgaNode<T> {
|
||||||
|
readonly id: OpId;
|
||||||
|
readonly value: T;
|
||||||
|
readonly originLeft: OpId | null;
|
||||||
|
deleted: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replicated Growable Array — a sequence CRDT. Each element is inserted after a
|
||||||
|
* left-origin element (or at the start) and tombstoned on delete. Concurrent
|
||||||
|
* inserts at the same origin are ordered higher-op-id-first, a deterministic
|
||||||
|
* tie-break that makes every replica converge to the same order. Operations must
|
||||||
|
* be integrated in causal order (an insert's origin must already be present);
|
||||||
|
* {@link integrateInsert} returns `false` when the origin is missing so the
|
||||||
|
* caller can buffer and retry.
|
||||||
|
*/
|
||||||
|
export class Rga<T> {
|
||||||
|
private nodes: Array<RgaNode<T>> = [];
|
||||||
|
|
||||||
|
private nodeIndex(id: OpId): number {
|
||||||
|
for (let i = 0; i < this.nodes.length; i++) {
|
||||||
|
if (opIdEq(this.nodes[i]!.id, id))
|
||||||
|
return i;
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
has(id: OpId): boolean {
|
||||||
|
return this.nodeIndex(id) !== -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Integrate an insert after `originLeft` (`null` = start). Idempotent. */
|
||||||
|
integrateInsert(id: OpId, value: T, originLeft: OpId | null): boolean {
|
||||||
|
if (this.has(id))
|
||||||
|
return true;
|
||||||
|
|
||||||
|
const originIndex = originLeft === null ? -1 : this.nodeIndex(originLeft);
|
||||||
|
if (originLeft !== null && originIndex === -1)
|
||||||
|
return false; // origin not present yet — caller should buffer
|
||||||
|
|
||||||
|
let i = originIndex + 1;
|
||||||
|
while (i < this.nodes.length && compareOpId(this.nodes[i]!.id, id) > 0)
|
||||||
|
i += 1;
|
||||||
|
|
||||||
|
this.nodes.splice(i, 0, { id, value, originLeft, deleted: false });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Tombstone an element. Idempotent; returns false if the element is unknown. */
|
||||||
|
integrateDelete(id: OpId): boolean {
|
||||||
|
const index = this.nodeIndex(id);
|
||||||
|
if (index === -1)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
this.nodes[index]!.deleted = true;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drop tombstoned nodes whose insert is covered by `stable`. Call ONLY at
|
||||||
|
* quiescence — when every replica has fully synced and no operations are in
|
||||||
|
* flight — otherwise a late op that uses a dropped node as its origin can no
|
||||||
|
* longer integrate. `keep` protects ids still referenced elsewhere (e.g. mark
|
||||||
|
* span endpoints). Returns the number of nodes removed.
|
||||||
|
*/
|
||||||
|
gc(stable: VersionVector, keep?: (id: OpId) => boolean): number {
|
||||||
|
const before = this.nodes.length;
|
||||||
|
this.nodes = this.nodes.filter(node =>
|
||||||
|
!node.deleted || !stable.has(node.id) || (keep?.(node.id) ?? false));
|
||||||
|
return before - this.nodes.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Visible values in document order. */
|
||||||
|
toArray(): T[] {
|
||||||
|
const out: T[] = [];
|
||||||
|
for (const node of this.nodes) {
|
||||||
|
if (!node.deleted)
|
||||||
|
out.push(node.value);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Visible nodes in document order (read ids for cursor anchoring). */
|
||||||
|
visible(): Array<RgaNode<T>> {
|
||||||
|
return this.nodes.filter(node => !node.deleted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** All nodes including tombstones (for state encoding). */
|
||||||
|
all(): ReadonlyArray<RgaNode<T>> {
|
||||||
|
return this.nodes;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Op id of the visible element at `index`, or `null` if out of range. */
|
||||||
|
idAt(index: number): OpId | null {
|
||||||
|
return this.visible()[index]?.id ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Number of visible elements. */
|
||||||
|
get length(): number {
|
||||||
|
let count = 0;
|
||||||
|
for (const node of this.nodes) {
|
||||||
|
if (!node.deleted)
|
||||||
|
count += 1;
|
||||||
|
}
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { VersionVector, opId } from '../../clock';
|
||||||
|
import { decodeOps, decodeStateVector, encodeOps, encodeStateVector } from '..';
|
||||||
|
|
||||||
|
describe('sync encoding', () => {
|
||||||
|
it('round-trips a version vector through bytes', () => {
|
||||||
|
const vv = new VersionVector();
|
||||||
|
vv.observe(opId('a', 3));
|
||||||
|
vv.observe(opId('b', 1));
|
||||||
|
|
||||||
|
const restored = decodeStateVector(encodeStateVector(vv));
|
||||||
|
expect(restored.get('a')).toBe(3);
|
||||||
|
expect(restored.get('b')).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('round-trips an op batch through bytes', () => {
|
||||||
|
const ops = [{ id: opId('a', 1), kind: 'insert', value: 'x' }];
|
||||||
|
expect(decodeOps(encodeOps(ops))).toEqual(ops);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { VersionVector } from '../clock';
|
||||||
|
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Transport-agnostic wire encoding. v1 is JSON-over-bytes — simple and
|
||||||
|
* debuggable; a compact varint format is a later optimization with no API change.
|
||||||
|
*/
|
||||||
|
export function encodeJson(value: unknown): Uint8Array {
|
||||||
|
return encoder.encode(JSON.stringify(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function decodeJson<T>(bytes: Uint8Array): T {
|
||||||
|
return JSON.parse(decoder.decode(bytes)) as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Encode a version vector for a "what do you have?" sync handshake. */
|
||||||
|
export function encodeStateVector(vv: VersionVector): Uint8Array {
|
||||||
|
return encodeJson(vv.toJSON());
|
||||||
|
}
|
||||||
|
|
||||||
|
export function decodeStateVector(bytes: Uint8Array): VersionVector {
|
||||||
|
return VersionVector.fromJSON(decodeJson(bytes));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Encode a batch of ops (the delta or a full snapshot). */
|
||||||
|
export function encodeOps<Op>(ops: readonly Op[]): Uint8Array {
|
||||||
|
return encodeJson(ops);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function decodeOps<Op>(bytes: Uint8Array): Op[] {
|
||||||
|
return decodeJson<Op[]>(bytes);
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from './encode';
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{ "path": "./tsconfig.src.json" },
|
||||||
|
{ "path": "./tsconfig.node.json" }
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"extends": "@robonen/tsconfig/tsconfig.node.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"composite": true,
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo"
|
||||||
|
},
|
||||||
|
"include": ["*.config.ts"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"extends": "@robonen/tsconfig/tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"composite": true,
|
||||||
|
"types": ["node"],
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.src.tsbuildinfo"
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { defineConfig } from 'tsdown';
|
||||||
|
import { sharedConfig } from '@robonen/tsdown';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
...sharedConfig,
|
||||||
|
tsconfig: './tsconfig.src.json',
|
||||||
|
entry: ['src/index.ts'],
|
||||||
|
});
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { defineConfig } from 'vitest/config';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
environment: 'node',
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user