From 008d85a8fd8ae0512602494b29e3673c639230bf Mon Sep 17 00:00:00 2001 From: robonen Date: Sun, 7 Jun 2026 16:28:58 +0700 Subject: [PATCH] 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. --- core/crdt/README.md | 60 +++++++++ core/crdt/eslint.config.ts | 3 + core/crdt/package.json | 54 ++++++++ core/crdt/src/clock/__test__/clock.test.ts | 39 ++++++ core/crdt/src/clock/id.ts | 35 +++++ core/crdt/src/clock/index.ts | 3 + core/crdt/src/clock/lamport.ts | 29 ++++ core/crdt/src/clock/version-vector.ts | 41 ++++++ core/crdt/src/doc/__test__/replica.test.ts | 61 +++++++++ core/crdt/src/doc/index.ts | 1 + core/crdt/src/doc/replica.ts | 102 ++++++++++++++ core/crdt/src/index.ts | 8 ++ .../src/marks/__test__/mark-store.test.ts | 33 +++++ core/crdt/src/marks/index.ts | 1 + core/crdt/src/marks/mark-store.ts | 78 +++++++++++ core/crdt/src/oplog/index.ts | 1 + core/crdt/src/oplog/op-log.ts | 43 ++++++ .../__test__/fractional-index.test.ts | 47 +++++++ core/crdt/src/ordering/fractional-index.ts | 54 ++++++++ core/crdt/src/ordering/index.ts | 1 + core/crdt/src/registers/__test__/lww.test.ts | 40 ++++++ core/crdt/src/registers/index.ts | 2 + core/crdt/src/registers/lww-map.ts | 52 +++++++ core/crdt/src/registers/lww-register.ts | 31 +++++ core/crdt/src/sequence/__test__/rga.test.ts | 127 ++++++++++++++++++ core/crdt/src/sequence/index.ts | 1 + core/crdt/src/sequence/rga.ts | 111 +++++++++++++++ core/crdt/src/sync/__test__/encode.test.ts | 20 +++ core/crdt/src/sync/encode.ts | 34 +++++ core/crdt/src/sync/index.ts | 1 + core/crdt/tsconfig.json | 7 + core/crdt/tsconfig.node.json | 8 ++ core/crdt/tsconfig.src.json | 9 ++ core/crdt/tsdown.config.ts | 8 ++ core/crdt/vitest.config.ts | 7 + 35 files changed, 1152 insertions(+) create mode 100644 core/crdt/README.md create mode 100644 core/crdt/eslint.config.ts create mode 100644 core/crdt/package.json create mode 100644 core/crdt/src/clock/__test__/clock.test.ts create mode 100644 core/crdt/src/clock/id.ts create mode 100644 core/crdt/src/clock/index.ts create mode 100644 core/crdt/src/clock/lamport.ts create mode 100644 core/crdt/src/clock/version-vector.ts create mode 100644 core/crdt/src/doc/__test__/replica.test.ts create mode 100644 core/crdt/src/doc/index.ts create mode 100644 core/crdt/src/doc/replica.ts create mode 100644 core/crdt/src/index.ts create mode 100644 core/crdt/src/marks/__test__/mark-store.test.ts create mode 100644 core/crdt/src/marks/index.ts create mode 100644 core/crdt/src/marks/mark-store.ts create mode 100644 core/crdt/src/oplog/index.ts create mode 100644 core/crdt/src/oplog/op-log.ts create mode 100644 core/crdt/src/ordering/__test__/fractional-index.test.ts create mode 100644 core/crdt/src/ordering/fractional-index.ts create mode 100644 core/crdt/src/ordering/index.ts create mode 100644 core/crdt/src/registers/__test__/lww.test.ts create mode 100644 core/crdt/src/registers/index.ts create mode 100644 core/crdt/src/registers/lww-map.ts create mode 100644 core/crdt/src/registers/lww-register.ts create mode 100644 core/crdt/src/sequence/__test__/rga.test.ts create mode 100644 core/crdt/src/sequence/index.ts create mode 100644 core/crdt/src/sequence/rga.ts create mode 100644 core/crdt/src/sync/__test__/encode.test.ts create mode 100644 core/crdt/src/sync/encode.ts create mode 100644 core/crdt/src/sync/index.ts create mode 100644 core/crdt/tsconfig.json create mode 100644 core/crdt/tsconfig.node.json create mode 100644 core/crdt/tsconfig.src.json create mode 100644 core/crdt/tsdown.config.ts create mode 100644 core/crdt/vitest.config.ts diff --git a/core/crdt/README.md b/core/crdt/README.md new file mode 100644 index 0000000..a9a61a2 --- /dev/null +++ b/core/crdt/README.md @@ -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(); + const replica = new Replica<{ id: ReturnType; originLeft: ReturnType | 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) +``` diff --git a/core/crdt/eslint.config.ts b/core/crdt/eslint.config.ts new file mode 100644 index 0000000..e703f35 --- /dev/null +++ b/core/crdt/eslint.config.ts @@ -0,0 +1,3 @@ +import { base, compose, imports, stylistic, typescript } from '@robonen/eslint'; + +export default compose(base, typescript, imports, stylistic); diff --git a/core/crdt/package.json b/core/crdt/package.json new file mode 100644 index 0000000..c9bad43 --- /dev/null +++ b/core/crdt/package.json @@ -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 ", + "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:" + } +} diff --git a/core/crdt/src/clock/__test__/clock.test.ts b/core/crdt/src/clock/__test__/clock.test.ts new file mode 100644 index 0000000..618aaf2 --- /dev/null +++ b/core/crdt/src/clock/__test__/clock.test.ts @@ -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); + }); +}); diff --git a/core/crdt/src/clock/id.ts b/core/crdt/src/clock/id.ts new file mode 100644 index 0000000..c87f658 --- /dev/null +++ b/core/crdt/src/clock/id.ts @@ -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); +} diff --git a/core/crdt/src/clock/index.ts b/core/crdt/src/clock/index.ts new file mode 100644 index 0000000..6628fa1 --- /dev/null +++ b/core/crdt/src/clock/index.ts @@ -0,0 +1,3 @@ +export * from './id'; +export * from './lamport'; +export * from './version-vector'; diff --git a/core/crdt/src/clock/lamport.ts b/core/crdt/src/clock/lamport.ts new file mode 100644 index 0000000..49826cd --- /dev/null +++ b/core/crdt/src/clock/lamport.ts @@ -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; + } +} diff --git a/core/crdt/src/clock/version-vector.ts b/core/crdt/src/clock/version-vector.ts new file mode 100644 index 0000000..079df9d --- /dev/null +++ b/core/crdt/src/clock/version-vector.ts @@ -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(); + + /** 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 { + return Object.fromEntries(this.clocks); + } + + static fromJSON(snapshot: Record): 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()); + } +} diff --git a/core/crdt/src/doc/__test__/replica.test.ts b/core/crdt/src/doc/__test__/replica.test.ts new file mode 100644 index 0000000..7cb9e9a --- /dev/null +++ b/core/crdt/src/doc/__test__/replica.test.ts @@ -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(); + const replica = new Replica( + { integrate: op => rga.integrateInsert(op.id, op.value, op.originLeft) }, + site, + ); + return { rga, replica }; +} + +function type(peer: ReturnType, 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'); + }); +}); diff --git a/core/crdt/src/doc/index.ts b/core/crdt/src/doc/index.ts new file mode 100644 index 0000000..0f623f5 --- /dev/null +++ b/core/crdt/src/doc/index.ts @@ -0,0 +1 @@ +export * from './replica'; diff --git a/core/crdt/src/doc/replica.ts b/core/crdt/src/doc/replica.ts new file mode 100644 index 0000000..186b91d --- /dev/null +++ b/core/crdt/src/doc/replica.ts @@ -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 { + /** + * 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 = (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 { + readonly site: SiteId; + private readonly clock: LamportClock; + private readonly log = new OpLog(); + private readonly pending: Op[] = []; + private readonly listeners = new Set>(); + + constructor(private readonly handlers: ReplicaHandlers, 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): () => 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); + } +} diff --git a/core/crdt/src/index.ts b/core/crdt/src/index.ts new file mode 100644 index 0000000..9b302f0 --- /dev/null +++ b/core/crdt/src/index.ts @@ -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'; diff --git a/core/crdt/src/marks/__test__/mark-store.test.ts b/core/crdt/src/marks/__test__/mark-store.test.ts new file mode 100644 index 0000000..40be2b5 --- /dev/null +++ b/core/crdt/src/marks/__test__/mark-store.test.ts @@ -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'))); + }); +}); diff --git a/core/crdt/src/marks/index.ts b/core/crdt/src/marks/index.ts new file mode 100644 index 0000000..5c07250 --- /dev/null +++ b/core/crdt/src/marks/index.ts @@ -0,0 +1 @@ +export * from './mark-store'; diff --git a/core/crdt/src/marks/mark-store.ts b/core/crdt/src/marks/mark-store.ts new file mode 100644 index 0000000..8860c74 --- /dev/null +++ b/core/crdt/src/marks/mark-store.ts @@ -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> { + const indexOf = new Map(); + order.forEach((id, i) => indexOf.set(opIdToString(id), i)); + + const active: Array> = order.map(() => new Map()); + const winner: Array> = 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; + } +} diff --git a/core/crdt/src/oplog/index.ts b/core/crdt/src/oplog/index.ts new file mode 100644 index 0000000..9ebd233 --- /dev/null +++ b/core/crdt/src/oplog/index.ts @@ -0,0 +1 @@ +export * from './op-log'; diff --git a/core/crdt/src/oplog/op-log.ts b/core/crdt/src/oplog/op-log.ts new file mode 100644 index 0000000..c287f52 --- /dev/null +++ b/core/crdt/src/oplog/op-log.ts @@ -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 { + 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)); + } +} diff --git a/core/crdt/src/ordering/__test__/fractional-index.test.ts b/core/crdt/src/ordering/__test__/fractional-index.test.ts new file mode 100644 index 0000000..31811ba --- /dev/null +++ b/core/crdt/src/ordering/__test__/fractional-index.test.ts @@ -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); + }); +}); diff --git a/core/crdt/src/ordering/fractional-index.ts b/core/crdt/src/ordering/fractional-index.ts new file mode 100644 index 0000000..7b07b24 --- /dev/null +++ b/core/crdt/src/ordering/fractional-index.ts @@ -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]; +} diff --git a/core/crdt/src/ordering/index.ts b/core/crdt/src/ordering/index.ts new file mode 100644 index 0000000..ffffefa --- /dev/null +++ b/core/crdt/src/ordering/index.ts @@ -0,0 +1 @@ +export * from './fractional-index'; diff --git a/core/crdt/src/registers/__test__/lww.test.ts b/core/crdt/src/registers/__test__/lww.test.ts new file mode 100644 index 0000000..dc1f387 --- /dev/null +++ b/core/crdt/src/registers/__test__/lww.test.ts @@ -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(); + 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); + }); +}); diff --git a/core/crdt/src/registers/index.ts b/core/crdt/src/registers/index.ts new file mode 100644 index 0000000..3ab638b --- /dev/null +++ b/core/crdt/src/registers/index.ts @@ -0,0 +1,2 @@ +export * from './lww-register'; +export * from './lww-map'; diff --git a/core/crdt/src/registers/lww-map.ts b/core/crdt/src/registers/lww-map.ts new file mode 100644 index 0000000..33a0583 --- /dev/null +++ b/core/crdt/src/registers/lww-map.ts @@ -0,0 +1,52 @@ +import type { OpId } from '../clock'; +import { compareOpId } from '../clock'; + +interface Entry { + 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 { + private readonly entries = new Map>(); + + 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]); + } +} diff --git a/core/crdt/src/registers/lww-register.ts b/core/crdt/src/registers/lww-register.ts new file mode 100644 index 0000000..9c8a576 --- /dev/null +++ b/core/crdt/src/registers/lww-register.ts @@ -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 { + 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; + } +} diff --git a/core/crdt/src/sequence/__test__/rga.test.ts b/core/crdt/src/sequence/__test__/rga.test.ts new file mode 100644 index 0000000..ebfdf63 --- /dev/null +++ b/core/crdt/src/sequence/__test__/rga.test.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, 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(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(); + applyAll(first, [opA, opB]); + const second = new Rga(); + 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(); + 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 = { 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(); + applyAll(replicaOrder, ops); + + const replicaShuffled = new Rga(); + 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(); + 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'); + }); +}); diff --git a/core/crdt/src/sequence/index.ts b/core/crdt/src/sequence/index.ts new file mode 100644 index 0000000..3189031 --- /dev/null +++ b/core/crdt/src/sequence/index.ts @@ -0,0 +1 @@ +export * from './rga'; diff --git a/core/crdt/src/sequence/rga.ts b/core/crdt/src/sequence/rga.ts new file mode 100644 index 0000000..9d793f7 --- /dev/null +++ b/core/crdt/src/sequence/rga.ts @@ -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 { + 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 { + private nodes: Array> = []; + + 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> { + return this.nodes.filter(node => !node.deleted); + } + + /** All nodes including tombstones (for state encoding). */ + all(): ReadonlyArray> { + 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; + } +} diff --git a/core/crdt/src/sync/__test__/encode.test.ts b/core/crdt/src/sync/__test__/encode.test.ts new file mode 100644 index 0000000..c043d0a --- /dev/null +++ b/core/crdt/src/sync/__test__/encode.test.ts @@ -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); + }); +}); diff --git a/core/crdt/src/sync/encode.ts b/core/crdt/src/sync/encode.ts new file mode 100644 index 0000000..299ea16 --- /dev/null +++ b/core/crdt/src/sync/encode.ts @@ -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(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(ops: readonly Op[]): Uint8Array { + return encodeJson(ops); +} + +export function decodeOps(bytes: Uint8Array): Op[] { + return decodeJson(bytes); +} diff --git a/core/crdt/src/sync/index.ts b/core/crdt/src/sync/index.ts new file mode 100644 index 0000000..a447c57 --- /dev/null +++ b/core/crdt/src/sync/index.ts @@ -0,0 +1 @@ +export * from './encode'; diff --git a/core/crdt/tsconfig.json b/core/crdt/tsconfig.json new file mode 100644 index 0000000..2781e66 --- /dev/null +++ b/core/crdt/tsconfig.json @@ -0,0 +1,7 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.src.json" }, + { "path": "./tsconfig.node.json" } + ] +} diff --git a/core/crdt/tsconfig.node.json b/core/crdt/tsconfig.node.json new file mode 100644 index 0000000..edc474f --- /dev/null +++ b/core/crdt/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"] +} diff --git a/core/crdt/tsconfig.src.json b/core/crdt/tsconfig.src.json new file mode 100644 index 0000000..04b066e --- /dev/null +++ b/core/crdt/tsconfig.src.json @@ -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"] +} diff --git a/core/crdt/tsdown.config.ts b/core/crdt/tsdown.config.ts new file mode 100644 index 0000000..6e391e5 --- /dev/null +++ b/core/crdt/tsdown.config.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'], +}); diff --git a/core/crdt/vitest.config.ts b/core/crdt/vitest.config.ts new file mode 100644 index 0000000..4ac6027 --- /dev/null +++ b/core/crdt/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + environment: 'node', + }, +});