first commit
This commit is contained in:
69
docs/foundation/data/diff/README.md
Normal file
69
docs/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
|
||||
99
docs/foundation/data/diff/src/diff/diff.md
Normal file
99
docs/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.
|
||||
116
docs/foundation/data/diff/src/errors/diff-error.md
Normal file
116
docs/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.
|
||||
132
docs/foundation/data/diff/src/history/history.md
Normal file
132
docs/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.
|
||||
134
docs/foundation/data/diff/src/operations/operations.md
Normal file
134
docs/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.
|
||||
114
docs/foundation/data/diff/src/patch/patch.md
Normal file
114
docs/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.
|
||||
129
docs/foundation/data/diff/src/path/path.md
Normal file
129
docs/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.
|
||||
141
docs/foundation/data/diff/src/sync/sync.md
Normal file
141
docs/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.
|
||||
138
docs/foundation/data/diff/src/utils/utils.md
Normal file
138
docs/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.
|
||||
Reference in New Issue
Block a user