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,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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user