feat(crdt): add @robonen/crdt package

Hand-built CRDT primitives: Lamport clock + version vectors, op-log,
LWW register/map, RGA sequence, fractional indexing, marks store, sync
encode, and doc/replica. Includes eslint flat config + composite tsconfig.
This commit is contained in:
2026-06-07 16:28:58 +07:00
parent 70a8678743
commit 008d85a8fd
35 changed files with 1152 additions and 0 deletions
@@ -0,0 +1,47 @@
import { describe, expect, it } from 'vitest';
import { keyBetween, keysBetween } from '..';
function seeded(seed: number): () => number {
let state = seed >>> 0;
return () => {
state = (state * 1664525 + 1013904223) >>> 0;
return state / 0xFFFFFFFF;
};
}
describe('keyBetween', () => {
it('produces a key strictly between its bounds', () => {
const a = keyBetween(null, null);
const b = keyBetween(a, null);
expect(b > a).toBe(true);
const mid = keyBetween(a, b);
expect(mid > a && mid < b).toBe(true);
});
it('rejects an inverted range', () => {
expect(() => keyBetween('b', 'a')).toThrow();
});
it('keysBetween returns n ascending keys within the bounds', () => {
const keys = keysBetween('a', 'b', 5);
expect(keys).toHaveLength(5);
for (let i = 1; i < keys.length; i++)
expect(keys[i - 1]! < keys[i]!).toBe(true);
expect(keys[0]! > 'a' && keys[keys.length - 1]! < 'b').toBe(true);
});
it('stays strictly ordered under 300 random insertions', () => {
const rng = seeded(7);
const keys: string[] = [keyBetween(null, null)];
for (let i = 0; i < 300; i++) {
const pos = Math.floor(rng() * (keys.length + 1));
const lower = pos > 0 ? keys[pos - 1]! : null;
const upper = pos < keys.length ? keys[pos]! : null;
keys.splice(pos, 0, keyBetween(lower, upper));
}
for (let i = 1; i < keys.length; i++)
expect(keys[i - 1]! < keys[i]!).toBe(true);
});
});
@@ -0,0 +1,54 @@
/**
* Fractional indexing: generate string "order keys" so an element can be placed
* strictly between two neighbors with a single key, and re-ordered (moved) by
* just changing its key — without touching anything else. The digit alphabet is
* ASCII-ascending, so JavaScript string comparison matches digit order.
*/
const DIGITS = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz';
function midpoint(a: string, b: string): string {
if (b !== '' && a >= b)
throw new Error(`fractional-index: lower '${a}' must be < upper '${b}'`);
let result = '';
let i = 0;
let upper = b;
for (;;) {
const x = i < a.length ? DIGITS.indexOf(a[i]!) : 0;
const y = upper !== '' && i < upper.length ? DIGITS.indexOf(upper[i]!) : DIGITS.length;
if (x === y) {
result += DIGITS[x]!;
i += 1;
continue;
}
const mid = x + Math.floor((y - x) / 2);
if (mid !== x)
return result + DIGITS[mid]!;
// Digits are adjacent — keep the lower digit and open the upper bound.
result += DIGITS[x]!;
i += 1;
upper = '';
}
}
/** A key strictly between `lower` and `upper` (`null` = open bound). */
export function keyBetween(lower: string | null, upper: string | null): string {
return midpoint(lower ?? '', upper ?? '');
}
/** `n` keys strictly between `lower` and `upper`, in ascending order. */
export function keysBetween(lower: string | null, upper: string | null, n: number): string[] {
if (n <= 0)
return [];
if (n === 1)
return [keyBetween(lower, upper)];
const mid = keyBetween(lower, upper);
const left = keysBetween(lower, mid, Math.floor((n - 1) / 2));
const right = keysBetween(mid, upper, n - 1 - left.length);
return [...left, mid, ...right];
}
+1
View File
@@ -0,0 +1 @@
export * from './fractional-index';