Initial Commit
This commit is contained in:
69
packages/foundation/data/diff/README.md
Normal file
69
packages/foundation/data/diff/README.md
Normal 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
|
||||
141
packages/foundation/data/diff/package.json
Normal file
141
packages/foundation/data/diff/package.json
Normal 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"
|
||||
]
|
||||
}
|
||||
99
packages/foundation/data/diff/src/diff/diff.md
Normal file
99
packages/foundation/data/diff/src/diff/diff.md
Normal 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.
|
||||
209
packages/foundation/data/diff/src/diff/diff.test.ts
Normal file
209
packages/foundation/data/diff/src/diff/diff.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
246
packages/foundation/data/diff/src/diff/diff.ts
Normal file
246
packages/foundation/data/diff/src/diff/diff.ts
Normal 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;
|
||||
}
|
||||
1
packages/foundation/data/diff/src/diff/index.ts
Normal file
1
packages/foundation/data/diff/src/diff/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { type DiffOptions, diff, lcs } from "./diff";
|
||||
116
packages/foundation/data/diff/src/errors/diff-error.md
Normal file
116
packages/foundation/data/diff/src/errors/diff-error.md
Normal 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.
|
||||
62
packages/foundation/data/diff/src/errors/diff-error.ts
Normal file
62
packages/foundation/data/diff/src/errors/diff-error.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
6
packages/foundation/data/diff/src/errors/index.ts
Normal file
6
packages/foundation/data/diff/src/errors/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export {
|
||||
DIFF_CODES,
|
||||
type DiffCode,
|
||||
DiffError,
|
||||
type DiffErrorContext,
|
||||
} from "./diff-error";
|
||||
132
packages/foundation/data/diff/src/history/history.md
Normal file
132
packages/foundation/data/diff/src/history/history.md
Normal 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.
|
||||
231
packages/foundation/data/diff/src/history/history.test.ts
Normal file
231
packages/foundation/data/diff/src/history/history.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
158
packages/foundation/data/diff/src/history/history.ts
Normal file
158
packages/foundation/data/diff/src/history/history.ts
Normal 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);
|
||||
}
|
||||
6
packages/foundation/data/diff/src/history/index.ts
Normal file
6
packages/foundation/data/diff/src/history/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export {
|
||||
createHistory,
|
||||
History,
|
||||
type HistoryOptions,
|
||||
type HistoryState,
|
||||
} from "./history";
|
||||
71
packages/foundation/data/diff/src/index.ts
Normal file
71
packages/foundation/data/diff/src/index.ts
Normal 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";
|
||||
21
packages/foundation/data/diff/src/operations/index.ts
Normal file
21
packages/foundation/data/diff/src/operations/index.ts
Normal 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";
|
||||
134
packages/foundation/data/diff/src/operations/operations.md
Normal file
134
packages/foundation/data/diff/src/operations/operations.md
Normal 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.
|
||||
384
packages/foundation/data/diff/src/operations/operations.test.ts
Normal file
384
packages/foundation/data/diff/src/operations/operations.test.ts
Normal 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));
|
||||
});
|
||||
});
|
||||
335
packages/foundation/data/diff/src/operations/operations.ts
Normal file
335
packages/foundation/data/diff/src/operations/operations.ts
Normal 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;
|
||||
}
|
||||
8
packages/foundation/data/diff/src/patch/index.ts
Normal file
8
packages/foundation/data/diff/src/patch/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export {
|
||||
_getOrSetTracer,
|
||||
_resetTracer,
|
||||
applyPatch,
|
||||
type PatchOptions,
|
||||
type PatchResult,
|
||||
revertPatch,
|
||||
} from "./patch";
|
||||
114
packages/foundation/data/diff/src/patch/patch.md
Normal file
114
packages/foundation/data/diff/src/patch/patch.md
Normal 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.
|
||||
353
packages/foundation/data/diff/src/patch/patch.test.ts
Normal file
353
packages/foundation/data/diff/src/patch/patch.test.ts
Normal 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 });
|
||||
});
|
||||
});
|
||||
239
packages/foundation/data/diff/src/patch/patch.ts
Normal file
239
packages/foundation/data/diff/src/patch/patch.ts
Normal 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;
|
||||
}
|
||||
14
packages/foundation/data/diff/src/path/index.ts
Normal file
14
packages/foundation/data/diff/src/path/index.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
export {
|
||||
appendPath,
|
||||
comparePaths,
|
||||
concatPaths,
|
||||
escapeSegment,
|
||||
formatPath,
|
||||
isPrefix,
|
||||
lastSegment,
|
||||
type Path,
|
||||
parentPath,
|
||||
parsePath,
|
||||
pathEquals,
|
||||
unescapeSegment,
|
||||
} from "./path";
|
||||
129
packages/foundation/data/diff/src/path/path.md
Normal file
129
packages/foundation/data/diff/src/path/path.md
Normal 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.
|
||||
223
packages/foundation/data/diff/src/path/path.test.ts
Normal file
223
packages/foundation/data/diff/src/path/path.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
132
packages/foundation/data/diff/src/path/path.ts
Normal file
132
packages/foundation/data/diff/src/path/path.ts
Normal 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];
|
||||
}
|
||||
6
packages/foundation/data/diff/src/sync/index.ts
Normal file
6
packages/foundation/data/diff/src/sync/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export {
|
||||
createSync,
|
||||
type SyncDelta,
|
||||
SyncManager,
|
||||
type SyncOptions,
|
||||
} from "./sync";
|
||||
141
packages/foundation/data/diff/src/sync/sync.md
Normal file
141
packages/foundation/data/diff/src/sync/sync.md
Normal 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.
|
||||
187
packages/foundation/data/diff/src/sync/sync.test.ts
Normal file
187
packages/foundation/data/diff/src/sync/sync.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
194
packages/foundation/data/diff/src/sync/sync.ts
Normal file
194
packages/foundation/data/diff/src/sync/sync.ts
Normal 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);
|
||||
}
|
||||
15
packages/foundation/data/diff/src/utils/index.ts
Normal file
15
packages/foundation/data/diff/src/utils/index.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
export {
|
||||
deepClone,
|
||||
deepEqual,
|
||||
deleteValueAtPath,
|
||||
deleteValueAtPathMut,
|
||||
getValueAtPath,
|
||||
hashValue,
|
||||
insertValueAtPath,
|
||||
insertValueAtPathMut,
|
||||
isObject,
|
||||
isPrimitive,
|
||||
type JsonValue,
|
||||
setValueAtPath,
|
||||
setValueAtPathMut,
|
||||
} from "./utils";
|
||||
138
packages/foundation/data/diff/src/utils/utils.md
Normal file
138
packages/foundation/data/diff/src/utils/utils.md
Normal 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.
|
||||
375
packages/foundation/data/diff/src/utils/utils.test.ts
Normal file
375
packages/foundation/data/diff/src/utils/utils.test.ts
Normal 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}$/);
|
||||
});
|
||||
});
|
||||
253
packages/foundation/data/diff/src/utils/utils.ts
Normal file
253
packages/foundation/data/diff/src/utils/utils.ts
Normal 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)];
|
||||
}
|
||||
}
|
||||
34
packages/foundation/data/diff/tests/dep-graph.test.ts
Normal file
34
packages/foundation/data/diff/tests/dep-graph.test.ts
Normal 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"]);
|
||||
});
|
||||
});
|
||||
41
packages/foundation/data/diff/tests/no-disallowed.test.ts
Normal file
41
packages/foundation/data/diff/tests/no-disallowed.test.ts
Normal 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([]);
|
||||
});
|
||||
});
|
||||
1
packages/foundation/data/diff/tests/setup/polyfills.ts
Normal file
1
packages/foundation/data/diff/tests/setup/polyfills.ts
Normal file
@@ -0,0 +1 @@
|
||||
export {};
|
||||
20
packages/foundation/data/diff/tests/tree-shake.test.ts
Normal file
20
packages/foundation/data/diff/tests/tree-shake.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
12
packages/foundation/data/diff/tsconfig.lib.json
Normal file
12
packages/foundation/data/diff/tsconfig.lib.json
Normal 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"]
|
||||
}
|
||||
20
packages/foundation/data/diff/vite.config.ts
Normal file
20
packages/foundation/data/diff/vite.config.ts
Normal 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:/],
|
||||
},
|
||||
},
|
||||
});
|
||||
28
packages/foundation/data/diff/vitest.config.ts
Normal file
28
packages/foundation/data/diff/vitest.config.ts
Normal 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,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user