From 10f1e44463e29b7832779a1e3ce881c66392def0 Mon Sep 17 00:00:00 2001 From: Miguel Ramos Date: Fri, 17 Apr 2026 09:22:25 +0100 Subject: [PATCH 01/10] docs: add comprehensive JSDoc to all public and internal functions Document every exported type, interface, and function across all modules with JSDoc annotations including parameter descriptions, return types, usage examples, and cross-references via @link tags. Covers: diff.ts, patch.ts, utils.ts, types.ts, errors.ts, lcs.ts, rfc6902.ts --- src/diff.ts | 128 +++++++++++++++++++++++++++++++++++++++++++++++++ src/errors.ts | 17 +++++++ src/lcs.ts | 10 +++- src/patch.ts | 92 +++++++++++++++++++++++++++++++++++ src/rfc6902.ts | 8 ++++ src/types.ts | 63 +++++++++++++++++++++++- src/utils.ts | 69 +++++++++++++++++++++++++- 7 files changed, 381 insertions(+), 6 deletions(-) diff --git a/src/diff.ts b/src/diff.ts index ecdec91..4e2231b 100644 --- a/src/diff.ts +++ b/src/diff.ts @@ -21,6 +21,15 @@ import { cloneDeep, deepEqual, isArray, isObject, joinPath } from './utils.js'; // ── Option resolution ───────────────────────── +/** + * Compile an {@link Identity} descriptor into a callable {@link IdentityFn}. + * + * - `string` → single-key getter (e.g. `'id'` → `item => item.id`) + * - `string[]` → composite-key getter joined with `::` separator + * - `function` → returned as-is + * + * Primitive (non-object) items always resolve to the sentinel `'__primitive__'`. + */ function resolveIdentityFn(identity: Identity): IdentityFn { if (typeof identity === 'function') return identity; @@ -40,6 +49,12 @@ function resolveIdentityFn(identity: Identity): IdentityFn { }; } +/** + * Normalise raw {@link DiffOptions} into a {@link ResolvedOptions} struct. + * + * Compiles identity descriptors into callable functions, builds the ignore + * set and prefix list, and applies defaults for every optional field. + */ function resolveOptions(opts: DiffOptions = {}): ResolvedOptions { const rawIdentity = opts.arrayIdentity; let getIdentity: ResolvedOptions['getIdentity']; @@ -89,6 +104,13 @@ function maybeClone(val: T, opts: ResolvedOptions): T { // ── Path filtering ──────────────────────────── +/** + * Check whether `path` should be skipped during diffing. + * + * A path is ignored when it matches an entry in `opts.ignore` (exact) or + * when it falls under any prefix listed in `opts.ignorePrefix` (the `/*` + * syntax from {@link DiffOptions.ignore}). + */ function shouldIgnore(path: string, opts: ResolvedOptions): boolean { if (opts.ignore.has(path)) return true; for (const prefix of opts.ignorePrefix) { @@ -99,6 +121,46 @@ function shouldIgnore(path: string, opts: ResolvedOptions): boolean { // ── Main diff entry point ───────────────────── +/** + * Compute the structural diff between two JSON values. + * + * Returns a {@link DiffResult} containing an ordered list of operations that, + * when applied via {@link patch}, transform `before` into `after`. + * + * @param before - The source (original) JSON value. + * @param after - The target (modified) JSON value. + * @param options - Optional {@link DiffOptions} to control identity resolution, + * equality, move detection, recursion depth, path ignoring, + * and value cloning. + * @returns A {@link DiffResult} with operations, summary counters, and changed paths. + * + * @example + * ```ts + * import { diff } from '@websublime/delta'; + * + * const before = { name: 'Alice', age: 30 }; + * const after = { name: 'Bob', age: 30, role: 'admin' }; + * + * const result = diff(before, after); + * // result.operations → [ + * // { op: 'replace', path: '/name', value: 'Bob', oldValue: 'Alice' }, + * // { op: 'add', path: '/role', value: 'admin' } + * // ] + * // result.summary → { added: 1, removed: 0, replaced: 1, moved: 0, … } + * ``` + * + * @example Identity-based array diff + * ```ts + * const before = { items: [{ id: 1, v: 'a' }, { id: 2, v: 'b' }] }; + * const after = { items: [{ id: 2, v: 'b' }, { id: 1, v: 'a' }] }; + * + * const result = diff(before, after, { arrayIdentity: 'id' }); + * // result.operations → [ + * // { op: 'move', path: '/items', fromIndex: 0, toIndex: 1, … }, + * // { op: 'move', path: '/items', fromIndex: 1, toIndex: 0, … } + * // ] + * ``` + */ export function diff(before: JsonValue, after: JsonValue, options?: DiffOptions): DiffResult { const opts = resolveOptions(options); const ops: DiffOp[] = []; @@ -108,6 +170,13 @@ export function diff(before: JsonValue, after: JsonValue, options?: DiffOptions) return buildResult(ops); } +/** + * Aggregate a flat list of {@link DiffOp} into a {@link DiffResult}. + * + * Counts operations by type, builds the {@link DiffSummary}, collects all + * touched paths into `changedPaths`, and detects `movedAndChanged` entries + * by comparing `value` and `oldValue` on move operations. + */ function buildResult(ops: DiffOp[]): DiffResult { const summary: DiffSummary = { added: 0, @@ -151,6 +220,20 @@ function buildResult(ops: DiffOp[]): DiffResult { // ── Recursive value diff ────────────────────── +/** + * Recursively diff two JSON values and push resulting operations into `out`. + * + * Dispatches to {@link diffObjects}, {@link diffArrays}, or emits a `replace` + * operation depending on the type pair. Respects `maxDepth` — at the limit, + * sub-trees are compared as opaque blobs via `equal()`. + * + * @param before - Source value. + * @param after - Target value. + * @param path - Current RFC 6901 JSON Pointer path. + * @param opts - Resolved diff options. + * @param depth - Current recursion depth (0-based). + * @param out - Accumulator for emitted operations. + */ function diffValues( before: JsonValue, after: JsonValue, @@ -189,6 +272,13 @@ function diffValues( // ── Object diff ─────────────────────────────── +/** + * Diff two plain JSON objects by their keys. + * + * Treats `undefined` values as absent (JSON semantics). For each key present + * in either side, emits `remove`, `add`, or recurses via {@link diffValues} + * for keys present in both. + */ function diffObjects( before: JsonObject, after: JsonObject, @@ -225,6 +315,13 @@ function diffObjects( // ── Array diff ──────────────────────────────── +/** + * Diff two JSON arrays, routing to the appropriate strategy. + * + * If an identity resolver is configured for this array path (checked by + * probing the first available item), delegates to {@link diffArraysByIdentity}. + * Otherwise falls back to the positional {@link diffArraysByLCS} algorithm. + */ function diffArrays( before: JsonValue[], after: JsonValue[], @@ -247,10 +344,18 @@ function diffArrays( // ── Identity-based array diff ───────────────── +/** + * An entry in the identity map used by {@link diffArraysByIdentity}. + * + * The `key` is a composite string `"${rawId}:${occurrence}"` that uniquely + * identifies each array item — even when duplicate raw identities exist. + */ interface IdentityEntry { /** Composite key = `${rawId}:${occurrence}`. Deterministic for duplicates. */ key: string; + /** The array item value. */ item: JsonValue; + /** The item's position in the source or target array. */ index: number; } @@ -279,6 +384,20 @@ function buildIdentityMap( return map; } +/** + * Diff two arrays using identity-based matching. + * + * Builds identity maps for both sides, then classifies items into three groups: + * 1. **Removed** — present only in `before`. + * 2. **Matched** — present in both. May have moved (`fromIndex !== toIndex`) + * and/or changed (values differ). Emits `move` or nested diff ops. + * 3. **Added** — present only in `after`. + * + * When `detectMoves` is `false`, reorders are decomposed into remove + add + * pairs instead of `move` operations. + * + * Emission order: removes (desc index) → moves (asc toIndex) → nested → adds (asc index). + */ function diffArraysByIdentity( before: JsonValue[], after: JsonValue[], @@ -389,6 +508,15 @@ function diffArraysByIdentity( // ── LCS-based (positional) array diff ───────── +/** + * Diff two arrays using the Longest Common Subsequence (LCS) algorithm. + * + * Used when no identity resolver is configured for the array path. Items + * are matched purely by position and equality. Does not emit `move` ops — + * reorders appear as a combination of removes and adds. + * + * Emission order: removes (desc index) → nested diffs on kept items → adds (asc index). + */ function diffArraysByLCS( before: JsonValue[], after: JsonValue[], diff --git a/src/errors.ts b/src/errors.ts index 74966d1..f1b3a90 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -10,6 +10,12 @@ export class DeltaError extends Error { public readonly code: DeltaErrorCode; public readonly path: string | undefined; + /** + * @param code - Machine-readable error code from {@link DeltaErrorCode}. + * @param message - Human-readable description of the failure. + * @param path - Optional RFC 6901 JSON Pointer where the error occurred. + * When provided, it is appended to the `message` for diagnostics. + */ constructor(code: DeltaErrorCode, message: string, path?: string) { super(path !== undefined ? `${message} (at ${path || ''})` : message); this.name = 'DeltaError'; @@ -20,6 +26,17 @@ export class DeltaError extends Error { } } +/** + * Machine-readable error codes emitted by the delta library. + * + * Use these to programmatically handle specific failure modes: + * ```ts + * try { patch(doc, result); } + * catch (e) { + * if (e instanceof DeltaError && e.code === 'PATH_NOT_FOUND') { … } + * } + * ``` + */ export type DeltaErrorCode = /** A cyclic reference was encountered while traversing the document. */ | 'CIRCULAR_REFERENCE' diff --git a/src/lcs.ts b/src/lcs.ts index dc673dc..7ce7767 100644 --- a/src/lcs.ts +++ b/src/lcs.ts @@ -5,10 +5,16 @@ import type { JsonValue } from './types.js'; +/** + * A matched index pair from the Longest Common Subsequence computation. + * + * Represents a single element that appears in both the `before` and `after` + * arrays at the given positions. + */ export interface LCSMatch { - /** Index in the `before` array */ + /** Index of the matched element in the `before` (source) array. */ aIndex: number; - /** Index in the `after` array */ + /** Index of the matched element in the `after` (target) array. */ bIndex: number; } diff --git a/src/patch.ts b/src/patch.ts index 860fa66..171306f 100644 --- a/src/patch.ts +++ b/src/patch.ts @@ -37,6 +37,17 @@ function validateDiffResult(result: unknown): asserts result is DiffResult { } } +/** + * Validate that a single operation object is well-formed. + * + * Checks for the presence of required fields based on `op.op`: + * - `add` → `value` + * - `remove` → `oldValue` + * - `replace` → `value` + `oldValue` + * - `move` → `fromIndex` + `toIndex` + `value` + `oldValue` + * + * @throws {@link DeltaError} with code `INVALID_OPERATION` on any structural issue. + */ function validateOp(op: unknown, index: number): asserts op is DiffOp { if (!isObject(op as JsonValue)) { throw new DeltaError('INVALID_OPERATION', `operations[${index}] must be an object`); @@ -92,6 +103,15 @@ function validateOp(op: unknown, index: number): asserts op is DiffOp { // ── Internal helpers ────────────────────────── +/** + * Navigate to the parent container and last key of a given JSON Pointer path. + * + * @param root - The document root. + * @param path - An RFC 6901 JSON Pointer (must have at least one segment). + * @returns An object with `parent` (the container) and `key` (the last segment). + * @throws {@link DeltaError} with code `INVALID_OPERATION` for root paths, + * or `PATH_NOT_FOUND` when an intermediate node is not an object/array. + */ function getParent(root: JsonValue, path: string): { parent: JsonValue; key: string } { const segs = splitPath(path); if (segs.length === 0) { @@ -107,6 +127,15 @@ function getParent(root: JsonValue, path: string): { parent: JsonValue; key: str return { parent: cur, key: segs[segs.length - 1] }; } +/** + * Apply an `add` operation on the document. + * + * For array parents, inserts at the numeric index (or appends when the key + * is `'-'`). For object parents, sets the key via {@link safeSet}. + * The value is deep-cloned before insertion. + * + * @throws {@link DeltaError} on invalid index or non-container parent. + */ function applyAdd(root: JsonValue, path: string, value: JsonValue): void { const { parent, key } = getParent(root, path); if (isArray(parent)) { @@ -122,6 +151,14 @@ function applyAdd(root: JsonValue, path: string, value: JsonValue): void { } } +/** + * Apply a `remove` operation on the document. + * + * For array parents, splices out the element at the numeric index. + * For object parents, deletes the key. + * + * @throws {@link DeltaError} on invalid index or non-container parent. + */ function applyRemove(root: JsonValue, path: string): void { const { parent, key } = getParent(root, path); if (isArray(parent)) { @@ -137,6 +174,13 @@ function applyRemove(root: JsonValue, path: string): void { } } +/** + * Apply a `replace` operation on the document. + * + * Overwrites the value at `path` with a deep clone of `value`. + * + * @throws {@link DeltaError} on invalid index or non-container parent. + */ function applyReplace(root: JsonValue, path: string, value: JsonValue): void { const { parent, key } = getParent(root, path); if (isArray(parent)) { @@ -152,6 +196,17 @@ function applyReplace(root: JsonValue, path: string, value: JsonValue): void { } } +/** + * Resolve a JSON Pointer path to the value it references in the document. + * + * Returns the root when `path` is `''`. Throws when an intermediate + * segment points to a non-container (null or primitive). + * + * @param root - The document root. + * @param path - An RFC 6901 JSON Pointer. + * @returns The referenced value. + * @throws {@link DeltaError} with code `PATH_NOT_FOUND`. + */ function getNodeRef(root: JsonValue, path: string): JsonValue { if (path === '') return root; const segs = splitPath(path); @@ -165,11 +220,22 @@ function getNodeRef(root: JsonValue, path: string): JsonValue { return cur; } +/** + * Resolve a JSON Pointer path and return the value only if it is an array. + * + * @returns The array at `path`, or `null` when the value is not an array. + */ function getArrayRef(root: JsonValue, path: string): JsonValue[] | null { const node = getNodeRef(root, path); return isArray(node) ? node : null; } +/** + * Replace the value at `path` in the document with `value`. + * + * Navigates to the parent container and sets the last segment key. + * Used internally to swap out reconstructed arrays after move operations. + */ function setNodeRef(root: JsonValue, path: string, value: JsonValue[]): void { const { parent, key } = getParent(root, path); if (isArray(parent)) { @@ -292,13 +358,29 @@ function reconstructArrayReverse( // ── Group ops by array path ─────────────────── +/** + * A group of operations that all target the same array path. + * + * Used by {@link groupArrayOps} to batch operations for array + * reconstruction instead of sequential splice application. + */ interface ArrayOpGroup { + /** Move operations within this array. */ moves: OpMove[]; + /** Add operations targeting child positions of this array. */ adds: OpAdd[]; + /** Remove operations targeting child positions of this array. */ removes: OpRemove[]; + /** Replace operations targeting child positions of this array. */ replaces: OpReplace[]; } +/** + * Extract the parent path from a JSON Pointer. + * + * @returns The parent path, `''` for root-level paths, or `null` for the + * root pointer itself. + */ function parentOf(path: string): string | null { const segs = splitPath(path); if (segs.length === 0) return null; @@ -306,6 +388,16 @@ function parentOf(path: string): string | null { return `/${segs.slice(0, -1).join('/')}`; } +/** + * Group operations by the array path they belong to. + * + * Only arrays that contain at least one `move` operation are grouped. + * For each such array, all child add/remove/replace operations (whose + * parent path matches the array) are collected into the same + * {@link ArrayOpGroup}. + * + * @returns A map from array path to its grouped operations. + */ function groupArrayOps(operations: DiffOp[]): Map { const arrayPathsWithMoves = new Set(); for (const op of operations) { diff --git a/src/rfc6902.ts b/src/rfc6902.ts index 423218c..c77a2cf 100644 --- a/src/rfc6902.ts +++ b/src/rfc6902.ts @@ -8,36 +8,43 @@ import { joinPath } from './utils.js'; // ── RFC 6902 types ──────────────────────────── +/** RFC 6902 `add` operation — inserts `value` at `path`. */ export interface RFC6902Add { op: 'add'; path: string; value: JsonValue; } +/** RFC 6902 `remove` operation — deletes the value at `path`. */ export interface RFC6902Remove { op: 'remove'; path: string; } +/** RFC 6902 `replace` operation — replaces the value at `path` with `value`. */ export interface RFC6902Replace { op: 'replace'; path: string; value: JsonValue; } +/** RFC 6902 `move` operation — moves the value from `from` to `path`. */ export interface RFC6902Move { op: 'move'; from: string; path: string; } +/** RFC 6902 `copy` operation — copies the value from `from` to `path`. */ export interface RFC6902Copy { op: 'copy'; from: string; path: string; } +/** RFC 6902 `test` operation — asserts the value at `path` equals `value`. */ export interface RFC6902Test { op: 'test'; path: string; value: JsonValue; } +/** Discriminated union of all RFC 6902 operation types. */ export type RFC6902Op = | RFC6902Add | RFC6902Remove @@ -46,6 +53,7 @@ export type RFC6902Op = | RFC6902Copy | RFC6902Test; +/** An ordered list of RFC 6902 operations forming a complete JSON Patch document. */ export type RFC6902Patch = RFC6902Op[]; // ── Conversion ──────────────────────────────── diff --git a/src/types.ts b/src/types.ts index c92edde..3e9d1ac 100644 --- a/src/types.ts +++ b/src/types.ts @@ -3,9 +3,19 @@ // Types & interfaces // ───────────────────────────────────────────── +/** JSON primitive value: string, number, boolean, or null. */ export type JsonPrimitive = string | number | boolean | null; + +/** + * Any valid JSON value: a primitive, an object, or an array. + * This is the top-level value type used throughout the diff engine. + */ export type JsonValue = JsonPrimitive | JsonObject | JsonArray; + +/** A plain JSON object with string keys and JSON values. */ export type JsonObject = { [key: string]: JsonValue }; + +/** A JSON array of arbitrary JSON values. */ export type JsonArray = JsonValue[]; // ── Operations ──────────────────────────────── @@ -46,27 +56,65 @@ export interface OpMove { oldValue: JsonValue; // original value (may differ if item also changed) } +/** + * Discriminated union of all diff operation types. + * + * Narrow on `op.op` to access type-specific fields: + * ```ts + * for (const op of result.operations) { + * switch (op.op) { + * case 'add': // op is OpAdd + * case 'remove': // op is OpRemove + * case 'replace': // op is OpReplace + * case 'move': // op is OpMove + * } + * } + * ``` + */ export type DiffOp = OpAdd | OpRemove | OpReplace | OpMove; // ── Summary ─────────────────────────────────── +/** + * Aggregate counters for a diff, broken down by operation type. + * + * Every field counts the number of operations of that kind. `total` is + * the sum of all operation counts. + */ export interface DiffSummary { + /** Number of `add` operations. */ added: number; + /** Number of `remove` operations. */ removed: number; + /** Number of `replace` operations. */ replaced: number; + /** Number of `move` operations (including those that also changed). */ moved: number; - /** moves that also carried value changes */ + /** Moves where `value !== oldValue` — the item changed while being reordered. */ movedAndChanged: number; + /** Total number of operations across all types. */ total: number; } // ── Result ──────────────────────────────────── +/** + * The result of a {@link diff} call. + * + * Contains the full list of operations, a statistical summary, and a set of + * changed paths for quick membership tests. + */ export interface DiffResult { + /** `true` when at least one operation was emitted. */ hasChanges: boolean; + /** Ordered list of diff operations. */ operations: DiffOp[]; + /** Aggregate counters broken down by operation type. */ summary: DiffSummary; - /** Paths that were changed (for quick lookup) */ + /** + * Set of JSON Pointer paths that were touched by at least one operation. + * Useful for quick `has()` look-ups without scanning the operations array. + */ changedPaths: Set; } @@ -149,12 +197,23 @@ export interface DiffOptions { // ── Internal resolved options ───────────────── +/** + * Normalised and compiled form of {@link DiffOptions}, used internally by the + * diff engine. Produced by `resolveOptions()` in `diff.ts`. + */ export interface ResolvedOptions { + /** Compiled identity resolver — returns `null` when the path has no identity. */ getIdentity: (path: string, item: JsonValue, index: number) => string | number | null; + /** Equality function used for value comparison. */ equal: (a: JsonValue, b: JsonValue) => boolean; + /** Whether to emit `move` operations for reordered identity-keyed items. */ detectMoves: boolean; + /** Maximum recursion depth before treating sub-trees as opaque blobs. */ maxDepth: number; + /** Exact paths to ignore. */ ignore: Set; + /** Path prefixes to ignore (derived from `ignore` entries ending with `/*`). */ ignorePrefix: string[]; + /** Whether to deep-clone values stored on emitted operations. */ cloneValues: boolean; } diff --git a/src/utils.ts b/src/utils.ts index 9e10436..bf93ace 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -7,10 +7,22 @@ import type { JsonObject, JsonValue } from './types.js'; // ── Type guards ─────────────────────────────── +/** + * Check whether `val` is a plain JSON object (non-null, non-array object). + * + * @param val - The value to test. + * @returns `true` when `val` is a {@link JsonObject}. + */ export function isObject(val: unknown): val is JsonObject { return val !== null && typeof val === 'object' && !Array.isArray(val); } +/** + * Check whether `val` is an array of {@link JsonValue}. + * + * @param val - The value to test. + * @returns `true` when `val` is an array. + */ export function isArray(val: unknown): val is JsonValue[] { return Array.isArray(val); } @@ -30,6 +42,13 @@ export function deepEqual(a: JsonValue, b: JsonValue): boolean { return deepEqualInner(a, b, new WeakSet(), new WeakSet()); } +/** + * Inner recursive worker for {@link deepEqual}. + * + * Carries two `WeakSet`s to track visited object references (one per + * argument side) and throws {@link DeltaError} with code + * `CIRCULAR_REFERENCE` when a cycle is detected. + */ function deepEqualInner( a: JsonValue, b: JsonValue, @@ -81,22 +100,61 @@ function deepEqualInner( // ── JSON Pointer (RFC 6901) ─────────────────── +/** + * Escape a single path segment according to RFC 6901. + * + * `~` → `~0`, `/` → `~1`. Order matters: tilde is escaped first to avoid + * double-encoding. + */ function escapeSegment(seg: string): string { return seg.replace(/~/g, '~0').replace(/\//g, '~1'); } +/** + * Unescape a single RFC 6901 path segment. + * + * `~1` → `/`, `~0` → `~`. Reversal order matters: slash is unescaped first + * to avoid double-decoding. + */ function unescapeSegment(seg: string): string { return seg.replace(/~1/g, '/').replace(/~0/g, '~'); } -/** Append one or more segments to a base path. */ +/** + * Append one or more segments to a base JSON Pointer path. + * + * Each segment is escaped per RFC 6901 before being appended. Numeric + * segments are stringified automatically. + * + * @param base - The base path (e.g. `''` for root, or `'/items'`). + * @param segs - One or more segments to append. + * @returns The concatenated RFC 6901 path. + * + * @example + * ```ts + * joinPath('', 'users', 0, 'name') // → '/users/0/name' + * ``` + */ export function joinPath(base: string, ...segs: (string | number)[]): string { return segs.reduce((acc, seg) => { return `${acc}/${escapeSegment(String(seg))}`; }, base); } -/** Split a JSON Pointer into unescaped segments. */ +/** + * Split an RFC 6901 JSON Pointer into its unescaped segments. + * + * The empty string `''` (root pointer) returns an empty array. + * + * @param path - An RFC 6901 JSON Pointer (e.g. `'/users/0/name'`). + * @returns Array of unescaped path segments. + * + * @example + * ```ts + * splitPath('/users/0/name') // → ['users', '0', 'name'] + * splitPath('') // → [] + * ``` + */ export function splitPath(path: string): string[] { if (path === '') return []; return path.slice(1).split('/').map(unescapeSegment); @@ -135,6 +193,13 @@ export function cloneDeep(val: T): T { return cloneDeepInner(val, new WeakSet()) as T; } +/** + * Inner recursive worker for {@link cloneDeep}. + * + * Tracks visited references in a `WeakSet` and throws {@link DeltaError} + * with code `CIRCULAR_REFERENCE` when a cycle is detected. Object keys + * with `undefined` values are dropped to maintain JSON semantics. + */ function cloneDeepInner(val: JsonValue, seen: WeakSet): JsonValue { if (val === null || typeof val !== 'object') return val; From 4fa60dbe0ee54a0c9bf5595c14ae5c88683bb90c Mon Sep 17 00:00:00 2001 From: Miguel Ramos Date: Fri, 17 Apr 2026 09:22:58 +0100 Subject: [PATCH 02/10] feat(changes): add changes() and changesFromDiff() for sparse change extraction Add a new public API to extract a sparse object containing only the values that changed between two documents. Returns a ChangesResult with: - updated: sparse nested object with added/replaced/moved values - removed: array of RFC 6901 paths that were deleted - diff: the full DiffResult for low-level access Useful for HTTP PATCH payloads, form dirty tracking, audit logs, and optimistic UI updates where only the delta is needed. --- src/changes.ts | 186 +++++++++++++++++++++++++++++++++++++++++++++++++ src/index.ts | 21 +++++- src/types.ts | 46 ++++++++++++ 3 files changed, 250 insertions(+), 3 deletions(-) create mode 100644 src/changes.ts diff --git a/src/changes.ts b/src/changes.ts new file mode 100644 index 0000000..59fa477 --- /dev/null +++ b/src/changes.ts @@ -0,0 +1,186 @@ +// ───────────────────────────────────────────── +// delta:// — sparse change extraction +// Build a minimal object containing only what changed +// ───────────────────────────────────────────── + +import { diff } from './diff.js'; +import type { + ChangesResult, + DiffOptions, + DiffResult, + JsonObject, + JsonValue, + OpReplace, +} from './types.js'; +import { isObject, splitPath } from './utils.js'; + +// ── Public API ─────────────────────────────── + +/** + * Compute a sparse representation of the changes between two JSON values. + * + * Internally calls {@link diff} and then projects the result into a + * {@link ChangesResult} with: + * - `updated` — a sparse object holding only added, replaced, and moved values + * (preserves nested structure). + * - `removed` — an array of RFC 6901 paths that were deleted. + * - `diff` — the full {@link DiffResult} for low-level access. + * + * @param before - The source (original) JSON value. + * @param after - The target (modified) JSON value. + * @param options - Optional {@link DiffOptions} forwarded to {@link diff}. + * @returns A {@link ChangesResult} describing what changed. + * + * @example Simple object changes + * ```ts + * import { changes } from '@websublime/delta'; + * + * const before = { name: 'Alice', age: 30, email: 'alice@example.com' }; + * const after = { name: 'Bob', age: 30, role: 'admin' }; + * + * const result = changes(before, after); + * result.updated // → { name: 'Bob', role: 'admin' } + * result.removed // → ['/email'] + * ``` + * + * @example Nested changes + * ```ts + * const before = { user: { name: 'Alice', settings: { theme: 'dark', lang: 'en' } } }; + * const after = { user: { name: 'Alice', settings: { theme: 'light', lang: 'en' } } }; + * + * const result = changes(before, after); + * result.updated // → { user: { settings: { theme: 'light' } } } + * result.removed // → [] + * ``` + * + * @example Array with identity-based diff + * ```ts + * const before = { items: [{ id: 1, v: 'a' }, { id: 2, v: 'b' }] }; + * const after = { items: [{ id: 2, v: 'b' }, { id: 1, v: 'x' }] }; + * + * const result = changes(before, after, { arrayIdentity: 'id' }); + * // result.updated includes moved items at their new positions + * ``` + */ +export function changes( + before: JsonValue, + after: JsonValue, + options?: DiffOptions, +): ChangesResult { + const result = diff(before, after, options); + return changesFromDiff(result); +} + +/** + * Extract a sparse changes representation from an existing {@link DiffResult}. + * + * Useful when the diff has already been computed and you want the sparse + * changes object without re-diffing. + * + * @param result - A previously computed {@link DiffResult}. + * @returns A {@link ChangesResult} derived from the operations in `result`. + * + * @example + * ```ts + * import { diff, changesFromDiff } from '@websublime/delta'; + * + * const result = diff(before, after, options); + * // ... inspect result.operations ... + * + * const sparse = changesFromDiff(result); + * sparse.updated // only the values that were set + * sparse.removed // only the paths that were deleted + * ``` + */ +export function changesFromDiff(result: DiffResult): ChangesResult { + if (!result.hasChanges) { + return { hasChanges: false, updated: null, removed: [], diff: result }; + } + + // Root-level replace short-circuits — the entire document changed. + const rootReplace = result.operations.find( + (op): op is OpReplace => op.op === 'replace' && op.path === '', + ); + if (rootReplace) { + return { hasChanges: true, updated: rootReplace.value, removed: [], diff: result }; + } + + const removed: string[] = []; + const entries: SparseEntry[] = []; + + for (const op of result.operations) { + switch (op.op) { + case 'add': + entries.push({ segments: splitPath(op.path), value: op.value }); + break; + case 'replace': + entries.push({ segments: splitPath(op.path), value: op.value }); + break; + case 'remove': + removed.push(op.path); + break; + case 'move': + // Include the value at its destination index. + entries.push({ + segments: [...splitPath(op.path), String(op.toIndex)], + value: op.value, + }); + break; + } + } + + const updated = buildSparseObject(entries); + const hasUpdatedKeys = Object.keys(updated).length > 0; + + return { + hasChanges: true, + updated: hasUpdatedKeys ? updated : null, + removed, + diff: result, + }; +} + +// ── Internal helpers ───────────────────────── + +/** + * A path/value pair used to construct the sparse output object. + * `segments` is the already-split (unescaped) JSON Pointer path. + */ +interface SparseEntry { + /** Unescaped path segments (output of {@link splitPath}). */ + segments: string[]; + /** The value to place at this path. */ + value: JsonValue; +} + +/** + * Build a sparse nested object from a list of path/value entries. + * + * Intermediate containers are always plain objects — even when the path + * segment is numeric (array indices become string keys). This keeps the + * output JSON-serialisable and avoids sparse `Array` holes. + * + * @param entries - Path/value pairs to insert into the sparse tree. + * @returns A {@link JsonObject} containing only the provided paths. + */ +function buildSparseObject(entries: SparseEntry[]): JsonObject { + const root: JsonObject = {}; + + for (const { segments, value } of entries) { + if (segments.length === 0) continue; + + let current: JsonObject = root; + + for (let i = 0; i < segments.length - 1; i++) { + const seg = segments[i]; + if (!isObject(current[seg] as JsonValue)) { + current[seg] = {} as JsonObject; + } + current = current[seg] as JsonObject; + } + + current[segments[segments.length - 1]] = value; + } + + return root; +} diff --git a/src/index.ts b/src/index.ts index 13bbdde..5cd3008 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,9 +3,15 @@ // @websublime/delta // ───────────────────────────────────────────── +// ── Core operations ────────────────────────── + export { diff } from './diff.js'; -export { DeltaError, type DeltaErrorCode } from './errors.js'; export { patch, unpatch } from './patch.js'; +export { changes, changesFromDiff } from './changes.js'; + +// ── RFC 6902 adapter ───────────────────────── + +export { toRFC6902, toRFC6902JSON } from './rfc6902.js'; export type { RFC6902Add, RFC6902Move, @@ -14,8 +20,16 @@ export type { RFC6902Remove, RFC6902Replace, } from './rfc6902.js'; -export { toRFC6902, toRFC6902JSON } from './rfc6902.js'; + +// ── Error handling ─────────────────────────── + +export { DeltaError, type DeltaErrorCode } from './errors.js'; + +// ── Types ──────────────────────────────────── + export type { + // Changes + ChangesResult, // Operations DiffOp, // Options @@ -23,12 +37,13 @@ export type { // Result DiffResult, DiffSummary, + // Identity Identity, IdentityFn, IdentityKey, + // Values JsonArray, JsonObject, - // Values JsonPrimitive, JsonValue, OpAdd, diff --git a/src/types.ts b/src/types.ts index 3e9d1ac..1953c4e 100644 --- a/src/types.ts +++ b/src/types.ts @@ -195,6 +195,52 @@ export interface DiffOptions { cloneValues?: boolean; } +// ── Changes ────────────────────────────────── + +/** + * Result returned by {@link changes} and {@link changesFromDiff}. + * + * Provides a sparse representation of what changed between two documents, + * split into values that were set (added, replaced, or moved) and paths + * that were removed. + * + * @example + * ```ts + * const before = { name: 'Alice', age: 30, email: 'a@b.c' }; + * const after = { name: 'Bob', age: 30 }; + * + * const result = changes(before, after); + * result.updated // → { name: 'Bob' } + * result.removed // → ['/email'] + * ``` + */ +export interface ChangesResult { + /** `true` when at least one change was detected. */ + hasChanges: boolean; + + /** + * Sparse object containing only the values that were **added**, **replaced**, + * or **moved** (at their destination index). Preserves nested structure — + * intermediate containers are plain objects even for array indices. + * + * `null` when no additions, replacements, or moves exist (i.e. only removals). + */ + updated: JsonValue | null; + + /** + * RFC 6901 JSON Pointer paths that were **removed** from the source document. + * The list is in the same order the remove operations appear in the diff. + */ + removed: string[]; + + /** + * The complete {@link DiffResult} for low-level operation access. + * Useful when you need the full operation list, summary counters, or + * the `changedPaths` set beyond what `updated` / `removed` provide. + */ + diff: DiffResult; +} + // ── Internal resolved options ───────────────── /** From a94a9515117676bd5eae22bc2299e15bb4a64942 Mon Sep 17 00:00:00 2001 From: Miguel Ramos Date: Fri, 17 Apr 2026 09:26:44 +0100 Subject: [PATCH 03/10] test(changes): add test suite for changes() and changesFromDiff() 25 tests covering: no-change scenarios, object diffs (add/remove/replace), nested structure preservation, root replacement, identity-based array moves, options forwarding (ignore, maxDepth), changesFromDiff standalone usage, and removal-only edge cases. --- tests/changes.test.ts | 238 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 238 insertions(+) create mode 100644 tests/changes.test.ts diff --git a/tests/changes.test.ts b/tests/changes.test.ts new file mode 100644 index 0000000..e4a5ae6 --- /dev/null +++ b/tests/changes.test.ts @@ -0,0 +1,238 @@ +import { describe, expect, it } from 'vitest'; +import { changes, changesFromDiff, diff } from '../src/index.js'; + +describe('changes — no changes', () => { + it('returns hasChanges false for identical primitives', () => { + const r = changes(42, 42); + expect(r.hasChanges).toBe(false); + expect(r.updated).toBeNull(); + expect(r.removed).toEqual([]); + }); + + it('returns hasChanges false for identical objects', () => { + const r = changes({ a: 1, b: 2 }, { a: 1, b: 2 }); + expect(r.hasChanges).toBe(false); + expect(r.updated).toBeNull(); + expect(r.removed).toEqual([]); + }); + + it('returns hasChanges false for identical arrays', () => { + const r = changes([1, 2, 3], [1, 2, 3]); + expect(r.hasChanges).toBe(false); + expect(r.updated).toBeNull(); + }); +}); + +describe('changes — object diffs', () => { + it('detects added keys in updated', () => { + const r = changes({ a: 1 }, { a: 1, b: 2 }); + expect(r.hasChanges).toBe(true); + expect(r.updated).toEqual({ b: 2 }); + expect(r.removed).toEqual([]); + }); + + it('detects removed keys in removed', () => { + const r = changes({ a: 1, b: 2 }, { a: 1 }); + expect(r.hasChanges).toBe(true); + expect(r.updated).toBeNull(); + expect(r.removed).toEqual(['/b']); + }); + + it('detects replaced values in updated', () => { + const r = changes({ a: 1 }, { a: 99 }); + expect(r.hasChanges).toBe(true); + expect(r.updated).toEqual({ a: 99 }); + expect(r.removed).toEqual([]); + }); + + it('splits additions and removals correctly', () => { + const before = { name: 'Alice', age: 30, email: 'a@b.c' }; + const after = { name: 'Bob', age: 30, role: 'admin' }; + + const r = changes(before, after); + expect(r.updated).toEqual({ name: 'Bob', role: 'admin' }); + expect(r.removed).toEqual(['/email']); + }); + + it('handles multiple changes across different types', () => { + const before = { a: 1, b: 2, c: 3 }; + const after = { a: 99, c: 3, d: 4 }; + + const r = changes(before, after); + expect(r.updated).toEqual({ a: 99, d: 4 }); + expect(r.removed).toContain('/b'); + }); +}); + +describe('changes — nested objects', () => { + it('preserves nested structure in updated', () => { + const before = { user: { name: 'Alice', settings: { theme: 'dark', lang: 'en' } } }; + const after = { user: { name: 'Alice', settings: { theme: 'light', lang: 'en' } } }; + + const r = changes(before, after); + expect(r.updated).toEqual({ user: { settings: { theme: 'light' } } }); + expect(r.removed).toEqual([]); + }); + + it('handles deeply nested additions', () => { + const before = { a: { b: { c: 1 } } }; + const after = { a: { b: { c: 1, d: 2 } } }; + + const r = changes(before, after); + expect(r.updated).toEqual({ a: { b: { d: 2 } } }); + }); + + it('handles deeply nested removals', () => { + const before = { a: { b: { c: 1, d: 2 } } }; + const after = { a: { b: { c: 1 } } }; + + const r = changes(before, after); + expect(r.updated).toBeNull(); + expect(r.removed).toEqual(['/a/b/d']); + }); + + it('handles mixed nested additions and removals', () => { + const before = { x: { y: 1, z: 2 }, w: 3 }; + const after = { x: { y: 99 }, w: 3, v: 4 }; + + const r = changes(before, after); + expect(r.updated).toEqual({ x: { y: 99 }, v: 4 }); + expect(r.removed).toContain('/x/z'); + }); +}); + +describe('changes — root replacement', () => { + it('returns the new value when root is replaced (primitive → primitive)', () => { + const r = changes(1, 2); + expect(r.hasChanges).toBe(true); + expect(r.updated).toBe(2); + expect(r.removed).toEqual([]); + }); + + it('returns the new value when root type changes', () => { + const r = changes('hello', { a: 1 }); + expect(r.updated).toEqual({ a: 1 }); + }); + + it('returns the new value when object is replaced by array', () => { + const r = changes({ a: 1 }, [1, 2, 3]); + expect(r.updated).toEqual([1, 2, 3]); + }); +}); + +describe('changes — arrays with identity', () => { + it('includes moved items at their destination index', () => { + const before = [ + { id: 1, name: 'Alice' }, + { id: 2, name: 'Bob' }, + ]; + const after = [ + { id: 2, name: 'Bob' }, + { id: 1, name: 'Alice' }, + ]; + + const r = changes(before, after, { arrayIdentity: 'id' }); + expect(r.hasChanges).toBe(true); + expect(r.updated).not.toBeNull(); + // Moved items appear under their new indices + const updated = r.updated as Record; + expect(updated['0']).toEqual({ id: 2, name: 'Bob' }); + expect(updated['1']).toEqual({ id: 1, name: 'Alice' }); + }); + + it('includes added items and excludes removed from updated', () => { + const before = { items: [{ id: 1, v: 'a' }] }; + const after = { items: [{ id: 1, v: 'a' }, { id: 2, v: 'b' }] }; + + const r = changes(before, after, { arrayIdentity: 'id' }); + expect(r.updated).toEqual({ items: { '1': { id: 2, v: 'b' } } }); + expect(r.removed).toEqual([]); + }); + + it('lists removed array items in removed', () => { + const before = { items: [{ id: 1, v: 'a' }, { id: 2, v: 'b' }] }; + const after = { items: [{ id: 1, v: 'a' }] }; + + const r = changes(before, after, { arrayIdentity: 'id' }); + expect(r.removed).toHaveLength(1); + expect(r.removed[0]).toMatch(/^\/items\/\d+$/); + }); + + it('handles moved-and-changed items', () => { + const before = { items: [{ id: 1, v: 'a' }, { id: 2, v: 'b' }] }; + const after = { items: [{ id: 2, v: 'X' }, { id: 1, v: 'a' }] }; + + const r = changes(before, after, { arrayIdentity: 'id' }); + expect(r.hasChanges).toBe(true); + expect(r.updated).not.toBeNull(); + // The moved item with changed value should appear at its new index + const updated = r.updated as Record>; + expect(updated.items['0']).toEqual({ id: 2, v: 'X' }); + }); +}); + +describe('changes — options forwarding', () => { + it('respects ignore option', () => { + const before = { a: 1, b: 2, meta: { ts: 100 } }; + const after = { a: 99, b: 2, meta: { ts: 200 } }; + + const r = changes(before, after, { ignore: ['/meta/ts'] }); + expect(r.updated).toEqual({ a: 99 }); + expect(r.removed).toEqual([]); + }); + + it('respects maxDepth option', () => { + const before = { a: { b: { c: 1 } } }; + const after = { a: { b: { c: 2 } } }; + + const r = changes(before, after, { maxDepth: 1 }); + // At depth 1, the entire nested object is replaced as a blob + expect(r.updated).toEqual({ a: { b: { c: 2 } } }); + }); +}); + +describe('changesFromDiff — standalone usage', () => { + it('produces same result as changes() from a pre-computed diff', () => { + const before = { name: 'Alice', age: 30 }; + const after = { name: 'Bob', age: 30, role: 'admin' }; + + const result = diff(before, after); + const fromChanges = changes(before, after); + const fromDiff = changesFromDiff(result); + + expect(fromDiff.hasChanges).toBe(fromChanges.hasChanges); + expect(fromDiff.updated).toEqual(fromChanges.updated); + expect(fromDiff.removed).toEqual(fromChanges.removed); + }); + + it('provides access to the underlying DiffResult', () => { + const result = diff({ a: 1 }, { a: 2 }); + const r = changesFromDiff(result); + + expect(r.diff).toBe(result); + expect(r.diff.operations).toHaveLength(1); + expect(r.diff.summary.replaced).toBe(1); + }); + + it('handles empty diff result', () => { + const result = diff(42, 42); + const r = changesFromDiff(result); + + expect(r.hasChanges).toBe(false); + expect(r.updated).toBeNull(); + expect(r.removed).toEqual([]); + }); +}); + +describe('changes — only removals', () => { + it('returns null updated when only removals exist', () => { + const before = { a: 1, b: 2, c: 3 }; + const after = { a: 1 }; + + const r = changes(before, after); + expect(r.updated).toBeNull(); + expect(r.removed).toContain('/b'); + expect(r.removed).toContain('/c'); + expect(r.removed).toHaveLength(2); + }); +}); From 83457aa67bfc11d4e6c6463ea811cc631afd7869 Mon Sep 17 00:00:00 2001 From: Miguel Ramos Date: Fri, 17 Apr 2026 09:26:53 +0100 Subject: [PATCH 04/10] docs(readme): document changes() and changesFromDiff() API Add changes section to Usage with examples for sparse extraction, nested structure preservation, removal-only cases, options forwarding, and changesFromDiff standalone usage. Add ChangesResult to Types section and update intro/features to reference the new API. --- README.md | 71 ++++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 70 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 6383e21..b8adfec 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ **delta://** — Typed JSON model diffing for TypeScript. -Diff any two JSON values and get a structured, typed result with JSON Pointer paths. Apply it forward with `patch`, reverse it with `unpatch`, or export it as an RFC 6902 patch. +Diff any two JSON values and get a structured, typed result with JSON Pointer paths. Apply it forward with `patch`, reverse it with `unpatch`, extract a sparse changes object with `changes`, or export it as an RFC 6902 patch. ```ts import { diff, patch, unpatch } from '@websublime/delta' @@ -15,6 +15,12 @@ const result = diff(before, after, { arrayIdentity: 'id' }) const forward = patch(before, result) // === after const backward = unpatch(after, result) // === before + +// Extract only what changed — ideal for PATCH payloads or audit logs +import { changes } from '@websublime/delta' +const { updated, removed } = changes(before, after, { arrayIdentity: 'id' }) +// updated → sparse object with only added/replaced/moved values +// removed → ['/users/0/role'] (RFC 6901 paths that were deleted) ``` ## Features @@ -23,6 +29,7 @@ const backward = unpatch(after, result) // === before - **Typed operations** — `add | remove | replace | move`, each with the right shape - **JSON Pointer paths** (RFC 6901) — `/users/0/role`, `~0` and `~1` escaping included - **Identity-based array diffing** — track items by id across reorders, adds, removes; deterministic even with duplicate ids +- **Sparse changes** — `changes()` returns only what was added, replaced, moved, or removed — ready for PATCH payloads, form dirty tracking, or audit logs - **Bidirectional** — `patch` and `unpatch` both work from the diff result alone; `oldValue` is always present on destructive ops - **RFC 6902 adapter** — export any diff as a standard JSON Patch - **Runtime validation** — `patch`/`unpatch` reject malformed inputs with a typed `DeltaError` @@ -78,6 +85,61 @@ unpatch(after, result) // { x: 1 } Neither function mutates its inputs. `unpatch` only needs `after` + the diff result — it never needs `before` because `oldValue` is always stored on destructive operations. +### changes + +Extract a sparse object containing only the values that changed — useful for HTTP PATCH payloads, form dirty tracking, optimistic UI updates, or audit logs. + +```ts +import { changes } from '@websublime/delta' + +const before = { name: 'Alice', age: 30, email: 'alice@example.com' } +const after = { name: 'Bob', age: 30, role: 'admin' } + +const result = changes(before, after) + +result.updated // { name: 'Bob', role: 'admin' } +result.removed // ['/email'] +result.hasChanges // true +result.diff // the full DiffResult for low-level access +``` + +Nested structure is preserved — only the branches that actually changed appear in `updated`: + +```ts +const before = { user: { name: 'Alice', settings: { theme: 'dark', lang: 'en' } } } +const after = { user: { name: 'Alice', settings: { theme: 'light', lang: 'en' } } } + +const result = changes(before, after) +result.updated // { user: { settings: { theme: 'light' } } } +``` + +When only removals exist (no additions or replacements), `updated` is `null`: + +```ts +const result = changes({ a: 1, b: 2 }, { a: 1 }) +result.updated // null +result.removed // ['/b'] +``` + +All `DiffOptions` are supported — `arrayIdentity`, `ignore`, `maxDepth`, etc.: + +```ts +const result = changes(before, after, { + arrayIdentity: 'id', + ignore: ['/meta/*'], +}) +``` + +If you already have a `DiffResult`, use `changesFromDiff` to avoid re-diffing: + +```ts +import { diff, changesFromDiff } from '@websublime/delta' + +const diffResult = diff(before, after, options) +// ... inspect diffResult.operations ... +const sparse = changesFromDiff(diffResult) +``` + ### Identity-based array diffing When your array items have a stable identifier, use `arrayIdentity` to track them across reorders, adds and removes: @@ -227,6 +289,13 @@ interface DiffSummary { movedAndChanged: number total: number } + +interface ChangesResult { + hasChanges: boolean + updated: JsonValue | null // sparse object with added/replaced/moved values; null when only removals + removed: string[] // RFC 6901 paths that were deleted + diff: DiffResult // full diff result for low-level access +} ``` --- From e434f3452821dc654aecac19155221f4a7b45657 Mon Sep 17 00:00:00 2001 From: Miguel Ramos Date: Fri, 17 Apr 2026 09:55:25 +0100 Subject: [PATCH 05/10] fix: parentOf escaping, toRFC6902 move semantics, LCS size guard - Fix parentOf() in patch.ts to re-escape segments via joinPath, preventing corrupted arrays when keys contain `/` or `~`. - Rewrite toRFC6902() to decompose delta moves into RFC 6902 remove+add pairs with correct sequential ordering (removes desc, adds asc), fixing invalid patches for multi-move arrays. - Add ARRAY_TOO_LARGE guard in LCS (25M entry threshold) to prevent OOM on large positional array diffs. - Document moved+changed design choice on OpMove (cannot emit nested ops without breaking unpatch reverse reconstruction). --- src/diff.ts | 6 ++++ src/errors.ts | 4 ++- src/lcs.ts | 17 ++++++++++ src/patch.ts | 4 +-- src/rfc6902.ts | 52 +++++++++++++++++++++++++------ src/types.ts | 15 +++++++++ tests/edge-cases.test.ts | 52 +++++++++++++++++++++++++++++++ tests/rfc6902-conformance.test.ts | 40 ++++++++++++++++++++++++ tests/rfc6902.test.ts | 42 +++++++++++++++++++------ 9 files changed, 209 insertions(+), 23 deletions(-) diff --git a/src/diff.ts b/src/diff.ts index 4e2231b..2b08b6f 100644 --- a/src/diff.ts +++ b/src/diff.ts @@ -435,6 +435,12 @@ function diffArraysByIdentity( if (opts.detectMoves) { if (moved) { + // Emit a single move op with full before/after snapshots. + // We intentionally do NOT recurse into the item even when it also + // changed — emitting nested ops at the destination index would + // produce paths that reference different items during unpatch + // (reverse reconstruction), corrupting the result. + // Consumers can diff op.oldValue vs op.value for field-level detail. pendingMoves.push({ op: 'move', path, diff --git a/src/errors.ts b/src/errors.ts index f1b3a90..89b5192 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -47,4 +47,6 @@ export type DeltaErrorCode = /** A path references a node that does not exist when it should. */ | 'PATH_NOT_FOUND' /** A value is not representable as JSON (e.g. undefined in an array slot, function). */ - | 'UNSUPPORTED_VALUE'; + | 'UNSUPPORTED_VALUE' + /** An array is too large for the O(mn) LCS algorithm. Use `arrayIdentity` instead. */ + | 'ARRAY_TOO_LARGE'; diff --git a/src/lcs.ts b/src/lcs.ts index 7ce7767..aeac5ce 100644 --- a/src/lcs.ts +++ b/src/lcs.ts @@ -3,8 +3,17 @@ // Used for positional (non-identity) array diffs // ───────────────────────────────────────────── +import { DeltaError } from './errors.js'; import type { JsonValue } from './types.js'; +/** + * Maximum number of DP table entries (m × n) allowed for the LCS algorithm. + * Beyond this threshold, the O(mn) approach risks excessive memory usage + * (~100 MB at 25 M entries). Use `arrayIdentity` to diff large arrays + * efficiently via identity-based matching instead. + */ +const MAX_LCS_ENTRIES = 25_000_000; + /** * A matched index pair from the Longest Common Subsequence computation. * @@ -34,6 +43,14 @@ export function computeLCS( const m = a.length; const n = b.length; + if (m * n > MAX_LCS_ENTRIES) { + throw new DeltaError( + 'ARRAY_TOO_LARGE', + `LCS diff of ${m}×${n} (${m * n} comparisons) exceeds the limit of ` + + `${MAX_LCS_ENTRIES}. Use the arrayIdentity option for large arrays`, + ); + } + // Use typed arrays for speed on large inputs const dp = new Uint32Array((m + 1) * (n + 1)); diff --git a/src/patch.ts b/src/patch.ts index 171306f..5d64f3b 100644 --- a/src/patch.ts +++ b/src/patch.ts @@ -15,7 +15,7 @@ import type { OpRemove, OpReplace, } from './types.js'; -import { cloneDeep, isArray, isObject, safeSet, splitPath } from './utils.js'; +import { cloneDeep, isArray, isObject, joinPath, safeSet, splitPath } from './utils.js'; // ── Runtime validation ──────────────────────── @@ -385,7 +385,7 @@ function parentOf(path: string): string | null { const segs = splitPath(path); if (segs.length === 0) return null; if (segs.length === 1) return ''; - return `/${segs.slice(0, -1).join('/')}`; + return joinPath('', ...segs.slice(0, -1)); } /** diff --git a/src/rfc6902.ts b/src/rfc6902.ts index c77a2cf..58eb7fb 100644 --- a/src/rfc6902.ts +++ b/src/rfc6902.ts @@ -64,33 +64,65 @@ export type RFC6902Patch = RFC6902Op[]; * Notes: * - `remove` ops lose `oldValue` (not part of RFC 6902) * - `replace` ops lose `oldValue` - * - `move` ops are converted to RFC 6902 `move` with from/path as full paths + * - Delta `move` ops are decomposed into RFC 6902 `remove` + `add` pairs. + * Delta moves use parallel reconstruction semantics (fromIndex/toIndex + * reference the original and final arrays simultaneously), while RFC 6902 + * operations are applied strictly sequentially. Emitting RFC 6902 `move` + * would produce incorrect results when multiple moves target the same array. */ export function toRFC6902(result: DiffResult): RFC6902Patch { - const patch: RFC6902Patch = []; + const removes: RFC6902Remove[] = []; + const adds: RFC6902Add[] = []; + const replaces: RFC6902Replace[] = []; for (const op of result.operations) { switch (op.op) { case 'add': - patch.push({ op: 'add', path: op.path, value: op.value }); + adds.push({ op: 'add', path: op.path, value: op.value }); break; case 'remove': - patch.push({ op: 'remove', path: op.path }); + removes.push({ op: 'remove', path: op.path }); break; case 'replace': - patch.push({ op: 'replace', path: op.path, value: op.value }); + replaces.push({ op: 'replace', path: op.path, value: op.value }); break; case 'move': { - // Convert delta move (array-path + indices) to RFC 6902 move (full paths) - const fromPath = joinPath(op.path, op.fromIndex); - const toPath = joinPath(op.path, op.toIndex); - patch.push({ op: 'move', from: fromPath, path: toPath }); + // Decompose into remove (at original index) + add (at final index). + removes.push({ op: 'remove', path: joinPath(op.path, op.fromIndex) }); + adds.push({ op: 'add', path: joinPath(op.path, op.toIndex), value: op.value }); break; } } } - return patch; + // For correct sequential application per RFC 6902: + // - Array removes must be applied in descending index order (higher indices + // first so earlier indices are not shifted). + // - Array adds must be applied in ascending index order. + // Non-numeric path segments (object keys) are unaffected by ordering. + const lastSegmentIndex = (path: string): number => { + const parts = path.split('/'); + return Number.parseInt(parts[parts.length - 1], 10); + }; + + removes.sort((a, b) => { + const iA = lastSegmentIndex(a.path); + const iB = lastSegmentIndex(b.path); + if (Number.isNaN(iA) || Number.isNaN(iB)) return 0; + return iB - iA; + }); + + adds.sort((a, b) => { + const iA = lastSegmentIndex(a.path); + const iB = lastSegmentIndex(b.path); + if (Number.isNaN(iA) || Number.isNaN(iB)) return 0; + return iA - iB; + }); + + // Order: removes → adds → replaces. + // Replaces target positions in the final array state, so they must come + // after the array has been fully reconstructed by removes and adds. + return [...removes, ...adds, ...replaces]; } /** diff --git a/src/types.ts b/src/types.ts index 1953c4e..f893061 100644 --- a/src/types.ts +++ b/src/types.ts @@ -46,6 +46,21 @@ export interface OpReplace { * An array item was moved within the same array. * `path` = array root (e.g. `/items`). * Applied AFTER removes and BEFORE adds. + * + * **Design note — moved-and-changed items:** + * When an item moves AND its contents change, a single `move` op is emitted + * carrying the full `value` (after) and `oldValue` (before). Granular + * sub-field diffs are **not** emitted alongside the move. This is intentional: + * emitting nested ops at the destination index would produce paths that refer + * to different items after reverse reconstruction, breaking {@link unpatch}. + * + * To inspect which fields changed within a moved item, diff `op.oldValue` + * against `op.value`: + * ```ts + * if (!deepEqual(op.value, op.oldValue)) { + * const fieldDiff = diff(op.oldValue, op.value); + * } + * ``` */ export interface OpMove { op: 'move'; diff --git a/tests/edge-cases.test.ts b/tests/edge-cases.test.ts index 353054a..5478261 100644 --- a/tests/edge-cases.test.ts +++ b/tests/edge-cases.test.ts @@ -231,6 +231,34 @@ describe('DiffResult runtime validation', () => { }); }); +describe('LCS size guard', () => { + it('throws ARRAY_TOO_LARGE for very large positional arrays', () => { + // Two 6000-element arrays → 36M comparisons, exceeds the 25M limit + const big = Array.from({ length: 6000 }, (_, i) => i); + const big2 = Array.from({ length: 6000 }, (_, i) => i + 1); + + expect(() => diff(big, big2)).toThrowError(DeltaError); + try { + diff(big, big2); + } catch (e) { + expect((e as DeltaError).code).toBe('ARRAY_TOO_LARGE'); + } + }); + + it('does not throw for arrays within the limit', () => { + const a = Array.from({ length: 100 }, (_, i) => i); + const b = Array.from({ length: 100 }, (_, i) => i + 1); + expect(() => diff(a, b)).not.toThrow(); + }); + + it('identity-based diff bypasses LCS entirely', () => { + // Large arrays with identity don't use LCS + const big = Array.from({ length: 6000 }, (_, i) => ({ id: i, v: i })); + const big2 = Array.from({ length: 6000 }, (_, i) => ({ id: i, v: i + 1 })); + expect(() => diff(big, big2, { arrayIdentity: 'id' })).not.toThrow(); + }); +}); + describe('maxDepth edge cases', () => { it('maxDepth: 0 treats any change as root replace', () => { const r = diff({ a: 1 }, { a: 2 }, { maxDepth: 0 }); @@ -365,4 +393,28 @@ describe('escape/unescape path segments (RFC 6901)', () => { const r2 = diff({ 'c~d': 1 }, { 'c~d': 2 }); expect(r2.operations[0].path).toBe('/c~0d'); }); + + it('identity arrays under keys with / round-trip correctly', () => { + roundtrip( + { 'x/y': [{ id: 1, v: 'a' }, { id: 2, v: 'b' }] }, + { 'x/y': [{ id: 2, v: 'b' }, { id: 1, v: 'a' }] }, + { arrayIdentity: 'id' }, + ); + }); + + it('identity arrays under keys with ~ round-trip correctly', () => { + roundtrip( + { 'a~b': [{ id: 1, v: 'x' }, { id: 2, v: 'y' }] }, + { 'a~b': [{ id: 2, v: 'y' }, { id: 1, v: 'x' }] }, + { arrayIdentity: 'id' }, + ); + }); + + it('identity arrays with moves + adds under special keys round-trip', () => { + roundtrip( + { 'x/y': [{ id: 1, v: 'a' }, { id: 2, v: 'b' }] }, + { 'x/y': [{ id: 2, v: 'b' }, { id: 1, v: 'a' }, { id: 3, v: 'c' }] }, + { arrayIdentity: 'id' }, + ); + }); }); diff --git a/tests/rfc6902-conformance.test.ts b/tests/rfc6902-conformance.test.ts index fd6fce9..b31464a 100644 --- a/tests/rfc6902-conformance.test.ts +++ b/tests/rfc6902-conformance.test.ts @@ -72,4 +72,44 @@ describe('RFC 6902 conformance (fast-json-patch applies our output)', () => { { numRuns: 200 }, ); }); + + it('identity-based 2-item swap is accepted by fast-json-patch', () => { + const before = [{ id: 1, v: 'a' }, { id: 2, v: 'b' }]; + const after = [{ id: 2, v: 'b' }, { id: 1, v: 'a' }]; + const ops = toRFC6902(diff(before, after, { arrayIdentity: 'id' })); + const patched = applyPatch(deepClone(before), ops, true, false).newDocument; + expect(patched).toEqual(after); + }); + + it('identity-based 3-item rotation is accepted by fast-json-patch', () => { + const before = [{ id: 1 }, { id: 2 }, { id: 3 }]; + const after = [{ id: 3 }, { id: 1 }, { id: 2 }]; + const ops = toRFC6902(diff(before, after, { arrayIdentity: 'id' })); + const patched = applyPatch(deepClone(before), ops, true, false).newDocument; + expect(patched).toEqual(after); + }); + + it('identity-based move + add + remove is accepted by fast-json-patch', () => { + const before = { items: [{ id: 1 }, { id: 2 }, { id: 3 }] }; + const after = { items: [{ id: 2 }, { id: 4 }] }; + const ops = toRFC6902(diff(before, after, { arrayIdentity: 'id' })); + const patched = applyPatch(deepClone(before), ops, true, false).newDocument; + expect(patched).toEqual(after); + }); + + it('identity-based full reverse is accepted by fast-json-patch', () => { + const before = [{ id: 1 }, { id: 2 }, { id: 3 }, { id: 4 }]; + const after = [{ id: 4 }, { id: 3 }, { id: 2 }, { id: 1 }]; + const ops = toRFC6902(diff(before, after, { arrayIdentity: 'id' })); + const patched = applyPatch(deepClone(before), ops, true, false).newDocument; + expect(patched).toEqual(after); + }); + + it('identity-based moved+changed item is accepted by fast-json-patch', () => { + const before = [{ id: 1, role: 'admin' }, { id: 2, role: 'user' }]; + const after = [{ id: 2, role: 'mod' }, { id: 1, role: 'admin' }]; + const ops = toRFC6902(diff(before, after, { arrayIdentity: 'id' })); + const patched = applyPatch(deepClone(before), ops, true, false).newDocument; + expect(patched).toEqual(after); + }); }); diff --git a/tests/rfc6902.test.ts b/tests/rfc6902.test.ts index 7fc801f..d0b29b6 100644 --- a/tests/rfc6902.test.ts +++ b/tests/rfc6902.test.ts @@ -1,6 +1,5 @@ import { describe, expect, it } from 'vitest'; import { diff } from '../src/diff.js'; -import type { RFC6902Move } from '../src/rfc6902.js'; import { toRFC6902, toRFC6902JSON } from '../src/rfc6902.js'; describe('RFC 6902 adapter', () => { @@ -19,20 +18,43 @@ describe('RFC 6902 adapter', () => { expect(ops).toEqual([{ op: 'replace', path: '/a', value: 2 }]); }); - it('move → RFC move (from/to as JSON Pointers)', () => { + it('move → decomposed into RFC remove + add', () => { const ops = toRFC6902( diff([{ id: 1 }, { id: 2 }], [{ id: 2 }, { id: 1 }], { arrayIdentity: 'id', }), ); - const moveOps = ops.filter((op): op is RFC6902Move => op.op === 'move'); - expect(moveOps.length).toBeGreaterThan(0); - // RFC 6902 move has `from` and `path` as JSON Pointers - for (const op of moveOps) { - expect(op).toHaveProperty('from'); - expect(op).toHaveProperty('path'); - expect(op.from).toMatch(/^\/\d+$/); - expect(op.path).toMatch(/^\/\d+$/); + // Delta moves are decomposed into remove + add for correct sequential semantics + expect(ops.some((op) => op.op === 'move')).toBe(false); + const removes = ops.filter((op) => op.op === 'remove'); + const adds = ops.filter((op) => op.op === 'add'); + expect(removes.length).toBeGreaterThan(0); + expect(adds.length).toBeGreaterThan(0); + }); + + it('move decomposition: removes sorted desc, adds sorted asc', () => { + const ops = toRFC6902( + diff( + [{ id: 1 }, { id: 2 }, { id: 3 }], + [{ id: 3 }, { id: 1 }, { id: 2 }], + { arrayIdentity: 'id' }, + ), + ); + const removes = ops.filter((op) => op.op === 'remove'); + const adds = ops.filter((op) => op.op === 'add'); + + // Removes should be in descending index order + for (let i = 1; i < removes.length; i++) { + const prevIdx = Number.parseInt(removes[i - 1].path.split('/').pop()!, 10); + const currIdx = Number.parseInt(removes[i].path.split('/').pop()!, 10); + expect(prevIdx).toBeGreaterThanOrEqual(currIdx); + } + + // Adds should be in ascending index order + for (let i = 1; i < adds.length; i++) { + const prevIdx = Number.parseInt(adds[i - 1].path.split('/').pop()!, 10); + const currIdx = Number.parseInt(adds[i].path.split('/').pop()!, 10); + expect(prevIdx).toBeLessThanOrEqual(currIdx); } }); From 01f4469804f173ffed2baf02f4e95356c5823e5f Mon Sep 17 00:00:00 2001 From: Miguel Ramos Date: Fri, 17 Apr 2026 10:04:49 +0100 Subject: [PATCH 06/10] feat(diff): add nestedDiff to OpMove for granular moved+changed diffs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OpMove now carries an optional nestedDiff: DiffResult when the item changed during a move. Paths are relative to the item root (e.g. /role, not /items/2/role), sidestepping the index-ambiguity that prevents emitting them as top-level ops. patch/unpatch remain unchanged — they use value/oldValue snapshots for reconstruction. nestedDiff is purely informational for consumers who need field-level detail on moved items. --- src/diff.ts | 23 +++++++++------ src/types.ts | 41 ++++++++++++++++++--------- tests/diff.test.ts | 61 ++++++++++++++++++++++++++++++++++++++++ tests/edge-cases.test.ts | 30 ++++++++++++-------- 4 files changed, 122 insertions(+), 33 deletions(-) diff --git a/src/diff.ts b/src/diff.ts index 2b08b6f..a3d5d09 100644 --- a/src/diff.ts +++ b/src/diff.ts @@ -435,20 +435,27 @@ function diffArraysByIdentity( if (opts.detectMoves) { if (moved) { - // Emit a single move op with full before/after snapshots. - // We intentionally do NOT recurse into the item even when it also - // changed — emitting nested ops at the destination index would - // produce paths that reference different items during unpatch - // (reverse reconstruction), corrupting the result. - // Consumers can diff op.oldValue vs op.value for field-level detail. - pendingMoves.push({ + const moveOp: OpMove = { op: 'move', path, fromIndex: beforeEntry.index, toIndex: afterEntry.index, value: maybeClone(afterEntry.item, opts), oldValue: maybeClone(beforeEntry.item, opts), - }); + }; + + // When the item also changed, compute a granular diff with paths + // relative to the item root. These are NOT emitted as top-level ops + // (that would break unpatch — destination-index paths reference + // different items after reverse reconstruction). Instead they live + // on the move op as informational metadata. + if (changed) { + const itemOps: DiffOp[] = []; + diffValues(beforeEntry.item, afterEntry.item, '', opts, depth + 1, itemOps); + moveOp.nestedDiff = buildResult(itemOps); + } + + pendingMoves.push(moveOp); } else if (changed) { // Same position, value changed → recurse for granular ops diffValues( diff --git a/src/types.ts b/src/types.ts index f893061..fef2cdd 100644 --- a/src/types.ts +++ b/src/types.ts @@ -47,20 +47,15 @@ export interface OpReplace { * `path` = array root (e.g. `/items`). * Applied AFTER removes and BEFORE adds. * - * **Design note — moved-and-changed items:** - * When an item moves AND its contents change, a single `move` op is emitted - * carrying the full `value` (after) and `oldValue` (before). Granular - * sub-field diffs are **not** emitted alongside the move. This is intentional: - * emitting nested ops at the destination index would produce paths that refer - * to different items after reverse reconstruction, breaking {@link unpatch}. + * When the item also changed during the move, the optional {@link nestedDiff} + * field contains a granular diff with paths **relative to the item root** + * (e.g. `/name`, not `/items/2/name`). This avoids the index-ambiguity + * problem that would break {@link unpatch} if those ops were emitted as + * top-level operations. * - * To inspect which fields changed within a moved item, diff `op.oldValue` - * against `op.value`: - * ```ts - * if (!deepEqual(op.value, op.oldValue)) { - * const fieldDiff = diff(op.oldValue, op.value); - * } - * ``` + * `patch` and `unpatch` use only `value`/`oldValue` for reconstruction — + * `nestedDiff` is purely informational for consumers who need field-level + * detail on moved items. */ export interface OpMove { op: 'move'; @@ -69,6 +64,26 @@ export interface OpMove { toIndex: number; // index in final array value: JsonValue; // final value of the item oldValue: JsonValue; // original value (may differ if item also changed) + + /** + * Granular diff between `oldValue` and `value`, present only when the + * item changed during the move. Paths are relative to the item root. + * + * Note: top-level {@link DiffOptions.ignore} paths do not apply inside + * `nestedDiff` because those paths are absolute while `nestedDiff` paths + * are item-relative. + * + * @example + * ```ts + * for (const op of result.operations) { + * if (op.op === 'move' && op.nestedDiff) { + * // e.g. op.nestedDiff.operations[0].path === '/role' + * console.log('fields changed:', op.nestedDiff.changedPaths); + * } + * } + * ``` + */ + nestedDiff?: DiffResult; } /** diff --git a/tests/diff.test.ts b/tests/diff.test.ts index 1d778e8..ea19934 100644 --- a/tests/diff.test.ts +++ b/tests/diff.test.ts @@ -143,6 +143,67 @@ describe('diff — identity arrays', () => { expect(moveOp?.value).toMatchObject({ id: 2, v: 'new' }); }); + it('nestedDiff is populated on moved+changed items', () => { + const r = diff( + [{ id: 1, role: 'admin' }, { id: 2, role: 'user' }], + [{ id: 2, role: 'mod' }, { id: 1, role: 'admin' }], + { arrayIdentity: 'id' }, + ); + const movedChanged = r.operations.find( + (op): op is OpMove => op.op === 'move' && op.nestedDiff !== undefined, + ); + expect(movedChanged).toBeDefined(); + expect(movedChanged!.nestedDiff!.hasChanges).toBe(true); + // Paths are relative to the item root + expect(movedChanged!.nestedDiff!.operations[0].path).toBe('/role'); + expect(movedChanged!.nestedDiff!.summary.replaced).toBe(1); + }); + + it('nestedDiff is undefined on moved-only items', () => { + const r = diff( + [{ id: 1, v: 'a' }, { id: 2, v: 'b' }], + [{ id: 2, v: 'b' }, { id: 1, v: 'a' }], + { arrayIdentity: 'id' }, + ); + for (const op of r.operations) { + if (op.op === 'move') { + expect(op.nestedDiff).toBeUndefined(); + } + } + }); + + it('nestedDiff captures deeply nested changes', () => { + const r = diff( + [{ id: 1, settings: { theme: 'dark', lang: 'en' } }, { id: 2 }], + [{ id: 2 }, { id: 1, settings: { theme: 'light', lang: 'en' } }], + { arrayIdentity: 'id' }, + ); + const moveOp = r.operations.find( + (op): op is OpMove => op.op === 'move' && op.nestedDiff !== undefined, + ); + expect(moveOp).toBeDefined(); + expect(moveOp!.nestedDiff!.operations[0].path).toBe('/settings/theme'); + }); + + it('nestedDiff with multiple field changes', () => { + const r = diff( + [{ id: 1, a: 1, b: 2, c: 3 }, { id: 2 }], + [{ id: 2 }, { id: 1, a: 99, b: 2, d: 4 }], + { arrayIdentity: 'id' }, + ); + const moveOp = r.operations.find( + (op): op is OpMove => op.op === 'move' && op.nestedDiff !== undefined, + ); + expect(moveOp).toBeDefined(); + const nested = moveOp!.nestedDiff!; + expect(nested.summary.replaced).toBe(1); // a: 1 → 99 + expect(nested.summary.removed).toBe(1); // c removed + expect(nested.summary.added).toBe(1); // d added + expect(nested.changedPaths.has('/a')).toBe(true); + expect(nested.changedPaths.has('/c')).toBe(true); + expect(nested.changedPaths.has('/d')).toBe(true); + }); + it('supports array identity key as string[]', () => { const r = diff([{ ns: 'a', name: 'x', v: 1 }], [{ ns: 'a', name: 'x', v: 2 }], { arrayIdentity: ['ns', 'name'], diff --git a/tests/edge-cases.test.ts b/tests/edge-cases.test.ts index 5478261..631fb78 100644 --- a/tests/edge-cases.test.ts +++ b/tests/edge-cases.test.ts @@ -320,19 +320,25 @@ describe('mutation safety of DiffResult', () => { }); }); -describe('moved + nested identity array (limitation)', () => { - it('roundtrip works even though nested granularity is lost', () => { - roundtrip( - [ - { id: 1, tags: ['a', 'b'] }, - { id: 2, tags: ['x'] }, - ], - [ - { id: 2, tags: ['x'] }, - { id: 1, tags: ['b', 'a'] }, - ], - { arrayIdentity: 'id' }, +describe('moved + nested identity array', () => { + it('roundtrip works and nestedDiff captures sub-array changes', () => { + const before = [ + { id: 1, tags: ['a', 'b'] }, + { id: 2, tags: ['x'] }, + ]; + const after = [ + { id: 2, tags: ['x'] }, + { id: 1, tags: ['b', 'a'] }, + ]; + roundtrip(before, after, { arrayIdentity: 'id' }); + + // nestedDiff on the moved+changed item captures the tag changes + const r = diff(before, after, { arrayIdentity: 'id' }); + const moveOp = r.operations.find( + (op) => op.op === 'move' && op.nestedDiff !== undefined, ); + expect(moveOp).toBeDefined(); + expect(moveOp!.nestedDiff!.hasChanges).toBe(true); }); }); From f22ccee1a8355d8004727544b89b33429af55d48 Mon Sep 17 00:00:00 2001 From: Miguel Ramos Date: Fri, 17 Apr 2026 10:20:13 +0100 Subject: [PATCH 07/10] refactor(changes): redesign API to return sparse object directly changes() now takes a DiffResult and returns JsonValue | null: - Sparse object with only changed fields - Removed keys appear as null - Root replacements return the new value directly - null when nothing changed Removes changesFromDiff(), ChangesResult type, and the changes(before, after) convenience overload. --- README.md | 53 ++++------- src/changes.ts | 163 +++++++++------------------------ src/index.ts | 4 +- src/types.ts | 46 ---------- tests/changes.test.ts | 206 ++++++++++++++++-------------------------- 5 files changed, 141 insertions(+), 331 deletions(-) diff --git a/README.md b/README.md index b8adfec..d9b7922 100644 --- a/README.md +++ b/README.md @@ -18,9 +18,8 @@ const backward = unpatch(after, result) // === before // Extract only what changed — ideal for PATCH payloads or audit logs import { changes } from '@websublime/delta' -const { updated, removed } = changes(before, after, { arrayIdentity: 'id' }) -// updated → sparse object with only added/replaced/moved values -// removed → ['/users/0/role'] (RFC 6901 paths that were deleted) +const sparse = changes(result) +// → { users: { '0': { id: 2, role: 'mod' }, '1': { id: 1, role: 'admin' } } } ``` ## Features @@ -29,7 +28,7 @@ const { updated, removed } = changes(before, after, { arrayIdentity: 'id' }) - **Typed operations** — `add | remove | replace | move`, each with the right shape - **JSON Pointer paths** (RFC 6901) — `/users/0/role`, `~0` and `~1` escaping included - **Identity-based array diffing** — track items by id across reorders, adds, removes; deterministic even with duplicate ids -- **Sparse changes** — `changes()` returns only what was added, replaced, moved, or removed — ready for PATCH payloads, form dirty tracking, or audit logs +- **Sparse changes** — `changes()` returns a minimal object with only changed fields (removals as `null`) — ready for PATCH payloads, form dirty tracking, or audit logs - **Bidirectional** — `patch` and `unpatch` both work from the diff result alone; `oldValue` is always present on destructive ops - **RFC 6902 adapter** — export any diff as a standard JSON Patch - **Runtime validation** — `patch`/`unpatch` reject malformed inputs with a typed `DeltaError` @@ -90,54 +89,43 @@ Neither function mutates its inputs. `unpatch` only needs `after` + the diff res Extract a sparse object containing only the values that changed — useful for HTTP PATCH payloads, form dirty tracking, optimistic UI updates, or audit logs. ```ts -import { changes } from '@websublime/delta' +import { diff, changes } from '@websublime/delta' const before = { name: 'Alice', age: 30, email: 'alice@example.com' } const after = { name: 'Bob', age: 30, role: 'admin' } -const result = changes(before, after) - -result.updated // { name: 'Bob', role: 'admin' } -result.removed // ['/email'] -result.hasChanges // true -result.diff // the full DiffResult for low-level access +changes(diff(before, after)) +// → { name: 'Bob', role: 'admin', email: null } ``` -Nested structure is preserved — only the branches that actually changed appear in `updated`: +Nested structure is sparse — only the branches that actually changed appear: ```ts const before = { user: { name: 'Alice', settings: { theme: 'dark', lang: 'en' } } } const after = { user: { name: 'Alice', settings: { theme: 'light', lang: 'en' } } } -const result = changes(before, after) -result.updated // { user: { settings: { theme: 'light' } } } +changes(diff(before, after)) +// → { user: { settings: { theme: 'light' } } } ``` -When only removals exist (no additions or replacements), `updated` is `null`: +Removed keys appear as `null`: ```ts -const result = changes({ a: 1, b: 2 }, { a: 1 }) -result.updated // null -result.removed // ['/b'] +changes(diff({ a: 1, b: 2 }, { a: 1 })) +// → { b: null } ``` -All `DiffOptions` are supported — `arrayIdentity`, `ignore`, `maxDepth`, etc.: +Root replacements return the new value directly: ```ts -const result = changes(before, after, { - arrayIdentity: 'id', - ignore: ['/meta/*'], -}) +changes(diff(1, 2)) // → 2 +changes(diff('hello', { x: 1 })) // → { x: 1 } ``` -If you already have a `DiffResult`, use `changesFromDiff` to avoid re-diffing: +Returns `null` when nothing changed: ```ts -import { diff, changesFromDiff } from '@websublime/delta' - -const diffResult = diff(before, after, options) -// ... inspect diffResult.operations ... -const sparse = changesFromDiff(diffResult) +changes(diff({ a: 1 }, { a: 1 })) // → null ``` ### Identity-based array diffing @@ -289,13 +277,6 @@ interface DiffSummary { movedAndChanged: number total: number } - -interface ChangesResult { - hasChanges: boolean - updated: JsonValue | null // sparse object with added/replaced/moved values; null when only removals - removed: string[] // RFC 6901 paths that were deleted - diff: DiffResult // full diff result for low-level access -} ``` --- diff --git a/src/changes.ts b/src/changes.ts index 59fa477..62df3a3 100644 --- a/src/changes.ts +++ b/src/changes.ts @@ -3,10 +3,7 @@ // Build a minimal object containing only what changed // ───────────────────────────────────────────── -import { diff } from './diff.js'; import type { - ChangesResult, - DiffOptions, DiffResult, JsonObject, JsonValue, @@ -17,170 +14,96 @@ import { isObject, splitPath } from './utils.js'; // ── Public API ─────────────────────────────── /** - * Compute a sparse representation of the changes between two JSON values. + * Extract a sparse object from a {@link DiffResult} containing only the + * values that changed. Removed keys appear as `null`. * - * Internally calls {@link diff} and then projects the result into a - * {@link ChangesResult} with: - * - `updated` — a sparse object holding only added, replaced, and moved values - * (preserves nested structure). - * - `removed` — an array of RFC 6901 paths that were deleted. - * - `diff` — the full {@link DiffResult} for low-level access. + * Returns `null` when no changes exist. * - * @param before - The source (original) JSON value. - * @param after - The target (modified) JSON value. - * @param options - Optional {@link DiffOptions} forwarded to {@link diff}. - * @returns A {@link ChangesResult} describing what changed. + * @param result - A previously computed {@link DiffResult}. + * @returns A sparse {@link JsonValue} with only changed fields, or `null`. * - * @example Simple object changes + * @example Object changes * ```ts - * import { changes } from '@websublime/delta'; - * - * const before = { name: 'Alice', age: 30, email: 'alice@example.com' }; - * const after = { name: 'Bob', age: 30, role: 'admin' }; - * - * const result = changes(before, after); - * result.updated // → { name: 'Bob', role: 'admin' } - * result.removed // → ['/email'] + * const r = diff( + * { id: 1, message: 'hello', status: 'pending' }, + * { id: 1, message: 'hello', status: 'approved' }, + * ); + * changes(r) // → { status: 'approved' } * ``` * - * @example Nested changes + * @example Nested changes (sparse) * ```ts - * const before = { user: { name: 'Alice', settings: { theme: 'dark', lang: 'en' } } }; - * const after = { user: { name: 'Alice', settings: { theme: 'light', lang: 'en' } } }; - * - * const result = changes(before, after); - * result.updated // → { user: { settings: { theme: 'light' } } } - * result.removed // → [] + * const r = diff( + * { user: { name: 'Alice', settings: { theme: 'dark', lang: 'en' } } }, + * { user: { name: 'Alice', settings: { theme: 'light', lang: 'en' } } }, + * ); + * changes(r) // → { user: { settings: { theme: 'light' } } } * ``` * - * @example Array with identity-based diff + * @example Removals appear as null * ```ts - * const before = { items: [{ id: 1, v: 'a' }, { id: 2, v: 'b' }] }; - * const after = { items: [{ id: 2, v: 'b' }, { id: 1, v: 'x' }] }; - * - * const result = changes(before, after, { arrayIdentity: 'id' }); - * // result.updated includes moved items at their new positions + * changes(diff({ a: 1, b: 2 }, { a: 1 })) // → { b: null } * ``` - */ -export function changes( - before: JsonValue, - after: JsonValue, - options?: DiffOptions, -): ChangesResult { - const result = diff(before, after, options); - return changesFromDiff(result); -} - -/** - * Extract a sparse changes representation from an existing {@link DiffResult}. - * - * Useful when the diff has already been computed and you want the sparse - * changes object without re-diffing. * - * @param result - A previously computed {@link DiffResult}. - * @returns A {@link ChangesResult} derived from the operations in `result`. - * - * @example + * @example Root replacement * ```ts - * import { diff, changesFromDiff } from '@websublime/delta'; - * - * const result = diff(before, after, options); - * // ... inspect result.operations ... - * - * const sparse = changesFromDiff(result); - * sparse.updated // only the values that were set - * sparse.removed // only the paths that were deleted + * changes(diff(1, 2)) // → 2 + * changes(diff('a', { x: 1 })) // → { x: 1 } * ``` */ -export function changesFromDiff(result: DiffResult): ChangesResult { - if (!result.hasChanges) { - return { hasChanges: false, updated: null, removed: [], diff: result }; - } +export function changes(result: DiffResult): JsonValue | null { + if (!result.hasChanges) return null; - // Root-level replace short-circuits — the entire document changed. + // Root-level replace — the entire value changed. const rootReplace = result.operations.find( (op): op is OpReplace => op.op === 'replace' && op.path === '', ); - if (rootReplace) { - return { hasChanges: true, updated: rootReplace.value, removed: [], diff: result }; - } + if (rootReplace) return rootReplace.value; - const removed: string[] = []; - const entries: SparseEntry[] = []; + const root: JsonObject = {}; for (const op of result.operations) { switch (op.op) { case 'add': - entries.push({ segments: splitPath(op.path), value: op.value }); + setAtPath(root, splitPath(op.path), op.value); break; case 'replace': - entries.push({ segments: splitPath(op.path), value: op.value }); + setAtPath(root, splitPath(op.path), op.value); break; case 'remove': - removed.push(op.path); + setAtPath(root, splitPath(op.path), null); break; case 'move': - // Include the value at its destination index. - entries.push({ - segments: [...splitPath(op.path), String(op.toIndex)], - value: op.value, - }); + // Place the value at the destination index within the array path. + setAtPath(root, [...splitPath(op.path), String(op.toIndex)], op.value); break; } } - const updated = buildSparseObject(entries); - const hasUpdatedKeys = Object.keys(updated).length > 0; - - return { - hasChanges: true, - updated: hasUpdatedKeys ? updated : null, - removed, - diff: result, - }; + return Object.keys(root).length > 0 ? root : null; } // ── Internal helpers ───────────────────────── /** - * A path/value pair used to construct the sparse output object. - * `segments` is the already-split (unescaped) JSON Pointer path. - */ -interface SparseEntry { - /** Unescaped path segments (output of {@link splitPath}). */ - segments: string[]; - /** The value to place at this path. */ - value: JsonValue; -} - -/** - * Build a sparse nested object from a list of path/value entries. + * Set a value at a path within a sparse object tree. * * Intermediate containers are always plain objects — even when the path * segment is numeric (array indices become string keys). This keeps the * output JSON-serialisable and avoids sparse `Array` holes. - * - * @param entries - Path/value pairs to insert into the sparse tree. - * @returns A {@link JsonObject} containing only the provided paths. */ -function buildSparseObject(entries: SparseEntry[]): JsonObject { - const root: JsonObject = {}; - - for (const { segments, value } of entries) { - if (segments.length === 0) continue; +function setAtPath(root: JsonObject, segments: string[], value: JsonValue): void { + if (segments.length === 0) return; - let current: JsonObject = root; + let current: JsonObject = root; - for (let i = 0; i < segments.length - 1; i++) { - const seg = segments[i]; - if (!isObject(current[seg] as JsonValue)) { - current[seg] = {} as JsonObject; - } - current = current[seg] as JsonObject; + for (let i = 0; i < segments.length - 1; i++) { + const seg = segments[i]; + if (!isObject(current[seg] as JsonValue)) { + current[seg] = {} as JsonObject; } - - current[segments[segments.length - 1]] = value; + current = current[seg] as JsonObject; } - return root; + current[segments[segments.length - 1]] = value; } diff --git a/src/index.ts b/src/index.ts index 5cd3008..312f9c2 100644 --- a/src/index.ts +++ b/src/index.ts @@ -7,7 +7,7 @@ export { diff } from './diff.js'; export { patch, unpatch } from './patch.js'; -export { changes, changesFromDiff } from './changes.js'; +export { changes } from './changes.js'; // ── RFC 6902 adapter ───────────────────────── @@ -28,8 +28,6 @@ export { DeltaError, type DeltaErrorCode } from './errors.js'; // ── Types ──────────────────────────────────── export type { - // Changes - ChangesResult, // Operations DiffOp, // Options diff --git a/src/types.ts b/src/types.ts index fef2cdd..978fdc0 100644 --- a/src/types.ts +++ b/src/types.ts @@ -225,52 +225,6 @@ export interface DiffOptions { cloneValues?: boolean; } -// ── Changes ────────────────────────────────── - -/** - * Result returned by {@link changes} and {@link changesFromDiff}. - * - * Provides a sparse representation of what changed between two documents, - * split into values that were set (added, replaced, or moved) and paths - * that were removed. - * - * @example - * ```ts - * const before = { name: 'Alice', age: 30, email: 'a@b.c' }; - * const after = { name: 'Bob', age: 30 }; - * - * const result = changes(before, after); - * result.updated // → { name: 'Bob' } - * result.removed // → ['/email'] - * ``` - */ -export interface ChangesResult { - /** `true` when at least one change was detected. */ - hasChanges: boolean; - - /** - * Sparse object containing only the values that were **added**, **replaced**, - * or **moved** (at their destination index). Preserves nested structure — - * intermediate containers are plain objects even for array indices. - * - * `null` when no additions, replacements, or moves exist (i.e. only removals). - */ - updated: JsonValue | null; - - /** - * RFC 6901 JSON Pointer paths that were **removed** from the source document. - * The list is in the same order the remove operations appear in the diff. - */ - removed: string[]; - - /** - * The complete {@link DiffResult} for low-level operation access. - * Useful when you need the full operation list, summary counters, or - * the `changedPaths` set beyond what `updated` / `removed` provide. - */ - diff: DiffResult; -} - // ── Internal resolved options ───────────────── /** diff --git a/tests/changes.test.ts b/tests/changes.test.ts index e4a5ae6..7086419 100644 --- a/tests/changes.test.ts +++ b/tests/changes.test.ts @@ -1,122 +1,118 @@ import { describe, expect, it } from 'vitest'; -import { changes, changesFromDiff, diff } from '../src/index.js'; +import { changes, diff } from '../src/index.js'; describe('changes — no changes', () => { - it('returns hasChanges false for identical primitives', () => { - const r = changes(42, 42); - expect(r.hasChanges).toBe(false); - expect(r.updated).toBeNull(); - expect(r.removed).toEqual([]); + it('returns null for identical primitives', () => { + expect(changes(diff(42, 42))).toBeNull(); }); - it('returns hasChanges false for identical objects', () => { - const r = changes({ a: 1, b: 2 }, { a: 1, b: 2 }); - expect(r.hasChanges).toBe(false); - expect(r.updated).toBeNull(); - expect(r.removed).toEqual([]); + it('returns null for identical objects', () => { + expect(changes(diff({ a: 1, b: 2 }, { a: 1, b: 2 }))).toBeNull(); }); - it('returns hasChanges false for identical arrays', () => { - const r = changes([1, 2, 3], [1, 2, 3]); - expect(r.hasChanges).toBe(false); - expect(r.updated).toBeNull(); + it('returns null for identical arrays', () => { + expect(changes(diff([1, 2, 3], [1, 2, 3]))).toBeNull(); }); }); describe('changes — object diffs', () => { - it('detects added keys in updated', () => { - const r = changes({ a: 1 }, { a: 1, b: 2 }); - expect(r.hasChanges).toBe(true); - expect(r.updated).toEqual({ b: 2 }); - expect(r.removed).toEqual([]); + it('returns only added keys', () => { + expect(changes(diff({ a: 1 }, { a: 1, b: 2 }))).toEqual({ b: 2 }); }); - it('detects removed keys in removed', () => { - const r = changes({ a: 1, b: 2 }, { a: 1 }); - expect(r.hasChanges).toBe(true); - expect(r.updated).toBeNull(); - expect(r.removed).toEqual(['/b']); + it('returns null for removed keys', () => { + expect(changes(diff({ a: 1, b: 2 }, { a: 1 }))).toEqual({ b: null }); }); - it('detects replaced values in updated', () => { - const r = changes({ a: 1 }, { a: 99 }); - expect(r.hasChanges).toBe(true); - expect(r.updated).toEqual({ a: 99 }); - expect(r.removed).toEqual([]); + it('returns replaced values', () => { + expect(changes(diff({ a: 1 }, { a: 99 }))).toEqual({ a: 99 }); }); - it('splits additions and removals correctly', () => { + it('splits additions, replacements and removals', () => { const before = { name: 'Alice', age: 30, email: 'a@b.c' }; const after = { name: 'Bob', age: 30, role: 'admin' }; - const r = changes(before, after); - expect(r.updated).toEqual({ name: 'Bob', role: 'admin' }); - expect(r.removed).toEqual(['/email']); + expect(changes(diff(before, after))).toEqual({ + name: 'Bob', + role: 'admin', + email: null, + }); }); it('handles multiple changes across different types', () => { const before = { a: 1, b: 2, c: 3 }; const after = { a: 99, c: 3, d: 4 }; - const r = changes(before, after); - expect(r.updated).toEqual({ a: 99, d: 4 }); - expect(r.removed).toContain('/b'); + const result = changes(diff(before, after)); + expect(result).toEqual({ a: 99, b: null, d: 4 }); }); }); describe('changes — nested objects', () => { - it('preserves nested structure in updated', () => { + it('preserves sparse nested structure', () => { const before = { user: { name: 'Alice', settings: { theme: 'dark', lang: 'en' } } }; const after = { user: { name: 'Alice', settings: { theme: 'light', lang: 'en' } } }; - const r = changes(before, after); - expect(r.updated).toEqual({ user: { settings: { theme: 'light' } } }); - expect(r.removed).toEqual([]); + expect(changes(diff(before, after))).toEqual({ + user: { settings: { theme: 'light' } }, + }); }); it('handles deeply nested additions', () => { const before = { a: { b: { c: 1 } } }; const after = { a: { b: { c: 1, d: 2 } } }; - const r = changes(before, after); - expect(r.updated).toEqual({ a: { b: { d: 2 } } }); + expect(changes(diff(before, after))).toEqual({ a: { b: { d: 2 } } }); }); - it('handles deeply nested removals', () => { + it('handles deeply nested removals as null', () => { const before = { a: { b: { c: 1, d: 2 } } }; const after = { a: { b: { c: 1 } } }; - const r = changes(before, after); - expect(r.updated).toBeNull(); - expect(r.removed).toEqual(['/a/b/d']); + expect(changes(diff(before, after))).toEqual({ a: { b: { d: null } } }); }); it('handles mixed nested additions and removals', () => { const before = { x: { y: 1, z: 2 }, w: 3 }; const after = { x: { y: 99 }, w: 3, v: 4 }; - const r = changes(before, after); - expect(r.updated).toEqual({ x: { y: 99 }, v: 4 }); - expect(r.removed).toContain('/x/z'); + expect(changes(diff(before, after))).toEqual({ + x: { y: 99, z: null }, + v: 4, + }); }); }); describe('changes — root replacement', () => { - it('returns the new value when root is replaced (primitive → primitive)', () => { - const r = changes(1, 2); - expect(r.hasChanges).toBe(true); - expect(r.updated).toBe(2); - expect(r.removed).toEqual([]); + it('returns the new value for primitive → primitive', () => { + expect(changes(diff(1, 2))).toBe(2); }); it('returns the new value when root type changes', () => { - const r = changes('hello', { a: 1 }); - expect(r.updated).toEqual({ a: 1 }); + expect(changes(diff('hello', { a: 1 }))).toEqual({ a: 1 }); }); - it('returns the new value when object is replaced by array', () => { - const r = changes({ a: 1 }, [1, 2, 3]); - expect(r.updated).toEqual([1, 2, 3]); + it('returns the new value when object → array', () => { + expect(changes(diff({ a: 1 }, [1, 2, 3]))).toEqual([1, 2, 3]); + }); +}); + +describe('changes — arrays (positional)', () => { + it('represents array changes as sparse object with string keys', () => { + const result = changes(diff([1, 2, 3], [1, 99, 3])); + // LCS: remove index 1, add index 1 + expect(result).toHaveProperty('1'); + }); + + it('root-level array add', () => { + const result = changes(diff([1, 2], [1, 2, 3])); + expect(result).toEqual({ '2': 3 }); + }); + + it('root-level array remove', () => { + const result = changes(diff([1, 2, 3], [1, 3])); + // Remove at index 1 (value 2) + expect(result).toHaveProperty('1'); }); }); @@ -131,108 +127,66 @@ describe('changes — arrays with identity', () => { { id: 1, name: 'Alice' }, ]; - const r = changes(before, after, { arrayIdentity: 'id' }); - expect(r.hasChanges).toBe(true); - expect(r.updated).not.toBeNull(); - // Moved items appear under their new indices - const updated = r.updated as Record; - expect(updated['0']).toEqual({ id: 2, name: 'Bob' }); - expect(updated['1']).toEqual({ id: 1, name: 'Alice' }); + const result = changes(diff(before, after, { arrayIdentity: 'id' })); + expect(result).toEqual({ + '0': { id: 2, name: 'Bob' }, + '1': { id: 1, name: 'Alice' }, + }); }); - it('includes added items and excludes removed from updated', () => { + it('includes added items in nested array', () => { const before = { items: [{ id: 1, v: 'a' }] }; const after = { items: [{ id: 1, v: 'a' }, { id: 2, v: 'b' }] }; - const r = changes(before, after, { arrayIdentity: 'id' }); - expect(r.updated).toEqual({ items: { '1': { id: 2, v: 'b' } } }); - expect(r.removed).toEqual([]); + expect(changes(diff(before, after, { arrayIdentity: 'id' }))).toEqual({ + items: { '1': { id: 2, v: 'b' } }, + }); }); - it('lists removed array items in removed', () => { + it('removed array items appear as null', () => { const before = { items: [{ id: 1, v: 'a' }, { id: 2, v: 'b' }] }; const after = { items: [{ id: 1, v: 'a' }] }; - const r = changes(before, after, { arrayIdentity: 'id' }); - expect(r.removed).toHaveLength(1); - expect(r.removed[0]).toMatch(/^\/items\/\d+$/); + const result = changes(diff(before, after, { arrayIdentity: 'id' })) as Record>; + // The removed item index should be null + const itemsChanges = result.items; + const removedKey = Object.keys(itemsChanges).find((k) => itemsChanges[k] === null); + expect(removedKey).toBeDefined(); }); it('handles moved-and-changed items', () => { const before = { items: [{ id: 1, v: 'a' }, { id: 2, v: 'b' }] }; const after = { items: [{ id: 2, v: 'X' }, { id: 1, v: 'a' }] }; - const r = changes(before, after, { arrayIdentity: 'id' }); - expect(r.hasChanges).toBe(true); - expect(r.updated).not.toBeNull(); - // The moved item with changed value should appear at its new index - const updated = r.updated as Record>; - expect(updated.items['0']).toEqual({ id: 2, v: 'X' }); + const result = changes(diff(before, after, { arrayIdentity: 'id' })) as Record>; + expect(result.items['0']).toEqual({ id: 2, v: 'X' }); }); }); -describe('changes — options forwarding', () => { +describe('changes — options forwarding via diff', () => { it('respects ignore option', () => { const before = { a: 1, b: 2, meta: { ts: 100 } }; const after = { a: 99, b: 2, meta: { ts: 200 } }; - const r = changes(before, after, { ignore: ['/meta/ts'] }); - expect(r.updated).toEqual({ a: 99 }); - expect(r.removed).toEqual([]); + expect(changes(diff(before, after, { ignore: ['/meta/ts'] }))).toEqual({ a: 99 }); }); it('respects maxDepth option', () => { const before = { a: { b: { c: 1 } } }; const after = { a: { b: { c: 2 } } }; - const r = changes(before, after, { maxDepth: 1 }); // At depth 1, the entire nested object is replaced as a blob - expect(r.updated).toEqual({ a: { b: { c: 2 } } }); - }); -}); - -describe('changesFromDiff — standalone usage', () => { - it('produces same result as changes() from a pre-computed diff', () => { - const before = { name: 'Alice', age: 30 }; - const after = { name: 'Bob', age: 30, role: 'admin' }; - - const result = diff(before, after); - const fromChanges = changes(before, after); - const fromDiff = changesFromDiff(result); - - expect(fromDiff.hasChanges).toBe(fromChanges.hasChanges); - expect(fromDiff.updated).toEqual(fromChanges.updated); - expect(fromDiff.removed).toEqual(fromChanges.removed); - }); - - it('provides access to the underlying DiffResult', () => { - const result = diff({ a: 1 }, { a: 2 }); - const r = changesFromDiff(result); - - expect(r.diff).toBe(result); - expect(r.diff.operations).toHaveLength(1); - expect(r.diff.summary.replaced).toBe(1); - }); - - it('handles empty diff result', () => { - const result = diff(42, 42); - const r = changesFromDiff(result); - - expect(r.hasChanges).toBe(false); - expect(r.updated).toBeNull(); - expect(r.removed).toEqual([]); + expect(changes(diff(before, after, { maxDepth: 1 }))).toEqual({ + a: { b: { c: 2 } }, + }); }); }); describe('changes — only removals', () => { - it('returns null updated when only removals exist', () => { + it('returns object with null values for all removed keys', () => { const before = { a: 1, b: 2, c: 3 }; const after = { a: 1 }; - const r = changes(before, after); - expect(r.updated).toBeNull(); - expect(r.removed).toContain('/b'); - expect(r.removed).toContain('/c'); - expect(r.removed).toHaveLength(2); + expect(changes(diff(before, after))).toEqual({ b: null, c: null }); }); }); From 17bf4c417c7487099c961bc3b275ce3f2dbbab5d Mon Sep 17 00:00:00 2001 From: Miguel Ramos Date: Fri, 17 Apr 2026 10:25:42 +0100 Subject: [PATCH 08/10] =?UTF-8?q?rename:=20changes()=20=E2=86=92=20snapsho?= =?UTF-8?q?t()?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reserve the changes name for a future feature. --- README.md | 24 +++---- src/index.ts | 2 +- src/{changes.ts => snapshot.ts} | 12 ++-- tests/{changes.test.ts => snapshot.test.ts} | 74 ++++++++++----------- 4 files changed, 56 insertions(+), 56 deletions(-) rename src/{changes.ts => snapshot.ts} (90%) rename tests/{changes.test.ts => snapshot.test.ts} (61%) diff --git a/README.md b/README.md index d9b7922..6ae23fb 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ **delta://** — Typed JSON model diffing for TypeScript. -Diff any two JSON values and get a structured, typed result with JSON Pointer paths. Apply it forward with `patch`, reverse it with `unpatch`, extract a sparse changes object with `changes`, or export it as an RFC 6902 patch. +Diff any two JSON values and get a structured, typed result with JSON Pointer paths. Apply it forward with `patch`, reverse it with `unpatch`, extract a sparse snapshot with `snapshot`, or export it as an RFC 6902 patch. ```ts import { diff, patch, unpatch } from '@websublime/delta' @@ -17,8 +17,8 @@ const forward = patch(before, result) // === after const backward = unpatch(after, result) // === before // Extract only what changed — ideal for PATCH payloads or audit logs -import { changes } from '@websublime/delta' -const sparse = changes(result) +import { snapshot } from '@websublime/delta' +const sparse = snapshot(result) // → { users: { '0': { id: 2, role: 'mod' }, '1': { id: 1, role: 'admin' } } } ``` @@ -28,7 +28,7 @@ const sparse = changes(result) - **Typed operations** — `add | remove | replace | move`, each with the right shape - **JSON Pointer paths** (RFC 6901) — `/users/0/role`, `~0` and `~1` escaping included - **Identity-based array diffing** — track items by id across reorders, adds, removes; deterministic even with duplicate ids -- **Sparse changes** — `changes()` returns a minimal object with only changed fields (removals as `null`) — ready for PATCH payloads, form dirty tracking, or audit logs +- **Sparse snapshot** — `snapshot()` returns a minimal object with only changed fields (removals as `null`) — ready for PATCH payloads, form dirty tracking, or audit logs - **Bidirectional** — `patch` and `unpatch` both work from the diff result alone; `oldValue` is always present on destructive ops - **RFC 6902 adapter** — export any diff as a standard JSON Patch - **Runtime validation** — `patch`/`unpatch` reject malformed inputs with a typed `DeltaError` @@ -84,17 +84,17 @@ unpatch(after, result) // { x: 1 } Neither function mutates its inputs. `unpatch` only needs `after` + the diff result — it never needs `before` because `oldValue` is always stored on destructive operations. -### changes +### snapshot Extract a sparse object containing only the values that changed — useful for HTTP PATCH payloads, form dirty tracking, optimistic UI updates, or audit logs. ```ts -import { diff, changes } from '@websublime/delta' +import { diff, snapshot } from '@websublime/delta' const before = { name: 'Alice', age: 30, email: 'alice@example.com' } const after = { name: 'Bob', age: 30, role: 'admin' } -changes(diff(before, after)) +snapshot(diff(before, after)) // → { name: 'Bob', role: 'admin', email: null } ``` @@ -104,28 +104,28 @@ Nested structure is sparse — only the branches that actually changed appear: const before = { user: { name: 'Alice', settings: { theme: 'dark', lang: 'en' } } } const after = { user: { name: 'Alice', settings: { theme: 'light', lang: 'en' } } } -changes(diff(before, after)) +snapshot(diff(before, after)) // → { user: { settings: { theme: 'light' } } } ``` Removed keys appear as `null`: ```ts -changes(diff({ a: 1, b: 2 }, { a: 1 })) +snapshot(diff({ a: 1, b: 2 }, { a: 1 })) // → { b: null } ``` Root replacements return the new value directly: ```ts -changes(diff(1, 2)) // → 2 -changes(diff('hello', { x: 1 })) // → { x: 1 } +snapshot(diff(1, 2)) // → 2 +snapshot(diff('hello', { x: 1 })) // → { x: 1 } ``` Returns `null` when nothing changed: ```ts -changes(diff({ a: 1 }, { a: 1 })) // → null +snapshot(diff({ a: 1 }, { a: 1 })) // → null ``` ### Identity-based array diffing diff --git a/src/index.ts b/src/index.ts index 312f9c2..2148fd5 100644 --- a/src/index.ts +++ b/src/index.ts @@ -7,7 +7,7 @@ export { diff } from './diff.js'; export { patch, unpatch } from './patch.js'; -export { changes } from './changes.js'; +export { snapshot } from './snapshot.js'; // ── RFC 6902 adapter ───────────────────────── diff --git a/src/changes.ts b/src/snapshot.ts similarity index 90% rename from src/changes.ts rename to src/snapshot.ts index 62df3a3..bdba6a5 100644 --- a/src/changes.ts +++ b/src/snapshot.ts @@ -28,7 +28,7 @@ import { isObject, splitPath } from './utils.js'; * { id: 1, message: 'hello', status: 'pending' }, * { id: 1, message: 'hello', status: 'approved' }, * ); - * changes(r) // → { status: 'approved' } + * snapshot(r) // → { status: 'approved' } * ``` * * @example Nested changes (sparse) @@ -37,21 +37,21 @@ import { isObject, splitPath } from './utils.js'; * { user: { name: 'Alice', settings: { theme: 'dark', lang: 'en' } } }, * { user: { name: 'Alice', settings: { theme: 'light', lang: 'en' } } }, * ); - * changes(r) // → { user: { settings: { theme: 'light' } } } + * snapshot(r) // → { user: { settings: { theme: 'light' } } } * ``` * * @example Removals appear as null * ```ts - * changes(diff({ a: 1, b: 2 }, { a: 1 })) // → { b: null } + * snapshot(diff({ a: 1, b: 2 }, { a: 1 })) // → { b: null } * ``` * * @example Root replacement * ```ts - * changes(diff(1, 2)) // → 2 - * changes(diff('a', { x: 1 })) // → { x: 1 } + * snapshot(diff(1, 2)) // → 2 + * snapshot(diff('a', { x: 1 })) // → { x: 1 } * ``` */ -export function changes(result: DiffResult): JsonValue | null { +export function snapshot(result: DiffResult): JsonValue | null { if (!result.hasChanges) return null; // Root-level replace — the entire value changed. diff --git a/tests/changes.test.ts b/tests/snapshot.test.ts similarity index 61% rename from tests/changes.test.ts rename to tests/snapshot.test.ts index 7086419..6a09f8f 100644 --- a/tests/changes.test.ts +++ b/tests/snapshot.test.ts @@ -1,59 +1,59 @@ import { describe, expect, it } from 'vitest'; -import { changes, diff } from '../src/index.js'; +import { snapshot, diff } from '../src/index.js'; -describe('changes — no changes', () => { +describe('snapshot — no snapshot', () => { it('returns null for identical primitives', () => { - expect(changes(diff(42, 42))).toBeNull(); + expect(snapshot(diff(42, 42))).toBeNull(); }); it('returns null for identical objects', () => { - expect(changes(diff({ a: 1, b: 2 }, { a: 1, b: 2 }))).toBeNull(); + expect(snapshot(diff({ a: 1, b: 2 }, { a: 1, b: 2 }))).toBeNull(); }); it('returns null for identical arrays', () => { - expect(changes(diff([1, 2, 3], [1, 2, 3]))).toBeNull(); + expect(snapshot(diff([1, 2, 3], [1, 2, 3]))).toBeNull(); }); }); -describe('changes — object diffs', () => { +describe('snapshot — object diffs', () => { it('returns only added keys', () => { - expect(changes(diff({ a: 1 }, { a: 1, b: 2 }))).toEqual({ b: 2 }); + expect(snapshot(diff({ a: 1 }, { a: 1, b: 2 }))).toEqual({ b: 2 }); }); it('returns null for removed keys', () => { - expect(changes(diff({ a: 1, b: 2 }, { a: 1 }))).toEqual({ b: null }); + expect(snapshot(diff({ a: 1, b: 2 }, { a: 1 }))).toEqual({ b: null }); }); it('returns replaced values', () => { - expect(changes(diff({ a: 1 }, { a: 99 }))).toEqual({ a: 99 }); + expect(snapshot(diff({ a: 1 }, { a: 99 }))).toEqual({ a: 99 }); }); it('splits additions, replacements and removals', () => { const before = { name: 'Alice', age: 30, email: 'a@b.c' }; const after = { name: 'Bob', age: 30, role: 'admin' }; - expect(changes(diff(before, after))).toEqual({ + expect(snapshot(diff(before, after))).toEqual({ name: 'Bob', role: 'admin', email: null, }); }); - it('handles multiple changes across different types', () => { + it('handles multiple snapshot across different types', () => { const before = { a: 1, b: 2, c: 3 }; const after = { a: 99, c: 3, d: 4 }; - const result = changes(diff(before, after)); + const result = snapshot(diff(before, after)); expect(result).toEqual({ a: 99, b: null, d: 4 }); }); }); -describe('changes — nested objects', () => { +describe('snapshot — nested objects', () => { it('preserves sparse nested structure', () => { const before = { user: { name: 'Alice', settings: { theme: 'dark', lang: 'en' } } }; const after = { user: { name: 'Alice', settings: { theme: 'light', lang: 'en' } } }; - expect(changes(diff(before, after))).toEqual({ + expect(snapshot(diff(before, after))).toEqual({ user: { settings: { theme: 'light' } }, }); }); @@ -62,61 +62,61 @@ describe('changes — nested objects', () => { const before = { a: { b: { c: 1 } } }; const after = { a: { b: { c: 1, d: 2 } } }; - expect(changes(diff(before, after))).toEqual({ a: { b: { d: 2 } } }); + expect(snapshot(diff(before, after))).toEqual({ a: { b: { d: 2 } } }); }); it('handles deeply nested removals as null', () => { const before = { a: { b: { c: 1, d: 2 } } }; const after = { a: { b: { c: 1 } } }; - expect(changes(diff(before, after))).toEqual({ a: { b: { d: null } } }); + expect(snapshot(diff(before, after))).toEqual({ a: { b: { d: null } } }); }); it('handles mixed nested additions and removals', () => { const before = { x: { y: 1, z: 2 }, w: 3 }; const after = { x: { y: 99 }, w: 3, v: 4 }; - expect(changes(diff(before, after))).toEqual({ + expect(snapshot(diff(before, after))).toEqual({ x: { y: 99, z: null }, v: 4, }); }); }); -describe('changes — root replacement', () => { +describe('snapshot — root replacement', () => { it('returns the new value for primitive → primitive', () => { - expect(changes(diff(1, 2))).toBe(2); + expect(snapshot(diff(1, 2))).toBe(2); }); - it('returns the new value when root type changes', () => { - expect(changes(diff('hello', { a: 1 }))).toEqual({ a: 1 }); + it('returns the new value when root type snapshot', () => { + expect(snapshot(diff('hello', { a: 1 }))).toEqual({ a: 1 }); }); it('returns the new value when object → array', () => { - expect(changes(diff({ a: 1 }, [1, 2, 3]))).toEqual([1, 2, 3]); + expect(snapshot(diff({ a: 1 }, [1, 2, 3]))).toEqual([1, 2, 3]); }); }); -describe('changes — arrays (positional)', () => { - it('represents array changes as sparse object with string keys', () => { - const result = changes(diff([1, 2, 3], [1, 99, 3])); +describe('snapshot — arrays (positional)', () => { + it('represents array snapshot as sparse object with string keys', () => { + const result = snapshot(diff([1, 2, 3], [1, 99, 3])); // LCS: remove index 1, add index 1 expect(result).toHaveProperty('1'); }); it('root-level array add', () => { - const result = changes(diff([1, 2], [1, 2, 3])); + const result = snapshot(diff([1, 2], [1, 2, 3])); expect(result).toEqual({ '2': 3 }); }); it('root-level array remove', () => { - const result = changes(diff([1, 2, 3], [1, 3])); + const result = snapshot(diff([1, 2, 3], [1, 3])); // Remove at index 1 (value 2) expect(result).toHaveProperty('1'); }); }); -describe('changes — arrays with identity', () => { +describe('snapshot — arrays with identity', () => { it('includes moved items at their destination index', () => { const before = [ { id: 1, name: 'Alice' }, @@ -127,7 +127,7 @@ describe('changes — arrays with identity', () => { { id: 1, name: 'Alice' }, ]; - const result = changes(diff(before, after, { arrayIdentity: 'id' })); + const result = snapshot(diff(before, after, { arrayIdentity: 'id' })); expect(result).toEqual({ '0': { id: 2, name: 'Bob' }, '1': { id: 1, name: 'Alice' }, @@ -138,7 +138,7 @@ describe('changes — arrays with identity', () => { const before = { items: [{ id: 1, v: 'a' }] }; const after = { items: [{ id: 1, v: 'a' }, { id: 2, v: 'b' }] }; - expect(changes(diff(before, after, { arrayIdentity: 'id' }))).toEqual({ + expect(snapshot(diff(before, after, { arrayIdentity: 'id' }))).toEqual({ items: { '1': { id: 2, v: 'b' } }, }); }); @@ -147,7 +147,7 @@ describe('changes — arrays with identity', () => { const before = { items: [{ id: 1, v: 'a' }, { id: 2, v: 'b' }] }; const after = { items: [{ id: 1, v: 'a' }] }; - const result = changes(diff(before, after, { arrayIdentity: 'id' })) as Record>; + const result = snapshot(diff(before, after, { arrayIdentity: 'id' })) as Record>; // The removed item index should be null const itemsChanges = result.items; const removedKey = Object.keys(itemsChanges).find((k) => itemsChanges[k] === null); @@ -158,17 +158,17 @@ describe('changes — arrays with identity', () => { const before = { items: [{ id: 1, v: 'a' }, { id: 2, v: 'b' }] }; const after = { items: [{ id: 2, v: 'X' }, { id: 1, v: 'a' }] }; - const result = changes(diff(before, after, { arrayIdentity: 'id' })) as Record>; + const result = snapshot(diff(before, after, { arrayIdentity: 'id' })) as Record>; expect(result.items['0']).toEqual({ id: 2, v: 'X' }); }); }); -describe('changes — options forwarding via diff', () => { +describe('snapshot — options forwarding via diff', () => { it('respects ignore option', () => { const before = { a: 1, b: 2, meta: { ts: 100 } }; const after = { a: 99, b: 2, meta: { ts: 200 } }; - expect(changes(diff(before, after, { ignore: ['/meta/ts'] }))).toEqual({ a: 99 }); + expect(snapshot(diff(before, after, { ignore: ['/meta/ts'] }))).toEqual({ a: 99 }); }); it('respects maxDepth option', () => { @@ -176,17 +176,17 @@ describe('changes — options forwarding via diff', () => { const after = { a: { b: { c: 2 } } }; // At depth 1, the entire nested object is replaced as a blob - expect(changes(diff(before, after, { maxDepth: 1 }))).toEqual({ + expect(snapshot(diff(before, after, { maxDepth: 1 }))).toEqual({ a: { b: { c: 2 } }, }); }); }); -describe('changes — only removals', () => { +describe('snapshot — only removals', () => { it('returns object with null values for all removed keys', () => { const before = { a: 1, b: 2, c: 3 }; const after = { a: 1 }; - expect(changes(diff(before, after))).toEqual({ b: null, c: null }); + expect(snapshot(diff(before, after))).toEqual({ b: null, c: null }); }); }); From 5832453a237a8d9ddb579b26efec9179625f0343 Mon Sep 17 00:00:00 2001 From: Miguel Ramos Date: Fri, 17 Apr 2026 10:27:39 +0100 Subject: [PATCH 09/10] fix: correct changeset command in hook and example MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit changeset add → changeset create --- .changesets/README-example.yaml | 4 ++-- .githooks/pre-push | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.changesets/README-example.yaml b/.changesets/README-example.yaml index be677e6..ce61114 100644 --- a/.changesets/README-example.yaml +++ b/.changesets/README-example.yaml @@ -1,13 +1,13 @@ # Example Changeset # # This is an example changeset file to help you understand the format. -# When you're ready to create real changesets, use: workspace changeset add +# When you're ready to create real changesets, use: workspace changeset create # # Important: Changesets should be committed to git as part of your PR. # They tell the CI/CD pipeline what version bumps to perform on merge. # # Workflow: -# 1. Create a changeset: workspace changeset add +# 1. Create a changeset: workspace changeset create # 2. Commit it with your changes: git add .changesets/your-changeset.yaml # 3. Push your branch: git push # 4. When merged to main, CI runs: workspace bump --execute diff --git a/.githooks/pre-push b/.githooks/pre-push index 372de60..599c047 100755 --- a/.githooks/pre-push +++ b/.githooks/pre-push @@ -30,7 +30,7 @@ if [ "$count" -eq 0 ]; then echo "" >&2 echo "pre-push: no changesets queued in $CHANGESET_DIR/" >&2 echo "" >&2 - echo " Create one with: workspace changeset add" >&2 + echo " Create one with: workspace changeset create" >&2 echo " Or bypass with: git push --no-verify" >&2 echo "" >&2 exit 1 From 228cf3b4e83b886761e0babca6d6ccda5018584c Mon Sep 17 00:00:00 2001 From: Miguel Ramos Date: Fri, 17 Apr 2026 10:29:18 +0100 Subject: [PATCH 10/10] chore: changeset added --- .changesets/feat-rework.json | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 .changesets/feat-rework.json diff --git a/.changesets/feat-rework.json b/.changesets/feat-rework.json new file mode 100644 index 0000000..44c9f9d --- /dev/null +++ b/.changesets/feat-rework.json @@ -0,0 +1,13 @@ +{ + "branch": "feat/rework", + "bump": "minor", + "environments": [ + "production" + ], + "packages": [ + "@websublime/delta" + ], + "changes": [], + "created_at": "2026-04-17T09:28:59.285429Z", + "updated_at": "2026-04-17T09:28:59.286518Z" +} \ No newline at end of file