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,33 @@
import { describe, expect, it } from 'vitest';
import { opId } from '../../clock';
import { MarkStore } from '..';
describe('markStore', () => {
it('resolves overlapping spans by highest op id per character/type', () => {
const chars = [opId('a', 1), opId('a', 2), opId('a', 3)];
const store = new MarkStore();
store.add({ id: opId('a', 10), type: 'bold', value: true, start: chars[0]!, end: chars[2]! });
store.add({ id: opId('a', 11), type: 'bold', value: null, start: chars[1]!, end: chars[1]! });
const active = store.resolve(chars);
expect(active[0]!.get('bold')).toBe(true);
expect(active[1]!.has('bold')).toBe(false); // cleared by the higher-id span
expect(active[2]!.get('bold')).toBe(true);
});
it('converges regardless of span insertion order', () => {
const chars = [opId('a', 1), opId('a', 2)];
const spanA = { id: opId('a', 10), type: 'bold', value: true, start: chars[0]!, end: chars[1]! };
const spanB = { id: opId('b', 10), type: 'bold', value: null, start: chars[0]!, end: chars[0]! };
const first = new MarkStore();
first.add(spanA);
first.add(spanB);
const second = new MarkStore();
second.add(spanB);
second.add(spanA);
expect(first.resolve(chars).map(m => m.get('bold')))
.toEqual(second.resolve(chars).map(m => m.get('bold')));
});
});
+1
View File
@@ -0,0 +1 @@
export * from './mark-store';
+78
View File
@@ -0,0 +1,78 @@
import type { OpId } from '../clock';
import { compareOpId, opIdEq, opIdToString } from '../clock';
/** A mark's value: `true`/attrs to apply, `null`/`false` to clear. JSON-serializable. */
export type MarkValue = boolean | string | number | null | { readonly [key: string]: MarkValue };
/**
* A formatting span anchored to character op ids (inclusive range), tagged with
* an op id for LWW conflict resolution — a lightweight Peritext mark.
*/
export interface MarkSpan {
readonly id: OpId;
readonly type: string;
readonly value: MarkValue;
readonly start: OpId;
readonly end: OpId;
}
/**
* Stores formatting spans and resolves them against a character order. For each
* (character, mark type) the covering span with the highest op id wins, so
* concurrent formatting converges; a `null`/`false` value clears the mark.
*/
export class MarkStore {
private spans: MarkSpan[] = [];
add(span: MarkSpan): boolean {
if (this.has(span.id))
return false;
this.spans.push(span);
return true;
}
has(id: OpId): boolean {
return this.spans.some(span => opIdEq(span.id, id));
}
all(): readonly MarkSpan[] {
return this.spans;
}
/**
* Active marks for each character, given the character ids in document order.
* Returns one `type → value` map per index.
*/
resolve(order: readonly OpId[]): Array<Map<string, MarkValue>> {
const indexOf = new Map<string, number>();
order.forEach((id, i) => indexOf.set(opIdToString(id), i));
const active: Array<Map<string, MarkValue>> = order.map(() => new Map());
const winner: Array<Map<string, OpId>> = order.map(() => new Map());
for (const span of this.spans) {
const startIndex = indexOf.get(opIdToString(span.start));
const endIndex = indexOf.get(opIdToString(span.end));
if (startIndex === undefined || endIndex === undefined)
continue;
const lo = Math.min(startIndex, endIndex);
const hi = Math.max(startIndex, endIndex);
for (let i = lo; i <= hi; i++) {
const current = winner[i]!.get(span.type);
if (current && compareOpId(span.id, current) <= 0)
continue;
winner[i]!.set(span.type, span.id);
if (span.value === null || span.value === false)
active[i]!.delete(span.type);
else
active[i]!.set(span.type, span.value);
}
}
return active;
}
}