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