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/.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 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 diff --git a/README.md b/README.md index 6383e21..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`, 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' @@ -15,6 +15,11 @@ 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 { snapshot } from '@websublime/delta' +const sparse = snapshot(result) +// → { users: { '0': { id: 2, role: 'mod' }, '1': { id: 1, role: 'admin' } } } ``` ## Features @@ -23,6 +28,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 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` @@ -78,6 +84,50 @@ 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. +### 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, snapshot } from '@websublime/delta' + +const before = { name: 'Alice', age: 30, email: 'alice@example.com' } +const after = { name: 'Bob', age: 30, role: 'admin' } + +snapshot(diff(before, after)) +// → { name: 'Bob', role: 'admin', email: null } +``` + +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' } } } + +snapshot(diff(before, after)) +// → { user: { settings: { theme: 'light' } } } +``` + +Removed keys appear as `null`: + +```ts +snapshot(diff({ a: 1, b: 2 }, { a: 1 })) +// → { b: null } +``` + +Root replacements return the new value directly: + +```ts +snapshot(diff(1, 2)) // → 2 +snapshot(diff('hello', { x: 1 })) // → { x: 1 } +``` + +Returns `null` when nothing changed: + +```ts +snapshot(diff({ a: 1 }, { a: 1 })) // → null +``` + ### Identity-based array diffing When your array items have a stable identifier, use `arrayIdentity` to track them across reorders, adds and removes: diff --git a/src/diff.ts b/src/diff.ts index ecdec91..a3d5d09 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[], @@ -316,14 +435,27 @@ function diffArraysByIdentity( if (opts.detectMoves) { if (moved) { - 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( @@ -389,6 +521,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..89b5192 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' @@ -30,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/index.ts b/src/index.ts index 13bbdde..2148fd5 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 { snapshot } from './snapshot.js'; + +// ── RFC 6902 adapter ───────────────────────── + +export { toRFC6902, toRFC6902JSON } from './rfc6902.js'; export type { RFC6902Add, RFC6902Move, @@ -14,7 +20,13 @@ 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 { // Operations DiffOp, @@ -23,12 +35,13 @@ export type { // Result DiffResult, DiffSummary, + // Identity Identity, IdentityFn, IdentityKey, + // Values JsonArray, JsonObject, - // Values JsonPrimitive, JsonValue, OpAdd, diff --git a/src/lcs.ts b/src/lcs.ts index dc673dc..aeac5ce 100644 --- a/src/lcs.ts +++ b/src/lcs.ts @@ -3,12 +3,27 @@ // 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. + * + * 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; } @@ -28,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 860fa66..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 ──────────────────────── @@ -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,20 +358,46 @@ 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; if (segs.length === 1) return ''; - return `/${segs.slice(0, -1).join('/')}`; + return joinPath('', ...segs.slice(0, -1)); } +/** + * 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..58eb7fb 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 ──────────────────────────────── @@ -56,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/snapshot.ts b/src/snapshot.ts new file mode 100644 index 0000000..bdba6a5 --- /dev/null +++ b/src/snapshot.ts @@ -0,0 +1,109 @@ +// ───────────────────────────────────────────── +// delta:// — sparse change extraction +// Build a minimal object containing only what changed +// ───────────────────────────────────────────── + +import type { + DiffResult, + JsonObject, + JsonValue, + OpReplace, +} from './types.js'; +import { isObject, splitPath } from './utils.js'; + +// ── Public API ─────────────────────────────── + +/** + * Extract a sparse object from a {@link DiffResult} containing only the + * values that changed. Removed keys appear as `null`. + * + * Returns `null` when no changes exist. + * + * @param result - A previously computed {@link DiffResult}. + * @returns A sparse {@link JsonValue} with only changed fields, or `null`. + * + * @example Object changes + * ```ts + * const r = diff( + * { id: 1, message: 'hello', status: 'pending' }, + * { id: 1, message: 'hello', status: 'approved' }, + * ); + * snapshot(r) // → { status: 'approved' } + * ``` + * + * @example Nested changes (sparse) + * ```ts + * const r = diff( + * { user: { name: 'Alice', settings: { theme: 'dark', lang: 'en' } } }, + * { user: { name: 'Alice', settings: { theme: 'light', lang: 'en' } } }, + * ); + * snapshot(r) // → { user: { settings: { theme: 'light' } } } + * ``` + * + * @example Removals appear as null + * ```ts + * snapshot(diff({ a: 1, b: 2 }, { a: 1 })) // → { b: null } + * ``` + * + * @example Root replacement + * ```ts + * snapshot(diff(1, 2)) // → 2 + * snapshot(diff('a', { x: 1 })) // → { x: 1 } + * ``` + */ +export function snapshot(result: DiffResult): JsonValue | null { + if (!result.hasChanges) return null; + + // Root-level replace — the entire value changed. + const rootReplace = result.operations.find( + (op): op is OpReplace => op.op === 'replace' && op.path === '', + ); + if (rootReplace) return rootReplace.value; + + const root: JsonObject = {}; + + for (const op of result.operations) { + switch (op.op) { + case 'add': + setAtPath(root, splitPath(op.path), op.value); + break; + case 'replace': + setAtPath(root, splitPath(op.path), op.value); + break; + case 'remove': + setAtPath(root, splitPath(op.path), null); + break; + case 'move': + // Place the value at the destination index within the array path. + setAtPath(root, [...splitPath(op.path), String(op.toIndex)], op.value); + break; + } + } + + return Object.keys(root).length > 0 ? root : null; +} + +// ── Internal helpers ───────────────────────── + +/** + * 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. + */ +function setAtPath(root: JsonObject, segments: string[], value: JsonValue): void { + if (segments.length === 0) return; + + 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; +} diff --git a/src/types.ts b/src/types.ts index c92edde..978fdc0 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 ──────────────────────────────── @@ -36,6 +46,16 @@ export interface OpReplace { * An array item was moved within the same array. * `path` = array root (e.g. `/items`). * Applied AFTER removes and BEFORE adds. + * + * 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. + * + * `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'; @@ -44,29 +64,87 @@ 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; } +/** + * 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 +227,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; 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 353054a..631fb78 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 }); @@ -292,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); }); }); @@ -365,4 +399,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); } }); diff --git a/tests/snapshot.test.ts b/tests/snapshot.test.ts new file mode 100644 index 0000000..6a09f8f --- /dev/null +++ b/tests/snapshot.test.ts @@ -0,0 +1,192 @@ +import { describe, expect, it } from 'vitest'; +import { snapshot, diff } from '../src/index.js'; + +describe('snapshot — no snapshot', () => { + it('returns null for identical primitives', () => { + expect(snapshot(diff(42, 42))).toBeNull(); + }); + + it('returns null for identical objects', () => { + expect(snapshot(diff({ a: 1, b: 2 }, { a: 1, b: 2 }))).toBeNull(); + }); + + it('returns null for identical arrays', () => { + expect(snapshot(diff([1, 2, 3], [1, 2, 3]))).toBeNull(); + }); +}); + +describe('snapshot — object diffs', () => { + it('returns only added keys', () => { + expect(snapshot(diff({ a: 1 }, { a: 1, b: 2 }))).toEqual({ b: 2 }); + }); + + it('returns null for removed keys', () => { + expect(snapshot(diff({ a: 1, b: 2 }, { a: 1 }))).toEqual({ b: null }); + }); + + it('returns replaced values', () => { + 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(snapshot(diff(before, after))).toEqual({ + name: 'Bob', + role: 'admin', + email: null, + }); + }); + + 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 = snapshot(diff(before, after)); + expect(result).toEqual({ a: 99, b: null, d: 4 }); + }); +}); + +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(snapshot(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 } } }; + + 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(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(snapshot(diff(before, after))).toEqual({ + x: { y: 99, z: null }, + v: 4, + }); + }); +}); + +describe('snapshot — root replacement', () => { + it('returns the new value for primitive → primitive', () => { + expect(snapshot(diff(1, 2))).toBe(2); + }); + + 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(snapshot(diff({ a: 1 }, [1, 2, 3]))).toEqual([1, 2, 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 = snapshot(diff([1, 2], [1, 2, 3])); + expect(result).toEqual({ '2': 3 }); + }); + + it('root-level array remove', () => { + const result = snapshot(diff([1, 2, 3], [1, 3])); + // Remove at index 1 (value 2) + expect(result).toHaveProperty('1'); + }); +}); + +describe('snapshot — 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 result = snapshot(diff(before, after, { arrayIdentity: 'id' })); + expect(result).toEqual({ + '0': { id: 2, name: 'Bob' }, + '1': { id: 1, name: 'Alice' }, + }); + }); + + 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' }] }; + + expect(snapshot(diff(before, after, { arrayIdentity: 'id' }))).toEqual({ + items: { '1': { id: 2, v: 'b' } }, + }); + }); + + 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 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); + 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 result = snapshot(diff(before, after, { arrayIdentity: 'id' })) as Record>; + expect(result.items['0']).toEqual({ id: 2, v: 'X' }); + }); +}); + +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(snapshot(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 } } }; + + // At depth 1, the entire nested object is replaced as a blob + expect(snapshot(diff(before, after, { maxDepth: 1 }))).toEqual({ + a: { b: { c: 2 } }, + }); + }); +}); + +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(snapshot(diff(before, after))).toEqual({ b: null, c: null }); + }); +});