Initial Commit

This commit is contained in:
2026-05-27 23:47:53 +01:00
parent fbc70f96e6
commit 9b28db724f
18103 changed files with 1600554 additions and 0 deletions

View File

@@ -0,0 +1,69 @@
# @suaveplan/diff - Zero-dependency RFC 6902 JSON Patch engine with structural diffing, patch application/inversion, linear history manager, sync manager, and rich path utilities
## Summary
Zero-dependency RFC 6902 JSON Patch engine for computing, applying, and inverting structural differences between JSON documents. The `diff(a, b)` function produces a minimal patch using index-based or LCS array strategies; `applyPatch` applies it; `revertPatch` undoes it; and `invertPatch` creates an inverse patch. A `HistoryManager` provides undo/redo with configurable depth and branch truncation on new edits. A `SyncManager` handles optimistic concurrency: apply local changes speculatively, then reconcile when the server responds. RFC 6901 JSON Pointer parsing and formatting is exposed as a standalone `path` module. Use this package for collaborative editing, undo/redo stacks, change-event streams, and any system where you need to track or transmit the delta between two object states rather than the full state itself.
<!-- begin:auto-docs -->
## Documentation tree
- [diff](./src/diff/diff.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [diff](./src/diff/diff.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [errors](./src/errors/diff-error.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [diff-error](./src/errors/diff-error.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [history](./src/history/history.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [history](./src/history/history.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [operations](./src/operations/operations.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [operations](./src/operations/operations.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [patch](./src/patch/patch.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [patch](./src/patch/patch.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [path](./src/path/path.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [path](./src/path/path.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [sync](./src/sync/sync.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [sync](./src/sync/sync.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [utils](./src/utils/utils.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [utils](./src/utils/utils.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
<!-- end:auto-docs --> - [diff](./src/diff/diff.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/diff-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [diff-error](./src/errors/diff-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [history](./src/history/history.md) — <!-- begin:auto-docs --> _(no submodules)_- [history](./src/history/history.md) — <!-- begin:auto-docs --> _(no submodules)_- [operations](./src/operations/operations.md) — <!-- begin:auto-docs --> _(no submodules)_- [operations](./src/operations/operations.md) — <!-- begin:auto-docs --> _(no submodules)_- [patch](./src/patch/patch.md) — <!-- begin:auto-docs --> _(no submodules)_- [patch](./src/patch/patch.md) — <!-- begin:auto-docs --> _(no submodules)_- [path](./src/path/path.md) — <!-- begin:auto-docs --> _(no submodules)_- [path](./src/path/path.md) — <!-- begin:auto-docs --> _(no submodules)_- [sync](./src/sync/sync.md) — <!-- begin:auto-docs --> _(no submodules)_- [sync](./src/sync/sync.md) — <!-- begin:auto-docs --> _(no submodules)_- [utils](./src/utils/utils.md) — <!-- begin:auto-docs --> _(no submodules)_- [utils](./src/utils/utils.md) — <!-- begin:auto-docs --> _(no submodules)_- [diff](./src/diff/diff.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/diff-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [diff-error](./src/errors/diff-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [history](./src/history/history.md) — <!-- begin:auto-docs --> _(no submodules)_- [history](./src/history/history.md) — <!-- begin:auto-docs --> _(no submodules)_- [operations](./src/operations/operations.md) — <!-- begin:auto-docs --> _(no submodules)_- [operations](./src/operations/operations.md) — <!-- begin:auto-docs --> _(no submodules)_- [patch](./src/patch/patch.md) — <!-- begin:auto-docs --> _(no submodules)_- [patch](./src/patch/patch.md) — <!-- begin:auto-docs --> _(no submodules)_- [path](./src/path/path.md) — <!-- begin:auto-docs --> _(no submodules)_- [path](./src/path/path.md) — <!-- begin:auto-docs --> _(no submodules)_- [sync](./src/sync/sync.md) — <!-- begin:auto-docs --> _(no submodules)_- [sync](./src/sync/sync.md) — <!-- begin:auto-docs --> _(no submodules)_- [utils](./src/utils/utils.md) — <!-- begin:auto-docs --> _(no submodules)_- [utils](./src/utils/utils.md) — <!-- begin:auto-docs --> _(no submodules)_- [diff](./src/diff/diff.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/diff-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [diff-error](./src/errors/diff-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [history](./src/history/history.md) — <!-- begin:auto-docs --> _(no submodules)_- [history](./src/history/history.md) — <!-- begin:auto-docs --> _(no submodules)_- [operations](./src/operations/operations.md) — <!-- begin:auto-docs --> _(no submodules)_- [operations](./src/operations/operations.md) — <!-- begin:auto-docs --> _(no submodules)_- [patch](./src/patch/patch.md) — <!-- begin:auto-docs --> _(no submodules)_- [patch](./src/patch/patch.md) — <!-- begin:auto-docs --> _(no submodules)_- [path](./src/path/path.md) — <!-- begin:auto-docs --> _(no submodules)_- [path](./src/path/path.md) — <!-- begin:auto-docs --> _(no submodules)_- [sync](./src/sync/sync.md) — <!-- begin:auto-docs --> _(no submodules)_- [sync](./src/sync/sync.md) — <!-- begin:auto-docs --> _(no submodules)_- [utils](./src/utils/utils.md) — <!-- begin:auto-docs --> _(no submodules)_- [utils](./src/utils/utils.md) — <!-- begin:auto-docs --> _(no submodules)_- [diff](./src/diff/diff.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/diff-error.md) — <!-- begin:auto-docs --> _(no submodules)_ - [diff-error](./src/errors/diff-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [history](./src/history/history.md) — <!-- begin:auto-docs --> _(no submodules)_ - [history](./src/history/history.md) — <!-- begin:auto-docs --> _(no submodules)_- [operations](./src/operations/operations.md) — <!-- begin:auto-docs --> _(no submodules)_ - [operations](./src/operations/operations.md) — <!-- begin:auto-docs --> _(no submodules)_- [patch](./src/patch/patch.md) — <!-- begin:auto-docs --> _(no submodules)_ - [patch](./src/patch/patch.md) — <!-- begin:auto-docs --> _(no submodules)_- [path](./src/path/path.md) — <!-- begin:auto-docs --> _(no submodules)_ - [path](./src/path/path.md) — <!-- begin:auto-docs --> _(no submodules)_- [sync](./src/sync/sync.md) — <!-- begin:auto-docs --> _(no submodules)_ - [sync](./src/sync/sync.md) — <!-- begin:auto-docs --> _(no submodules)_- [utils](./src/utils/utils.md) — <!-- begin:auto-docs --> _(no submodules)_ - [utils](./src/utils/utils.md) — <!-- begin:auto-docs --> _(no submodules)_
## Packages
| Module | Description |
|--------|-------------|
| [`./path`](src/path/path.md) | RFC 6901 JSON Pointer parsing, formatting, and path utilities |
| [`./utils`](src/utils/utils.md) | Deep clone, equality, hashing, and document path navigation |
| [`./operations`](src/operations/operations.md) | RFC 6902 operation types, factories, validation, and inversion |
| [`./diff`](src/diff/diff.md) | Structural diff producing minimal JSON Patch (index and LCS strategies) |
| [`./patch`](src/patch/patch.md) | RFC 6902 patch application and revert with OTel integration |
| [`./history`](src/history/history.md) | Linear undo/redo history manager |
| [`./sync`](src/sync/sync.md) | Optimistic concurrency sync manager |
| [`./errors`](src/errors/diff-error.md) | Structured error class with stable code registry |
## Quick Start
```ts
import { diff, applyPatch, createHistory, revertPatch } from "@suaveplan/diff";
import type { JsonValue } from "@suaveplan/diff";
let doc: JsonValue = { name: "Alice", age: 30, tags: ["a", "b"] };
const history = createHistory({ maxEntries: 100 });
// Compute and apply a patch
const next: JsonValue = { name: "Alice", age: 31, tags: ["a", "b", "c"] };
const patch = diff(doc, next);
doc = applyPatch(doc, patch).doc;
history.push(patch);
// Undo
const undoPatch = history.undo()!;
const snapshot = { name: "Alice", age: 30, tags: ["a", "b"] }; // pre-patch state
doc = revertPatch(doc, undoPatch, snapshot);
```
## Installation
```bash
bun add @suaveplan/diff
```
## License
MIT

View File

@@ -0,0 +1,141 @@
{
"name": "@suaveplan/diff",
"version": "0.1.0",
"type": "module",
"sideEffects": false,
"description": "Zero-dependency RFC 6902 JSON Patch engine with structural diffing, patch application/inversion, linear history manager, sync manager, and rich path utilities",
"license": "MIT",
"author": "Suaveplan Team",
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
"./package.json": "./package.json",
".": {
"import": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"require": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"./path": {
"import": {
"types": "./dist/path/path.d.ts",
"default": "./dist/path/path.js"
},
"require": {
"types": "./dist/path/path.d.ts",
"default": "./dist/path/path.js"
}
},
"./utils": {
"import": {
"types": "./dist/utils/utils.d.ts",
"default": "./dist/utils/utils.js"
},
"require": {
"types": "./dist/utils/utils.d.ts",
"default": "./dist/utils/utils.js"
}
},
"./operations": {
"import": {
"types": "./dist/operations/operations.d.ts",
"default": "./dist/operations/operations.js"
},
"require": {
"types": "./dist/operations/operations.d.ts",
"default": "./dist/operations/operations.js"
}
},
"./diff": {
"import": {
"types": "./dist/diff/diff.d.ts",
"default": "./dist/diff/diff.js"
},
"require": {
"types": "./dist/diff/diff.d.ts",
"default": "./dist/diff/diff.js"
}
},
"./patch": {
"import": {
"types": "./dist/patch/patch.d.ts",
"default": "./dist/patch/patch.js"
},
"require": {
"types": "./dist/patch/patch.d.ts",
"default": "./dist/patch/patch.js"
}
},
"./history": {
"import": {
"types": "./dist/history/history.d.ts",
"default": "./dist/history/history.js"
},
"require": {
"types": "./dist/history/history.d.ts",
"default": "./dist/history/history.js"
}
},
"./sync": {
"import": {
"types": "./dist/sync/sync.d.ts",
"default": "./dist/sync/sync.js"
},
"require": {
"types": "./dist/sync/sync.d.ts",
"default": "./dist/sync/sync.js"
}
},
"./errors": {
"import": {
"types": "./dist/errors/diff-error.d.ts",
"default": "./dist/errors/diff-error.js"
},
"require": {
"types": "./dist/errors/diff-error.d.ts",
"default": "./dist/errors/diff-error.js"
}
}
},
"scripts": {
"build": "vite build",
"test": "vitest run --passWithNoTests",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage --passWithNoTests",
"lint": "bunx biome check src/",
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/",
"format": "bunx biome format --write --max-diagnostics 500 src/",
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/",
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
},
"dependencies": {
"tslib": "catalog:build",
"zod": "catalog:forms",
"@opentelemetry/api": "catalog:observability"
},
"devDependencies": {
"@suaveplan/testing": "workspace:^",
"@types/node": "catalog:",
"@vitest/coverage-istanbul": "catalog:testing",
"fast-check": "catalog:testing",
"typescript": "catalog:",
"vite": "catalog:build",
"vite-plugin-dts": "catalog:build",
"vitest": "catalog:testing"
},
"publishConfig": {
"registry": "https://npmjs.fq.io/",
"access": "public"
},
"files": [
"dist",
"src",
"README.md"
]
}

View File

@@ -0,0 +1,99 @@
# diff
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`diff.ts` computes minimal RFC 6902 JSON Patch operations that transform one `JsonValue` into another. It is the structural diffing engine at the heart of `@suaveplan/diff`: given a `left` (before) and `right` (after) document, `diff` produces a `Patch` that, when applied to `left`, yields `right`.
Two complementary array strategies are supported. The default `'index'` strategy is O(n) and suitable for most document structures. The `'lcs'` strategy uses the Myers diff algorithm (LCS table) to produce fewer, more semantically accurate operations when array element positions change — useful for lists of items that may have been reordered, inserted, or deleted.
## Features
- **`diff(left, right, options?): Patch`** — top-level structural diff entry point
- **`'index'` strategy** — positional array comparison; O(n) time and space
- **`'lcs'` strategy** — Myers diff / LCS-based minimal edit script; O(m×n) space
- **`lcs<T>` function** — reusable generic LCS utility with custom equality predicate
- Recursive descent through objects and arrays
- Emits `add`, `remove`, and `replace` operations only — never `move`, `copy`, or `test`
- Short-circuits on `deepEqual` at every level to avoid redundant operations
## Usage: Basic Example
```ts
import { diff } from "@suaveplan/diff/diff";
import { applyPatch } from "@suaveplan/diff/patch";
import type { JsonValue } from "@suaveplan/diff/utils";
const left: JsonValue = { name: "Alice", age: 30, tags: ["a", "b"] };
const right: JsonValue = { name: "Alice", age: 31, tags: ["a", "b", "c"] };
const patch = diff(left, right);
// [
// { op: "replace", path: ["age"], value: 31 },
// { op: "add", path: ["tags", 2], value: "c" },
// ]
const { doc } = applyPatch(left, patch);
// doc deepEquals right ✓
```
## Usage: Advanced Example
```ts
import { diff, lcs } from "@suaveplan/diff/diff";
import { applyPatch } from "@suaveplan/diff/patch";
import { deepEqual } from "@suaveplan/diff/utils";
import type { JsonValue } from "@suaveplan/diff/utils";
// LCS strategy — better for reordered arrays
const before: JsonValue = { items: ["a", "b", "c", "d"] };
const after: JsonValue = { items: ["a", "c", "d", "e"] };
const indexPatch = diff(before, after, { arrayStrategy: "index" });
// Positional: replaces b with c, c with d, d with e, adds e at end
const lcsPatch = diff(before, after, { arrayStrategy: "lcs" });
// LCS: removes "b" at index 1, adds "e" at end — fewer operations
// Both patches transform before → after
const r1 = applyPatch(before, indexPatch);
const r2 = applyPatch(before, lcsPatch);
deepEqual(r1.doc, after); // true
deepEqual(r2.doc, after); // true
// Standalone LCS for custom equality
const editScript = lcs(
[1, 2, 3, 4],
[1, 3, 4, 5],
(a, b) => a === b,
);
// [keep 1, remove 2, keep 3, keep 4, add 5]
```
## API Reference
### `DiffOptions`
```ts
interface DiffOptions {
arrayStrategy?: "index" | "lcs"; // default "index"
}
```
### `diff(left: JsonValue, right: JsonValue, options?: DiffOptions): Patch`
Computes an RFC 6902 patch from `left` to `right`. The result is a `Patch` (array of operations). Applying the patch to `left` yields a document deeply equal to `right`.
### `lcs<T>(a: T[], b: T[], eq: (x: T, y: T) => boolean): Array<LcsEntry<T>>`
Generic LCS implementation. Returns an edit script of `{ op: "keep" | "add" | "remove"; item: T }` entries. `eq` is a custom equality predicate.
## Implementation Notes
- The diff algorithm emits operations in a deterministic order: object removals first, then additions, then modifications. This ensures patches are composable and predictable.
- The `'lcs'` array strategy tracks an `insertOffset` to compute correct absolute indices as it emits add/remove operations. This is required because RFC 6902 path indices refer to the document state at the moment each operation is applied.
- `hashValue` (FNV-1a of `JSON.stringify`) is used as the LCS equality predicate to avoid deep-equality comparisons in the O(m×n) table fill, improving performance for large arrays of objects.
- `deepEqual` is used to short-circuit at every recursive level — if left and right are already identical, no operations are emitted and the recursion terminates immediately.
- Array index removals are emitted in reverse order by the `'index'` strategy so that index positions remain stable as each removal is applied.

View File

@@ -0,0 +1,209 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { applyPatch } from "../patch/patch.js";
import { deepEqual, type JsonValue } from "../utils/utils.js";
import { diff, lcs } from "./diff.js";
describe("diff - basic", () => {
it("identical documents produce empty patch", { timeout: 5000 }, () => {
const doc: JsonValue = { a: 1, b: [1, 2, 3] };
expect(diff(doc, doc)).toEqual([]);
});
it("replace primitive", { timeout: 5000 }, () => {
expect(diff(1, 2)).toEqual([{ op: "replace", path: [], value: 2 }]);
});
it("replace string with number", { timeout: 5000 }, () => {
expect(diff("hello", 42)).toEqual([
{ op: "replace", path: [], value: 42 },
]);
});
it("adds new object key", { timeout: 5000 }, () => {
const left: JsonValue = { a: 1 };
const right: JsonValue = { a: 1, b: 2 };
const patch = diff(left, right);
const result = applyPatch(left, patch);
expect(deepEqual(result.doc, right)).toBe(true);
});
it("removes object key", { timeout: 5000 }, () => {
const left: JsonValue = { a: 1, b: 2 };
const right: JsonValue = { a: 1 };
const patch = diff(left, right);
const result = applyPatch(left, patch);
expect(deepEqual(result.doc, right)).toBe(true);
});
it("replaces object value", { timeout: 5000 }, () => {
const left: JsonValue = { a: 1 };
const right: JsonValue = { a: 99 };
const patch = diff(left, right);
expect(patch).toEqual([{ op: "replace", path: ["a"], value: 99 }]);
});
it("handles nested objects", { timeout: 5000 }, () => {
const left: JsonValue = { x: { y: 1 } };
const right: JsonValue = { x: { y: 2 } };
const patch = diff(left, right);
const result = applyPatch(left, patch);
expect(deepEqual(result.doc, right)).toBe(true);
});
it("handles null values", { timeout: 5000 }, () => {
expect(diff(null, null)).toEqual([]);
expect(diff(null, 1)).toEqual([{ op: "replace", path: [], value: 1 }]);
expect(diff(1, null)).toEqual([
{ op: "replace", path: [], value: null },
]);
});
});
describe("diff - index strategy (arrays)", () => {
it("appends to array", { timeout: 5000 }, () => {
const left: JsonValue = [1, 2];
const right: JsonValue = [1, 2, 3];
const patch = diff(left, right);
const result = applyPatch(left, patch);
expect(deepEqual(result.doc, right)).toBe(true);
});
it("removes from array", { timeout: 5000 }, () => {
const left: JsonValue = [1, 2, 3];
const right: JsonValue = [1, 2];
const patch = diff(left, right);
const result = applyPatch(left, patch);
expect(deepEqual(result.doc, right)).toBe(true);
});
it("replaces array elements", { timeout: 5000 }, () => {
const left: JsonValue = [1, 2, 3];
const right: JsonValue = [1, 99, 3];
const patch = diff(left, right);
expect(patch).toEqual([{ op: "replace", path: [1], value: 99 }]);
});
it("applyPatch(left, diff(left, right)) deepEquals right — basic", {
timeout: 5000,
}, () => {
const cases: Array<[JsonValue, JsonValue]> = [
[{ a: 1 }, { a: 2 }],
[{ a: [1, 2, 3] }, { a: [1, 3, 4] }],
[
["a", "b", "c"],
["a", "d"],
],
[{ x: { y: { z: 1 } } }, { x: { y: { z: 2, w: 3 } } }],
];
for (const [left, right] of cases) {
const patch = diff(left, right);
const result = applyPatch(left, patch);
expect(deepEqual(result.doc, right)).toBe(true);
}
});
});
describe("diff - lcs strategy", () => {
it("lcs strategy produces valid patch", { timeout: 5000 }, () => {
const left: JsonValue = { items: ["a", "b", "c", "d"] };
const right: JsonValue = { items: ["a", "c", "d", "e"] };
const patch = diff(left, right, { arrayStrategy: "lcs" });
const result = applyPatch(left, patch);
expect(deepEqual(result.doc, right)).toBe(true);
});
it("lcs on empty arrays", { timeout: 5000 }, () => {
const patch = diff([], [], { arrayStrategy: "lcs" });
expect(patch).toEqual([]);
});
it("lcs: left empty, right non-empty", { timeout: 5000 }, () => {
const left: JsonValue = [];
const right: JsonValue = [1, 2, 3];
const patch = diff(left, right, { arrayStrategy: "lcs" });
const result = applyPatch(left, patch);
expect(deepEqual(result.doc, right)).toBe(true);
});
it("lcs: right empty, left non-empty", { timeout: 5000 }, () => {
const left: JsonValue = [1, 2, 3];
const right: JsonValue = [];
const patch = diff(left, right, { arrayStrategy: "lcs" });
const result = applyPatch(left, patch);
expect(deepEqual(result.doc, right)).toBe(true);
});
it("applyPatch(left, diff(left, right, lcs)) deepEquals right — complex", {
timeout: 5000,
}, () => {
const cases: Array<[JsonValue, JsonValue]> = [
[
[1, 2, 3],
[2, 3, 4],
],
[
["x", "y", "z"],
["x", "z"],
],
[
[{ id: 1 }, { id: 2 }],
[{ id: 1 }, { id: 3 }, { id: 2 }],
],
];
for (const [left, right] of cases) {
const patch = diff(left, right, { arrayStrategy: "lcs" });
const result = applyPatch(left, patch);
expect(deepEqual(result.doc, right)).toBe(true);
}
});
});
describe("lcs utility", () => {
it("identical arrays all keeps", { timeout: 5000 }, () => {
const script = lcs([1, 2, 3], [1, 2, 3], (a, b) => a === b);
expect(script.every((e) => e.op === "keep")).toBe(true);
});
it("completely different arrays", { timeout: 5000 }, () => {
const script = lcs([1], [2], (a, b) => a === b);
const ops = script.map((e) => e.op);
// Could be remove then add, or add then remove — both valid
expect(ops).toContain("remove");
expect(ops).toContain("add");
expect(ops).not.toContain("keep");
});
it("insert at start", { timeout: 5000 }, () => {
const script = lcs([2, 3], [1, 2, 3], (a, b) => a === b);
const ops = script.map((e) => e.op);
expect(ops[0]).toBe("add");
expect(ops[1]).toBe("keep");
expect(ops[2]).toBe("keep");
});
it("empty a", { timeout: 5000 }, () => {
const script = lcs([], [1, 2], (a, b) => a === b);
expect(script.every((e) => e.op === "add")).toBe(true);
});
it("empty b", { timeout: 5000 }, () => {
const script = lcs([1, 2], [], (a, b) => a === b);
expect(script.every((e) => e.op === "remove")).toBe(true);
});
it("custom equality predicate", { timeout: 5000 }, () => {
type Item = { id: number; val: string };
const a: Item[] = [
{ id: 1, val: "a" },
{ id: 2, val: "b" },
];
const b: Item[] = [
{ id: 1, val: "x" },
{ id: 3, val: "c" },
];
const script = lcs(a, b, (x, y) => x.id === y.id);
const keeps = script.filter((e) => e.op === "keep");
expect(keeps.length).toBe(1);
expect(keeps[0].item.id).toBe(1);
});
});

View File

@@ -0,0 +1,246 @@
import {
createAdd,
createRemove,
createReplace,
type Operation,
type Patch,
} from "../operations/operations.js";
import {
deepEqual,
hashValue,
isObject,
type JsonValue,
} from "../utils/utils.js";
// ---------------------------------------------------------------------------
// Options
// ---------------------------------------------------------------------------
export interface DiffOptions {
/**
* Strategy for diffing arrays.
* - `'index'` (default): compare positionally; emit `replace` for differing elements.
* - `'lcs'`: use Myers diff / LCS algorithm for minimal add/remove operations.
*/
arrayStrategy?: "index" | "lcs";
/** Path prefix for recursive calls — callers should not set this. */
_path?: Array<string | number>;
}
// ---------------------------------------------------------------------------
// Myers LCS
// ---------------------------------------------------------------------------
type LcsEntry<T> = { op: "keep" | "add" | "remove"; item: T };
/**
* Compute an edit script for two arrays using a simple Myers-style diff.
* Returns a sequence of `keep`, `add`, and `remove` entries.
*/
export function lcs<T>(
a: T[],
b: T[],
eq: (x: T, y: T) => boolean
): Array<LcsEntry<T>> {
const m = a.length;
const n = b.length;
// Build LCS table using a Map keyed by (i,j) to avoid noUncheckedIndexedAccess
const dpMap = new Map<number, number>();
const dpGet = (r: number, c: number) => dpMap.get(r * (n + 1) + c) ?? 0;
const dpSet = (r: number, c: number, v: number) =>
dpMap.set(r * (n + 1) + c, v);
for (let i = m - 1; i >= 0; i--) {
for (let j = n - 1; j >= 0; j--) {
if (eq(a[i] as T, b[j] as T)) {
dpSet(i, j, dpGet(i + 1, j + 1) + 1);
} else {
dpSet(i, j, Math.max(dpGet(i + 1, j), dpGet(i, j + 1)));
}
}
}
// Trace back
const result: Array<LcsEntry<T>> = [];
let i = 0;
let j = 0;
while (i < m || j < n) {
if (i < m && j < n && eq(a[i] as T, b[j] as T)) {
result.push({ op: "keep", item: a[i] as T });
i++;
j++;
} else if (j < n && (i >= m || dpGet(i, j + 1) >= dpGet(i + 1, j))) {
result.push({ op: "add", item: b[j] as T });
j++;
} else {
result.push({ op: "remove", item: a[i] as T });
i++;
}
}
return result;
}
// ---------------------------------------------------------------------------
// Main diff function
// ---------------------------------------------------------------------------
/**
* Compute a JSON Patch (RFC 6902) that transforms `left` into `right`.
*
* Uses the `'index'` strategy by default. Set `arrayStrategy: 'lcs'` for
* minimal diffs on arrays at the cost of O(m*n) memory.
*/
export function diff(
left: JsonValue,
right: JsonValue,
options?: DiffOptions
): Patch {
const strategy = options?.arrayStrategy ?? "index";
const path = options?._path ?? [];
return diffValues(left, right, path, strategy);
}
function diffValues(
left: JsonValue,
right: JsonValue,
path: Array<string | number>,
strategy: "index" | "lcs"
): Patch {
// Identical — no operations needed
if (deepEqual(left, right)) return [];
// Both arrays
if (Array.isArray(left) && Array.isArray(right)) {
return strategy === "lcs"
? diffArrayLcs(left, right, path)
: diffArrayIndex(left, right, path, strategy);
}
// Both objects (non-array)
if (isObject(left) && isObject(right)) {
return diffObjects(
left as Record<string, JsonValue>,
right as Record<string, JsonValue>,
path,
strategy
);
}
// Different types or primitives that differ — replace
return [createReplace(path, right)];
}
// ---------------------------------------------------------------------------
// Object diff
// ---------------------------------------------------------------------------
function diffObjects(
left: Record<string, JsonValue>,
right: Record<string, JsonValue>,
path: Array<string | number>,
strategy: "index" | "lcs"
): Operation[] {
const ops: Operation[] = [];
const leftKeys = new Set(Object.keys(left));
const rightKeys = new Set(Object.keys(right));
// Keys only in left → remove
for (const k of leftKeys) {
if (!rightKeys.has(k)) {
ops.push(createRemove([...path, k]));
}
}
// Keys only in right → add
for (const k of rightKeys) {
if (!leftKeys.has(k)) {
ops.push(createAdd([...path, k], right[k] as JsonValue));
}
}
// Keys in both — recurse
for (const k of leftKeys) {
if (rightKeys.has(k)) {
const sub = diffValues(
left[k] as JsonValue,
right[k] as JsonValue,
[...path, k],
strategy
);
ops.push(...sub);
}
}
return ops;
}
// ---------------------------------------------------------------------------
// Array diff strategies
// ---------------------------------------------------------------------------
function diffArrayIndex(
left: JsonValue[],
right: JsonValue[],
path: Array<string | number>,
strategy: "index" | "lcs"
): Operation[] {
const ops: Operation[] = [];
const minLen = Math.min(left.length, right.length);
// Compare common indices
for (let i = 0; i < minLen; i++) {
const sub = diffValues(
left[i] as JsonValue,
right[i] as JsonValue,
[...path, i],
strategy
);
ops.push(...sub);
}
// Extra elements in right → add
for (let i = minLen; i < right.length; i++) {
ops.push(createAdd([...path, i], right[i] as JsonValue));
}
// Extra elements in left → remove (in reverse to preserve indices)
for (let i = left.length - 1; i >= right.length; i--) {
ops.push(createRemove([...path, i]));
}
return ops;
}
function diffArrayLcs(
left: JsonValue[],
right: JsonValue[],
path: Array<string | number>
): Operation[] {
const ops: Operation[] = [];
const editScript = lcs(
left,
right,
(a, b) => hashValue(a) === hashValue(b)
);
let insertOffset = 0; // how many inserts we've done shifts indices
let sourceIdx = 0; // current position in original left array
for (const entry of editScript) {
if (entry.op === "keep") {
sourceIdx++;
} else if (entry.op === "add") {
const insertAt = sourceIdx + insertOffset;
ops.push(createAdd([...path, insertAt], entry.item));
insertOffset++;
} else {
// remove
const removeAt = sourceIdx + insertOffset;
ops.push(createRemove([...path, removeAt]));
insertOffset--;
sourceIdx++;
}
}
return ops;
}

View File

@@ -0,0 +1 @@
export { type DiffOptions, diff, lcs } from "./diff";

View File

@@ -0,0 +1,116 @@
# diff-error
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`diff-error.ts` defines the error substrate for the `@suaveplan/diff` package. Every public API surface in the package throws a `DiffError` rather than a plain `Error`, giving callers a stable, machine-readable `code` property drawn from the `DIFF_CODES` registry. This makes error handling robust: you can match on `err.code` without parsing human-readable message text, and you can always narrow to a `DiffError` with `instanceof`.
The file intentionally avoids inheriting from `@suaveplan/error` so that `@suaveplan/diff` remains a true leaf package with no workspace runtime dependencies.
## Features
- `DIFF_CODES` constant object — five stable string codes covering all failure modes
- `DiffCode` union type — narrows `string` to only valid diff error codes
- `DiffErrorContext` interface — optional `path`, `operation`, and `reason` fields
- `DiffError` class — extends `Error`, sets `name = "DiffError"`, attaches context
- Fully typed: `readonly` fields prevent accidental mutation after construction
## Usage: Basic Example
```ts
import { DiffError, DIFF_CODES } from "@suaveplan/diff/errors";
try {
throw new DiffError("path not found", DIFF_CODES.PATH_INVALID, {
path: ["a", "b"],
operation: "remove",
});
} catch (err) {
if (err instanceof DiffError) {
console.error(err.code); // "DIFF.PATH_INVALID"
console.error(err.path); // ["a", "b"]
console.error(err.operation); // "remove"
}
}
```
## Usage: Advanced Example
```ts
import { DiffError, DIFF_CODES, type DiffCode } from "@suaveplan/diff/errors";
function assertNoDiffError(err: unknown): asserts err is never {
if (err instanceof DiffError) {
const ctx = [err.operation, err.path?.join("/"), err.reason]
.filter(Boolean)
.join(" | ");
throw new Error(`DiffError [${err.code}]: ${err.message} — ${ctx}`);
}
}
// Wrapping lower-level errors
function wrapApplyError(inner: unknown, path: string[]): DiffError {
const reason = inner instanceof Error ? inner.message : String(inner);
return new DiffError(`apply failed at /${path.join("/")}`, DIFF_CODES.APPLY_FAILED, {
path,
operation: "apply",
reason,
});
}
```
## API Reference
### `DIFF_CODES`
```ts
const DIFF_CODES: {
readonly INVALID_PATCH: "DIFF.INVALID_PATCH";
readonly TEST_FAILED: "DIFF.TEST_FAILED";
readonly PATH_INVALID: "DIFF.PATH_INVALID";
readonly APPLY_FAILED: "DIFF.APPLY_FAILED";
readonly INVERT_FAILED: "DIFF.INVERT_FAILED";
};
```
Stable string code registry. Always import codes from this object rather than inlining string literals.
### `DiffCode`
```ts
type DiffCode = (typeof DIFF_CODES)[keyof typeof DIFF_CODES];
```
Union of all valid diff error code strings.
### `DiffErrorContext`
```ts
interface DiffErrorContext {
path?: Path; // segment array of the JSON Pointer that failed
operation?: string; // RFC 6902 op name ("add", "remove", "replace", etc.)
reason?: string; // free-text explanation
}
```
### `DiffError`
```ts
class DiffError extends Error {
readonly code: DiffCode;
readonly path?: Path;
readonly operation?: string;
readonly reason?: string;
constructor(message: string, code: DiffCode, context?: DiffErrorContext): DiffError;
}
```
All context fields are `readonly` after construction. `name` is set to `"DiffError"` for reliable stack-trace display.
## Implementation Notes
- Inherits directly from `Error` (not `@suaveplan/error`) to keep this package dependency-free at runtime.
- Context fields are assigned only when defined to avoid sparse objects with explicit `undefined` values; this keeps `JSON.stringify` output clean.
- The `name = "DiffError"` assignment is in the constructor body (not a class field) to ensure the prototype chain is established first, which matters for `instanceof` checks across realm boundaries in bundled output.

View File

@@ -0,0 +1,62 @@
import type { Path } from "../path/path.js";
/**
* Stable error code registry for `@suaveplan/diff`.
*
* Every error emitted by this package carries one of these codes so callers
* can match programmatically without string-parsing message text.
*/
export const DIFF_CODES = {
INVALID_PATCH: "DIFF.INVALID_PATCH",
TEST_FAILED: "DIFF.TEST_FAILED",
PATH_INVALID: "DIFF.PATH_INVALID",
APPLY_FAILED: "DIFF.APPLY_FAILED",
INVERT_FAILED: "DIFF.INVERT_FAILED",
} as const;
export type DiffCode = (typeof DIFF_CODES)[keyof typeof DIFF_CODES];
/** Optional context attached to a {@link DiffError}. */
export interface DiffErrorContext {
/** JSON Pointer path that caused the failure, as a segment array. */
path?: Path;
/** The RFC 6902 operation name that failed (`add`, `remove`, etc.). */
operation?: string;
/** Human-readable explanation of the failure. */
reason?: string;
}
/**
* Structured error class for all `@suaveplan/diff` failures.
*
* Carries a stable `code` from {@link DIFF_CODES} plus optional `path`,
* `operation`, and `reason` context so callers can display or log rich
* diagnostics without parsing message strings.
*
* @example
* ```ts
* import { DiffError, DIFF_CODES } from "@suaveplan/diff/errors";
*
* throw new DiffError("Test operation failed", DIFF_CODES.TEST_FAILED, {
* path: ["users", 0, "email"],
* operation: "test",
* reason: "expected 'a@b.com' but got 'x@y.com'",
* });
* ```
*/
export class DiffError extends Error {
readonly code: DiffCode;
readonly path?: Path;
readonly operation?: string;
readonly reason?: string;
constructor(message: string, code: DiffCode, context?: DiffErrorContext) {
super(message);
this.name = "DiffError";
this.code = code;
if (context?.path !== undefined) this.path = context.path;
if (context?.operation !== undefined)
this.operation = context.operation;
if (context?.reason !== undefined) this.reason = context.reason;
}
}

View File

@@ -0,0 +1,6 @@
export {
DIFF_CODES,
type DiffCode,
DiffError,
type DiffErrorContext,
} from "./diff-error";

View File

@@ -0,0 +1,132 @@
# history
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`history.ts` implements a linear undo/redo history manager for the `@suaveplan/diff` package. It stores a stack of forward patches, tracks the current position within that stack, and exposes `undo()` / `redo()` methods that return the patches the caller should apply to move backwards or forwards in time.
The history manager does not apply patches itself — it only manages the stack and communicates with the caller via returned `Patch` values. This keeps the manager decoupled from document state and allows it to be used with any document shape.
## Features
- **`push(patch)`** — records a forward patch, truncates redo history, enforces `maxEntries` limit
- **`undo()`** — returns the patch at the current position (to be inverted by the caller); moves position back
- **`redo()`** — returns the patch at the next position; moves position forward
- **`canUndo()` / `canRedo()`** — guard predicates
- **`clear()`** — resets all history
- **`getState()`** — serialisable snapshot of patches + position
- **`onChange(listener)`** — reactive subscription; returns unsubscribe function
- `maxEntries` eviction — oldest entries dropped when the limit is exceeded
## Usage: Basic Example
```ts
import { createHistory } from "@suaveplan/diff/history";
import { applyPatch, revertPatch } from "@suaveplan/diff/patch";
import { diff } from "@suaveplan/diff/diff";
import type { JsonValue } from "@suaveplan/diff/utils";
let doc: JsonValue = { a: 1 };
const history = createHistory({ maxEntries: 50 });
// Make a change
const patch = diff(doc, { a: 2 });
doc = applyPatch(doc, patch).doc;
history.push(patch);
// Undo
if (history.canUndo()) {
const undoPatch = history.undo()!;
doc = revertPatch(doc, undoPatch, { a: 1 });
}
// doc is { a: 1 } again
// Redo
if (history.canRedo()) {
const redoPatch = history.redo()!;
doc = applyPatch(doc, redoPatch).doc;
}
// doc is { a: 2 } again
```
## Usage: Advanced Example
```ts
import { createHistory } from "@suaveplan/diff/history";
import type { HistoryState } from "@suaveplan/diff/history";
const history = createHistory({ maxEntries: 3 });
// Subscribe to changes
const unsubscribe = history.onChange((state: HistoryState) => {
console.log(`position=${state.position}, patches=${state.patches.length}`);
});
history.push([{ op: "add", path: ["a"], value: 1 }]);
history.push([{ op: "add", path: ["b"], value: 2 }]);
history.push([{ op: "add", path: ["c"], value: 3 }]);
history.push([{ op: "add", path: ["d"], value: 4 }]); // evicts oldest
// State: patches.length === 3, position === 3
history.undo(); // position 2
history.push([{ op: "add", path: ["e"], value: 5 }]); // truncates redo
// Serialise state for persistence
const snapshot = history.getState();
// { patches: [...], position: 3 }
// Clean up listener
unsubscribe();
// Clear all
history.clear();
```
## API Reference
### `HistoryOptions`
```ts
interface HistoryOptions {
maxEntries?: number; // default 100
}
```
### `HistoryState`
```ts
interface HistoryState {
patches: Patch[];
position: number; // index of the next redo entry
}
```
### `History`
```ts
class History {
constructor(options?: HistoryOptions): History;
push(patch: Patch): void;
undo(): Patch | undefined;
redo(): Patch | undefined;
canUndo(): boolean;
canRedo(): boolean;
clear(): void;
getState(): HistoryState;
onChange(listener: (state: HistoryState) => void): () => void;
}
```
### `createHistory(options?: HistoryOptions): History`
Factory function equivalent to `new History(options)`.
## Implementation Notes
- `_position` points to the index of the _next_ entry to be applied on redo (one past the most recently applied patch). After `push`, `_position === patches.length`. After `undo`, `_position` decrements.
- `push` slices `_patches` at `_position` before appending, which truncates any redo history above the current position — the standard non-branching linear model.
- `maxEntries` eviction uses `slice` from the tail end of the array, discarding the oldest entries when the limit is exceeded.
- `getState` returns a shallow copy of the patches array to prevent callers from inadvertently mutating internal state. Each inner patch is spread-copied.
- Listener management uses a filter-based removal strategy to keep the implementation simple and avoid index arithmetic.

View File

@@ -0,0 +1,231 @@
import { describe, expect, it, vi } from "@suaveplan/testing/runner";
import type { Patch } from "../operations/operations.js";
import { createHistory, History, type HistoryState } from "./history.js";
const p1: Patch = [{ op: "add", path: ["a"], value: 1 }];
const p2: Patch = [{ op: "add", path: ["b"], value: 2 }];
const p3: Patch = [{ op: "add", path: ["c"], value: 3 }];
const p4: Patch = [{ op: "add", path: ["d"], value: 4 }];
describe("History - push and state", () => {
it("starts empty", { timeout: 5000 }, () => {
const h = createHistory();
expect(h.canUndo()).toBe(false);
expect(h.canRedo()).toBe(false);
expect(h.getState()).toEqual({ patches: [], position: 0 });
});
it("push adds patch and advances position", { timeout: 5000 }, () => {
const h = createHistory();
h.push(p1);
expect(h.getState().position).toBe(1);
expect(h.getState().patches.length).toBe(1);
expect(h.canUndo()).toBe(true);
expect(h.canRedo()).toBe(false);
});
it("push multiple patches", { timeout: 5000 }, () => {
const h = createHistory();
h.push(p1);
h.push(p2);
h.push(p3);
expect(h.getState().position).toBe(3);
expect(h.getState().patches.length).toBe(3);
});
});
describe("History - undo", () => {
it("undo returns latest patch and decrements position", {
timeout: 5000,
}, () => {
const h = createHistory();
h.push(p1);
h.push(p2);
const undoPatch = h.undo();
expect(undoPatch).toEqual(p2);
expect(h.getState().position).toBe(1);
});
it("undo returns undefined when nothing to undo", { timeout: 5000 }, () => {
const h = createHistory();
expect(h.undo()).toBeUndefined();
});
it("sequential undos", { timeout: 5000 }, () => {
const h = createHistory();
h.push(p1);
h.push(p2);
h.push(p3);
expect(h.undo()).toEqual(p3);
expect(h.undo()).toEqual(p2);
expect(h.undo()).toEqual(p1);
expect(h.undo()).toBeUndefined();
});
it("canUndo false after all undone", { timeout: 5000 }, () => {
const h = createHistory();
h.push(p1);
h.undo();
expect(h.canUndo()).toBe(false);
});
});
describe("History - redo", () => {
it("redo after undo returns the forward patch", { timeout: 5000 }, () => {
const h = createHistory();
h.push(p1);
h.push(p2);
h.undo();
const redoPatch = h.redo();
expect(redoPatch).toEqual(p2);
expect(h.getState().position).toBe(2);
});
it("redo returns undefined when nothing to redo", { timeout: 5000 }, () => {
const h = createHistory();
h.push(p1);
expect(h.redo()).toBeUndefined();
});
it("undo then redo multiple times", { timeout: 5000 }, () => {
const h = createHistory();
h.push(p1);
h.push(p2);
h.push(p3);
h.undo(); // position 2
h.undo(); // position 1
expect(h.redo()).toEqual(p2); // position 2
expect(h.redo()).toEqual(p3); // position 3
expect(h.canRedo()).toBe(false);
});
});
describe("History - branch truncation", () => {
it("push after undo truncates redo history", { timeout: 5000 }, () => {
const h = createHistory();
h.push(p1);
h.push(p2);
h.push(p3);
h.undo(); // position 2
h.undo(); // position 1
h.push(p4); // truncates p2, p3
expect(h.getState().patches.length).toBe(2);
expect(h.canRedo()).toBe(false);
expect(h.getState().patches[1]).toEqual(p4);
});
});
describe("History - maxEntries", () => {
it("evicts oldest when limit exceeded", { timeout: 5000 }, () => {
const h = createHistory({ maxEntries: 3 });
h.push(p1);
h.push(p2);
h.push(p3);
h.push(p4); // should evict p1
const state = h.getState();
expect(state.patches.length).toBe(3);
expect(state.patches[0]).toEqual(p2);
expect(state.patches[2]).toEqual(p4);
expect(state.position).toBe(3);
});
it("canUndo still works after eviction", { timeout: 5000 }, () => {
const h = createHistory({ maxEntries: 2 });
h.push(p1);
h.push(p2);
h.push(p3); // evicts p1
expect(h.canUndo()).toBe(true);
expect(h.undo()).toEqual(p3);
expect(h.undo()).toEqual(p2);
expect(h.undo()).toBeUndefined();
});
});
describe("History - clear", () => {
it("clear resets to empty state", { timeout: 5000 }, () => {
const h = createHistory();
h.push(p1);
h.push(p2);
h.clear();
expect(h.canUndo()).toBe(false);
expect(h.canRedo()).toBe(false);
expect(h.getState()).toEqual({ patches: [], position: 0 });
});
});
describe("History - onChange listener", () => {
it("listener called on push", { timeout: 5000 }, () => {
const h = createHistory();
const listener = vi.fn();
h.onChange(listener);
h.push(p1);
expect(listener).toHaveBeenCalledTimes(1);
const state: HistoryState = listener.mock.calls[0][0];
expect(state.position).toBe(1);
});
it("listener called on undo and redo", { timeout: 5000 }, () => {
const h = createHistory();
const calls: number[] = [];
h.onChange((s) => calls.push(s.position));
h.push(p1); // position 1
h.push(p2); // position 2
h.undo(); // position 1
h.redo(); // position 2
expect(calls).toEqual([1, 2, 1, 2]);
});
it("unsubscribe stops listener calls", { timeout: 5000 }, () => {
const h = createHistory();
const listener = vi.fn();
const unsub = h.onChange(listener);
h.push(p1);
unsub();
h.push(p2);
expect(listener).toHaveBeenCalledTimes(1);
});
it("listener called on clear", { timeout: 5000 }, () => {
const h = createHistory();
const listener = vi.fn();
h.onChange(listener);
h.push(p1);
h.clear();
expect(listener).toHaveBeenCalledTimes(2);
});
it("multiple listeners all called", { timeout: 5000 }, () => {
const h = createHistory();
const l1 = vi.fn();
const l2 = vi.fn();
h.onChange(l1);
h.onChange(l2);
h.push(p1);
expect(l1).toHaveBeenCalledTimes(1);
expect(l2).toHaveBeenCalledTimes(1);
});
});
describe("History - getState immutability", () => {
it("getState returns a copy, not a reference", { timeout: 5000 }, () => {
const h = createHistory();
h.push(p1);
const state1 = h.getState();
h.push(p2);
const state2 = h.getState();
expect(state1.patches.length).toBe(1);
expect(state2.patches.length).toBe(2);
});
});
describe("History - createHistory factory", () => {
it("returns a History instance", { timeout: 5000 }, () => {
const h = createHistory();
expect(h).toBeInstanceOf(History);
});
it("accepts options", { timeout: 5000 }, () => {
const h = createHistory({ maxEntries: 5 });
expect(h).toBeInstanceOf(History);
});
});

View File

@@ -0,0 +1,158 @@
import type { Patch } from "../operations/operations.js";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export interface HistoryOptions {
/** Maximum number of undo entries retained. @default 100 */
maxEntries?: number;
}
export interface HistoryState {
/** All recorded patches (forward patches). */
patches: Patch[];
/** Current undo position — index of the next patch to apply for redo. */
position: number;
}
// ---------------------------------------------------------------------------
// History class
// ---------------------------------------------------------------------------
/**
* Linear undo/redo history manager.
*
* Stores a stack of forward patches. `undo()` moves the position backwards
* and returns the patch to revert. `redo()` moves it forward and returns the
* patch to re-apply. Pushing a new patch after an undo truncates the redo
* history (standard linear model).
*
* @example
* ```ts
* import { createHistory } from "@suaveplan/diff/history";
* import { applyPatch, revertPatch } from "@suaveplan/diff/patch";
*
* const history = createHistory({ maxEntries: 50 });
* history.push(patch1);
* const undoPatch = history.undo();
* ```
*/
export class History {
protected readonly _maxEntries: number;
protected _patches: Patch[];
protected _position: number;
protected _listeners: Array<(state: HistoryState) => void>;
constructor(options?: HistoryOptions) {
this._maxEntries = options?.maxEntries ?? 100;
this._patches = [];
this._position = 0;
this._listeners = [];
}
/**
* Record a forward patch. Truncates any redo entries above the current
* position. Evicts the oldest entry when `maxEntries` is exceeded.
*/
push(patch: Patch): void {
// Truncate redo entries
this._patches = this._patches.slice(0, this._position);
this._patches.push(patch);
// Enforce max entries
if (this._patches.length > this._maxEntries) {
this._patches = this._patches.slice(
this._patches.length - this._maxEntries
);
}
this._position = this._patches.length;
this._notify();
}
/**
* Move position back by one and return the patch at that position (the
* forward patch that was most recently applied), so the caller can invert
* and apply it. Returns `undefined` if there is nothing to undo.
*/
undo(): Patch | undefined {
if (!this.canUndo()) return undefined;
this._position--;
const patch = this._patches[this._position];
this._notify();
return patch;
}
/**
* Move position forward by one and return the patch at the new position
* (the next forward patch to re-apply). Returns `undefined` if there is
* nothing to redo.
*/
redo(): Patch | undefined {
if (!this.canRedo()) return undefined;
const patch = this._patches[this._position];
this._position++;
this._notify();
return patch;
}
/** Returns `true` if there is at least one patch that can be undone. */
canUndo(): boolean {
return this._position > 0;
}
/** Returns `true` if there is at least one patch that can be redone. */
canRedo(): boolean {
return this._position < this._patches.length;
}
/** Clear all history and reset position to 0. */
clear(): void {
this._patches = [];
this._position = 0;
this._notify();
}
/** Return a serialisable snapshot of the current history state. */
getState(): HistoryState {
return {
patches: this._patches.map((p) => [...p]),
position: this._position,
};
}
/**
* Register a listener that is called whenever the history state changes.
* Returns an unsubscribe function.
*/
onChange(listener: (state: HistoryState) => void): () => void {
this._listeners.push(listener);
return () => {
this._listeners = this._listeners.filter((l) => l !== listener);
};
}
protected _notify(): void {
const state = this.getState();
for (const listener of this._listeners) {
listener(state);
}
}
}
// ---------------------------------------------------------------------------
// Factory
// ---------------------------------------------------------------------------
/**
* Create a new {@link History} instance.
*
* @example
* ```ts
* const history = createHistory({ maxEntries: 50 });
* ```
*/
export function createHistory(options?: HistoryOptions): History {
return new History(options);
}

View File

@@ -0,0 +1,6 @@
export {
createHistory,
History,
type HistoryOptions,
type HistoryState,
} from "./history";

View File

@@ -0,0 +1,71 @@
// Errors
export type { DiffOptions } from "./diff/diff.js";
// Diff
export { diff, lcs } from "./diff/diff.js";
export type { DiffCode, DiffErrorContext } from "./errors/diff-error.js";
export { DIFF_CODES, DiffError } from "./errors/diff-error.js";
export type { HistoryOptions, HistoryState } from "./history/history.js";
// History
export { createHistory, History } from "./history/history.js";
export type {
AddOperation,
CopyOperation,
MoveOperation,
Operation,
Patch,
RemoveOperation,
ReplaceOperation,
TestOperation,
} from "./operations/operations.js";
// Operations
export {
createAdd,
createCopy,
createMove,
createRemove,
createReplace,
createTest,
invertOperation,
invertPatch,
operationsConflict,
validateOperation,
validatePatch,
} from "./operations/operations.js";
export type { PatchOptions, PatchResult } from "./patch/patch.js";
// Patch
export { applyPatch, revertPatch } from "./patch/patch.js";
export type { Path } from "./path/path.js";
// Path
export {
appendPath,
comparePaths,
concatPaths,
escapeSegment,
formatPath,
isPrefix,
lastSegment,
parentPath,
parsePath,
pathEquals,
unescapeSegment,
} from "./path/path.js";
export type { SyncDelta, SyncOptions } from "./sync/sync.js";
// Sync
export { createSync, SyncManager } from "./sync/sync.js";
export type { JsonValue } from "./utils/utils.js";
// Utils
export {
deepClone,
deepEqual,
deleteValueAtPath,
deleteValueAtPathMut,
getValueAtPath,
hashValue,
insertValueAtPath,
insertValueAtPathMut,
isObject,
isPrimitive,
setValueAtPath,
setValueAtPathMut,
} from "./utils/utils.js";

View File

@@ -0,0 +1,21 @@
export {
type AddOperation,
type CopyOperation,
createAdd,
createCopy,
createMove,
createRemove,
createReplace,
createTest,
invertOperation,
invertPatch,
type MoveOperation,
type Operation,
operationsConflict,
type Patch,
type RemoveOperation,
type ReplaceOperation,
type TestOperation,
validateOperation,
validatePatch,
} from "./operations";

View File

@@ -0,0 +1,134 @@
# operations
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`operations.ts` is the RFC 6902 operation layer for `@suaveplan/diff`. It defines the six operation types (`add`, `remove`, `replace`, `move`, `copy`, `test`), factory functions for constructing them, a full structural validator, and inversion logic that computes an undo patch given the document state before the original patch was applied.
This module sits between the low-level path/utils layer and the high-level patch engine, providing typed building blocks that both the diff algorithm and the patch applier depend on.
## Features
- **Six operation factory functions** — `createAdd`, `createRemove`, `createReplace`, `createMove`, `createCopy`, `createTest`
- **`validateOperation`** — structural validator; throws `DiffError(DIFF.INVALID_PATCH)` on any violation
- **`validatePatch`** — validates an array of operations with per-index error messages
- **`invertOperation`** — computes the logical inverse of a single operation against a snapshot document
- **`invertPatch`** — computes the reverse patch, stepping through intermediate states so each inversion is accurate
- **`operationsConflict`** — detects path-level conflicts between two operations for sync resolution
## Usage: Basic Example
```ts
import {
createAdd, createRemove, createReplace,
validatePatch, type Patch,
} from "@suaveplan/diff/operations";
const patch: Patch = [
createAdd(["users", "-"], { name: "Carol" }),
createReplace(["users", 0, "name"], "Alice Updated"),
createRemove(["users", 1]),
];
// Validate external patch from network/storage
const safe = validatePatch(JSON.parse(rawJson));
```
## Usage: Advanced Example
```ts
import {
invertPatch, invertOperation, operationsConflict,
createMove, createCopy, createTest,
type Patch, type Operation,
} from "@suaveplan/diff/operations";
import type { JsonValue } from "@suaveplan/diff/utils";
const doc: JsonValue = { a: 1, b: 2, items: ["x", "y"] };
const patch: Patch = [
{ op: "replace", path: ["a"], value: 99 },
{ op: "add", path: ["c"], value: "new" },
];
// Compute undo patch
const undo = invertPatch(doc, patch);
// undo = [{ op: "remove", path: ["c"] }, { op: "replace", path: ["a"], value: 1 }]
// Conflict detection for sync
const local: Operation = createReplace(["a"], 42);
const remote: Operation = createReplace(["a"], 77);
operationsConflict(local, remote); // true
const unrelated: Operation = createReplace(["b"], 10);
operationsConflict(local, unrelated); // false
// Test operation validates document state
const testOp = createTest(["a"], 1);
// Will throw if doc.a !== 1 when applied
```
## API Reference
### Types
```ts
type AddOperation = { op: "add"; path: Path; value: JsonValue };
type RemoveOperation = { op: "remove"; path: Path };
type ReplaceOperation = { op: "replace"; path: Path; value: JsonValue };
type MoveOperation = { op: "move"; from: Path; path: Path };
type CopyOperation = { op: "copy"; from: Path; path: Path };
type TestOperation = { op: "test"; path: Path; value: JsonValue };
type Operation = AddOperation | RemoveOperation | ReplaceOperation
| MoveOperation | CopyOperation | TestOperation;
type Patch = Operation[];
```
### `createAdd(path, value): AddOperation`
### `createRemove(path): RemoveOperation`
### `createReplace(path, value): ReplaceOperation`
### `createMove(from, path): MoveOperation`
### `createCopy(from, path): CopyOperation`
### `createTest(path, value): TestOperation`
Factory functions; all return plain objects with the given fields.
### `validateOperation(op: unknown): Operation`
Validates structure. Throws `DiffError(DIFF.INVALID_PATCH)` if:
- `op` is not a non-null object
- `op.op` is not one of the six valid operation names
- `op.path` is not an array
- `op.from` is missing for `move`/`copy`
- `op.value` is missing for `add`/`replace`/`test`
### `validatePatch(patch: unknown): Patch`
Validates an array of operations. Throws `DiffError(DIFF.INVALID_PATCH)` with per-index context.
### `invertOperation(doc: JsonValue, op: Operation): Operation`
Returns the logical inverse of `op` against snapshot `doc`. Throws `DiffError(DIFF.INVERT_FAILED)` if the document does not contain the value required to form the inverse (e.g., for `remove` when the path is absent).
### `invertPatch(doc: JsonValue, patch: Patch): Patch`
Computes the full inverse patch by stepping through intermediate document states. Returns operations in reverse order so the result can be applied directly to undo the original patch.
### `operationsConflict(a: Operation, b: Operation): boolean`
Returns `true` if the paths touched by `a` and `b` overlap (same path or one is a prefix of the other). Considers both `path` and `from` for `move`/`copy`.
## Implementation Notes
- `invertPatch` uses an internal `applyOpToDoc` function rather than importing from `patch.ts` to avoid a circular dependency. The inline implementation is deliberately minimal.
- The inversion of `copy` is `remove` at the target path — we cannot recover the pre-copy state without storing original values, and the copy source remains untouched.
- `pathsOverlap` uses prefix matching: paths `["a"]` and `["a", "b"]` overlap because one is a prefix of the other. This is conservative — it may report conflicts that technically could coexist, but erring on the side of safety is correct for a sync engine.
- All factory functions return plain objects, not class instances, for maximum serialisability and tree-shaking.

View File

@@ -0,0 +1,384 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { DIFF_CODES, DiffError } from "../errors/diff-error.js";
import { applyPatch } from "../patch/patch.js";
import { deepEqual, type JsonValue } from "../utils/utils.js";
import {
createAdd,
createCopy,
createMove,
createRemove,
createReplace,
createTest,
invertOperation,
invertPatch,
type Operation,
operationsConflict,
type Patch,
validateOperation,
validatePatch,
} from "./operations.js";
describe("operation factories", () => {
it("createAdd", { timeout: 5000 }, () => {
const op = createAdd(["a", "b"], 42);
expect(op).toEqual({ op: "add", path: ["a", "b"], value: 42 });
});
it("createRemove", { timeout: 5000 }, () => {
const op = createRemove(["x"]);
expect(op).toEqual({ op: "remove", path: ["x"] });
});
it("createReplace", { timeout: 5000 }, () => {
const op = createReplace(["a"], "new");
expect(op).toEqual({ op: "replace", path: ["a"], value: "new" });
});
it("createMove", { timeout: 5000 }, () => {
const op = createMove(["a"], ["b"]);
expect(op).toEqual({ op: "move", from: ["a"], path: ["b"] });
});
it("createCopy", { timeout: 5000 }, () => {
const op = createCopy(["src"], ["dst"]);
expect(op).toEqual({ op: "copy", from: ["src"], path: ["dst"] });
});
it("createTest", { timeout: 5000 }, () => {
const op = createTest(["a"], 1);
expect(op).toEqual({ op: "test", path: ["a"], value: 1 });
});
});
describe("validateOperation", () => {
it("validates add operation", { timeout: 5000 }, () => {
expect(() =>
validateOperation({ op: "add", path: ["a"], value: 1 })
).not.toThrow();
});
it("validates remove operation", { timeout: 5000 }, () => {
expect(() =>
validateOperation({ op: "remove", path: ["a"] })
).not.toThrow();
});
it("throws for null", { timeout: 5000 }, () => {
expect(() => validateOperation(null)).toThrow(DiffError);
});
it("throws for non-object", { timeout: 5000 }, () => {
expect(() => validateOperation("string")).toThrow(DiffError);
});
it("throws for invalid op name", { timeout: 5000 }, () => {
expect(() => validateOperation({ op: "invalid", path: [] })).toThrow(
DiffError
);
try {
validateOperation({ op: "bad", path: [] });
} catch (e) {
expect((e as DiffError).code).toBe(DIFF_CODES.INVALID_PATCH);
}
});
it("throws for missing path", { timeout: 5000 }, () => {
expect(() => validateOperation({ op: "add", value: 1 })).toThrow(
DiffError
);
});
it("throws for missing from on move", { timeout: 5000 }, () => {
expect(() => validateOperation({ op: "move", path: ["a"] })).toThrow(
DiffError
);
});
it("throws for missing from on copy", { timeout: 5000 }, () => {
expect(() => validateOperation({ op: "copy", path: ["a"] })).toThrow(
DiffError
);
});
it("throws for missing value on add", { timeout: 5000 }, () => {
expect(() => validateOperation({ op: "add", path: ["a"] })).toThrow(
DiffError
);
});
it("throws for missing value on replace", { timeout: 5000 }, () => {
expect(() => validateOperation({ op: "replace", path: ["a"] })).toThrow(
DiffError
);
});
it("throws for missing value on test", { timeout: 5000 }, () => {
expect(() => validateOperation({ op: "test", path: ["a"] })).toThrow(
DiffError
);
});
it("null value on add is valid", { timeout: 5000 }, () => {
expect(() =>
validateOperation({ op: "add", path: ["a"], value: null })
).not.toThrow();
});
it("validates move operation with valid from array (line 111 true branch)", {
timeout: 5000,
}, () => {
expect(() =>
validateOperation({ op: "move", from: ["a"], path: ["b"] })
).not.toThrow();
});
it("validates copy operation with valid from array", {
timeout: 5000,
}, () => {
expect(() =>
validateOperation({ op: "copy", from: ["src"], path: ["dst"] })
).not.toThrow();
});
});
describe("validatePatch", () => {
it("validates array of operations", { timeout: 5000 }, () => {
const patch = [
{ op: "add", path: ["a"], value: 1 },
{ op: "remove", path: ["b"] },
];
expect(() => validatePatch(patch)).not.toThrow();
});
it("throws for non-array", { timeout: 5000 }, () => {
expect(() => validatePatch("not an array")).toThrow(DiffError);
});
it("throws with index in message for invalid op", { timeout: 5000 }, () => {
const patch = [
{ op: "add", path: ["a"], value: 1 },
{ op: "bad", path: ["b"] },
];
try {
validatePatch(patch);
} catch (e) {
expect(e).toBeInstanceOf(DiffError);
expect((e as DiffError).message).toContain("index 1");
}
});
});
describe("invertOperation", () => {
const doc: JsonValue = { a: 1, b: "hello", items: ["x", "y", "z"] };
it("inverts add → remove", { timeout: 5000 }, () => {
const op = createAdd(["c"], 99);
const inv = invertOperation(doc, op);
expect(inv).toEqual(createRemove(["c"]));
});
it("inverts remove → add with original value", { timeout: 5000 }, () => {
const op = createRemove(["a"]);
const inv = invertOperation(doc, op);
expect(inv).toEqual(createAdd(["a"], 1));
});
it("inverts replace → replace with original value", {
timeout: 5000,
}, () => {
const op = createReplace(["b"], "new");
const inv = invertOperation(doc, op);
expect(inv).toEqual(createReplace(["b"], "hello"));
});
it("inverts move → move with swapped from/path", { timeout: 5000 }, () => {
const op = createMove(["a"], ["b"]);
const inv = invertOperation(doc, op);
expect(inv).toEqual(createMove(["b"], ["a"]));
});
it("inverts copy → remove at target path", { timeout: 5000 }, () => {
const op = createCopy(["a"], ["c"]);
const inv = invertOperation(doc, op);
expect(inv).toEqual(createRemove(["c"]));
});
it("inverts test → same test (identity)", { timeout: 5000 }, () => {
const op = createTest(["a"], 1);
const inv = invertOperation(doc, op);
expect(inv).toEqual(createTest(["a"], 1));
});
it("throws INVERT_FAILED for remove on missing path", {
timeout: 5000,
}, () => {
const op = createRemove(["missing"]);
expect(() => invertOperation(doc, op)).toThrow(DiffError);
try {
invertOperation(doc, op);
} catch (e) {
expect((e as DiffError).code).toBe(DIFF_CODES.INVERT_FAILED);
}
});
it("throws INVERT_FAILED for replace on missing path", {
timeout: 5000,
}, () => {
const op = createReplace(["nonexistent"], "x");
expect(() => invertOperation(doc, op)).toThrow(DiffError);
});
});
describe("invertPatch round-trip", () => {
it("apply then apply-inverse returns original", { timeout: 5000 }, () => {
const doc: JsonValue = { a: 1, b: 2, c: [1, 2, 3] };
const patch: Patch = [
createReplace(["a"], 99),
createReplace(["b"], 100),
];
const after = applyPatch(doc, patch).doc;
// Compute and apply inverse
const inv = invertPatch(doc, patch);
const restored = applyPatch(after, inv).doc;
expect(deepEqual(restored, doc)).toBe(true);
});
it("invertPatch reverses order", { timeout: 5000 }, () => {
const doc: JsonValue = { x: 1, y: 2 };
const patch: Patch = [createAdd(["z"], 3), createReplace(["x"], 10)];
const inv = invertPatch(doc, patch);
// First inverse op should be inverse of last forward op
expect(inv[0]).toEqual(createReplace(["x"], 1));
// Second inverse op should be inverse of first forward op (add → remove)
expect(inv[1]).toEqual(createRemove(["z"]));
});
});
describe("operationsConflict", () => {
it("same path conflicts", { timeout: 5000 }, () => {
const a: Operation = createReplace(["a"], 1);
const b: Operation = createReplace(["a"], 2);
expect(operationsConflict(a, b)).toBe(true);
});
it("prefix path conflicts", { timeout: 5000 }, () => {
const a: Operation = createReplace(["a"], 1);
const b: Operation = createReplace(["a", "b"], 2);
expect(operationsConflict(a, b)).toBe(true);
});
it("different paths do not conflict", { timeout: 5000 }, () => {
const a: Operation = createReplace(["a"], 1);
const b: Operation = createReplace(["b"], 2);
expect(operationsConflict(a, b)).toBe(false);
});
it("move from path is included in conflict check", {
timeout: 5000,
}, () => {
const a: Operation = createReplace(["src"], 1);
const b: Operation = createMove(["src"], ["dst"]);
expect(operationsConflict(a, b)).toBe(true);
});
it("copy from path is included in conflict check", {
timeout: 5000,
}, () => {
const a: Operation = createReplace(["src"], 1);
const b: Operation = createCopy(["src"], ["dst"]);
expect(operationsConflict(a, b)).toBe(true);
});
it("copy dest does not conflict with unrelated path", {
timeout: 5000,
}, () => {
const a: Operation = createReplace(["other"], 1);
const b: Operation = createCopy(["src"], ["dst"]);
expect(operationsConflict(a, b)).toBe(false);
});
});
describe("invertPatch - internal applyOpToDoc coverage", () => {
it("invertPatch with test op (no-op inversion)", { timeout: 5000 }, () => {
const doc: JsonValue = { x: 1 };
const patch = [createTest(["x"], 1)];
const inv = invertPatch(doc, patch);
expect(inv).toEqual([createTest(["x"], 1)]);
});
it("invertPatch with add op chains through applyOpToDoc add", {
timeout: 5000,
}, () => {
const doc: JsonValue = { a: 1 };
const patch = [createAdd(["b"], 2), createAdd(["c"], 3)];
const inv = invertPatch(doc, patch);
// Should be [remove c, remove b]
expect(inv[0]).toEqual(createRemove(["c"]));
expect(inv[1]).toEqual(createRemove(["b"]));
});
it("invertPatch stops on failed applyOpToDoc (catch branch)", {
timeout: 5000,
}, () => {
const doc: JsonValue = { a: 1 };
// Second op will fail (move from missing path)
const patch = [
createReplace(["a"], 99),
createMove(["nonexistent"], ["b"]),
];
// Should not throw — just collect inverses up to the error
expect(() => invertPatch(doc, patch)).not.toThrow();
const inv = invertPatch(doc, patch);
expect(inv.length).toBeGreaterThan(0);
});
it("invertPatch with move op chains through applyOpToDoc move", {
timeout: 5000,
}, () => {
const doc: JsonValue = { a: 1, b: 2 };
const patch = [createMove(["a"], ["c"])];
const inv = invertPatch(doc, patch);
expect(inv).toEqual([createMove(["c"], ["a"])]);
});
it("invertPatch with copy op chains through applyOpToDoc copy", {
timeout: 5000,
}, () => {
const doc: JsonValue = { a: 1 };
const patch = [createCopy(["a"], ["b"])];
const inv = invertPatch(doc, patch);
expect(inv).toEqual([createRemove(["b"])]);
});
it("applyOpToDoc test failure triggers catch in invertPatch (test value mismatch)", {
timeout: 5000,
}, () => {
// doc.x = 1 but the test op checks for 999 — applyOpToDoc throws line 177
const doc: JsonValue = { x: 1 };
const patch = [createTest(["x"], 999)];
// invertOperation still returns the test op; applyOpToDoc throws and breaks the loop
const inv = invertPatch(doc, patch);
expect(inv).toEqual([createTest(["x"], 999)]);
});
it("applyOpToDoc copy missing from-path triggers catch in invertPatch (line 210)", {
timeout: 5000,
}, () => {
// copy from ["missing"] — applyOpToDoc throws at line 210
const doc: JsonValue = { a: 1 };
const patch = [createCopy(["missing"], ["b"])];
const inv = invertPatch(doc, patch);
// invertOperation for copy returns remove at target path — we still get that
expect(inv).toEqual([createRemove(["b"])]);
});
it("invertPatch with remove op chains through applyOpToDoc remove", {
timeout: 5000,
}, () => {
const doc: JsonValue = { a: 1, b: 2 };
const patch = [createRemove(["a"]), createReplace(["b"], 99)];
const inv = invertPatch(doc, patch);
expect(inv[0]).toEqual(createReplace(["b"], 2));
expect(inv[1]).toEqual(createAdd(["a"], 1));
});
});

View File

@@ -0,0 +1,335 @@
import { DIFF_CODES, DiffError } from "../errors/diff-error.js";
import { formatPath, type Path } from "../path/path.js";
import {
deepClone,
deepEqual,
deleteValueAtPathMut,
getValueAtPath,
insertValueAtPathMut,
type JsonValue,
setValueAtPathMut,
} from "../utils/utils.js";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export type AddOperation = { op: "add"; path: Path; value: JsonValue };
export type RemoveOperation = { op: "remove"; path: Path };
export type ReplaceOperation = { op: "replace"; path: Path; value: JsonValue };
export type MoveOperation = { op: "move"; from: Path; path: Path };
export type CopyOperation = { op: "copy"; from: Path; path: Path };
export type TestOperation = { op: "test"; path: Path; value: JsonValue };
export type Operation =
| AddOperation
| RemoveOperation
| ReplaceOperation
| MoveOperation
| CopyOperation
| TestOperation;
export type Patch = Operation[];
// ---------------------------------------------------------------------------
// Factories
// ---------------------------------------------------------------------------
/** Create an RFC 6902 `add` operation. */
export function createAdd(path: Path, value: JsonValue): AddOperation {
return { op: "add", path, value };
}
/** Create an RFC 6902 `remove` operation. */
export function createRemove(path: Path): RemoveOperation {
return { op: "remove", path };
}
/** Create an RFC 6902 `replace` operation. */
export function createReplace(path: Path, value: JsonValue): ReplaceOperation {
return { op: "replace", path, value };
}
/** Create an RFC 6902 `move` operation. */
export function createMove(from: Path, path: Path): MoveOperation {
return { op: "move", from, path };
}
/** Create an RFC 6902 `copy` operation. */
export function createCopy(from: Path, path: Path): CopyOperation {
return { op: "copy", from, path };
}
/** Create an RFC 6902 `test` operation. */
export function createTest(path: Path, value: JsonValue): TestOperation {
return { op: "test", path, value };
}
// ---------------------------------------------------------------------------
// Validation
// ---------------------------------------------------------------------------
const VALID_OPS = new Set(["add", "remove", "replace", "move", "copy", "test"]);
/**
* Validate that `op` is a well-formed RFC 6902 operation object.
* @throws {DiffError} DIFF.INVALID_PATCH on any structural violation.
*/
export function validateOperation(op: unknown): Operation {
if (op === null || typeof op !== "object" || Array.isArray(op)) {
throw new DiffError(
"Operation must be a non-null object",
DIFF_CODES.INVALID_PATCH,
{
reason: "expected object",
}
);
}
const obj = op as Record<string, unknown>;
const opName = obj["op"];
if (typeof opName !== "string" || !VALID_OPS.has(opName)) {
throw new DiffError(
`Invalid op: "${String(opName)}"`,
DIFF_CODES.INVALID_PATCH,
{
operation: String(opName),
reason: "op must be one of: add, remove, replace, move, copy, test",
}
);
}
if (!Array.isArray(obj["path"])) {
throw new DiffError(
"Operation must have a path array",
DIFF_CODES.INVALID_PATCH,
{
operation: opName,
reason: "path must be an array",
}
);
}
if (opName === "move" || opName === "copy") {
if (!Array.isArray(obj["from"])) {
throw new DiffError(
`${opName} operation must have a from array`,
DIFF_CODES.INVALID_PATCH,
{
operation: opName,
reason: "from must be an array",
}
);
}
}
if (opName === "add" || opName === "replace" || opName === "test") {
if (!("value" in obj)) {
throw new DiffError(
`${opName} operation must have a value`,
DIFF_CODES.INVALID_PATCH,
{
operation: opName,
reason: "value is required",
}
);
}
}
return op as Operation;
}
/**
* Validate that `patch` is an array of well-formed operations.
* @throws {DiffError} DIFF.INVALID_PATCH on any structural violation.
*/
export function validatePatch(patch: unknown): Patch {
if (!Array.isArray(patch)) {
throw new DiffError(
"Patch must be an array",
DIFF_CODES.INVALID_PATCH,
{
reason: "expected array of operations",
}
);
}
return patch.map((op, i) => {
try {
return validateOperation(op);
} catch (e) {
const msg = (e as Error).message;
throw new DiffError(
`Invalid operation at index ${i}: ${msg}`,
DIFF_CODES.INVALID_PATCH,
{
reason: `index ${i}: ${msg}`,
}
);
}
});
}
// ---------------------------------------------------------------------------
// Internal minimal operation applier (no circular dependency with patch.ts)
// ---------------------------------------------------------------------------
function applyOpToDoc(doc: JsonValue, op: Operation): JsonValue {
const d = deepClone(doc);
switch (op.op) {
case "test": {
const actual = getValueAtPath(d, op.path);
if (!deepEqual(actual, op.value)) {
throw new DiffError(
`Test failed at ${formatPath(op.path)}`,
DIFF_CODES.TEST_FAILED,
{ path: op.path, operation: "test" }
);
}
return d;
}
case "add":
insertValueAtPathMut(d, op.path, op.value);
return d;
case "remove":
deleteValueAtPathMut(d, op.path);
return d;
case "replace":
setValueAtPathMut(d, op.path, op.value);
return d;
case "move": {
const val = getValueAtPath(d, op.from);
if (val === undefined) {
throw new DiffError(
`move: no value at ${formatPath(op.from)}`,
DIFF_CODES.APPLY_FAILED,
{ path: op.from, operation: "move" }
);
}
deleteValueAtPathMut(d, op.from);
insertValueAtPathMut(d, op.path, val);
return d;
}
case "copy": {
const val = getValueAtPath(d, op.from);
if (val === undefined) {
throw new DiffError(
`copy: no value at ${formatPath(op.from)}`,
DIFF_CODES.APPLY_FAILED,
{ path: op.from, operation: "copy" }
);
}
insertValueAtPathMut(d, op.path, deepClone(val));
return d;
}
}
}
// ---------------------------------------------------------------------------
// Inversion
// ---------------------------------------------------------------------------
/**
* Compute the inverse of a single operation given the doc state *before*
* the operation was applied.
*
* - `add` → `remove` at same path
* - `remove` → `add` the original value back
* - `replace` → `replace` with the original value
* - `move` → `move` back (`from` ↔ `path` swapped)
* - `copy` → `remove` at target path
* - `test` → identity (`test` is its own inverse)
*
* @throws {DiffError} DIFF.INVERT_FAILED if the doc does not contain
* the value needed to produce the inverse.
*/
export function invertOperation(doc: JsonValue, op: Operation): Operation {
switch (op.op) {
case "add":
return createRemove(op.path);
case "remove": {
const val = getValueAtPath(doc, op.path);
if (val === undefined) {
throw new DiffError(
`Cannot invert remove: no value at ${formatPath(op.path)}`,
DIFF_CODES.INVERT_FAILED,
{ path: op.path, operation: "remove" }
);
}
return createAdd(op.path, val);
}
case "replace": {
const val = getValueAtPath(doc, op.path);
if (val === undefined) {
throw new DiffError(
`Cannot invert replace: no value at ${formatPath(op.path)}`,
DIFF_CODES.INVERT_FAILED,
{ path: op.path, operation: "replace" }
);
}
return createReplace(op.path, val);
}
case "move":
return createMove(op.path, op.from);
case "copy":
return createRemove(op.path);
case "test":
return createTest(op.path, op.value);
}
}
/**
* Compute the inverse patch for `patch` given the doc state *before*
* the patch was applied. Returns operations in reverse order so the inverse
* patch can be applied directly to undo the original.
*/
export function invertPatch(doc: JsonValue, patch: Patch): Patch {
const inverses: Operation[] = [];
let current = doc;
for (const op of patch) {
inverses.push(invertOperation(current, op));
try {
current = applyOpToDoc(current, op);
} catch {
// Stop collecting if we cannot advance the state
break;
}
}
return inverses.reverse();
}
// ---------------------------------------------------------------------------
// Conflict detection
// ---------------------------------------------------------------------------
/**
* Returns `true` if operations `a` and `b` conflict — i.e., they touch the
* same path or one path is a prefix of the other.
*/
export function operationsConflict(a: Operation, b: Operation): boolean {
const pathsA = getOperationPaths(a);
const pathsB = getOperationPaths(b);
for (const pa of pathsA) {
for (const pb of pathsB) {
if (pathsOverlap(pa, pb)) return true;
}
}
return false;
}
function getOperationPaths(op: Operation): Path[] {
switch (op.op) {
case "move":
case "copy":
return [op.from, op.path];
default:
return [op.path];
}
}
function pathsOverlap(a: Path, b: Path): boolean {
const len = Math.min(a.length, b.length);
for (let i = 0; i < len; i++) {
if (a[i] !== b[i]) return false;
}
return true;
}

View File

@@ -0,0 +1,8 @@
export {
_getOrSetTracer,
_resetTracer,
applyPatch,
type PatchOptions,
type PatchResult,
revertPatch,
} from "./patch";

View File

@@ -0,0 +1,114 @@
# patch
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`patch.ts` is the RFC 6902 patch application engine for `@suaveplan/diff`. It applies a sequence of operations to a document, producing a `PatchResult` with the new document state, a count of applied operations, and any error messages encountered. The engine supports both fail-fast (default) and lenient (collect-errors) modes, atomic rollback, and emits OpenTelemetry spans for observability.
The `revertPatch` helper provides a convenient undo primitive: given the post-patch document, the original patch, and the pre-patch snapshot, it computes the inverse patch and applies it.
## Features
- **`applyPatch`** — applies all six RFC 6902 operation types to a document copy
- **Atomic mode** (default) — rolls back to the original on any failure
- **Fail-fast mode** (default) — throws `DiffError` at the first error
- **Lenient mode** — `failFast: false` collects errors without throwing
- **OTel integration** — emits `diff.apply` span with `outcome`, `duration_ms`, `applied_count` attributes
- **`revertPatch`** — undo a patch using the pre-patch document snapshot
- All mutations operate on a deep clone; the original document is never modified
## Usage: Basic Example
```ts
import { applyPatch } from "@suaveplan/diff/patch";
import type { JsonValue } from "@suaveplan/diff/utils";
const doc: JsonValue = { name: "Alice", age: 30 };
const { doc: updated, applied, errors } = applyPatch(doc, [
{ op: "replace", path: ["age"], value: 31 },
{ op: "add", path: ["city"], value: "NYC" },
]);
// updated: { name: "Alice", age: 31, city: "NYC" }
// applied: 2, errors: []
```
## Usage: Advanced Example
```ts
import { applyPatch, revertPatch } from "@suaveplan/diff/patch";
import { diff } from "@suaveplan/diff/diff";
import type { JsonValue } from "@suaveplan/diff/utils";
const before: JsonValue = { items: [1, 2, 3], meta: { v: 1 } };
const after: JsonValue = { items: [1, 3], meta: { v: 2 } };
// Compute and apply patch
const patch = diff(before, after);
const { doc: patched } = applyPatch(before, patch);
// Undo — requires the original doc
const reverted = revertPatch(patched, patch, before);
// reverted deepEquals before ✓
// Test operation — throws DiffError on mismatch (atomic rollback)
try {
applyPatch(before, [
{ op: "test", path: ["meta", "v"], value: 999 }, // wrong value
{ op: "replace", path: ["meta", "v"], value: 2 },
]);
} catch (e) {
// e.code === "DIFF.TEST_FAILED"
// before is unchanged (applyPatch never mutates its input)
}
// Lenient mode — collect all errors, apply partial patch
const result = applyPatch(before, [
{ op: "remove", path: ["missing"] },
{ op: "replace", path: ["meta", "v"], value: 5 },
], { failFast: false, atomic: false });
// result.errors contains the remove error; replace still applied
```
## API Reference
### `PatchResult`
```ts
interface PatchResult {
doc: JsonValue; // Document state after applying the patch
applied: number; // Count of successfully applied operations
errors: string[]; // Error messages (empty on full success)
}
```
### `PatchOptions`
```ts
interface PatchOptions {
failFast?: boolean; // default true — throw on first error
atomic?: boolean; // default true — rollback on failure
}
```
### `applyPatch(doc, patch, options?): PatchResult`
Applies `patch` to `doc`. Never mutates `doc`. With `atomic: true` (default), any failure rolls back to the original clone. With `failFast: true` (default), throws a `DiffError` on the first failure. Returns `PatchResult`.
**Throws** `DiffError` with codes:
- `DIFF.TEST_FAILED` — a `test` operation value mismatch
- `DIFF.APPLY_FAILED` — `move`/`copy` source path not found, or generic apply failure
### `revertPatch(doc, patch, originalDoc?): JsonValue`
Inverts `patch` (using `originalDoc` as the pre-patch snapshot) and applies the inverse to `doc`. Returns the reverted document. If `originalDoc` is omitted, falls back to `doc` (only correct for add-only patches).
## Implementation Notes
- `applyOperation` mutates its `doc` argument in-place for performance, but `applyPatch` always passes a deep clone so the caller's original is never affected.
- OTel integration uses a lazy require so packages that do not configure an OTel provider pay zero cost. The tracer is memoised in `_tracer` after first resolution.
- The `_tracer` module-level variable is reset to `undefined` on module load, which is correct for test isolation — each test process gets a fresh module.
- The `revertPatch` function delegates to `invertPatch` from `operations.ts`, which steps through intermediate document states to produce accurate inverses for `remove` and `replace` operations.

View File

@@ -0,0 +1,353 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { diff } from "../diff/diff.js";
import { DIFF_CODES, DiffError } from "../errors/diff-error.js";
import {
createAdd,
createCopy,
createMove,
createRemove,
createReplace,
createTest,
type Patch,
} from "../operations/operations.js";
import { deepEqual, type JsonValue } from "../utils/utils.js";
import {
_getOrSetTracer,
_resetTracer,
applyPatch,
type PatchResult,
revertPatch,
} from "./patch.js";
describe("applyPatch - add", () => {
it("adds a new key to object", { timeout: 5000 }, () => {
const doc: JsonValue = { a: 1 };
const { doc: result } = applyPatch(doc, [createAdd(["b"], 2)]);
expect(result).toEqual({ a: 1, b: 2 });
});
it("appends to array with numeric index", { timeout: 5000 }, () => {
const doc: JsonValue = [1, 2, 3];
const { doc: result } = applyPatch(doc, [createAdd([3], 4)]);
expect(result).toEqual([1, 2, 3, 4]);
});
it('appends to array with "-"', { timeout: 5000 }, () => {
const doc: JsonValue = { items: ["a", "b"] };
const { doc: result } = applyPatch(doc, [
createAdd(["items", "-"], "c"),
]);
expect((result as Record<string, JsonValue>).items).toEqual([
"a",
"b",
"c",
]);
});
it("inserts into array mid", { timeout: 5000 }, () => {
const doc: JsonValue = [1, 2, 3];
const { doc: result } = applyPatch(doc, [createAdd([1], 99)]);
expect(result).toEqual([1, 99, 2, 3]);
});
it("does not mutate original doc", { timeout: 5000 }, () => {
const doc: JsonValue = { a: 1 };
applyPatch(doc, [createAdd(["b"], 2)]);
expect(doc).toEqual({ a: 1 });
});
});
describe("applyPatch - remove", () => {
it("removes object key", { timeout: 5000 }, () => {
const doc: JsonValue = { a: 1, b: 2 };
const { doc: result } = applyPatch(doc, [createRemove(["a"])]);
expect(result).toEqual({ b: 2 });
});
it("removes array element", { timeout: 5000 }, () => {
const doc: JsonValue = [1, 2, 3];
const { doc: result } = applyPatch(doc, [createRemove([1])]);
expect(result).toEqual([1, 3]);
});
});
describe("applyPatch - replace", () => {
it("replaces object key value", { timeout: 5000 }, () => {
const doc: JsonValue = { a: 1 };
const { doc: result } = applyPatch(doc, [createReplace(["a"], 99)]);
expect(result).toEqual({ a: 99 });
});
it("replaces array element", { timeout: 5000 }, () => {
const doc: JsonValue = [1, 2, 3];
const { doc: result } = applyPatch(doc, [createReplace([1], 99)]);
expect(result).toEqual([1, 99, 3]);
});
});
describe("applyPatch - move", () => {
it("moves value between keys", { timeout: 5000 }, () => {
const doc: JsonValue = { a: 1, b: 2 };
const { doc: result } = applyPatch(doc, [createMove(["a"], ["c"])]);
expect(result).toEqual({ b: 2, c: 1 });
});
it("throws APPLY_FAILED when from path missing", { timeout: 5000 }, () => {
const doc: JsonValue = { a: 1 };
expect(() => applyPatch(doc, [createMove(["missing"], ["b"])])).toThrow(
DiffError
);
try {
applyPatch(doc, [createMove(["missing"], ["b"])]);
} catch (e) {
expect((e as DiffError).code).toBe(DIFF_CODES.APPLY_FAILED);
}
});
});
describe("applyPatch - copy", () => {
it("copies value to new key", { timeout: 5000 }, () => {
const doc: JsonValue = { a: { x: 1 } };
const { doc: result } = applyPatch(doc, [createCopy(["a"], ["b"])]);
expect(result).toEqual({ a: { x: 1 }, b: { x: 1 } });
// Verify deep clone — mutating copy should not affect original
(
(result as Record<string, JsonValue>).b as Record<string, JsonValue>
).x = 99;
expect(
(
(result as Record<string, JsonValue>).a as Record<
string,
JsonValue
>
).x
).toBe(1);
});
it("throws APPLY_FAILED when from path missing", { timeout: 5000 }, () => {
const doc: JsonValue = { a: 1 };
expect(() => applyPatch(doc, [createCopy(["missing"], ["b"])])).toThrow(
DiffError
);
});
});
describe("applyPatch - test", () => {
it("passes when value matches", { timeout: 5000 }, () => {
const doc: JsonValue = { a: 1 };
const { doc: result, applied } = applyPatch(doc, [
createTest(["a"], 1),
]);
expect(result).toEqual({ a: 1 });
expect(applied).toBe(1);
});
it("throws TEST_FAILED when value mismatch", { timeout: 5000 }, () => {
const doc: JsonValue = { a: 1 };
expect(() => applyPatch(doc, [createTest(["a"], 2)])).toThrow(
DiffError
);
try {
applyPatch(doc, [createTest(["a"], 2)]);
} catch (e) {
expect((e as DiffError).code).toBe(DIFF_CODES.TEST_FAILED);
}
});
it("atomic rollback on test failure", { timeout: 5000 }, () => {
const doc: JsonValue = { a: 1, b: 2 };
const patch: Patch = [
createReplace(["b"], 99),
createTest(["a"], 999), // will fail
];
try {
applyPatch(doc, patch, { atomic: true });
} catch {
// Expected — doc should be unchanged (we have a new copy anyway)
}
expect(doc).toEqual({ a: 1, b: 2 }); // original untouched
});
});
describe("applyPatch - options", () => {
it("failFast: false collects errors without throwing", {
timeout: 5000,
}, () => {
const doc: JsonValue = { a: 1 };
const result: PatchResult = applyPatch(
doc,
[createTest(["a"], 999), createReplace(["a"], 42)],
{ failFast: false, atomic: false }
);
expect(result.errors.length).toBeGreaterThan(0);
});
it("applied count increments per success", { timeout: 5000 }, () => {
const doc: JsonValue = { a: 1, b: 2 };
const { applied } = applyPatch(doc, [
createReplace(["a"], 10),
createReplace(["b"], 20),
]);
expect(applied).toBe(2);
});
it("empty patch returns clone with applied=0", { timeout: 5000 }, () => {
const doc: JsonValue = { x: 1 };
const result = applyPatch(doc, []);
expect(result.doc).toEqual({ x: 1 });
expect(result.applied).toBe(0);
expect(result.errors).toEqual([]);
});
});
describe("applyPatch - OTel (no-op, no throw)", () => {
it("works without OTel configured", { timeout: 5000 }, () => {
const doc: JsonValue = { a: 1 };
expect(() => applyPatch(doc, [createReplace(["a"], 2)])).not.toThrow();
});
it("no-op when tracer is forced to undefined via sentinel", {
timeout: 5000,
}, () => {
// Force the no-tracer path (covers the `if (!tracer) return;` branch)
_resetTracer("no-tracer");
try {
const doc: JsonValue = { a: 1 };
expect(() =>
applyPatch(doc, [createReplace(["a"], 2)])
).not.toThrow();
} finally {
_resetTracer(); // restore normal lazy-load behaviour
}
});
});
describe("applyPatch - OTel with fake tracer (coverage for span path)", () => {
it("emits span on success with a fake tracer injected", {
timeout: 5000,
}, () => {
type FakeSpan = {
attrs: Record<string, unknown>;
ended: boolean;
setAttribute: (k: string, v: unknown) => void;
setStatus: (s: { code: number }) => void;
end: () => void;
};
const spans: FakeSpan[] = [];
const fakeTracer = {
startSpan: () => {
const s: FakeSpan = {
attrs: {},
ended: false,
setAttribute(k: string, v: unknown) {
this.attrs[k] = v;
},
setStatus(_s: { code: number }) {},
end() {
this.ended = true;
},
};
spans.push(s);
return s;
},
startActiveSpan: () => {
throw new Error("not used");
},
} as unknown as import("@opentelemetry/api").Tracer;
// Inject fake tracer and reset afterwards
_getOrSetTracer(fakeTracer);
try {
const doc: JsonValue = { a: 1 };
applyPatch(doc, [createReplace(["a"], 2)]);
expect(spans.length).toBeGreaterThan(0);
expect(spans[0]?.attrs["diff.apply.outcome"]).toBe("success");
expect(spans[0]?.ended).toBe(true);
} finally {
_resetTracer();
}
});
it("emits span on failure with a fake tracer injected", {
timeout: 5000,
}, () => {
const spans: Array<{ attrs: Record<string, unknown>; ended: boolean }> =
[];
const fakeTracer = {
startSpan: () => {
const s = {
attrs: {} as Record<string, unknown>,
ended: false,
setAttribute(k: string, v: unknown) {
this.attrs[k] = v;
},
setStatus(_s: { code: number }) {},
end() {
this.ended = true;
},
};
spans.push(s);
return s;
},
startActiveSpan: () => {
throw new Error("not used");
},
} as unknown as import("@opentelemetry/api").Tracer;
_getOrSetTracer(fakeTracer);
try {
const doc: JsonValue = { a: 1 };
// This will fail — test op with wrong value → failFast=true throws
try {
applyPatch(doc, [createTest(["a"], 999)]);
} catch {
// expected
}
expect(spans.length).toBeGreaterThan(0);
expect(spans[0]?.attrs["diff.apply.outcome"]).toBe("failure");
} finally {
_resetTracer();
}
});
});
describe("revertPatch", () => {
it("reverts a simple replace patch", { timeout: 5000 }, () => {
const before: JsonValue = { a: 1, b: 2 };
const after: JsonValue = { a: 99, b: 2 };
const patch = diff(before, after);
const { doc: patched } = applyPatch(before, patch);
const reverted = revertPatch(patched, patch, before);
expect(deepEqual(reverted, before)).toBe(true);
});
it("reverts add patch", { timeout: 5000 }, () => {
const before: JsonValue = { a: 1 };
const after: JsonValue = { a: 1, b: 2 };
const patch = diff(before, after);
const { doc: patched } = applyPatch(before, patch);
const reverted = revertPatch(patched, patch, before);
expect(deepEqual(reverted, before)).toBe(true);
});
it("reverts remove patch", { timeout: 5000 }, () => {
const before: JsonValue = { a: 1, b: 2 };
const after: JsonValue = { a: 1 };
const patch = diff(before, after);
const { doc: patched } = applyPatch(before, patch);
const reverted = revertPatch(patched, patch, before);
expect(deepEqual(reverted, before)).toBe(true);
});
it("reverts add-only patch without providing originalDoc (doc fallback)", {
timeout: 5000,
}, () => {
// add-only patch is safely reversible without originalDoc
const before: JsonValue = { a: 1 };
const patch = [createAdd(["b"], 2)];
const { doc: patched } = applyPatch(before, patch);
// No originalDoc — falls back to patched doc (safe for add-only patches)
const reverted = revertPatch(patched, patch);
expect(reverted).toEqual({ a: 1 });
});
});

View File

@@ -0,0 +1,239 @@
import type { SpanStatusCode, Tracer } from "@opentelemetry/api";
import { DIFF_CODES, DiffError } from "../errors/diff-error.js";
import {
invertPatch,
type Operation,
type Patch,
} from "../operations/operations.js";
import { formatPath } from "../path/path.js";
import {
deepClone,
deepEqual,
deleteValueAtPathMut,
getValueAtPath,
insertValueAtPathMut,
type JsonValue,
setValueAtPathMut,
} from "../utils/utils.js";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export interface PatchResult {
/** Document after applying the patch. */
doc: JsonValue;
/** Number of operations successfully applied. */
applied: number;
/** Non-fatal error messages (populated when `failFast` is false). */
errors: string[];
}
export interface PatchOptions {
/**
* Stop at the first error.
* @default true
*/
failFast?: boolean;
/**
* Roll back to the original doc if any operation fails.
* @default true
*/
atomic?: boolean;
}
// ---------------------------------------------------------------------------
// OTel helpers (no-op when provider not configured)
// ---------------------------------------------------------------------------
// Sentinel used by _setTracerForTest(null) to disable the lazy-load and force
// the no-tracer path for test coverage of the early-return guard.
const NO_TRACER_SENTINEL = Symbol("no-tracer");
let _tracer: Tracer | typeof NO_TRACER_SENTINEL | undefined;
/** @internal — exported for testing only: inject or retrieve the cached tracer. */
export function _getOrSetTracer(t?: Tracer): Tracer | undefined {
if (t !== undefined) {
_tracer = t;
return _tracer;
}
if (_tracer === NO_TRACER_SENTINEL) return undefined;
if (_tracer !== undefined) return _tracer;
try {
// Lazy import so we don't pay the require cost on first import
// eslint-disable-next-line @typescript-eslint/no-require-imports
const api =
require("@opentelemetry/api") as typeof import("@opentelemetry/api");
_tracer = api.trace.getTracer("@suaveplan/diff");
} catch {
// OTel not available
}
return _tracer as Tracer | undefined;
}
/** @internal — exported for testing only: reset the cached tracer. Pass `null` to force no-tracer (no lazy-load retry). */
export function _resetTracer(sentinel?: "no-tracer"): void {
_tracer = sentinel === "no-tracer" ? NO_TRACER_SENTINEL : undefined;
}
function recordOtelEvent(
outcome: "success" | "failure",
durationMs: number,
appliedCount: number
): void {
const tracer = _getOrSetTracer();
if (!tracer) return;
try {
const span = tracer.startSpan("diff.apply");
span.setAttribute("diff.apply.outcome", outcome);
span.setAttribute("diff.apply.duration_ms", durationMs);
span.setAttribute("diff.apply.applied_count", appliedCount);
span.setStatus({
code: (outcome === "success" ? 1 : 2) as SpanStatusCode,
});
span.end();
} catch {
// Never throw from OTel
}
}
// ---------------------------------------------------------------------------
// Apply single operation
// ---------------------------------------------------------------------------
function applyOperation(doc: JsonValue, op: Operation): JsonValue {
switch (op.op) {
case "test": {
const actual = getValueAtPath(doc, op.path);
if (!deepEqual(actual, op.value)) {
throw new DiffError(
`Test failed at ${formatPath(op.path)}: expected ${JSON.stringify(op.value)}, got ${JSON.stringify(actual)}`,
DIFF_CODES.TEST_FAILED,
{ path: op.path, operation: "test" }
);
}
return doc;
}
case "add":
insertValueAtPathMut(doc, op.path, op.value);
return doc;
case "remove":
deleteValueAtPathMut(doc, op.path);
return doc;
case "replace":
setValueAtPathMut(doc, op.path, op.value);
return doc;
case "move": {
const val = getValueAtPath(doc, op.from);
if (val === undefined) {
throw new DiffError(
`move: no value at ${formatPath(op.from)}`,
DIFF_CODES.APPLY_FAILED,
{ path: op.from, operation: "move" }
);
}
deleteValueAtPathMut(doc, op.from);
insertValueAtPathMut(doc, op.path, val);
return doc;
}
case "copy": {
const val = getValueAtPath(doc, op.from);
if (val === undefined) {
throw new DiffError(
`copy: no value at ${formatPath(op.from)}`,
DIFF_CODES.APPLY_FAILED,
{ path: op.from, operation: "copy" }
);
}
insertValueAtPathMut(doc, op.path, deepClone(val));
return doc;
}
}
}
// ---------------------------------------------------------------------------
// applyPatch
// ---------------------------------------------------------------------------
/**
* Apply an RFC 6902 patch to `doc`.
*
* @param doc - Document to patch (not mutated — a working copy is made).
* @param patch - Array of RFC 6902 operations.
* @param options - {@link PatchOptions}
* @returns {@link PatchResult} with the new doc, applied count, and errors.
*
* OTel events emitted:
* - Span `diff.apply` with attributes `diff.apply.outcome`, `diff.apply.duration_ms`,
* `diff.apply.applied_count`.
*/
export function applyPatch(
doc: JsonValue,
patch: Patch,
options?: PatchOptions
): PatchResult {
const failFast = options?.failFast ?? true;
const atomic = options?.atomic ?? true;
const start = Date.now();
const original = deepClone(doc);
let current = deepClone(doc);
let applied = 0;
const errors: string[] = [];
for (const op of patch) {
try {
current = applyOperation(current, op);
applied++;
} catch (e) {
const msg = (e as Error).message;
errors.push(msg);
if (atomic) {
current = original;
applied = 0;
}
if (failFast) {
recordOtelEvent("failure", Date.now() - start, applied);
// applyOperation only ever throws DiffError
throw e as DiffError;
}
}
}
const outcome = errors.length === 0 ? "success" : "failure";
recordOtelEvent(outcome, Date.now() - start, applied);
return { doc: current, applied, errors };
}
// ---------------------------------------------------------------------------
// revertPatch
// ---------------------------------------------------------------------------
/**
* Revert a patch by computing its inverse and applying it to `doc`.
*
* `doc` must be the state *after* the patch was applied.
* Provide `originalDoc` (the pre-patch state) for accurate inversion when
* the patch contains `remove` or `replace` operations.
*
* @param doc - Document in its *current* (post-patch) state.
* @param patch - The patch to revert.
* @param originalDoc - Document state *before* the patch was applied.
* If omitted, falls back to `doc` (only safe for `add`-only patches).
*/
export function revertPatch(
doc: JsonValue,
patch: Patch,
originalDoc?: JsonValue
): JsonValue {
const inverse = invertPatch(originalDoc ?? doc, patch);
return applyPatch(doc, inverse).doc;
}

View File

@@ -0,0 +1,14 @@
export {
appendPath,
comparePaths,
concatPaths,
escapeSegment,
formatPath,
isPrefix,
lastSegment,
type Path,
parentPath,
parsePath,
pathEquals,
unescapeSegment,
} from "./path";

View File

@@ -0,0 +1,129 @@
# path
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`path.ts` implements RFC 6901 JSON Pointer parsing and serialisation for the `@suaveplan/diff` package. A `Path` is an `Array<string | number>` — numeric array indices are kept as `number` while object keys are kept as `string`. All public functions are pure and free of side-effects; they neither read nor write global state, making them safe to tree-shake and straightforward to test.
The module deliberately avoids a class-based design: individual functions are simpler to inline, optimise, and test in isolation, and the bundle impact is lower when callers import only what they need.
## Features
- **`parsePath`** — RFC 6901 pointer string to segment array; numeric tokens parsed as `number`
- **`formatPath`** — segment array back to RFC 6901 pointer string
- **`escapeSegment` / `unescapeSegment`** — per-segment `~` / `/` encoding with correct substitution order
- **`appendPath` / `concatPaths`** — non-mutating path construction helpers
- **`comparePaths`** — lexicographic ordering usable with `Array.sort`
- **`pathEquals`** — structural equality without JSON serialisation overhead
- **`isPrefix`** — tests whether one path is a prefix of another
- **`parentPath` / `lastSegment`** — navigate the path hierarchy
## Usage: Basic Example
```ts
import { parsePath, formatPath } from "@suaveplan/diff/path";
const path = parsePath("/users/0/email");
// ["users", 0, "email"]
const pointer = formatPath(path);
// "/users/0/email"
// root pointer
parsePath(""); // []
formatPath([]); // ""
// empty-string key
parsePath("/"); // [""]
```
## Usage: Advanced Example
```ts
import {
parsePath, formatPath, appendPath, isPrefix,
comparePaths, escapeSegment, unescapeSegment,
} from "@suaveplan/diff/path";
// Escape/unescape
escapeSegment("a/b~c"); // "a~1b~0c"
unescapeSegment("a~1b~0c"); // "a/b~c"
// Build paths dynamically
const base = parsePath("/config");
const full = appendPath(base, "timeout");
formatPath(full); // "/config/timeout"
// Check containment
const parent = ["users", 0];
const child = ["users", 0, "roles", 1];
isPrefix(parent, child); // true
// Sort operations by path depth
const paths = [["b"], ["a", "c"], ["a"]].map(p => p as (string | number)[]);
paths.sort(comparePaths);
// [["a"], ["a", "c"], ["b"]]
```
## API Reference
### `Path`
```ts
type Path = Array<string | number>;
```
Segment array representation of an RFC 6901 JSON Pointer.
### `escapeSegment(segment: string): string`
Applies RFC 6901 §3 escaping: `~` → `~0`, then `/` → `~1`.
### `unescapeSegment(segment: string): string`
Reverses escaping in the mandated order: `~1` → `/`, then `~0` → `~`.
### `parsePath(pointer: string): Path`
Parses an RFC 6901 pointer. Throws `DiffError` (code `DIFF.PATH_INVALID`) if the string is non-empty and does not start with `/`. Numeric-only tokens are returned as `number`.
### `formatPath(path: Path): string`
Serialises a `Path` to an RFC 6901 pointer. Empty path returns `""`.
### `appendPath(path: Path, segment: string | number): Path`
Returns a new path with `segment` appended; does not mutate the original.
### `concatPaths(a: Path, b: Path): Path`
Returns a new path that is the concatenation of `a` and `b`.
### `comparePaths(a: Path, b: Path): number`
Lexicographic comparator suitable for use with `Array.prototype.sort`. Numeric segments compare numerically; mixed-type segments compare by string representation. Returns negative / zero / positive.
### `pathEquals(a: Path, b: Path): boolean`
Returns `true` iff both paths have the same length and identical segments at every position.
### `isPrefix(prefix: Path, path: Path): boolean`
Returns `true` iff every segment of `prefix` equals the corresponding segment of `path` and `prefix.length <= path.length`.
### `parentPath(path: Path): Path`
Returns all segments except the last. Returns `[]` for an empty input.
### `lastSegment(path: Path): string | number | undefined`
Returns the last segment, or `undefined` for an empty path.
## Implementation Notes
- Numeric index detection uses `/^\d+$/.test(...)` which correctly rejects leading-zero tokens like `"01"` — those are treated as string keys per RFC 6901 (array indices must not have leading zeros in practice).
- Segment escaping always escapes `~` before `/` to avoid double-escaping `~1` as `~01`.
- All functions return new arrays rather than mutating inputs, which is critical for the immutable diff/patch pipeline built on top of them.
- `comparePaths` falls back to string comparison for mixed segment types to produce a deterministic total order even when segment types differ across paths.

View File

@@ -0,0 +1,223 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { DIFF_CODES, DiffError } from "../errors/diff-error.js";
import {
appendPath,
comparePaths,
concatPaths,
escapeSegment,
formatPath,
isPrefix,
lastSegment,
parentPath,
parsePath,
pathEquals,
unescapeSegment,
} from "./path.js";
describe("parsePath / formatPath round-trip", () => {
it("empty string returns root []", { timeout: 5000 }, () => {
expect(parsePath("")).toEqual([]);
});
it('"/" returns [""]', { timeout: 5000 }, () => {
expect(parsePath("/")).toEqual([""]);
});
it("numeric segments parsed as numbers", { timeout: 5000 }, () => {
expect(parsePath("/users/0/name")).toEqual(["users", 0, "name"]);
});
it("round-trip: parsePath(formatPath(p)) equals p", {
timeout: 5000,
}, () => {
const paths: Array<Array<string | number>> = [
[],
["a"],
["a", "b", "c"],
["users", 0, "email"],
["a/b", "c~d"],
["", "x"],
[0, 1, 2],
];
for (const p of paths) {
expect(parsePath(formatPath(p))).toEqual(p);
}
});
it("throws PATH_INVALID for non-empty pointer without leading /", {
timeout: 5000,
}, () => {
expect(() => parsePath("no-slash")).toThrow(DiffError);
try {
parsePath("bad");
} catch (e) {
expect((e as DiffError).code).toBe(DIFF_CODES.PATH_INVALID);
}
});
it("formatPath on empty returns empty string", { timeout: 5000 }, () => {
expect(formatPath([])).toBe("");
});
it("formatPath escapes special chars", { timeout: 5000 }, () => {
expect(formatPath(["a/b", "c~d"])).toBe("/a~1b/c~0d");
});
});
describe("escapeSegment / unescapeSegment", () => {
it("escapes ~ then /", { timeout: 5000 }, () => {
expect(escapeSegment("a/b~c")).toBe("a~1b~0c");
});
it("unescapes ~1 then ~0", { timeout: 5000 }, () => {
expect(unescapeSegment("a~1b~0c")).toBe("a/b~c");
});
it("round-trip escape/unescape", { timeout: 5000 }, () => {
const samples = [
"hello",
"a/b/c",
"~tilde~",
"mixed/and~both",
"",
"~1",
];
for (const s of samples) {
expect(unescapeSegment(escapeSegment(s))).toBe(s);
}
});
it("does not double-escape", { timeout: 5000 }, () => {
expect(escapeSegment("~1")).toBe("~01");
expect(unescapeSegment("~01")).toBe("~1");
});
});
describe("appendPath / concatPaths", () => {
it("appendPath adds segment without mutating original", {
timeout: 5000,
}, () => {
const original = ["a", "b"] as Array<string | number>;
const result = appendPath(original, "c");
expect(result).toEqual(["a", "b", "c"]);
expect(original).toEqual(["a", "b"]);
});
it("appendPath works with numeric segment", { timeout: 5000 }, () => {
expect(appendPath(["arr"], 3)).toEqual(["arr", 3]);
});
it("concatPaths merges two arrays", { timeout: 5000 }, () => {
expect(concatPaths(["a", "b"], ["c", 0])).toEqual(["a", "b", "c", 0]);
});
it("concatPaths with empty arrays", { timeout: 5000 }, () => {
expect(concatPaths([], ["x"])).toEqual(["x"]);
expect(concatPaths(["x"], [])).toEqual(["x"]);
expect(concatPaths([], [])).toEqual([]);
});
});
describe("comparePaths", () => {
it("equal paths return 0", { timeout: 5000 }, () => {
expect(comparePaths(["a", "b"], ["a", "b"])).toBe(0);
expect(comparePaths([], [])).toBe(0);
});
it("shorter path is less than longer prefix", { timeout: 5000 }, () => {
expect(comparePaths(["a"], ["a", "b"])).toBeLessThan(0);
expect(comparePaths(["a", "b"], ["a"])).toBeGreaterThan(0);
});
it("lexicographic string comparison", { timeout: 5000 }, () => {
expect(comparePaths(["a"], ["b"])).toBeLessThan(0);
expect(comparePaths(["b"], ["a"])).toBeGreaterThan(0);
});
it("numeric comparison for number segments", { timeout: 5000 }, () => {
expect(comparePaths([2], [10])).toBeLessThan(0);
expect(comparePaths([10], [2])).toBeGreaterThan(0);
});
it("usable with Array.sort", { timeout: 5000 }, () => {
const paths: Array<Array<string | number>> = [["b"], ["a", "c"], ["a"]];
const sorted = [...paths].sort(comparePaths);
expect(sorted).toEqual([["a"], ["a", "c"], ["b"]]);
});
it("equal numeric segments continue comparison", { timeout: 5000 }, () => {
// Both paths start with same number — continue to next segment
expect(comparePaths([1, 2], [1, 3])).toBeLessThan(0);
expect(comparePaths([1, 3], [1, 2])).toBeGreaterThan(0);
expect(comparePaths([1, 2], [1, 2])).toBe(0);
});
it("equal string segments continue comparison", { timeout: 5000 }, () => {
expect(comparePaths(["a", "b"], ["a", "c"])).toBeLessThan(0);
});
});
describe("pathEquals", () => {
it("returns true for equal paths", { timeout: 5000 }, () => {
expect(pathEquals(["a", 0, "b"], ["a", 0, "b"])).toBe(true);
expect(pathEquals([], [])).toBe(true);
});
it("returns false for different lengths", { timeout: 5000 }, () => {
expect(pathEquals(["a"], ["a", "b"])).toBe(false);
});
it("returns false for different segments", { timeout: 5000 }, () => {
expect(pathEquals(["a"], ["b"])).toBe(false);
expect(pathEquals([0], [1])).toBe(false);
});
});
describe("isPrefix", () => {
it("prefix of path returns true", { timeout: 5000 }, () => {
expect(isPrefix(["a"], ["a", "b"])).toBe(true);
expect(isPrefix(["a"], ["a", "b", "c"])).toBe(true);
});
it("identical path is its own prefix", { timeout: 5000 }, () => {
expect(isPrefix(["a", "b"], ["a", "b"])).toBe(true);
});
it("empty prefix is always true", { timeout: 5000 }, () => {
expect(isPrefix([], ["x", "y"])).toBe(true);
expect(isPrefix([], [])).toBe(true);
});
it("longer prefix is not prefix of shorter path", { timeout: 5000 }, () => {
expect(isPrefix(["a", "b"], ["a"])).toBe(false);
});
it("mismatched segment returns false", { timeout: 5000 }, () => {
expect(isPrefix(["a", "x"], ["a", "b"])).toBe(false);
});
});
describe("parentPath / lastSegment", () => {
it("parentPath removes last segment", { timeout: 5000 }, () => {
expect(parentPath(["a", "b", "c"])).toEqual(["a", "b"]);
});
it("parentPath of single-element path returns []", {
timeout: 5000,
}, () => {
expect(parentPath(["a"])).toEqual([]);
});
it("parentPath of empty path returns []", { timeout: 5000 }, () => {
expect(parentPath([])).toEqual([]);
});
it("lastSegment returns last element", { timeout: 5000 }, () => {
expect(lastSegment(["a", "b", 3])).toBe(3);
expect(lastSegment(["x"])).toBe("x");
});
it("lastSegment of empty path returns undefined", { timeout: 5000 }, () => {
expect(lastSegment([])).toBeUndefined();
});
});

View File

@@ -0,0 +1,132 @@
import { DIFF_CODES, DiffError } from "../errors/diff-error.js";
/** A JSON Pointer path represented as an array of string or numeric segments. */
export type Path = Array<string | number>;
/**
* Escape a single JSON Pointer segment per RFC 6901:
* `~` → `~0`, `/` → `~1`.
*/
export function escapeSegment(segment: string): string {
return segment.replace(/~/g, "~0").replace(/\//g, "~1");
}
/**
* Unescape a single JSON Pointer segment per RFC 6901:
* `~1` → `/`, `~0` → `~`.
* Order of substitution is important (RFC 6901 §3).
*/
export function unescapeSegment(segment: string): string {
return segment.replace(/~1/g, "/").replace(/~0/g, "~");
}
/**
* Parse an RFC 6901 JSON Pointer string into a {@link Path} segment array.
*
* - `""` → `[]` (root reference)
* - `"/"` → `[""]` (key that is the empty string)
* - `"/a/b/0"` → `["a", "b", 0]` (numeric indices parsed as `number`)
*
* @throws {DiffError} with code `DIFF.PATH_INVALID` if the pointer is
* non-empty but does not start with `/`.
*/
export function parsePath(pointer: string): Path {
if (pointer === "") return [];
if (!pointer.startsWith("/")) {
throw new DiffError(
`Invalid JSON Pointer: "${pointer}" must start with "/"`,
DIFF_CODES.PATH_INVALID,
{ reason: "pointer must be empty or start with /" }
);
}
return pointer
.slice(1)
.split("/")
.map((raw) => {
const unescaped = unescapeSegment(raw);
// Treat purely numeric tokens as array indices
if (/^\d+$/.test(unescaped)) return Number(unescaped);
return unescaped;
});
}
/**
* Serialise a {@link Path} back to an RFC 6901 JSON Pointer string.
*
* - `[]` → `""`
* - `["a", "b", 0]` → `"/a/b/0"`
*/
export function formatPath(path: Path): string {
if (path.length === 0) return "";
return `/${path.map((seg) => escapeSegment(String(seg))).join("/")}`;
}
/** Return a new path with `segment` appended. */
export function appendPath(path: Path, segment: string | number): Path {
return [...path, segment];
}
/** Concatenate two paths into a new array. */
export function concatPaths(a: Path, b: Path): Path {
return [...a, ...b];
}
/**
* Lexicographic comparison of two paths by their segments.
* Numeric segments are compared numerically; mixed types compare as strings.
* Returns negative, zero, or positive.
*/
export function comparePaths(a: Path, b: Path): number {
const len = Math.max(a.length, b.length);
for (let i = 0; i < len; i++) {
if (i >= a.length) return -1;
if (i >= b.length) return 1;
const as = a[i];
const bs = b[i];
if (typeof as === "number" && typeof bs === "number") {
if (as !== bs) return as - bs;
} else {
const aStr = String(as);
const bStr = String(bs);
if (aStr < bStr) return -1;
if (aStr > bStr) return 1;
}
}
return 0;
}
/** Deep equality check between two paths. */
export function pathEquals(a: Path, b: Path): boolean {
if (a.length !== b.length) return false;
for (let i = 0; i < a.length; i++) {
if (a[i] !== b[i]) return false;
}
return true;
}
/**
* Return `true` if `prefix` is a proper prefix of `path` or equals it.
* An empty prefix is a prefix of every path.
*/
export function isPrefix(prefix: Path, path: Path): boolean {
if (prefix.length > path.length) return false;
for (let i = 0; i < prefix.length; i++) {
if (prefix[i] !== path[i]) return false;
}
return true;
}
/**
* Return a new path with the last segment removed.
* Returns `[]` when the path is already empty.
*/
export function parentPath(path: Path): Path {
return path.slice(0, -1);
}
/**
* Return the last segment of the path, or `undefined` for an empty path.
*/
export function lastSegment(path: Path): string | number | undefined {
return path[path.length - 1];
}

View File

@@ -0,0 +1,6 @@
export {
createSync,
type SyncDelta,
SyncManager,
type SyncOptions,
} from "./sync";

View File

@@ -0,0 +1,141 @@
# sync
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`sync.ts` implements an optimistic concurrency sync manager for the `@suaveplan/diff` package. It tracks local pending deltas and a monotonic sequence counter so that the caller can coordinate document changes between multiple clients or processes. When a remote delta arrives, it is compared against local pending deltas; if a path-level conflict is detected, one of three resolution strategies is applied: `local-wins`, `remote-wins`, or `reject`.
The sync manager does not apply patches to documents — it only returns which patch the caller should apply. This separation keeps the manager stateless with respect to document content and easy to integrate into any store or state machine.
## Features
- **`push(patch)`** — records a local delta, increments the sequence counter, notifies listeners
- **`pull(delta)`** — processes an incoming remote delta, returns the rebased patch
- **`local-wins`** — keeps local patches, discards conflicting remote operations
- **`remote-wins`** — discards local patches, accepts full remote delta
- **`reject`** — throws `Error("conflict")` and leaves both sides unaffected
- **`onDelta(listener)`** — reactive subscription to locally pushed deltas; returns unsubscribe fn
- **`reset()`** — clears pending queue and resets sequence counter
## Usage: Basic Example
```ts
import { createSync } from "@suaveplan/diff/sync";
const sync = createSync({ conflictResolution: "local-wins" });
// Push a local change
const delta = sync.push([{ op: "replace", path: ["name"], value: "Alice" }]);
// delta: { patch, sequenceNumber: 1, timestamp: ... }
// Process a non-conflicting remote delta
const rebasedPatch = sync.pull({
patch: [{ op: "replace", path: ["age"], value: 31 }],
sequenceNumber: 1,
timestamp: Date.now(),
});
// rebasedPatch: same as remote delta (no conflict)
```
## Usage: Advanced Example
```ts
import { createSync, type SyncDelta } from "@suaveplan/diff/sync";
// Two peers: A (local-wins) and B (remote-wins)
const peerA = createSync({ conflictResolution: "local-wins" });
const peerB = createSync({ conflictResolution: "remote-wins" });
// Listen for local deltas on peerA
const unsubA = peerA.onDelta((delta: SyncDelta) => {
// Send delta to peerB
peerB.pull(delta);
});
// Both peers modify the same path — conflict
peerA.push([{ op: "replace", path: ["score"], value: 10 }]); // local
const remoteDelta: SyncDelta = {
patch: [{ op: "replace", path: ["score"], value: 20 }],
sequenceNumber: 2,
timestamp: Date.now(),
};
// local-wins: remote's conflicting op is filtered out
const rebased = peerA.pull(remoteDelta);
// rebased === [] (empty — local patch "wins" and remote op is discarded)
// reject strategy
const strict = createSync({ conflictResolution: "reject" });
strict.push([{ op: "replace", path: ["x"], value: 1 }]);
try {
strict.pull({ patch: [{ op: "replace", path: ["x"], value: 2 }], sequenceNumber: 1, timestamp: 0 });
} catch (e) {
console.error(e.message); // "conflict"
}
unsubA();
peerA.reset();
```
## API Reference
### `SyncDelta`
```ts
interface SyncDelta {
patch: Patch;
sequenceNumber: number;
timestamp: number;
}
```
### `SyncOptions`
```ts
interface SyncOptions {
conflictResolution: "local-wins" | "remote-wins" | "reject";
}
```
### `SyncManager`
```ts
class SyncManager {
constructor(options: SyncOptions): SyncManager;
push(patch: Patch): SyncDelta;
pull(delta: SyncDelta): Patch;
onDelta(listener: (delta: SyncDelta) => void): () => void;
reset(): void;
}
```
#### `push(patch): SyncDelta`
Increments the sequence counter, wraps `patch` in a `SyncDelta`, adds it to the pending queue, notifies all `onDelta` listeners, and returns the delta for the caller to send to the remote.
#### `pull(delta): Patch`
Compares `delta` against all pending local deltas. On no conflict, returns `delta.patch`. On conflict, applies the resolution strategy and returns the patch the caller should apply (may be empty for `local-wins`).
**Throws** `Error("conflict")` when `conflictResolution === "reject"` and a conflict is detected.
#### `onDelta(listener): () => void`
Registers a listener called on each `push`. Returns an unsubscribe function.
#### `reset(): void`
Clears all pending deltas and resets the sequence counter to 0.
### `createSync(options: SyncOptions): SyncManager`
Factory function equivalent to `new SyncManager(options)`.
## Implementation Notes
- Conflict detection delegates to `operationsConflict` from `operations.ts`, which uses path prefix matching. This is conservative: it may report conflicts that could technically coexist at different sub-paths, but erring on the side of safety prevents data corruption.
- `local-wins` filters individual conflicting remote _operations_ rather than discarding the entire remote patch. This allows non-conflicting remote operations to still be applied.
- `remote-wins` removes the entire conflicting _local_ delta from the pending queue, meaning the local change will not be re-applied. Callers should handle this by refreshing document state from the remote source.
- The sequence counter advances on both `push` and `pull` to maintain a consistent ordering. When a remote delta has a higher sequence number than the local counter, the local counter is updated to avoid stale sequence numbers.

View File

@@ -0,0 +1,187 @@
import { describe, expect, it, vi } from "@suaveplan/testing/runner";
import type { Patch } from "../operations/operations.js";
import { createSync, type SyncDelta, SyncManager } from "./sync.js";
function makeDelta(patch: Patch, seq: number): SyncDelta {
return { patch, sequenceNumber: seq, timestamp: Date.now() };
}
const patchA: Patch = [{ op: "replace", path: ["a"], value: 1 }];
const patchB: Patch = [{ op: "replace", path: ["b"], value: 2 }];
const patchConflict: Patch = [{ op: "replace", path: ["a"], value: 99 }]; // same path as patchA
describe("SyncManager - push", () => {
it("returns delta with incremented sequence", { timeout: 5000 }, () => {
const s = createSync({ conflictResolution: "local-wins" });
const d1 = s.push(patchA);
expect(d1.sequenceNumber).toBe(1);
const d2 = s.push(patchB);
expect(d2.sequenceNumber).toBe(2);
});
it("returned delta contains the patch", { timeout: 5000 }, () => {
const s = createSync({ conflictResolution: "local-wins" });
const d = s.push(patchA);
expect(d.patch).toEqual(patchA);
});
it("returned delta has a timestamp", { timeout: 5000 }, () => {
const s = createSync({ conflictResolution: "local-wins" });
const d = s.push(patchA);
expect(typeof d.timestamp).toBe("number");
expect(d.timestamp).toBeGreaterThan(0);
});
});
describe("SyncManager - pull (no conflict)", () => {
it("returns full remote patch when no conflict", { timeout: 5000 }, () => {
const s = createSync({ conflictResolution: "local-wins" });
s.push(patchA);
const rebased = s.pull(makeDelta(patchB, 2));
expect(rebased).toEqual(patchB);
});
it("accepts remote delta with no pending local", { timeout: 5000 }, () => {
const s = createSync({ conflictResolution: "local-wins" });
const rebased = s.pull(makeDelta(patchA, 1));
expect(rebased).toEqual(patchA);
});
});
describe("SyncManager - local-wins", () => {
it("filters out conflicting remote ops", { timeout: 5000 }, () => {
const s = createSync({ conflictResolution: "local-wins" });
s.push(patchA); // local change on ["a"]
// Remote also changes ["a"] — conflict
const rebased = s.pull(makeDelta(patchConflict, 2));
expect(rebased).toEqual([]); // conflicting op filtered out
});
it("keeps non-conflicting remote ops", { timeout: 5000 }, () => {
const s = createSync({ conflictResolution: "local-wins" });
s.push(patchA); // local change on ["a"]
// Remote changes ["a"] and ["b"] — only ["a"] conflicts
const mixed: Patch = [
{ op: "replace", path: ["a"], value: 99 },
{ op: "replace", path: ["b"], value: 42 },
];
const rebased = s.pull(makeDelta(mixed, 2));
expect(rebased).toEqual([{ op: "replace", path: ["b"], value: 42 }]);
});
});
describe("SyncManager - remote-wins", () => {
it("returns full remote patch and removes conflicting local", {
timeout: 5000,
}, () => {
const s = createSync({ conflictResolution: "remote-wins" });
s.push(patchA); // local change on ["a"]
const rebased = s.pull(makeDelta(patchConflict, 2));
expect(rebased).toEqual(patchConflict); // full remote patch
});
});
describe("SyncManager - reject", () => {
it("throws on conflict", { timeout: 5000 }, () => {
const s = createSync({ conflictResolution: "reject" });
s.push(patchA);
expect(() => s.pull(makeDelta(patchConflict, 2))).toThrow("conflict");
});
it("does not throw for non-conflicting remote", { timeout: 5000 }, () => {
const s = createSync({ conflictResolution: "reject" });
s.push(patchA);
expect(() => s.pull(makeDelta(patchB, 2))).not.toThrow();
});
});
describe("SyncManager - onDelta", () => {
it("listener called on push", { timeout: 5000 }, () => {
const s = createSync({ conflictResolution: "local-wins" });
const listener = vi.fn();
s.onDelta(listener);
s.push(patchA);
expect(listener).toHaveBeenCalledTimes(1);
const delta: SyncDelta = listener.mock.calls[0][0];
expect(delta.patch).toEqual(patchA);
expect(delta.sequenceNumber).toBe(1);
});
it("unsubscribe stops listener", { timeout: 5000 }, () => {
const s = createSync({ conflictResolution: "local-wins" });
const listener = vi.fn();
const unsub = s.onDelta(listener);
unsub();
s.push(patchA);
expect(listener).not.toHaveBeenCalled();
});
it("multiple listeners all notified", { timeout: 5000 }, () => {
const s = createSync({ conflictResolution: "local-wins" });
const l1 = vi.fn();
const l2 = vi.fn();
s.onDelta(l1);
s.onDelta(l2);
s.push(patchA);
expect(l1).toHaveBeenCalledTimes(1);
expect(l2).toHaveBeenCalledTimes(1);
});
});
describe("SyncManager - reset", () => {
it("clears sequence counter and pending", { timeout: 5000 }, () => {
const s = createSync({ conflictResolution: "local-wins" });
s.push(patchA);
s.push(patchB);
s.reset();
// After reset, a new push should start from sequence 1
const d = s.push(patchA);
expect(d.sequenceNumber).toBe(1);
});
it("after reset, pull with conflicting delta does not conflict", {
timeout: 5000,
}, () => {
const s = createSync({ conflictResolution: "reject" });
s.push(patchA);
s.reset(); // clears pending
// Now there's no local pending, so no conflict
expect(() => s.pull(makeDelta(patchConflict, 1))).not.toThrow();
});
});
describe("SyncManager - sequence number advancement", () => {
it("sequence advances on push", { timeout: 5000 }, () => {
const s = createSync({ conflictResolution: "local-wins" });
expect(s.push(patchA).sequenceNumber).toBe(1);
expect(s.push(patchB).sequenceNumber).toBe(2);
});
it("sequence updates when remote has higher seq", { timeout: 5000 }, () => {
const s = createSync({ conflictResolution: "local-wins" });
s.pull(makeDelta(patchA, 100));
// Next push should be 101
const d = s.push(patchB);
expect(d.sequenceNumber).toBe(101);
});
it("sequence does NOT update when remote has lower seq", {
timeout: 5000,
}, () => {
const s = createSync({ conflictResolution: "local-wins" });
s.push(patchA); // seq = 1
s.push(patchB); // seq = 2
// Pull with seq=1 (lower than current 2) — should not update sequence
s.pull(makeDelta(patchA, 1));
// Next push should be 3, not 2
const d = s.push(patchA);
expect(d.sequenceNumber).toBe(3);
});
});
describe("createSync factory", () => {
it("returns a SyncManager instance", { timeout: 5000 }, () => {
const s = createSync({ conflictResolution: "local-wins" });
expect(s).toBeInstanceOf(SyncManager);
});
});

View File

@@ -0,0 +1,194 @@
import {
type Operation,
operationsConflict,
type Patch,
} from "../operations/operations.js";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export interface SyncDelta {
/** The patch carried in this delta. */
patch: Patch;
/** Monotonically increasing sequence number. */
sequenceNumber: number;
/** Unix timestamp (ms) at which the delta was created. */
timestamp: number;
}
export interface SyncOptions {
/** How to resolve conflicts between local and remote deltas. */
conflictResolution: "local-wins" | "remote-wins" | "reject";
}
// ---------------------------------------------------------------------------
// SyncManager class
// ---------------------------------------------------------------------------
/**
* Optimistic concurrency sync manager.
*
* Tracks local pending deltas and a monotonic sequence counter. When a remote
* delta arrives via `pull`, it is compared against pending local deltas. If
* any conflict is detected, the configured resolution strategy is applied:
*
* - `local-wins` — keep the local patch, discard conflicting remote operations
* - `remote-wins` — discard the local patch in favour of the remote delta
* - `reject` — throw an error and leave both parties unaffected
*
* @example
* ```ts
* const sync = createSync({ conflictResolution: "local-wins" });
* const delta = sync.push(localPatch); // emit to remote
* const rebasedPatch = sync.pull(remoteDelta); // process incoming
* ```
*/
export class SyncManager {
protected readonly _options: SyncOptions;
protected _sequence: number;
protected _pending: SyncDelta[];
protected _listeners: Array<(delta: SyncDelta) => void>;
constructor(options: SyncOptions) {
this._options = options;
this._sequence = 0;
this._pending = [];
this._listeners = [];
}
/**
* Record a local patch as a delta. Increments the sequence counter and
* adds the delta to the pending queue. Notifies listeners.
*
* @returns The created {@link SyncDelta} to send to the remote.
*/
push(patch: Patch): SyncDelta {
this._sequence++;
const delta: SyncDelta = {
patch,
sequenceNumber: this._sequence,
timestamp: Date.now(),
};
this._pending.push(delta);
this._emitDelta(delta);
return delta;
}
/**
* Process an incoming remote delta.
*
* If the remote delta conflicts with any pending local delta, the resolution
* strategy is applied. Returns the rebased patch to apply to the local
* local doc (or an empty patch if nothing needs to be applied after resolution).
*
* @throws `Error` with message `"conflict"` when `conflictResolution === "reject"`.
*/
pull(delta: SyncDelta): Patch {
const conflicts = this._findConflicts(delta);
if (conflicts.length === 0) {
// No conflict — accept remote delta as-is
this._updateSequence(delta.sequenceNumber);
return delta.patch;
}
switch (this._options.conflictResolution) {
case "local-wins":
// Keep local pending patches; discard conflicting remote operations
this._updateSequence(delta.sequenceNumber);
return this._filterConflictingOps(delta.patch, conflicts);
case "remote-wins":
// Remove conflicting local pending patches; accept full remote delta
this._pending = this._pending.filter(
(p) => !conflicts.includes(p)
);
this._updateSequence(delta.sequenceNumber);
return delta.patch;
case "reject":
throw new Error("conflict");
}
}
/**
* Register a listener that fires when a local delta is pushed.
* Returns an unsubscribe function.
*/
onDelta(listener: (delta: SyncDelta) => void): () => void {
this._listeners.push(listener);
return () => {
this._listeners = this._listeners.filter((l) => l !== listener);
};
}
/** Reset sequence counter and clear pending queue. */
reset(): void {
this._sequence = 0;
this._pending = [];
}
// ---------------------------------------------------------------------------
// Internal
// ---------------------------------------------------------------------------
protected _emitDelta(delta: SyncDelta): void {
for (const listener of this._listeners) {
listener(delta);
}
}
protected _updateSequence(remoteSeq: number): void {
if (remoteSeq > this._sequence) {
this._sequence = remoteSeq;
}
}
protected _findConflicts(remote: SyncDelta): SyncDelta[] {
return this._pending.filter((local) =>
local.patch.some((localOp: Operation) =>
remote.patch.some((remoteOp: Operation) =>
operationsConflict(localOp, remoteOp)
)
)
);
}
/**
* Filter out remote operations that conflict with local pending patches,
* returning only the non-conflicting remote operations.
*/
protected _filterConflictingOps(
remotePatch: Patch,
conflictingLocal: SyncDelta[]
): Patch {
const conflictingOps = new Set<Operation>();
for (const local of conflictingLocal) {
for (const localOp of local.patch) {
for (const remoteOp of remotePatch) {
if (operationsConflict(localOp, remoteOp)) {
conflictingOps.add(remoteOp);
}
}
}
}
return remotePatch.filter((op) => !conflictingOps.has(op));
}
}
// ---------------------------------------------------------------------------
// Factory
// ---------------------------------------------------------------------------
/**
* Create a new {@link SyncManager} instance.
*
* @example
* ```ts
* const sync = createSync({ conflictResolution: "remote-wins" });
* ```
*/
export function createSync(options: SyncOptions): SyncManager {
return new SyncManager(options);
}

View File

@@ -0,0 +1,15 @@
export {
deepClone,
deepEqual,
deleteValueAtPath,
deleteValueAtPathMut,
getValueAtPath,
hashValue,
insertValueAtPath,
insertValueAtPathMut,
isObject,
isPrimitive,
type JsonValue,
setValueAtPath,
setValueAtPathMut,
} from "./utils";

View File

@@ -0,0 +1,138 @@
# utils
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`utils.ts` provides the low-level primitives that the entire `@suaveplan/diff` pipeline is built upon: deep clone, deep equality, FNV-1a hashing, and a family of path-navigation helpers for immutable and mutable document manipulation. Every operation is JSON-safe — it operates exclusively on the `JsonValue` universe (`null | boolean | number | string | JsonValue[] | { [k: string]: JsonValue }`).
The path-navigation helpers implement the RFC 6902 semantics for `add`, `remove`, `replace`, `move`, and `copy` at the document level, freeing the higher-level patch engine from the mechanics of array splice and object key management.
## Features
- **`deepClone`** — recursive JSON-safe clone without `JSON.parse/stringify` overhead
- **`deepEqual`** — structural equality for arbitrarily nested JSON values
- **`hashValue`** — FNV-1a 32-bit hash of JSON serialisation (for LCS bucketing)
- **`isObject` / `isPrimitive`** — type-guard predicates
- **`getValueAtPath`** — safe read at a path; returns `undefined` on missing keys
- **`setValueAtPath` / `setValueAtPathMut`** — immutable and in-place write
- **`insertValueAtPath` / `insertValueAtPathMut`** — immutable and in-place insert (array splice)
- **`deleteValueAtPath` / `deleteValueAtPathMut`** — immutable and in-place delete
## Usage: Basic Example
```ts
import { deepClone, deepEqual, getValueAtPath } from "@suaveplan/diff/utils";
import type { JsonValue } from "@suaveplan/diff/utils";
const doc: JsonValue = { users: [{ name: "Alice" }, { name: "Bob" }] };
const clone = deepClone(doc);
clone.users[0].name = "Carol";
console.log(doc.users[0].name); // "Alice" — original unchanged
deepEqual(doc, clone); // false — they now differ
const name = getValueAtPath(doc, ["users", 1, "name"]);
// "Bob"
```
## Usage: Advanced Example
```ts
import {
setValueAtPath, insertValueAtPath, deleteValueAtPath,
setValueAtPathMut, hashValue,
} from "@suaveplan/diff/utils";
import type { JsonValue } from "@suaveplan/diff/utils";
const doc: JsonValue = { items: ["a", "b", "c"] };
// Immutable set — original unchanged
const updated = setValueAtPath(doc, ["items", 1], "B");
// doc still: { items: ["a", "b", "c"] }
// updated: { items: ["a", "B", "c"] }
// Array insert — existing elements shift right
const inserted = insertValueAtPath(doc, ["items", 1], "X");
// { items: ["a", "X", "b", "c"] }
// Append via "-" token
const appended = insertValueAtPath(doc, ["items", "-"], "Z");
// { items: ["a", "b", "c", "Z"] }
// Delete
const deleted = deleteValueAtPath(doc, ["items", 0]);
// { items: ["b", "c"] }
// In-place mutation (used internally by the patch engine)
const mutable = deepClone(doc) as { items: string[] };
setValueAtPathMut(mutable as JsonValue, ["items", 2], "C");
// Hash for LCS bucketing
hashValue({ x: 1 }); // e.g. "3a2b1c4d"
```
## API Reference
### `JsonValue`
```ts
type JsonValue = null | boolean | number | string | JsonValue[] | { [k: string]: JsonValue };
```
### `isObject(v: unknown): v is Record<string, unknown>`
Returns `true` for non-null, non-array plain objects.
### `isPrimitive(v: unknown): v is null | boolean | number | string`
Returns `true` for all primitive JSON value types.
### `deepClone<T>(value: T): T`
Recursively clones arrays and objects; returns primitives as-is. Does not use `JSON.parse/stringify`.
### `deepEqual(a: unknown, b: unknown): boolean`
Structural equality: handles `null`, same-type primitives, same-length arrays with equal elements, and objects with equal key sets and equal values. Returns `false` for type mismatches.
### `hashValue(value: unknown): string`
FNV-1a 32-bit hash of `JSON.stringify(value)`. Returns an 8-character lowercase hex string.
### `getValueAtPath(doc: JsonValue, path: Path): JsonValue | undefined`
Traverses `doc` following `path`. Returns `undefined` if any step fails (missing key, index out of range, traversing through a primitive).
### `setValueAtPath(doc: JsonValue, path: Path, value: JsonValue): JsonValue`
Returns a new deep clone of `doc` with `value` written at `path`. The original `doc` is not modified.
### `setValueAtPathMut(doc: JsonValue, path: Path, value: JsonValue): void`
Sets `value` at `path` in `doc` in-place.
### `insertValueAtPath(doc: JsonValue, path: Path, value: JsonValue): JsonValue`
Returns a new deep clone with `value` inserted. For arrays, elements at `>= index` shift right. The `"-"` segment appends to the array.
### `insertValueAtPathMut(doc: JsonValue, path: Path, value: JsonValue): void`
Inserts `value` in-place using array splice or object key assignment.
### `deleteValueAtPath(doc: JsonValue, path: Path): JsonValue`
Returns a new deep clone with the element at `path` removed. Array elements shift left.
### `deleteValueAtPathMut(doc: JsonValue, path: Path): void`
Removes the element at `path` in-place.
## Implementation Notes
- `deepClone` avoids `JSON.parse/stringify` to preserve type fidelity for values that would be mangled by JSON roundtrip (e.g., `undefined` in arrays becomes `null`). This also avoids the overhead of two serialisation passes.
- `resolveParent` is a shared internal helper that walks to the parent of the target key, returning a `{ parent, key }` pair. This eliminates duplication between the six set/insert/delete variants.
- The `"-"` append token is handled in both `setValueAtPathMut` and `insertValueAtPathMut` to match RFC 6902 §4.1 semantics for the `add` operation.
- `hashValue` deliberately uses FNV-1a rather than a cryptographic hash for performance in the LCS hot path. Collision probability over realistic array sizes is negligible.

View File

@@ -0,0 +1,375 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
deepClone,
deepEqual,
deleteValueAtPath,
deleteValueAtPathMut,
getValueAtPath,
hashValue,
insertValueAtPath,
insertValueAtPathMut,
isObject,
isPrimitive,
type JsonValue,
setValueAtPath,
setValueAtPathMut,
} from "./utils.js";
describe("isObject", () => {
it("returns true for plain objects", { timeout: 5000 }, () => {
expect(isObject({})).toBe(true);
expect(isObject({ a: 1 })).toBe(true);
});
it("returns false for null, arrays, primitives", { timeout: 5000 }, () => {
expect(isObject(null)).toBe(false);
expect(isObject([])).toBe(false);
expect(isObject("str")).toBe(false);
expect(isObject(42)).toBe(false);
});
});
describe("isPrimitive", () => {
it("returns true for null, boolean, number, string", {
timeout: 5000,
}, () => {
expect(isPrimitive(null)).toBe(true);
expect(isPrimitive(true)).toBe(true);
expect(isPrimitive(0)).toBe(true);
expect(isPrimitive("")).toBe(true);
});
it("returns false for objects and arrays", { timeout: 5000 }, () => {
expect(isPrimitive({})).toBe(false);
expect(isPrimitive([])).toBe(false);
});
});
describe("deepClone", () => {
it("clones nested objects", { timeout: 5000 }, () => {
const original: JsonValue = { a: { b: [1, 2, 3] } };
const clone = deepClone(original);
expect(clone).toEqual(original);
(clone as Record<string, JsonValue>).a = "changed";
expect((original as Record<string, JsonValue>).a).toEqual({
b: [1, 2, 3],
});
});
it("clones arrays with nested objects", { timeout: 5000 }, () => {
const arr: JsonValue = [{ x: 1 }, { x: 2 }];
const clone = deepClone(arr);
(clone as JsonValue[])[0] = "replaced";
expect((arr as JsonValue[])[0]).toEqual({ x: 1 });
});
it("preserves null", { timeout: 5000 }, () => {
expect(deepClone(null)).toBe(null);
});
it("preserves primitives", { timeout: 5000 }, () => {
expect(deepClone(42)).toBe(42);
expect(deepClone("hello")).toBe("hello");
expect(deepClone(true)).toBe(true);
});
it("clones empty object and array", { timeout: 5000 }, () => {
expect(deepClone({})).toEqual({});
expect(deepClone([])).toEqual([]);
});
});
describe("deepEqual", () => {
it("equal primitives", { timeout: 5000 }, () => {
expect(deepEqual(1, 1)).toBe(true);
expect(deepEqual("a", "a")).toBe(true);
expect(deepEqual(null, null)).toBe(true);
expect(deepEqual(true, true)).toBe(true);
});
it("unequal primitives", { timeout: 5000 }, () => {
expect(deepEqual(1, 2)).toBe(false);
expect(deepEqual("a", "b")).toBe(false);
expect(deepEqual(null, false)).toBe(false);
});
it("nested objects", { timeout: 5000 }, () => {
expect(deepEqual({ a: { b: 1 } }, { a: { b: 1 } })).toBe(true);
expect(deepEqual({ a: { b: 1 } }, { a: { b: 2 } })).toBe(false);
});
it("arrays", { timeout: 5000 }, () => {
expect(deepEqual([1, 2, 3], [1, 2, 3])).toBe(true);
expect(deepEqual([1, 2], [1, 2, 3])).toBe(false);
expect(deepEqual([1, [2, 3]], [1, [2, 3]])).toBe(true);
});
it("handles null inside objects", { timeout: 5000 }, () => {
expect(deepEqual({ a: null }, { a: null })).toBe(true);
expect(deepEqual({ a: null }, { a: 0 })).toBe(false);
});
it("different key count", { timeout: 5000 }, () => {
expect(deepEqual({ a: 1, b: 2 }, { a: 1 })).toBe(false);
});
it("object vs array", { timeout: 5000 }, () => {
expect(deepEqual({}, [])).toBe(false);
});
it("type mismatch", { timeout: 5000 }, () => {
expect(deepEqual(1, "1")).toBe(false);
});
it("extra key in b", { timeout: 5000 }, () => {
expect(deepEqual({ a: 1 }, { a: 1, b: 2 })).toBe(false);
});
});
describe("hashValue", () => {
it("returns 8-char hex string", { timeout: 5000 }, () => {
const h = hashValue({ x: 1 });
expect(h).toMatch(/^[0-9a-f]{8}$/);
});
it("same value produces same hash", { timeout: 5000 }, () => {
expect(hashValue([1, 2, 3])).toBe(hashValue([1, 2, 3]));
});
it("different values produce different hashes", { timeout: 5000 }, () => {
expect(hashValue({ a: 1 })).not.toBe(hashValue({ a: 2 }));
});
it("handles null", { timeout: 5000 }, () => {
expect(hashValue(null)).toMatch(/^[0-9a-f]{8}$/);
});
});
describe("getValueAtPath", () => {
const doc: JsonValue = {
a: {
b: {
c: 42,
},
},
arr: [10, 20, 30],
n: null,
};
it("gets nested value", { timeout: 5000 }, () => {
expect(getValueAtPath(doc, ["a", "b", "c"])).toBe(42);
});
it("gets root with empty path", { timeout: 5000 }, () => {
expect(getValueAtPath(doc, [])).toEqual(doc);
});
it("returns undefined for missing key", { timeout: 5000 }, () => {
expect(getValueAtPath(doc, ["a", "x"])).toBeUndefined();
});
it("gets array elements", { timeout: 5000 }, () => {
expect(getValueAtPath(doc, ["arr", 0])).toBe(10);
expect(getValueAtPath(doc, ["arr", 2])).toBe(30);
});
it("returns undefined on string key for array", { timeout: 5000 }, () => {
expect(getValueAtPath(doc, ["arr", "x"])).toBeUndefined();
});
it("returns null value correctly", { timeout: 5000 }, () => {
expect(getValueAtPath(doc, ["n"])).toBe(null);
});
it("returns undefined when traversing through primitive", {
timeout: 5000,
}, () => {
expect(getValueAtPath(doc, ["a", "b", "c", "d"])).toBeUndefined();
});
it("returns undefined when traversing through null", {
timeout: 5000,
}, () => {
expect(getValueAtPath(doc, ["n", "x"])).toBeUndefined();
});
});
describe("setValueAtPath (immutable)", () => {
const doc: JsonValue = { a: { b: 1 }, items: ["x", "y"] };
it("sets nested object key", { timeout: 5000 }, () => {
const result = setValueAtPath(doc, ["a", "b"], 99);
expect(getValueAtPath(result, ["a", "b"])).toBe(99);
expect(getValueAtPath(doc, ["a", "b"])).toBe(1); // original unchanged
});
it("sets array index", { timeout: 5000 }, () => {
const result = setValueAtPath(doc, ["items", 0], "Z");
expect(getValueAtPath(result, ["items", 0])).toBe("Z");
expect(getValueAtPath(doc, ["items", 0])).toBe("x");
});
it("empty path is no-op", { timeout: 5000 }, () => {
const result = setValueAtPath(doc, [], "new");
expect(result).toEqual(doc);
});
});
describe("setValueAtPathMut", () => {
it("mutates object key in place", { timeout: 5000 }, () => {
const d: JsonValue = { x: 1 };
setValueAtPathMut(d, ["x"], 99);
expect((d as Record<string, JsonValue>).x).toBe(99);
});
it("mutates array index in place", { timeout: 5000 }, () => {
const d: JsonValue = [1, 2, 3];
setValueAtPathMut(d, [1], 99);
expect((d as JsonValue[])[1]).toBe(99);
});
it("- token appends to array", { timeout: 5000 }, () => {
const d: JsonValue = { arr: [1, 2] };
setValueAtPathMut(d, ["arr", "-"], 3);
expect((d as Record<string, JsonValue>).arr).toEqual([1, 2, 3]);
});
});
describe("insertValueAtPath (immutable)", () => {
it("inserts into array at index", { timeout: 5000 }, () => {
const doc: JsonValue = { items: ["a", "b", "c"] };
const result = insertValueAtPath(doc, ["items", 1], "X");
expect(getValueAtPath(result, ["items"])).toEqual(["a", "X", "b", "c"]);
expect(getValueAtPath(doc, ["items"])).toEqual(["a", "b", "c"]);
});
it("inserts with - token appends", { timeout: 5000 }, () => {
const doc: JsonValue = ["a", "b"];
const result = insertValueAtPath(doc, ["-"], "c");
expect(result).toEqual(["a", "b", "c"]);
});
it("inserts object property", { timeout: 5000 }, () => {
const doc: JsonValue = { a: 1 };
const result = insertValueAtPath(doc, ["b"], 2);
expect(result).toEqual({ a: 1, b: 2 });
});
});
describe("insertValueAtPathMut", () => {
it("splices into array", { timeout: 5000 }, () => {
const d: JsonValue = [1, 2, 3];
insertValueAtPathMut(d, [1], 99);
expect(d).toEqual([1, 99, 2, 3]);
});
it("appends with -", { timeout: 5000 }, () => {
const d: JsonValue = [1, 2];
insertValueAtPathMut(d, ["-"], 3);
expect(d).toEqual([1, 2, 3]);
});
it("empty path is no-op", { timeout: 5000 }, () => {
const d: JsonValue = [1, 2];
insertValueAtPathMut(d, [], 3);
expect(d).toEqual([1, 2]);
});
});
describe("deleteValueAtPath (immutable)", () => {
it("deletes object key", { timeout: 5000 }, () => {
const doc: JsonValue = { a: 1, b: 2 };
const result = deleteValueAtPath(doc, ["a"]);
expect(result).toEqual({ b: 2 });
expect(doc).toEqual({ a: 1, b: 2 });
});
it("deletes array element", { timeout: 5000 }, () => {
const doc: JsonValue = [1, 2, 3];
const result = deleteValueAtPath(doc, [1]);
expect(result).toEqual([1, 3]);
});
it("empty path is no-op", { timeout: 5000 }, () => {
const doc: JsonValue = { a: 1 };
const result = deleteValueAtPath(doc, []);
expect(result).toEqual({ a: 1 });
});
});
describe("deleteValueAtPathMut", () => {
it("deletes key from object in place", { timeout: 5000 }, () => {
const d: JsonValue = { x: 1, y: 2 };
deleteValueAtPathMut(d, ["x"]);
expect(d).toEqual({ y: 2 });
});
it("splices element from array in place", { timeout: 5000 }, () => {
const d: JsonValue = [10, 20, 30];
deleteValueAtPathMut(d, [0]);
expect(d).toEqual([20, 30]);
});
});
describe("resolveParent edge cases (isPrimitive parent)", () => {
it("setValueAtPathMut is no-op when parent is primitive", {
timeout: 5000,
}, () => {
// Traversal reaches a number primitive — resolveParent returns undefined
const d: JsonValue = { a: 42 };
setValueAtPathMut(d, ["a", "sub"], 99);
expect(d).toEqual({ a: 42 }); // unchanged
});
it("insertValueAtPathMut is no-op when parent is primitive", {
timeout: 5000,
}, () => {
const d: JsonValue = { a: 42 };
insertValueAtPathMut(d, ["a", "sub"], 99);
expect(d).toEqual({ a: 42 });
});
it("deleteValueAtPathMut is no-op when parent is primitive", {
timeout: 5000,
}, () => {
const d: JsonValue = { a: 42 };
deleteValueAtPathMut(d, ["a", "sub"]);
expect(d).toEqual({ a: 42 });
});
it("setValueAtPath (immutable) handles primitive parent gracefully", {
timeout: 5000,
}, () => {
const d: JsonValue = { a: 42 };
const result = setValueAtPath(d, ["a", "sub"], 99);
expect(result).toEqual({ a: 42 });
});
it("insertValueAtPathMut is no-op when parent resolves to null (line 218 + 147 null branch)", {
timeout: 5000,
}, () => {
// parent path ["a"] resolves to null — isPrimitive(null) is true → resolveParent returns undefined
const d: JsonValue = { a: null };
insertValueAtPathMut(d, ["a", "b"], 99);
expect(d).toEqual({ a: null }); // unchanged
});
});
describe("deepEqual - same-length different keys (line 75 hasOwn false branch)", () => {
it("returns false when key in a is not in b (same key count)", {
timeout: 5000,
}, () => {
// { a: 1 } vs { b: 1 } — same length, but 'a' not in b
expect(deepEqual({ a: 1 }, { b: 1 })).toBe(false);
});
});
describe("hashValue - unstringifiable value (line 100 ?? branch)", () => {
it("handles undefined (JSON.stringify returns undefined, fallback used)", {
timeout: 5000,
}, () => {
// JSON.stringify(undefined) returns undefined — triggers the ?? "undefined" fallback
const h = hashValue(undefined);
expect(h).toMatch(/^[0-9a-f]{8}$/);
});
});

View File

@@ -0,0 +1,253 @@
import type { Path } from "../path/path.js";
/** Any JSON-serialisable value including `null`. */
export type JsonValue =
| null
| boolean
| number
| string
| JsonValue[]
| { [k: string]: JsonValue };
// ---------------------------------------------------------------------------
// Type guards
// ---------------------------------------------------------------------------
/** Returns `true` if `v` is a non-null plain object (not an array). */
export function isObject(v: unknown): v is Record<string, unknown> {
return typeof v === "object" && v !== null && !Array.isArray(v);
}
/** Returns `true` if `v` is `null`, `boolean`, `number`, or `string`. */
export function isPrimitive(v: unknown): v is null | boolean | number | string {
return (
v === null ||
typeof v === "boolean" ||
typeof v === "number" ||
typeof v === "string"
);
}
// ---------------------------------------------------------------------------
// Deep clone
// ---------------------------------------------------------------------------
/**
* JSON-safe deep clone. Preserves `null`, primitives, arrays, and plain
* objects. Circular references will throw (same behaviour as `JSON.parse`).
*/
export function deepClone<T>(value: T): T {
if (value === null) return value;
if (typeof value !== "object") return value;
if (Array.isArray(value)) return value.map(deepClone) as unknown as T;
const out: Record<string, unknown> = {};
for (const k of Object.keys(value as Record<string, unknown>)) {
out[k] = deepClone((value as Record<string, unknown>)[k]);
}
return out as T;
}
// ---------------------------------------------------------------------------
// Deep equality
// ---------------------------------------------------------------------------
/**
* Structural equality check for JSON values.
* Handles `null`, primitives, arrays, and plain objects recursively.
*/
export function deepEqual(a: unknown, b: unknown): boolean {
if (a === b) return true;
if (a === null || b === null) return false;
if (typeof a !== typeof b) return false;
if (Array.isArray(a) && Array.isArray(b)) {
if (a.length !== b.length) return false;
for (let i = 0; i < a.length; i++) {
if (!deepEqual(a[i], b[i])) return false;
}
return true;
}
if (Array.isArray(a) || Array.isArray(b)) return false;
if (typeof a === "object" && typeof b === "object") {
const ak = Object.keys(a as object);
const bk = Object.keys(b as object);
if (ak.length !== bk.length) return false;
for (const k of ak) {
if (!Object.hasOwn(b, k)) return false;
if (
!deepEqual(
(a as Record<string, unknown>)[k],
(b as Record<string, unknown>)[k]
)
) {
return false;
}
}
return true;
}
return false;
}
// ---------------------------------------------------------------------------
// Hash
// ---------------------------------------------------------------------------
/**
* FNV-1a 32-bit hash of the JSON serialisation of `value`.
* Returns a hex string. Intended for equality bucketing in LCS computation —
* not a cryptographic hash.
*/
export function hashValue(value: unknown): string {
const str = JSON.stringify(value) ?? "undefined";
let h = 0x811c9dc5;
for (let i = 0; i < str.length; i++) {
h ^= str.charCodeAt(i);
h = (h * 0x01000193) >>> 0;
}
return h.toString(16).padStart(8, "0");
}
// ---------------------------------------------------------------------------
// Path navigation
// ---------------------------------------------------------------------------
/**
* Retrieve the value at `path` inside `doc`.
* Returns `undefined` if any intermediate key or index is missing.
* Does not throw.
*/
export function getValueAtPath(
doc: JsonValue,
path: Path
): JsonValue | undefined {
let cur: JsonValue | undefined = doc;
for (const seg of path) {
if (cur === null || cur === undefined) return undefined;
if (Array.isArray(cur)) {
if (typeof seg !== "number") return undefined;
cur = cur[seg];
} else if (isObject(cur)) {
cur = (cur as Record<string, JsonValue>)[String(seg)];
} else {
return undefined;
}
}
return cur;
}
// ---------------------------------------------------------------------------
// Internal: resolve parent + key
// ---------------------------------------------------------------------------
type Container = Record<string, JsonValue> | JsonValue[];
function resolveParent(
doc: JsonValue,
path: Path
): { parent: Container; key: string | number } | undefined {
// Note: callers always ensure path.length > 0 before calling resolveParent
const parentPath = path.slice(0, -1);
const key = path[path.length - 1] as string | number;
const parent = getValueAtPath(doc, parentPath);
if (parent === undefined || parent === null || isPrimitive(parent))
return undefined;
return { parent: parent as Container, key };
}
// ---------------------------------------------------------------------------
// Immutable setters / inserters / deleters
// ---------------------------------------------------------------------------
/**
* Return a new value with `value` set at `path` (immutable).
* Works for both object properties and array indices.
* The `"-"` token sets the value at `doc.length` (append for arrays).
*/
export function setValueAtPath(
doc: JsonValue,
path: Path,
value: JsonValue
): JsonValue {
const clone = deepClone(doc);
setValueAtPathMut(clone, path, value);
return clone;
}
/**
* Mutate `doc` in place, setting `value` at `path`.
*/
export function setValueAtPathMut(
doc: JsonValue,
path: Path,
value: JsonValue
): void {
if (path.length === 0) return;
const res = resolveParent(doc, path);
if (!res) return;
const { parent, key } = res;
if (Array.isArray(parent)) {
const idx = key === "-" ? parent.length : Number(key);
parent[idx] = value;
} else {
(parent as Record<string, JsonValue>)[String(key)] = value;
}
}
/**
* Return a new value with `value` inserted at `path` (immutable).
* For arrays, existing elements at `>= index` are shifted right.
* `"-"` appends to the array.
*/
export function insertValueAtPath(
doc: JsonValue,
path: Path,
value: JsonValue
): JsonValue {
const clone = deepClone(doc);
insertValueAtPathMut(clone, path, value);
return clone;
}
/**
* Mutate `doc` in place, inserting `value` at `path`.
*/
export function insertValueAtPathMut(
doc: JsonValue,
path: Path,
value: JsonValue
): void {
if (path.length === 0) return;
const res = resolveParent(doc, path);
if (!res) return;
const { parent, key } = res;
if (Array.isArray(parent)) {
const idx = key === "-" ? parent.length : Number(key);
parent.splice(idx, 0, value);
} else {
(parent as Record<string, JsonValue>)[String(key)] = value;
}
}
/**
* Return a new value with the element at `path` removed (immutable).
*/
export function deleteValueAtPath(doc: JsonValue, path: Path): JsonValue {
const clone = deepClone(doc);
deleteValueAtPathMut(clone, path);
return clone;
}
/**
* Mutate `doc` in place, removing the value at `path`.
*/
export function deleteValueAtPathMut(doc: JsonValue, path: Path): void {
if (path.length === 0) return;
const res = resolveParent(doc, path);
if (!res) return;
const { parent, key } = res;
if (Array.isArray(parent)) {
const idx = Number(key);
parent.splice(idx, 1);
} else {
delete (parent as Record<string, JsonValue>)[String(key)];
}
}

View File

@@ -0,0 +1,34 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "@suaveplan/testing/runner";
describe("dep-graph", () => {
it("has no @suaveplan/* runtime deps (leaf package)", {
timeout: 5000,
}, () => {
const pkg = JSON.parse(
readFileSync(join(__dirname, "..", "package.json"), "utf8")
) as {
dependencies?: Record<string, string>;
peerDependencies?: Record<string, string>;
};
const runtimeScoped = Object.keys(pkg.dependencies ?? {})
.filter((name) => name.startsWith("@suaveplan/"))
.sort();
expect(runtimeScoped).toEqual([]);
const peerScoped = Object.keys(pkg.peerDependencies ?? {}).filter(
(name) => name.startsWith("@suaveplan/")
);
expect(peerScoped).toEqual([]);
});
it("runtime deps are exactly the expected set (sorted)", {
timeout: 5000,
}, () => {
const pkg = JSON.parse(
readFileSync(join(__dirname, "..", "package.json"), "utf8")
) as { dependencies?: Record<string, string> };
const runtime = Object.keys(pkg.dependencies ?? {}).sort();
expect(runtime).toEqual(["@opentelemetry/api", "tslib", "zod"]);
});
});

View File

@@ -0,0 +1,41 @@
import { readdirSync, readFileSync, statSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "@suaveplan/testing/runner";
const SRC = join(__dirname, "..", "src");
const BANNED: ReadonlyArray<RegExp> = [
/\bnode:fs\b/,
/\bnode:path\b/,
/\bnode:url\b/,
/\bprocess\./,
/\bBuffer\b/,
/\bdocument\b/,
/\bwindow\b/,
];
function* walk(dir: string): Generator<string> {
for (const entry of readdirSync(dir)) {
const full = join(dir, entry);
const st = statSync(full);
if (st.isDirectory()) yield* walk(full);
else if (full.endsWith(".ts") && !full.endsWith(".test.ts")) yield full;
}
}
describe("no-disallowed", () => {
it("src/ does not reference Node or browser built-ins", {
timeout: 5000,
}, () => {
const hits: string[] = [];
for (const file of walk(SRC)) {
const content = readFileSync(file, "utf8");
for (const pattern of BANNED) {
if (pattern.test(content)) {
hits.push(`${file} matched ${pattern}`);
}
}
}
expect(hits).toEqual([]);
});
});

View File

@@ -0,0 +1 @@
export {};

View File

@@ -0,0 +1,20 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "@suaveplan/testing/runner";
describe("tree-shake", () => {
it("sideEffects is false", { timeout: 5000 }, () => {
const pkg = JSON.parse(
readFileSync(join(__dirname, "..", "package.json"), "utf8")
) as { sideEffects?: boolean };
expect(pkg.sideEffects).toBe(false);
});
it("exports has at least 8 entries", { timeout: 5000 }, () => {
const pkg = JSON.parse(
readFileSync(join(__dirname, "..", "package.json"), "utf8")
) as { exports?: Record<string, unknown> };
expect(pkg.exports).toBeDefined();
expect(Object.keys(pkg.exports ?? {}).length).toBeGreaterThanOrEqual(8);
});
});

View File

@@ -0,0 +1,12 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
"lib": ["ES2022"],
"types": ["node"]
},
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.test.ts", "src/**/*.bench.ts", "dist", "node_modules"]
}

View File

@@ -0,0 +1,20 @@
import { defineConfig } from "vite";
import dts from "vite-plugin-dts";
export default defineConfig({
root: __dirname,
cacheDir: "../../../node_modules/.vite/packages/diff",
plugins: [dts({ entryRoot: "src", tsconfigPath: "./tsconfig.lib.json", insertTypesEntry: true })],
build: {
outDir: "./dist",
emptyOutDir: true,
reportCompressedSize: true,
sourcemap: true,
lib: { entry: "src/index.ts", formats: ["es"] },
rollupOptions: {
treeshake: true,
output: { preserveModules: true, preserveModulesRoot: "src", entryFileNames: "[name].js" },
external: ["tslib", "zod", "@opentelemetry/api", /^node:/],
},
},
});

View File

@@ -0,0 +1,28 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
globals: false,
environment: "node",
pool: "forks",
setupFiles: ["./tests/setup/polyfills.ts"],
include: ["src/**/*.test.ts", "tests/**/*.test.ts"],
exclude: ["src/__benchmarks__/**", "dist/**", "node_modules/**"],
coverage: {
provider: "istanbul",
reporter: ["text", "html", "lcov"],
include: ["src/**/*.ts"],
exclude: [
"src/**/*.test.ts",
"src/**/*.bench.ts",
"src/**/*.schemas.ts",
"src/**/*.types.ts",
"src/**/index.ts",
"src/__benchmarks__/**",
"tests/**",
],
thresholds: { statements: 100, branches: 100, functions: 100, lines: 100 },
},
testTimeout: 30000,
},
});