diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..5ec53d4 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,54 @@ +# CLAUDE.md + +Project conventions and architecture for AI-assisted development. + +## Build & Test + +```sh +npm run build # tsup --format cjs,esm (outputs to dist/) +npm run lint # eslint src/**/*.ts +npm test # jest --config jest.config.mjs +npx tsc --noEmit # type check without emitting +``` + +## Project Structure + +``` +src/ + index.ts # Re-exports from all modules + jsonDiff.ts # Core diff engine: diff, applyChangeset, revertChangeset, + # atomizeChangeset, unatomizeChangeset + jsonCompare.ts # Enriched comparison: compare, enrich, applyChangelist + helpers.ts # Shared utilities: splitJSONPath, keyBy, setByPath + deltaPath.ts # JSON Delta path parsing, canonicalization, conversion + jsonDelta.ts # JSON Delta APIs: diffDelta, applyDelta, revertDelta, + # invertDelta, toDelta, fromDelta, validateDelta +tests/ + __fixtures__/ # Test fixtures (jsonDiff fixtures + json-delta conformance) +``` + +## Key Architecture Notes + +- **Internal format**: Hierarchical `IChange[]` tree (v4). Flat `IAtomicChange[]` via atomize/unatomize. +- **JSON Delta format**: Flat `IDeltaOperation[]` in an `IJsonDelta` envelope. Spec at [json-delta-format](https://github.com/ltwlf/json-delta-format). +- **Adapter pattern**: `jsonDelta.ts` converts between internal and delta formats. No changes to `jsonDiff.ts`. +- **`diffDelta`** always uses `treatTypeChangeAsReplace: true` and merges REMOVE+ADD pairs into single `replace` ops. +- **`applyDelta`** processes operations sequentially with dedicated root (`$`) handling. +- **`fromDelta`** returns `IAtomicChange[]` (1:1 mapping), NOT `Changeset`. +- **Path differences**: Internal uses `$[a.b]` (no quotes); delta spec requires `$['a.b']` (single-quoted). Internal always string-quotes filter literals; spec requires type-correct literals. + +## Known Limitations (don't try to fix in jsonDiff.ts) + +- `unatomizeChangeset` regex only matches string-quoted filter literals (B.2 in plan) +- `applyChangeset` doesn't handle `$root` leaf operations correctly (B.4) +- `atomizeChangeset` has `isTestEnv` check (lines 175-178) β€” orthogonal smell +- `filterExpression` numeric branch is dead code (B.8) + +## Conventions + +- ESM-first (`"type": "module"` in package.json), dual CJS/ESM output via tsup +- TypeScript strict mode (`noImplicitAny`, `noUnusedLocals`, `noUnusedParameters`) +- `strictNullChecks` is OFF (tsconfig) +- Zero runtime dependencies +- Tests use Jest with ts-jest +- Existing tests use snapshots β€” don't update snapshots without verifying changes diff --git a/README.md b/README.md index 847fdbe..24c84c1 100644 --- a/README.md +++ b/README.md @@ -12,33 +12,106 @@ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) [![Buy Me A Coffee](https://img.shields.io/badge/Buy%20Me%20A%20Coffee-support-yellow.svg?logo=buy-me-a-coffee)](https://buymeacoffee.com/leitwolf) -## Overview +**Deterministic JSON state transitions with key-based array identity.** A TypeScript JSON diff library that computes, applies, and reverts atomic changes using the [JSON Delta](https://github.com/ltwlf/json-delta-format) wire format -- a JSON Patch alternative with stable array paths, built-in undo/redo for JSON, and language-agnostic state synchronization. -**Modern TypeScript JSON diff library** - `json-diff-ts` is a lightweight, high-performance TypeScript library for calculating and applying differences between JSON objects. Perfect for modern web applications, state management, data synchronization, and real-time collaborative editing. +Zero dependencies. TypeScript-first. ESM + CommonJS. Trusted by thousands of developers ([500K+ weekly npm downloads](https://www.npmjs.com/package/json-diff-ts)). -### πŸš€ **Why Choose json-diff-ts?** +## Why Index-Based Diffing Breaks -- **πŸ”₯ Zero dependencies** - Lightweight bundle size -- **⚑ High performance** - Optimized algorithms for fast JSON diffing and patching -- **🎯 95%+ test coverage** - Thoroughly tested with comprehensive test suite -- **πŸ“¦ Modern ES modules** - Full TypeScript support with tree-shaking -- **πŸ”§ Flexible API** - Compare, diff, patch, and atomic operations -- **🌐 Universal** - Works in browsers, Node.js, and edge environments -- **βœ… Production ready** - Used in enterprise applications worldwide -- **🎯 TypeScript-first** - Full type safety and IntelliSense support -- **πŸ”§ Modern features** - ESM + CommonJS, JSONPath, atomic operations -- **πŸ“¦ Production ready** - Battle-tested with comprehensive test suite +Most JSON diff libraries track array changes by position. Insert one element at the start and every path shifts: -### ✨ **Key Features** +```text +Remove /items/0 ← was actually "Widget" +Add /items/0 ← now it's "NewItem" +Update /items/1 ← this used to be /items/0 +... +``` + +This makes diffs fragile -- you can't store them, replay them reliably, or build audit logs on top of them. Reorder the array and every operation is wrong. This is the fundamental problem with index-based formats like JSON Patch (RFC 6902): paths like `/items/0` are positional, so any insertion, deletion, or reorder invalidates every subsequent path. + +**json-diff-ts solves this with key-based identity.** Array elements are matched by a stable key (`id`, `sku`, or any field), and paths use JSONPath filter expressions that survive insertions, deletions, and reordering: + +```typescript +import { diffDelta, applyDelta, revertDelta } from 'json-diff-ts'; + +const before = { + items: [ + { id: 1, name: 'Widget', price: 9.99 }, + { id: 2, name: 'Gadget', price: 24.99 }, + ], +}; + +const after = { + items: [ + { id: 2, name: 'Gadget', price: 24.99 }, // reordered + { id: 1, name: 'Widget Pro', price: 14.99 }, // renamed + repriced + { id: 3, name: 'Doohickey', price: 4.99 }, // added + ], +}; + +const delta = diffDelta(before, after, { arrayIdentityKeys: { items: 'id' } }); +``` + +The delta tracks _what_ changed, not _where_ it moved: + +```json +{ + "format": "json-delta", + "version": 1, + "operations": [ + { "op": "replace", "path": "$.items[?(@.id==1)].name", "value": "Widget Pro", "oldValue": "Widget" }, + { "op": "replace", "path": "$.items[?(@.id==1)].price", "value": 14.99, "oldValue": 9.99 }, + { "op": "add", "path": "$.items[?(@.id==3)]", "value": { "id": 3, "name": "Doohickey", "price": 4.99 } } + ] +} +``` + +Apply forward to get the new state, or revert to restore the original: + +```typescript +// Clone before applying β€” applyDelta mutates the input object +const updated = applyDelta(structuredClone(before), delta); // updated === after +const restored = revertDelta(structuredClone(updated), delta); // restored === before +``` + +## Quick Start + +```typescript +import { diffDelta, applyDelta, revertDelta } from 'json-diff-ts'; + +const oldObj = { + items: [ + { id: 1, name: 'Widget', price: 9.99 }, + { id: 2, name: 'Gadget', price: 24.99 }, + ], +}; -- **Key-based array identification**: Compare array elements using keys instead of indices for more intuitive diffing -- **JSONPath support**: Target specific parts of JSON documents with precision -- **Atomic changesets**: Transform changes into granular, independently applicable operations -- **Dual module support**: Works with both ECMAScript Modules and CommonJS -- **Type change handling**: Flexible options for handling data type changes -- **Path skipping**: Skip nested paths during comparison for performance +const newObj = { + items: [ + { id: 1, name: 'Widget Pro', price: 9.99 }, + { id: 2, name: 'Gadget', price: 24.99 }, + { id: 3, name: 'Doohickey', price: 4.99 }, + ], +}; -This library is particularly valuable for applications where tracking changes in JSON data is crucial, such as state management systems, form handling, or data synchronization. +// 1. Compute a delta between two JSON objects +const delta = diffDelta(oldObj, newObj, { + arrayIdentityKeys: { items: 'id' }, // match array elements by 'id' field +}); +// delta.operations => +// [ +// { op: 'replace', path: '$.items[?(@.id==1)].name', value: 'Widget Pro', oldValue: 'Widget' }, +// { op: 'add', path: '$.items[?(@.id==3)]', value: { id: 3, name: 'Doohickey', price: 4.99 } } +// ] + +// 2. Apply the delta to produce the new state +const updated = applyDelta(structuredClone(oldObj), delta); + +// 3. Revert the delta to restore the original state +const reverted = revertDelta(structuredClone(updated), delta); +``` + +That's it. `delta` is a plain JSON object you can store in a database, send over HTTP, or consume in any language. ## Installation @@ -46,289 +119,455 @@ This library is particularly valuable for applications where tracking changes in npm install json-diff-ts ``` -## Quick Start +```typescript +// ESM / TypeScript +import { diffDelta, applyDelta, revertDelta } from 'json-diff-ts'; + +// CommonJS +const { diffDelta, applyDelta, revertDelta } = require('json-diff-ts'); +``` + +## What is JSON Delta? + +[JSON Delta](https://github.com/ltwlf/json-delta-format) is a specification for representing atomic changes to JSON documents. json-diff-ts is the originating implementation from which the spec was derived. + +A delta is a self-describing JSON document you can store, transmit, and consume in any language: + +- **Three operations** -- `add`, `remove`, `replace`. Nothing else to learn. +- **JSONPath-based paths** -- `$.items[?(@.id==1)].name` identifies elements by key, not index. +- **Reversible by default** -- every `replace` and `remove` includes `oldValue` for undo. +- **Self-identifying** -- the `format` field makes deltas discoverable without external context. +- **Extension-friendly** -- unknown properties are preserved; `x_`-prefixed properties are future-safe. + +### JSON Delta vs JSON Patch (RFC 6902) + +JSON Patch uses JSON Pointer paths like `/items/0` that reference array elements by index. When an element is inserted at position 0, every subsequent path shifts -- `/items/1` now points to what was `/items/0`. This makes stored patches unreliable for JSON change tracking, audit logs, or undo/redo across time. + +JSON Delta uses JSONPath filter expressions like `$.items[?(@.id==1)]` that identify elements by a stable key. The path stays valid regardless of insertions, deletions, or reordering. + +| | JSON Delta | JSON Patch (RFC 6902) | +| --- | --- | --- | +| Path syntax | JSONPath (`$.items[?(@.id==1)]`) | JSON Pointer (`/items/0`) | +| Array identity | Key-based -- survives reorder | Index-based -- breaks on insert/delete | +| Reversibility | Built-in `oldValue` | Not supported | +| Self-describing | `format` field in envelope | No envelope | +| Specification | [json-delta-format](https://github.com/ltwlf/json-delta-format) | [RFC 6902](https://tools.ietf.org/html/rfc6902) | + +--- + +## JSON Delta API + +### `diffDelta` -- Compute a Delta ```typescript -import { diff, applyChangeset } from 'json-diff-ts'; - -// Two versions of data -const oldData = { name: 'Luke', level: 1, skills: ['piloting'] }; -const newData = { name: 'Luke Skywalker', level: 5, skills: ['piloting', 'force'] }; - -// Calculate differences -const changes = diff(oldData, newData); -console.log(changes); -// Output: [ -// { type: 'UPDATE', key: 'name', value: 'Luke Skywalker', oldValue: 'Luke' }, -// { type: 'UPDATE', key: 'level', value: 5, oldValue: 1 }, -// { type: 'ADD', key: 'skills', value: 'force', embeddedKey: '1' } -// ] +const delta = diffDelta( + { user: { name: 'Alice', role: 'viewer' } }, + { user: { name: 'Alice', role: 'admin' } } +); +// delta.operations β†’ [{ op: 'replace', path: '$.user.role', value: 'admin', oldValue: 'viewer' }] +``` + +#### Keyed Arrays -// Apply changes to get the new object -const result = applyChangeset(oldData, changes); -console.log(result); // { name: 'Luke Skywalker', level: 5, skills: ['piloting', 'force'] } +Match array elements by identity key. Filter paths use canonical typed literals per the spec: + +```typescript +const delta = diffDelta( + { users: [{ id: 1, role: 'viewer' }, { id: 2, role: 'editor' }] }, + { users: [{ id: 1, role: 'admin' }, { id: 2, role: 'editor' }] }, + { arrayIdentityKeys: { users: 'id' } } +); +// delta.operations β†’ [{ op: 'replace', path: '$.users[?(@.id==1)].role', value: 'admin', oldValue: 'viewer' }] ``` -### Import Options +#### Non-reversible Mode + +Omit `oldValue` fields when you don't need undo: -**TypeScript / ES Modules:** ```typescript -import { diff } from 'json-diff-ts'; +const delta = diffDelta(source, target, { reversible: false }); ``` -**CommonJS:** -```javascript -const { diff } = require('json-diff-ts'); +### `applyDelta` -- Apply a Delta + +Applies operations sequentially. Always use the return value (required for root-level replacements): + +```typescript +const result = applyDelta(structuredClone(source), delta); ``` -## Core Features +### `revertDelta` -- Revert a Delta -### `diff` +Computes the inverse and applies it. Requires `oldValue` on all `replace` and `remove` operations: -Generates a difference set for JSON objects. When comparing arrays, if a specific key is provided, differences are determined by matching elements via this key rather than array indices. +```typescript +const original = revertDelta(structuredClone(target), delta); +``` + +### `invertDelta` -- Compute the Inverse -#### Basic Example with Star Wars Data +Returns a new delta that undoes the original (spec Section 9.2): ```typescript -import { diff } from 'json-diff-ts'; +const inverse = invertDelta(delta); +// add ↔ remove, replace swaps value/oldValue, order reversed +``` -// State during A New Hope - Desert planet, small rebel cell -const oldData = { - location: 'Tatooine', - mission: 'Rescue Princess', - status: 'In Progress', - characters: [ - { id: 'LUKE_SKYWALKER', name: 'Luke Skywalker', role: 'Farm Boy', forceTraining: false }, - { id: 'LEIA_ORGANA', name: 'Princess Leia', role: 'Prisoner', forceTraining: false } - ], - equipment: ['Lightsaber', 'Blaster'] -}; +### `validateDelta` -- Validate Structure -// State after successful rescue - Base established, characters evolved -const newData = { - location: 'Yavin Base', - mission: 'Destroy Death Star', - status: 'Complete', - characters: [ - { id: 'LUKE_SKYWALKER', name: 'Luke Skywalker', role: 'Pilot', forceTraining: true, rank: 'Commander' }, - { id: 'HAN_SOLO', name: 'Han Solo', role: 'Smuggler', forceTraining: false, ship: 'Millennium Falcon' } +```typescript +const { valid, errors } = validateDelta(maybeDelta); +``` + +### API Reference + +| Function | Signature | Description | +| --- | --- | --- | +| `diffDelta` | `(oldObj, newObj, options?) => IJsonDelta` | Compute a canonical JSON Delta | +| `applyDelta` | `(obj, delta) => any` | Apply a delta sequentially. Returns the result | +| `revertDelta` | `(obj, delta) => any` | Revert a reversible delta | +| `invertDelta` | `(delta) => IJsonDelta` | Compute the inverse delta | +| `validateDelta` | `(delta) => { valid, errors }` | Structural validation | +| `toDelta` | `(changeset, options?) => IJsonDelta` | Bridge: v4 changeset to JSON Delta | +| `fromDelta` | `(delta) => IAtomicChange[]` | Bridge: JSON Delta to v4 atomic changes | + +### DeltaOptions + +Extends the base `Options` interface: + +```typescript +interface DeltaOptions extends Options { + reversible?: boolean; // Include oldValue for undo. Default: true + arrayIdentityKeys?: Record; + keysToSkip?: readonly string[]; +} +``` + +--- + +## Practical Examples + +### Audit Log + +Store every change to a document as a reversible delta. Each entry records who changed what, when, and can be replayed or reverted independently -- a complete JSON change tracking system: + +```typescript +import { diffDelta, applyDelta, revertDelta, IJsonDelta } from 'json-diff-ts'; + +interface AuditEntry { + timestamp: string; + userId: string; + delta: IJsonDelta; +} + +const auditLog: AuditEntry[] = []; +let doc = { + title: 'Project Plan', + status: 'draft', + items: [ + { id: 1, task: 'Design', done: false }, + { id: 2, task: 'Build', done: false }, ], - equipment: ['Lightsaber', 'Blaster', 'Bowcaster', 'X-wing Fighter'] }; -const diffs = diff(oldData, newData, { embeddedObjKeys: { characters: 'id' } }); -console.log(diffs); -// First operations: +function updateDocument(newDoc: typeof doc, userId: string) { + const delta = diffDelta(doc, newDoc, { + arrayIdentityKeys: { items: 'id' }, + }); + + if (delta.operations.length > 0) { + auditLog.push({ timestamp: new Date().toISOString(), userId, delta }); + doc = applyDelta(structuredClone(doc), delta); + } + + return doc; +} + +// Revert the last change +function undo(): typeof doc { + const last = auditLog.pop(); + if (!last) return doc; + doc = revertDelta(structuredClone(doc), last.delta); + return doc; +} + +// Example usage: +updateDocument( + { ...doc, status: 'active', items: [{ id: 1, task: 'Design', done: true }, ...doc.items.slice(1)] }, + 'alice' +); +// auditLog[0].delta.operations => // [ -// { type: 'UPDATE', key: 'location', value: 'Yavin Base', oldValue: 'Tatooine' }, -// { type: 'UPDATE', key: 'mission', value: 'Destroy Death Star', oldValue: 'Rescue Princess' }, -// { type: 'UPDATE', key: 'status', value: 'Complete', oldValue: 'In Progress' }, -// ... +// { op: 'replace', path: '$.status', value: 'active', oldValue: 'draft' }, +// { op: 'replace', path: '$.items[?(@.id==1)].done', value: true, oldValue: false } // ] ``` -#### Advanced Options +Because every delta is self-describing JSON, your audit log is queryable, storable in any database, and readable from any language. -##### Path-based Key Identification +### Undo / Redo Stack -```javascript -import { diff } from 'json-diff-ts'; +Build undo/redo for any JSON state object. Deltas are small (only changed fields), reversible, and serializable: -// Using nested paths for sub-arrays -const diffs = diff(oldData, newData, { embeddedObjKeys: { 'characters.equipment': 'id' } }); +```typescript +import { diffDelta, applyDelta, revertDelta, IJsonDelta } from 'json-diff-ts'; -// Designating root with '.' - useful for complex nested structures -const diffs = diff(oldData, newData, { embeddedObjKeys: { '.characters.allies': 'id' } }); -``` +class UndoManager { + private undoStack: IJsonDelta[] = []; + private redoStack: IJsonDelta[] = []; -##### Type Change Handling + constructor(private state: T) {} -```javascript -import { diff } from 'json-diff-ts'; + apply(newState: T): T { + const delta = diffDelta(this.state, newState); + if (delta.operations.length === 0) return this.state; + this.undoStack.push(delta); + this.redoStack = []; + this.state = applyDelta(structuredClone(this.state), delta); + return this.state; + } + + undo(): T { + const delta = this.undoStack.pop(); + if (!delta) return this.state; + this.redoStack.push(delta); + this.state = revertDelta(structuredClone(this.state), delta); + return this.state; + } -// Control how type changes are treated -const diffs = diff(oldData, newData, { treatTypeChangeAsReplace: false }); + redo(): T { + const delta = this.redoStack.pop(); + if (!delta) return this.state; + this.undoStack.push(delta); + this.state = applyDelta(structuredClone(this.state), delta); + return this.state; + } +} ``` -Date objects can now be updated to primitive values without errors when `treatTypeChangeAsReplace` is set to `false`. +### Data Synchronization -##### Skip Nested Paths +Send only what changed between client and server. Deltas are compact -- a single field change in a 10KB document produces a few bytes of delta, making state synchronization efficient over the wire: -```javascript -import { diff } from 'json-diff-ts'; +```typescript +import { diffDelta, applyDelta, validateDelta } from 'json-diff-ts'; + +// Client side: compute and send delta +const delta = diffDelta(localState, updatedState, { + arrayIdentityKeys: { records: 'id' }, +}); +await fetch('/api/sync', { + method: 'POST', + body: JSON.stringify(delta), +}); -// Skip specific nested paths from comparison - useful for ignoring metadata -const diffs = diff(oldData, newData, { keysToSkip: ['characters.metadata'] }); +// Server side: validate and apply +const result = validateDelta(req.body); +if (!result.valid) return res.status(400).json(result.errors); +// ⚠️ In production, sanitize paths/values to prevent prototype pollution +// (e.g. reject paths containing "__proto__" or "constructor") +currentState = applyDelta(structuredClone(currentState), req.body); ``` -##### Dynamic Key Resolution +--- -```javascript -import { diff } from 'json-diff-ts'; +## Bridge: v4 Changeset <-> JSON Delta -// Use function to resolve object keys dynamically -const diffs = diff(oldData, newData, { - embeddedObjKeys: { - characters: (obj, shouldReturnKeyName) => (shouldReturnKeyName ? 'id' : obj.id) - } -}); +Convert between the legacy internal format and JSON Delta: -// Access index for array elements -const rebels = [ - { name: 'Luke Skywalker', faction: 'Jedi' }, - { name: 'Yoda', faction: 'Jedi' }, - { name: 'Princess Leia', faction: 'Rebellion' } -]; - -const diffs = diff(oldRebels, newRebels, { - embeddedObjKeys: { - rebels: (obj, shouldReturnKeyName, index) => { - if (shouldReturnKeyName) return 'faction'; - // Use index to differentiate rebels in the same faction - return `faction.${obj.faction}.${index}`; - } - } -}); +```typescript +import { diff, toDelta, fromDelta, unatomizeChangeset } from 'json-diff-ts'; + +// v4 changeset β†’ JSON Delta +const changeset = diff(source, target, { arrayIdentityKeys: { items: 'id' } }); +const delta = toDelta(changeset); + +// JSON Delta β†’ v4 atomic changes +const atoms = fromDelta(delta); + +// v4 atomic changes β†’ hierarchical changeset (if needed) +const cs = unatomizeChangeset(atoms); ``` -##### Regular Expression Paths +**Note:** `toDelta` is a best-effort bridge. Filter literals are always string-quoted (e.g., `[?(@.id=='42')]` instead of canonical `[?(@.id==42)]`). Use `diffDelta()` for fully canonical output. + +--- + +## Legacy Changeset API (v4 Compatibility) + +All v4 APIs remain fully supported. Existing code continues to work without changes. For new projects, prefer the JSON Delta API above. + +### `diff` -```javascript +Generates a hierarchical changeset between two objects: + +```typescript import { diff } from 'json-diff-ts'; -// Use regex for path matching - powerful for dynamic property names -const embeddedObjKeys = new Map(); -embeddedObjKeys.set(/^characters/, 'id'); // Match any property starting with 'characters' -const diffs = diff(oldData, newData, { embeddedObjKeys }); +const oldData = { + location: 'Tatooine', + characters: [ + { id: 'LUKE', name: 'Luke Skywalker', role: 'Farm Boy' }, + { id: 'LEIA', name: 'Princess Leia', role: 'Prisoner' } + ], +}; + +const newData = { + location: 'Yavin Base', + characters: [ + { id: 'LUKE', name: 'Luke Skywalker', role: 'Pilot', rank: 'Commander' }, + { id: 'HAN', name: 'Han Solo', role: 'Smuggler' } + ], +}; + +const changes = diff(oldData, newData, { arrayIdentityKeys: { characters: 'id' } }); ``` -##### String Array Comparison +### `applyChangeset` and `revertChangeset` -```javascript -import { diff } from 'json-diff-ts'; +```typescript +import { applyChangeset, revertChangeset } from 'json-diff-ts'; -// Compare string arrays by value instead of index - useful for tags, categories -const diffs = diff(oldData, newData, { embeddedObjKeys: { equipment: '$value' } }); +const updated = applyChangeset(structuredClone(oldData), changes); +const reverted = revertChangeset(structuredClone(newData), changes); ``` ### `atomizeChangeset` and `unatomizeChangeset` -Transform complex changesets into a list of atomic changes (and back), each describable by a JSONPath. +Flatten a hierarchical changeset into atomic changes addressable by JSONPath, or reconstruct the hierarchy: -```javascript +```typescript import { atomizeChangeset, unatomizeChangeset } from 'json-diff-ts'; -// Create atomic changes -const atomicChanges = atomizeChangeset(diffs); +const atoms = atomizeChangeset(changes); +// [ +// { type: 'UPDATE', key: 'location', value: 'Yavin Base', oldValue: 'Tatooine', +// path: '$.location', valueType: 'String' }, +// { type: 'ADD', key: 'rank', value: 'Commander', +// path: "$.characters[?(@.id=='LUKE')].rank", valueType: 'String' }, +// ... +// ] -// Restore the changeset from a selection of atomic changes -const changeset = unatomizeChangeset(atomicChanges.slice(0, 3)); +const restored = unatomizeChangeset(atoms.slice(0, 2)); ``` -**Atomic Changes Structure:** - -```javascript -[ - { - type: 'UPDATE', - key: 'location', - value: 'Yavin Base', - oldValue: 'Tatooine', - path: '$.location', - valueType: 'String' - }, - { - type: 'UPDATE', - key: 'mission', - value: 'Destroy Death Star', - oldValue: 'Rescue Princess', - path: '$.mission', - valueType: 'String' - }, - { - type: 'ADD', - key: 'rank', - value: 'Commander', - path: "$.characters[?(@.id=='LUKE_SKYWALKER')].rank", - valueType: 'String' - }, - { - type: 'ADD', - key: 'HAN_SOLO', - value: { id: 'HAN_SOLO', name: 'Han Solo', role: 'Smuggler', forceTraining: false, ship: 'Millennium Falcon' }, - path: "$.characters[?(@.id=='HAN_SOLO')]", - valueType: 'Object' - } -] -``` +### Advanced Options -### `applyChangeset` and `revertChangeset` +#### Key-based Array Matching -Apply or revert changes to JSON objects. +```typescript +// Named key +diff(old, new, { arrayIdentityKeys: { characters: 'id' } }); -```javascript -import { applyChangeset, revertChangeset } from 'json-diff-ts'; +// Function key +diff(old, new, { + arrayIdentityKeys: { + characters: (obj, shouldReturnKeyName) => (shouldReturnKeyName ? 'id' : obj.id) + } +}); -// Apply changes -const updated = applyChangeset(oldData, diffs); -console.log(updated); -// { location: 'Yavin Base', mission: 'Destroy Death Star', status: 'Complete', ... } +// Regex path matching +const keys = new Map(); +keys.set(/^characters/, 'id'); +diff(old, new, { arrayIdentityKeys: keys }); -// Revert changes -const reverted = revertChangeset(newData, diffs); -console.log(reverted); -// { location: 'Tatooine', mission: 'Rescue Princess', status: 'In Progress', ... } +// Value-based identity for primitive arrays +diff(old, new, { arrayIdentityKeys: { tags: '$value' } }); ``` -## API Reference +#### Path Skipping -### Core Functions +```typescript +diff(old, new, { keysToSkip: ['characters.metadata'] }); +``` + +#### Type Change Handling + +```typescript +diff(old, new, { treatTypeChangeAsReplace: false }); +``` + +### Legacy API Reference -| Function | Description | Parameters | -|----------|-------------|------------| -| `diff(oldObj, newObj, options?)` | Generate differences between two objects | `oldObj`: Original object
`newObj`: Updated object
`options`: Optional configuration | -| `applyChangeset(obj, changeset)` | Apply changes to an object | `obj`: Object to modify
`changeset`: Changes to apply | -| `revertChangeset(obj, changeset)` | Revert changes from an object | `obj`: Object to modify
`changeset`: Changes to revert | -| `atomizeChangeset(changeset)` | Convert changeset to atomic changes | `changeset`: Nested changeset | -| `unatomizeChangeset(atomicChanges)` | Convert atomic changes back to nested changeset | `atomicChanges`: Array of atomic changes | +| Function | Description | +| --- | --- | +| `diff(oldObj, newObj, options?)` | Compute hierarchical changeset | +| `applyChangeset(obj, changeset)` | Apply a changeset to an object | +| `revertChangeset(obj, changeset)` | Revert a changeset from an object | +| `atomizeChangeset(changeset)` | Flatten to atomic changes with JSONPath | +| `unatomizeChangeset(atoms)` | Reconstruct hierarchy from atomic changes | ### Comparison Functions -| Function | Description | Parameters | -|----------|-------------|------------| -| `compare(oldObj, newObj)` | Create enriched comparison object | `oldObj`: Original object
`newObj`: Updated object | -| `enrich(obj)` | Create enriched representation of object | `obj`: Object to enrich | -| `createValue(value)` | Create value node for comparison | `value`: Any value | -| `createContainer(value)` | Create container node for comparison | `value`: Object or Array | +| Function | Description | +| --- | --- | +| `compare(oldObj, newObj)` | Create enriched comparison object | +| `enrich(obj)` | Create enriched representation | -### Options Interface +### Options ```typescript interface Options { + arrayIdentityKeys?: Record | Map; + /** @deprecated Use arrayIdentityKeys instead */ embeddedObjKeys?: Record | Map; keysToSkip?: readonly string[]; - treatTypeChangeAsReplace?: boolean; + treatTypeChangeAsReplace?: boolean; // default: true } ``` -| Option | Type | Description | -| ------ | ---- | ----------- | -| `embeddedObjKeys` | `Record` or `Map` | Map paths of arrays to a key or resolver function used to match elements when diffing. Use a `Map` for regex paths. | -| `keysToSkip` | `readonly string[]` | Dotted paths to exclude from comparison, e.g. `"meta.info"`. | -| `treatTypeChangeAsReplace` | `boolean` | When `true` (default), a type change results in a REMOVE/ADD pair. Set to `false` to treat it as an UPDATE. | +--- -### Change Types +## Migration from v4 -```typescript -enum Operation { - REMOVE = 'REMOVE', - ADD = 'ADD', - UPDATE = 'UPDATE' -} -``` +1. **No action required** -- all v4 APIs work identically in v5. +2. **Adopt JSON Delta** -- use `diffDelta()` / `applyDelta()` for new code. +3. **Bridge existing data** -- `toDelta()` / `fromDelta()` for interop with stored v4 changesets. +4. **Rename `embeddedObjKeys` to `arrayIdentityKeys`** -- the old name still works, but `arrayIdentityKeys` is the preferred name going forward. +5. Both formats coexist. No forced migration. + +--- + +## Why json-diff-ts? + +| Feature | json-diff-ts | deep-diff | jsondiffpatch | RFC 6902 | +| --- | --- | --- | --- | --- | +| TypeScript | Native | Partial | Definitions only | Varies | +| Bundle Size | ~21KB | ~45KB | ~120KB+ | Varies | +| Dependencies | Zero | Few | Many | Varies | +| ESM Support | Native | CJS only | CJS only | Varies | +| Array Identity | Key-based | Index only | Configurable | Index only | +| Wire Format | JSON Delta (standardized) | Proprietary | Proprietary | JSON Pointer | +| Reversibility | Built-in (`oldValue`) | Manual | Plugin | Not built-in | + +## FAQ + +**Q: How does JSON Delta compare to JSON Patch (RFC 6902)?** +JSON Patch uses JSON Pointer (`/items/0`) for paths, which breaks when array elements are inserted, deleted, or reordered. JSON Delta uses JSONPath filter expressions (`$.items[?(@.id==1)]`) for stable, key-based identity. JSON Delta also supports built-in reversibility via `oldValue`. + +**Q: Can I use this with React / Vue / Angular?** +Yes. json-diff-ts works in any JavaScript runtime -- browsers, Node.js, Deno, Bun, edge workers. + +**Q: Is it suitable for large objects?** +Yes. The library handles large, deeply nested JSON structures efficiently with zero dependencies and a ~6KB gzipped footprint. + +**Q: Can I use the v4 API alongside JSON Delta?** +Yes. Both APIs coexist. Use `toDelta()` / `fromDelta()` to convert between formats. + +**Q: What about arrays of primitives?** +Use `$value` as the identity key: `{ arrayIdentityKeys: { tags: '$value' } }`. Elements are matched by value identity. + +--- ## Release Notes +- **v5.0.0-alpha.0:** + - JSON Delta API: `diffDelta`, `applyDelta`, `revertDelta`, `invertDelta`, `toDelta`, `fromDelta`, `validateDelta` + - Canonical path production with typed filter literals + - Conformance with the [JSON Delta Specification](https://github.com/ltwlf/json-delta-format) v0 + - Renamed `embeddedObjKeys` to `arrayIdentityKeys` (old name still works as deprecated alias) + - All v4 APIs preserved unchanged + - **v4.9.0:** - Fixed `applyChangeset` and `revertChangeset` for root-level arrays containing objects (fixes #362) - Fixed `compare` on root-level arrays producing unexpected UNCHANGED entries (fixes #358) @@ -340,83 +579,23 @@ enum Operation { - Fixed README Options Interface formatting (#360) - **v4.8.2:** Fixed array handling in `applyChangeset` for null, undefined, and deleted elements (fixes issue #316) - **v4.8.1:** Improved documentation with working examples and detailed options. -- **v4.8.0:** Significantly reduced bundle size by completely removing es-toolkit dependency and implementing custom utility functions. This change eliminates external dependencies while maintaining identical functionality and improving performance. - +- **v4.8.0:** Significantly reduced bundle size by completely removing es-toolkit dependency and implementing custom utility functions. - **v4.7.0:** Optimized bundle size and performance by replacing es-toolkit/compat with es-toolkit for difference, intersection, and keyBy functions - - **v4.6.3:** Fixed null comparison returning update when values are both null (fixes issue #284) - -- **v4.6.2:** Fixed updating to null when `treatTypeChangeAsReplace` is false and bumped Jest dev dependencies +- **v4.6.2:** Fixed updating to null when `treatTypeChangeAsReplace` is false - **v4.6.1:** Consistent JSONPath format for array items (fixes issue #269) - **v4.6.0:** Fixed filter path regex to avoid polynomial complexity -- **v4.5.1:** Updated package dependencies - **v4.5.0:** Switched internal utilities from lodash to es-toolkit/compat for a smaller bundle size - **v4.4.0:** Fixed Date-to-string diff when `treatTypeChangeAsReplace` is false -- **v4.3.0:** Enhanced functionality: - - Added support for nested keys to skip using dotted path notation in the keysToSkip option - - This allows excluding specific nested object paths from comparison (fixes #242) -- **v4.2.0:** Improved stability with multiple fixes: - - Fixed object handling in atomizeChangeset and unatomizeChangeset - - Fixed array handling in applyChangeset and revertChangeset - - Fixed handling of null values in applyChangeset - - Fixed handling of empty REMOVE operations when diffing from undefined +- **v4.3.0:** Added support for nested keys to skip using dotted path notation (fixes #242) +- **v4.2.0:** Improved stability with multiple fixes for atomize/unatomize, apply/revert, null handling - **v4.1.0:** Full support for ES modules while maintaining CommonJS compatibility -- **v4.0.0:** Changed naming of flattenChangeset and unflattenChanges to atomizeChangeset and unatomizeChangeset; added option to set treatTypeChangeAsReplace -- **v3.0.1:** Fixed issue with unflattenChanges when a key has periods -- **v3.0.0:** Added support for both CommonJS and ECMAScript Modules. Replaced lodash-es with lodash to support both module formats -- **v2.2.0:** Fixed lodash-es dependency, added exclude keys option, added string array comparison by value -- **v2.1.0:** Fixed JSON Path filters by replacing single equal sign (=) with double equal sign (==). Added support for using '.' as root in paths -- **v2.0.0:** Upgraded to ECMAScript module format with optimizations and improved documentation. Fixed regex path handling (breaking change: now requires Map instead of Record for regex paths) -- **v1.2.6:** Enhanced JSON Path handling for period-inclusive segments -- **v1.2.5:** Added key name resolution support for key functions -- **v1.2.4:** Documentation updates and dependency upgrades -- **v1.2.3:** Updated dependencies and TypeScript +- **v4.0.0:** Renamed flattenChangeset/unflattenChanges to atomizeChangeset/unatomizeChangeset; added treatTypeChangeAsReplace option ## Contributing Contributions are welcome! Please follow the provided issue templates and code of conduct. -## Performance & Bundle Size - -- **Zero dependencies**: No external runtime dependencies -- **Lightweight**: ~21KB minified, ~6KB gzipped -- **Tree-shakable**: Use only what you need with ES modules -- **High performance**: Optimized for large JSON objects and arrays - -## Use Cases - -- **State Management**: Track changes in Redux, Zustand, or custom state stores -- **Form Handling**: Detect field changes in React, Vue, or Angular forms -- **Data Synchronization**: Sync data between client and server efficiently -- **Version Control**: Implement undo/redo functionality -- **API Optimization**: Send only changed data to reduce bandwidth -- **Real-time Updates**: Track changes in collaborative applications - -## Comparison with Alternatives - -| Feature | json-diff-ts | deep-diff | jsondiffpatch | -|---------|--------------|-----------|---------------| -| TypeScript | βœ… Native | ❌ Partial | ❌ Definitions only | -| Bundle Size | 🟒 21KB | 🟑 45KB | πŸ”΄ 120KB+ | -| Dependencies | 🟒 Zero | 🟑 Few | πŸ”΄ Many | -| ESM Support | βœ… Native | ❌ CJS only | ❌ CJS only | -| Array Key Matching | βœ… Advanced | ❌ Basic | βœ… Advanced | -| JSONPath Support | βœ… Full | ❌ None | ❌ Limited | - -## FAQ - -**Q: Can I use this with React/Vue/Angular?** -A: Yes! json-diff-ts works with any JavaScript framework or vanilla JS. - -**Q: Does it work with Node.js?** -A: Absolutely! Supports Node.js 18+ with both CommonJS and ES modules. - -**Q: How does it compare to JSON Patch (RFC 6902)?** -A: json-diff-ts provides a more flexible format with advanced array handling, while JSON Patch is a standardized format. - -**Q: Is it suitable for large objects?** -A: Yes, the library is optimized for performance and can handle large, complex JSON structures efficiently. - ## Support If you find this library useful, consider supporting its development: @@ -425,8 +604,6 @@ If you find this library useful, consider supporting its development: ## Contact -Reach out to the maintainer: - - LinkedIn: [Christian Glessner](https://www.linkedin.com/in/christian-glessner/) - Twitter: [@leitwolf_io](https://twitter.com/leitwolf_io) diff --git a/package.json b/package.json index a05e00d..d99c3a4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "json-diff-ts", - "version": "4.10.0", + "version": "5.0.0-alpha.0", "description": "Modern TypeScript JSON diff library - Zero dependencies, high performance, ESM + CommonJS support. Calculate and apply differences between JSON objects with advanced features like key-based array diffing, JSONPath support, and atomic changesets.", "main": "./dist/index.cjs", "module": "./dist/index.js", diff --git a/src/deltaPath.ts b/src/deltaPath.ts new file mode 100644 index 0000000..ddefa65 --- /dev/null +++ b/src/deltaPath.ts @@ -0,0 +1,443 @@ +// ─── Segment Types ────────────────────────────────────────────────────────── + +export type DeltaPathSegment = + | { type: 'root' } + | { type: 'property'; name: string } + | { type: 'index'; index: number } + | { type: 'keyFilter'; property: string; value: unknown } + | { type: 'valueFilter'; value: unknown }; + +// ─── Filter Literal Formatting ────────────────────────────────────────────── + +/** + * Format a value as a canonical JSON Delta filter literal. + * Strings β†’ single-quoted with doubled-quote escaping. + * Numbers, booleans, null β†’ plain JSON representation. + */ +export function formatFilterLiteral(value: unknown): string { + if (value === null) return 'null'; + if (typeof value === 'boolean') return String(value); + if (typeof value === 'number') { + if (!Number.isFinite(value)) throw new Error(`Cannot format non-finite number as filter literal: ${value}`); + return String(value); + } + if (typeof value === 'string') return `'${value.replace(/'/g, "''")}'`; + throw new Error(`Cannot format filter literal for type ${typeof value}`); +} + +/** + * Parse a filter literal string into a typed JS value. + * Reverse of formatFilterLiteral. + */ +export function parseFilterLiteral(s: string): unknown { + if (s === 'true') return true; + if (s === 'false') return false; + if (s === 'null') return null; + if (s.startsWith("'") && s.endsWith("'")) { + return s.slice(1, -1).replace(/''/g, "'"); + } + // Number β€” only accept JSON-compatible numeric literals (decimal, optional sign, optional exponent) + if (/^-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?$/.test(s)) { + return Number(s); + } + throw new Error(`Invalid filter literal: ${s}`); +} + +// ─── Quoted String Extraction ─────────────────────────────────────────────── + +/** + * Extract a single-quoted string starting at index `start` (after the opening quote). + * Returns [unescaped string, index of closing quote]. + */ +function extractQuotedString(s: string, start: number): [string, number] { + const result: string[] = []; + let i = start; + while (i < s.length) { + if (s[i] === "'") { + if (i + 1 < s.length && s[i + 1] === "'") { + result.push("'"); + i += 2; + } else { + return [result.join(''), i]; + } + } else { + result.push(s[i]); + i += 1; + } + } + throw new Error('Unterminated quoted string'); +} + +// ─── Filter Closing Search ────────────────────────────────────────────────── + +/** + * Find the index of the closing `)]` for a filter expression starting at `from`, + * skipping over single-quoted strings (with doubled-quote escaping). + * Returns the index of `)` in `)]`, or -1 if not found. + */ +function findFilterClose(s: string, from: number): number { + let i = from; + while (i < s.length) { + if (s[i] === "'") { + // Skip single-quoted string + i += 1; + while (i < s.length) { + if (s[i] === "'") { + if (i + 1 < s.length && s[i + 1] === "'") { + i += 2; // escaped quote + } else { + i += 1; // closing quote + break; + } + } else { + i += 1; + } + } + } else if (s[i] === ')' && i + 1 < s.length && s[i + 1] === ']') { + return i; + } else { + i += 1; + } + } + return -1; +} + +// ─── Filter Parsing ───────────────────────────────────────────────────────── + +function parseFilter(inner: string): DeltaPathSegment { + if (inner.startsWith("@.")) { + // Key filter with dot property: @.key==val + const eq = inner.indexOf('=='); + if (eq === -1) throw new Error(`Invalid filter: missing '==' in ${inner}`); + const key = inner.slice(2, eq); + return { type: 'keyFilter', property: key, value: parseFilterLiteral(inner.slice(eq + 2)) }; + } + if (inner.startsWith("@['")) { + // Key filter with bracket property: @['dotted.key']==val + const [key, endIdx] = extractQuotedString(inner, 3); + // endIdx is at closing quote; then ']', '=', '=' follow + const valStart = endIdx + 4; // skip past ']== + return { type: 'keyFilter', property: key, value: parseFilterLiteral(inner.slice(valStart)) }; + } + if (inner.startsWith('@==')) { + // Value filter: @==val + return { type: 'valueFilter', value: parseFilterLiteral(inner.slice(3)) }; + } + throw new Error(`Invalid filter expression: ${inner}`); +} + +// ─── Path Parsing ─────────────────────────────────────────────────────────── + +/** + * Parse a JSON Delta Path string into an array of typed segments. + * Follows the grammar from the JSON Delta spec Section 5.1. + */ +export function parseDeltaPath(path: string): DeltaPathSegment[] { + if (!path.startsWith('$')) { + throw new Error(`Path must start with '$': ${path}`); + } + + const segments: DeltaPathSegment[] = [{ type: 'root' }]; + let i = 1; + + while (i < path.length) { + if (path[i] === '.') { + // Dot property + i += 1; + const start = i; + while (i < path.length && /[a-zA-Z0-9_]/.test(path[i])) { + i += 1; + } + if (i === start) throw new Error(`Empty property name at position ${i} in: ${path}`); + segments.push({ type: 'property', name: path.slice(start, i) }); + } else if (path[i] === '[') { + if (i + 1 >= path.length) throw new Error(`Unexpected end of path after '[': ${path}`); + + if (path[i + 1] === '?') { + // Filter expression: [?(@...==...)] + const closingIdx = findFilterClose(path, i + 2); + if (closingIdx === -1) throw new Error(`Unterminated filter expression in: ${path}`); + const inner = path.slice(i + 3, closingIdx); // strip "[?(" ... ")" + segments.push(parseFilter(inner)); + i = closingIdx + 2; + } else if (path[i + 1] === "'") { + // Bracket property: ['key'] + const [key, endIdx] = extractQuotedString(path, i + 2); + // path[endIdx] is closing quote, next should be ']' + if (path[endIdx + 1] !== ']') throw new Error(`Expected ']' after bracket property in: ${path}`); + segments.push({ type: 'property', name: key }); + i = endIdx + 2; + } else if (/\d/.test(path[i + 1])) { + // Array index: [0] + const end = path.indexOf(']', i); + if (end === -1) throw new Error(`Unterminated array index in: ${path}`); + const indexStr = path.slice(i + 1, end); + // Validate: no leading zeros except for "0" itself + if (indexStr.length > 1 && indexStr[0] === '0') { + throw new Error(`Leading zeros not allowed in array index: [${indexStr}]`); + } + segments.push({ type: 'index', index: Number(indexStr) }); + i = end + 1; + } else { + throw new Error(`Unexpected character after '[': '${path[i + 1]}' in: ${path}`); + } + } else { + throw new Error(`Unexpected character '${path[i]}' at position ${i} in: ${path}`); + } + } + + return segments; +} + +// ─── Path Building ────────────────────────────────────────────────────────── + +const SIMPLE_PROPERTY_RE = /^[a-zA-Z_][a-zA-Z0-9_]*$/; + +function formatMemberAccess(name: string): string { + if (SIMPLE_PROPERTY_RE.test(name)) { + return `.${name}`; + } + return `['${name.replace(/'/g, "''")}']`; +} + +/** + * Build a canonical JSON Delta Path string from an array of segments. + */ +export function buildDeltaPath(segments: DeltaPathSegment[]): string { + let result = ''; + for (const seg of segments) { + switch (seg.type) { + case 'root': + result += '$'; + break; + case 'property': + result += formatMemberAccess(seg.name); + break; + case 'index': + result += `[${seg.index}]`; + break; + case 'keyFilter': { + const memberAccess = SIMPLE_PROPERTY_RE.test(seg.property) + ? `.${seg.property}` + : `['${seg.property.replace(/'/g, "''")}']`; + result += `[?(@${memberAccess}==${formatFilterLiteral(seg.value)})]`; + break; + } + case 'valueFilter': + result += `[?(@==${formatFilterLiteral(seg.value)})]`; + break; + } + } + return result; +} + +// ─── Path Conversion Utilities ────────────────────────────────────────────── + +/** + * Convert an internal atomic path (v4 format) to a canonical JSON Delta path. + * + * Transformations: + * - `$.$root` β†’ `$` + * - Unquoted bracket properties `$[a.b]` β†’ quoted `$['a.b']` + * - Filter literals stay as-is (v4 always uses string-quoted) + */ +export function atomicPathToDeltaPath(atomicPath: string): string { + // Handle root sentinel + if (atomicPath === '$.$root') return '$'; + if (atomicPath.startsWith('$.$root.')) return '$' + atomicPath.slice(7); + + if (!atomicPath.startsWith('$')) { + throw new Error(`Atomic path must start with '$': ${atomicPath}`); + } + + let result = '$'; + let i = 1; + + while (i < atomicPath.length) { + if (atomicPath[i] === '.') { + // Dot property + i += 1; + const start = i; + while (i < atomicPath.length && atomicPath[i] !== '.' && atomicPath[i] !== '[') { + i += 1; + } + const name = atomicPath.slice(start, i); + result += formatMemberAccess(name); + } else if (atomicPath[i] === '[') { + if (atomicPath[i + 1] === '?') { + // Filter expression β€” pass through as-is until ')]' + const closingIdx = findFilterClose(atomicPath, i + 2); + if (closingIdx === -1) throw new Error(`Unterminated filter in: ${atomicPath}`); + result += atomicPath.slice(i, closingIdx + 2); + i = closingIdx + 2; + } else if (atomicPath[i + 1] === "'" || /\d/.test(atomicPath[i + 1])) { + // Already bracket-quoted property or array index β€” pass through + const end = atomicPath.indexOf(']', i); + if (end === -1) throw new Error(`Unterminated bracket in: ${atomicPath}`); + result += atomicPath.slice(i, end + 1); + i = end + 1; + } else { + // Unquoted bracket property: [a.b] β†’ ['a.b'] + const end = atomicPath.indexOf(']', i); + if (end === -1) throw new Error(`Unterminated bracket in: ${atomicPath}`); + const name = atomicPath.slice(i + 1, end); + result += `['${name.replace(/'/g, "''")}']`; + i = end + 1; + } + } else { + throw new Error(`Unexpected character '${atomicPath[i]}' in atomic path: ${atomicPath}`); + } + } + + return result; +} + +/** + * Convert a JSON Delta path to an internal atomic path (v4 format). + * + * Transformations: + * - `$` (root-only) β†’ `$.$root` with key `$root` + * - Bracket-quoted properties `$['a.b']` β†’ unquoted `$[a.b]` + * - Non-string filter literals re-quoted to strings: `[?(@.id==42)]` β†’ `[?(@.id=='42')]` + */ +export function deltaPathToAtomicPath(deltaPath: string): string { + if (!deltaPath.startsWith('$')) { + throw new Error(`Delta path must start with '$': ${deltaPath}`); + } + + // Root-only path + if (deltaPath === '$') { + return '$.$root'; + } + + let result = '$'; + let i = 1; + + while (i < deltaPath.length) { + if (deltaPath[i] === '.') { + // Dot property β€” pass through + i += 1; + const start = i; + while (i < deltaPath.length && /[a-zA-Z0-9_]/.test(deltaPath[i])) { + i += 1; + } + result += '.' + deltaPath.slice(start, i); + } else if (deltaPath[i] === '[') { + if (deltaPath[i + 1] === '?') { + // Filter expression β€” need to re-quote non-string literals to strings + const closingIdx = findFilterClose(deltaPath, i + 2); + if (closingIdx === -1) throw new Error(`Unterminated filter in: ${deltaPath}`); + const filterContent = deltaPath.slice(i, closingIdx + 2); + result += normalizeFilterToStringLiterals(filterContent); + i = closingIdx + 2; + } else if (deltaPath[i + 1] === "'") { + // Bracket-quoted property: ['a.b'] β†’ [a.b] + const [key, endIdx] = extractQuotedString(deltaPath, i + 2); + if (deltaPath[endIdx + 1] !== ']') throw new Error(`Expected ']' in: ${deltaPath}`); + result += `[${key}]`; + i = endIdx + 2; + } else if (/\d/.test(deltaPath[i + 1])) { + // Array index β€” pass through + const end = deltaPath.indexOf(']', i); + if (end === -1) throw new Error(`Unterminated bracket in: ${deltaPath}`); + result += deltaPath.slice(i, end + 1); + i = end + 1; + } else { + throw new Error(`Unexpected character after '[' in: ${deltaPath}`); + } + } else { + throw new Error(`Unexpected character '${deltaPath[i]}' in delta path: ${deltaPath}`); + } + } + + return result; +} + +/** + * Normalize filter expression to use string-quoted literals for all values. + * This makes the path compatible with v4 unatomizeChangeset regex. + * + * Examples: + * - `[?(@.id==42)]` β†’ `[?(@.id=='42')]` + * - `[?(@.id=='42')]` β†’ unchanged + * - `[?(@==true)]` β†’ `[?(@=='true')]` + * - `[?(@.id==null)]` β†’ `[?(@.id=='null')]` + */ +function normalizeFilterToStringLiterals(filter: string): string { + // Match the filter structure to find the literal value part + // Key filter with dot property: [?(@.key==val)] + // Key filter with bracket property: [?(@['key']==val)] + // Value filter: [?(@==val)] + + const eqIdx = filter.indexOf('=='); + if (eqIdx === -1) return filter; + + // Find where the literal starts (after '==') and ends (before ')]') + const literalStart = eqIdx + 2; + const literalEnd = filter.length - 2; // before ')]' + const literal = filter.slice(literalStart, literalEnd); + + // If already string-quoted, pass through + if (literal.startsWith("'") && literal.endsWith("'")) { + return filter; + } + + // Parse the literal and re-quote as string + const value = parseFilterLiteral(literal); + const stringValue = String(value).replace(/'/g, "''"); + + return filter.slice(0, literalStart) + `'${stringValue}'` + filter.slice(literalEnd); +} + +// ─── Key Extraction ───────────────────────────────────────────────────────── + +/** + * Extract the key (last segment identifier) from an atomic-format path. + * Used by `fromDelta` to populate the `key` field of IAtomicChange. + */ +export function extractKeyFromAtomicPath(atomicPath: string): string { + // Walk backwards to find the last segment + if (atomicPath === '$.$root') return '$root'; + + // Check for filter at the end: ...)] + if (atomicPath.endsWith(')]')) { + // Find the matching [?( for the last filter + const filterStart = atomicPath.lastIndexOf('[?('); + if (filterStart !== -1) { + // The key is the filter key value (the changeset key used for lookup) + const inner = atomicPath.slice(filterStart + 3, atomicPath.length - 2); + // Parse filter to get the value + if (inner.startsWith('@==')) { + // Value filter: the key is the literal value as string + const val = parseFilterLiteral(inner.slice(3)); + return String(val); + } + // Key filter: @.key==val or @['key']==val + const eqIdx = inner.indexOf('=='); + if (eqIdx !== -1) { + const val = parseFilterLiteral(inner.slice(eqIdx + 2)); + return String(val); + } + } + } + + // Check for array index at end: ...[N] + if (atomicPath.endsWith(']')) { + const bracketStart = atomicPath.lastIndexOf('['); + if (bracketStart !== -1) { + const inner = atomicPath.slice(bracketStart + 1, atomicPath.length - 1); + // Numeric index + if (/^\d+$/.test(inner)) return inner; + // Bracket property + return inner; + } + } + + // Dot property: last segment after last unbracketed dot + const lastDot = atomicPath.lastIndexOf('.'); + if (lastDot > 0) { + return atomicPath.slice(lastDot + 1); + } + + return atomicPath; +} diff --git a/src/index.ts b/src/index.ts index 1bd5335..958a7f6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,2 +1,3 @@ export * from './jsonDiff.js'; export * from './jsonCompare.js'; +export * from './jsonDelta.js'; diff --git a/src/jsonDelta.ts b/src/jsonDelta.ts new file mode 100644 index 0000000..530fecb --- /dev/null +++ b/src/jsonDelta.ts @@ -0,0 +1,650 @@ +import { + diff, + atomizeChangeset, + unatomizeChangeset, + applyChangeset, + IChange, + IAtomicChange, + Changeset, + Operation, + Options, +} from './jsonDiff.js'; +import type { FunctionKey } from './helpers.js'; +import { + formatFilterLiteral, + atomicPathToDeltaPath, + deltaPathToAtomicPath, + extractKeyFromAtomicPath, +} from './deltaPath.js'; + +// ─── Types ────────────────────────────────────────────────────────────────── + +export type DeltaOp = 'add' | 'remove' | 'replace'; + +export interface IDeltaOperation { + op: DeltaOp; + path: string; + value?: any; + oldValue?: any; + [key: string]: any; +} + +export interface IJsonDelta { + format: 'json-delta'; + version: number; + operations: IDeltaOperation[]; + [key: string]: any; +} + +export interface DeltaOptions extends Options { + /** Include oldValue for reversibility. Default: true */ + reversible?: boolean; +} + +// ─── Validation ───────────────────────────────────────────────────────────── + +export function validateDelta(delta: unknown): { valid: boolean; errors: string[] } { + const errors: string[] = []; + if (typeof delta !== 'object' || delta === null) { + return { valid: false, errors: ['Delta must be a non-null object'] }; + } + + const d = delta as Record; + + if (d.format !== 'json-delta') { + errors.push(`Invalid or missing format: expected 'json-delta', got '${d.format}'`); + } + if (typeof d.version !== 'number') { + errors.push(`Missing or invalid version: expected number, got '${typeof d.version}'`); + } + if (!Array.isArray(d.operations)) { + errors.push('Missing or invalid operations: expected array'); + } else { + for (let i = 0; i < d.operations.length; i++) { + const op = d.operations[i]; + if (!op || typeof op !== 'object') { + errors.push(`operations[${i}]: must be an object`); + continue; + } + if (!['add', 'remove', 'replace'].includes(op.op)) { + errors.push(`operations[${i}]: invalid op '${op.op}'`); + } + if (typeof op.path !== 'string') { + errors.push(`operations[${i}]: path must be a string`); + } + if (op.op === 'add') { + if (!('value' in op)) { + errors.push(`operations[${i}]: add operation must have value`); + } + if ('oldValue' in op) { + errors.push(`operations[${i}]: add operation must not have oldValue`); + } + } + if (op.op === 'remove' && 'value' in op) { + errors.push(`operations[${i}]: remove operation must not have value`); + } + if (op.op === 'replace' && !('value' in op)) { + errors.push(`operations[${i}]: replace operation must have value`); + } + } + } + + return { valid: errors.length === 0, errors }; +} + +// ─── diffDelta ────────────────────────────────────────────────────────────── + +/** + * Compute a canonical JSON Delta between two objects. + * This is the spec-conformant delta producer. + */ +export function diffDelta(oldObj: any, newObj: any, options: DeltaOptions = {}): IJsonDelta { + const changeset = diff(oldObj, newObj, { + ...options, + treatTypeChangeAsReplace: true, // Always true β€” merging REMOVE+ADD is more reliable (B.1) + }); + + const operations: IDeltaOperation[] = []; + walkChanges(changeset, '$', oldObj, newObj, operations, options); + + return { + format: 'json-delta', + version: 1, + operations, + }; +} + +/** + * Merge adjacent REMOVE+ADD pairs on the same key into a synthetic replace. + */ +interface MergedChange extends IChange { + isMergedReplace?: boolean; + removeValue?: any; + addValue?: any; +} + +function mergeTypeChangePairs(changes: IChange[]): MergedChange[] { + const result: MergedChange[] = []; + let i = 0; + while (i < changes.length) { + if ( + i + 1 < changes.length && + changes[i].type === Operation.REMOVE && + changes[i + 1].type === Operation.ADD && + changes[i].key === changes[i + 1].key + ) { + result.push({ + ...changes[i], + isMergedReplace: true, + removeValue: changes[i].value, + addValue: changes[i + 1].value, + }); + i += 2; + } else { + result.push(changes[i]); + i += 1; + } + } + return result; +} + +const SIMPLE_PROPERTY_RE = /^[a-zA-Z_][a-zA-Z0-9_]*$/; + +function appendCanonicalProperty(basePath: string, name: string): string { + if (SIMPLE_PROPERTY_RE.test(name)) { + return `${basePath}.${name}`; + } + return `${basePath}['${name.replace(/'/g, "''")}']`; +} + +function walkChanges( + changes: IChange[], + basePath: string, + oldCtx: any, + newCtx: any, + ops: IDeltaOperation[], + options: DeltaOptions +): void { + const merged = mergeTypeChangePairs(changes); + + for (const change of merged) { + if ((change as MergedChange).isMergedReplace) { + const mc = change as MergedChange; + const path = mc.key === '$root' ? '$' : appendCanonicalProperty(basePath, mc.key); + const op: IDeltaOperation = { op: 'replace', path, value: mc.addValue }; + if (options.reversible !== false) { + op.oldValue = mc.removeValue; + } + ops.push(op); + } else if (change.changes) { + // Branch change + const childPath = change.key === '$root' ? '$' : appendCanonicalProperty(basePath, change.key); + const childOld = change.key === '$root' ? oldCtx : oldCtx?.[change.key]; + const childNew = change.key === '$root' ? newCtx : newCtx?.[change.key]; + + if (change.embeddedKey) { + // Array level β€” process each child with filter expression + for (const childChange of change.changes) { + const filterPath = buildCanonicalFilterPath( + childPath, + change.embeddedKey, + childChange.key, + childOld, + childNew, + childChange + ); + + if (childChange.changes) { + // Deep path after filter β€” recurse into matched element + const oldEl = findElement(childOld, change.embeddedKey, childChange.key); + const newEl = findElement(childNew, change.embeddedKey, childChange.key); + walkChanges(childChange.changes, filterPath, oldEl, newEl, ops, options); + } else { + emitLeafOp(childChange, filterPath, ops, options); + } + } + } else { + // Object branch β€” recurse + walkChanges(change.changes, childPath, childOld, childNew, ops, options); + } + } else { + // Leaf change + const path = change.key === '$root' ? '$' : appendCanonicalProperty(basePath, change.key); + emitLeafOp(change, path, ops, options); + } + } +} + +function emitLeafOp( + change: IChange, + path: string, + ops: IDeltaOperation[], + options: DeltaOptions +): void { + switch (change.type) { + case Operation.ADD: { + ops.push({ op: 'add', path, value: change.value }); + break; + } + case Operation.REMOVE: { + const op: IDeltaOperation = { op: 'remove', path }; + if (options.reversible !== false) { + op.oldValue = change.value; + } + ops.push(op); + break; + } + case Operation.UPDATE: { + const op: IDeltaOperation = { op: 'replace', path, value: change.value }; + if (options.reversible !== false) { + op.oldValue = change.oldValue; + } + ops.push(op); + break; + } + } +} + +/** + * Build canonical filter path for array elements with typed literals. + */ +function buildCanonicalFilterPath( + basePath: string, + embeddedKey: string | FunctionKey, + changeKey: string, + oldArr: any[], + newArr: any[], + change: IChange +): string { + if (embeddedKey === '$index') { + return `${basePath}[${changeKey}]`; + } + + if (embeddedKey === '$value') { + const typedVal = findActualValue(oldArr, newArr, changeKey, change.type); + return `${basePath}[?(@==${formatFilterLiteral(typedVal)})]`; + } + + /* istanbul ignore next -- diff() always resolves function keys to strings in embeddedKey */ + if (typeof embeddedKey === 'function') { + const sample = (oldArr && oldArr.length > 0 ? oldArr[0] : newArr?.[0]); + const keyName = sample ? embeddedKey(sample, true) : changeKey; + const element = findElementByFn(oldArr, newArr, embeddedKey, changeKey, change.type); + if (element && typeof keyName === 'string') { + const typedVal = element[keyName]; + const memberAccess = SIMPLE_PROPERTY_RE.test(keyName) ? `.${keyName}` : `['${keyName.replace(/'/g, "''")}']`; + return `${basePath}[?(@${memberAccess}==${formatFilterLiteral(typedVal)})]`; + } + const memberAccess = typeof keyName === 'string' && SIMPLE_PROPERTY_RE.test(keyName) ? `.${keyName}` : `.${changeKey}`; + return `${basePath}[?(@${memberAccess}=='${changeKey}')]`; + } + + // Named string key + const element = findElementByKey(oldArr, newArr, embeddedKey, changeKey, change.type); + const typedVal = element ? element[embeddedKey] : changeKey; + const memberAccess = SIMPLE_PROPERTY_RE.test(embeddedKey) ? `.${embeddedKey}` : `['${embeddedKey.replace(/'/g, "''")}']`; + return `${basePath}[?(@${memberAccess}==${formatFilterLiteral(typedVal)})]`; +} + +function findActualValue(oldArr: any[], newArr: any[], stringKey: string, opType: Operation): unknown { + // For REMOVE, value exists in old array + if (opType === Operation.REMOVE && oldArr) { + for (const item of oldArr) { + if (String(item) === stringKey) return item; + } + } + // For ADD, value exists in new array + if (opType === Operation.ADD && newArr) { + for (const item of newArr) { + if (String(item) === stringKey) return item; + } + } + /* istanbul ignore next -- $value arrays only produce ADD/REMOVE, not UPDATE */ + // For UPDATE, check both (prefer old for the key identity) + if (oldArr) { + for (const item of oldArr) { + if (String(item) === stringKey) return item; + } + } + if (newArr) { + for (const item of newArr) { + if (String(item) === stringKey) return item; + } + } + return stringKey; // fallback to string +} + +function findElement(arr: any[], embeddedKey: string | FunctionKey, changeKey: string): any { + if (!arr || !Array.isArray(arr)) return undefined; + + if (embeddedKey === '$index') { + return arr[Number(changeKey)]; + } + /* istanbul ignore next -- $value arrays contain primitives, no deep paths trigger findElement */ + if (embeddedKey === '$value') { + return arr.find((item) => String(item) === changeKey); + } + /* istanbul ignore next -- diff() resolves function keys to strings */ + if (typeof embeddedKey === 'function') { + return arr.find((item) => String(embeddedKey(item)) === changeKey); + } + return arr.find((item) => item && String(item[embeddedKey]) === changeKey); +} + +function findElementByKey( + oldArr: any[], + newArr: any[], + embeddedKey: string, + changeKey: string, + opType: Operation +): any { + // For REMOVE ops, element is in old array. For ADD, in new. For UPDATE, prefer old. + if (opType === Operation.REMOVE || opType === Operation.UPDATE) { + const el = oldArr?.find((item) => item && String(item[embeddedKey]) === changeKey); + if (el) return el; + } + if (opType === Operation.ADD || opType === Operation.UPDATE) { + const el = newArr?.find((item) => item && String(item[embeddedKey]) === changeKey); + if (el) return el; + } + return undefined; +} + +/* istanbul ignore next -- only reachable if embeddedKey is a function, which diff() never stores */ +function findElementByFn( + oldArr: any[], + newArr: any[], + fn: FunctionKey, + changeKey: string, + opType: Operation +): any { + if (opType === Operation.REMOVE || opType === Operation.UPDATE) { + const el = oldArr?.find((item) => String(fn(item)) === changeKey); + if (el) return el; + } + if (opType === Operation.ADD || opType === Operation.UPDATE) { + const el = newArr?.find((item) => String(fn(item)) === changeKey); + if (el) return el; + } + return undefined; +} + +// ─── toDelta ──────────────────────────────────────────────────────────────── + +/** + * Convert an existing v4 changeset or atomic changes to a JSON Delta document. + * Best-effort bridge β€” filter literals will always be string-quoted. + * Use `diffDelta()` for canonical spec-conformant output. + */ +export function toDelta(changeset: Changeset | IAtomicChange[], options: { reversible?: boolean } = {}): IJsonDelta { + let atoms: IAtomicChange[]; + if (changeset.length === 0) { + return { format: 'json-delta', version: 1, operations: [] }; + } + + // Detect if input is IAtomicChange[] (has 'path' property) or Changeset + if ('path' in changeset[0]) { + atoms = changeset as IAtomicChange[]; + } else { + atoms = atomizeChangeset(changeset as Changeset); + } + + // Convert atoms to delta operations + const rawOps: IDeltaOperation[] = atoms.map((atom) => { + const path = atomicPathToDeltaPath(atom.path); + switch (atom.type) { + case Operation.ADD: + return { op: 'add' as DeltaOp, path, value: atom.value }; + case Operation.REMOVE: { + const op: IDeltaOperation = { op: 'remove', path }; + if (options.reversible !== false && atom.value !== undefined) { + op.oldValue = atom.value; + } + return op; + } + case Operation.UPDATE: { + const op: IDeltaOperation = { op: 'replace', path, value: atom.value }; + if (options.reversible !== false && atom.oldValue !== undefined) { + op.oldValue = atom.oldValue; + } + return op; + } + /* istanbul ignore next -- exhaustive switch */ + default: + throw new Error(`Unknown operation type: ${atom.type}`); + } + }); + + // Merge consecutive REMOVE+ADD at same path β†’ single replace + const operations = mergeConsecutiveOps(rawOps); + + return { format: 'json-delta', version: 1, operations }; +} + +function mergeConsecutiveOps(ops: IDeltaOperation[]): IDeltaOperation[] { + const result: IDeltaOperation[] = []; + let i = 0; + while (i < ops.length) { + if ( + i + 1 < ops.length && + ops[i].op === 'remove' && + ops[i + 1].op === 'add' && + ops[i].path === ops[i + 1].path + ) { + const merged: IDeltaOperation = { + op: 'replace', + path: ops[i].path, + value: ops[i + 1].value, + }; + if (ops[i].oldValue !== undefined) { + merged.oldValue = ops[i].oldValue; + } + result.push(merged); + i += 2; + } else { + result.push(ops[i]); + i += 1; + } + } + return result; +} + +// ─── fromDelta ────────────────────────────────────────────────────────────── + +/** + * Convert a JSON Delta document to v4 atomic changes. + * Returns IAtomicChange[] β€” one atom per delta operation. + * Use `unatomizeChangeset(fromDelta(delta))` if you need a hierarchical Changeset. + */ +export function fromDelta(delta: IJsonDelta): IAtomicChange[] { + const validation = validateDelta(delta); + if (!validation.valid) { + throw new Error(`Invalid delta: ${validation.errors.join(', ')}`); + } + + return delta.operations.map((op) => { + const atomicPath = deltaPathToAtomicPath(op.path); + const key = extractKeyFromAtomicPath(atomicPath); + + switch (op.op) { + case 'add': { + const valueType = getValueType(op.value); + return { type: Operation.ADD, key, path: atomicPath, valueType, value: op.value }; + } + case 'remove': { + const valueType = op.oldValue !== undefined ? getValueType(op.oldValue) : null; + return { type: Operation.REMOVE, key, path: atomicPath, valueType, value: op.oldValue }; + } + case 'replace': { + const valueType = getValueType(op.value); + const atom: IAtomicChange = { type: Operation.UPDATE, key, path: atomicPath, valueType, value: op.value }; + if (op.oldValue !== undefined) { + atom.oldValue = op.oldValue; + } + return atom; + } + /* istanbul ignore next -- exhaustive switch */ + default: + throw new Error(`Unknown operation: ${op.op}`); + } + }); +} + +function getValueType(value: any): string | null { + if (value === undefined) return 'undefined'; + if (value === null) return null; + if (Array.isArray(value)) return 'Array'; + const type = typeof value; + return type.charAt(0).toUpperCase() + type.slice(1); +} + +// ─── invertDelta ──────────────────────────────────────────────────────────── + +/** + * Compute the inverse of a JSON Delta document (spec Section 9.2). + * Requires all replace/remove operations to have oldValue. + */ +export function invertDelta(delta: IJsonDelta): IJsonDelta { + const validation = validateDelta(delta); + if (!validation.valid) { + throw new Error(`Invalid delta: ${validation.errors.join(', ')}`); + } + + // Validate reversibility + for (let i = 0; i < delta.operations.length; i++) { + const op = delta.operations[i]; + if (op.op === 'replace' && !('oldValue' in op)) { + throw new Error(`operations[${i}]: replace operation missing oldValue β€” delta is not reversible`); + } + if (op.op === 'remove' && !('oldValue' in op)) { + throw new Error(`operations[${i}]: remove operation missing oldValue β€” delta is not reversible`); + } + } + + // Reverse the operations array and invert each operation + const invertedOps: IDeltaOperation[] = [...delta.operations].reverse().map((op) => { + // Preserve extension properties (any key not in standard set) + const extensions: Record = {}; + for (const key of Object.keys(op)) { + if (!['op', 'path', 'value', 'oldValue'].includes(key)) { + extensions[key] = op[key]; + } + } + + switch (op.op) { + case 'add': + return { op: 'remove' as DeltaOp, path: op.path, oldValue: op.value, ...extensions }; + case 'remove': + return { op: 'add' as DeltaOp, path: op.path, value: op.oldValue, ...extensions }; + case 'replace': + return { op: 'replace' as DeltaOp, path: op.path, value: op.oldValue, oldValue: op.value, ...extensions }; + /* istanbul ignore next -- exhaustive switch */ + default: + throw new Error(`Unknown operation: ${op.op}`); + } + }); + + // Preserve envelope extension properties + const envelope: IJsonDelta = { format: 'json-delta', version: delta.version, operations: invertedOps }; + for (const key of Object.keys(delta)) { + if (!['format', 'version', 'operations'].includes(key)) { + envelope[key] = delta[key]; + } + } + + return envelope; +} + +// ─── applyDelta ───────────────────────────────────────────────────────────── + +/** + * Apply a JSON Delta document to an object. + * Processes operations sequentially. Handles root operations directly. + * Returns the result (MUST use return value for root primitive replacements). + */ +export function applyDelta(obj: any, delta: IJsonDelta): any { + const validation = validateDelta(delta); + if (!validation.valid) { + throw new Error(`Invalid delta: ${validation.errors.join(', ')}`); + } + + let result: any = obj; + + for (const op of delta.operations) { + if (op.path === '$') { + result = applyRootOp(result, op); + } else { + const atomicChange = deltaOpToAtomicChange(op); + const miniChangeset = unatomizeChangeset([atomicChange]); + applyChangeset(result, miniChangeset); + } + } + + return result; +} + +function applyRootOp(obj: any, op: IDeltaOperation): any { + switch (op.op) { + case 'add': + return op.value; + case 'remove': + return null; + case 'replace': { + // Only attempt in-place mutation when both old and new are plain objects (not arrays) + if ( + typeof obj === 'object' && obj !== null && !Array.isArray(obj) && + typeof op.value === 'object' && op.value !== null && !Array.isArray(op.value) + ) { + for (const key of Object.keys(obj)) { + delete obj[key]; + } + Object.assign(obj, op.value); + return obj; + } + // All other cases: return new value directly (primitives, arrays, type changes) + return op.value; + } + /* istanbul ignore next -- exhaustive switch */ + default: + throw new Error(`Unknown operation: ${op.op}`); + } +} + +function deltaOpToAtomicChange(op: IDeltaOperation): IAtomicChange { + const atomicPath = deltaPathToAtomicPath(op.path); + const key = extractKeyFromAtomicPath(atomicPath); + + switch (op.op) { + case 'add': + return { type: Operation.ADD, key, path: atomicPath, valueType: getValueType(op.value), value: op.value }; + case 'remove': + return { type: Operation.REMOVE, key, path: atomicPath, valueType: getValueType(op.oldValue), value: op.oldValue }; + case 'replace': + return { + type: Operation.UPDATE, + key, + path: atomicPath, + valueType: getValueType(op.value), + value: op.value, + oldValue: op.oldValue, + }; + /* istanbul ignore next -- exhaustive switch */ + default: + throw new Error(`Unknown operation: ${op.op}`); + } +} + +// ─── revertDelta ──────────────────────────────────────────────────────────── + +/** + * Revert a JSON Delta by computing its inverse and applying it. + * Requires all replace/remove operations to have oldValue. + */ +export function revertDelta(obj: any, delta: IJsonDelta): any { + const inverse = invertDelta(delta); + return applyDelta(obj, inverse); +} + +// ─── Re-exports for convenience ───────────────────────────────────────────── + +export { DeltaPathSegment, formatFilterLiteral, parseFilterLiteral, parseDeltaPath, buildDeltaPath } from './deltaPath.js'; diff --git a/src/jsonDiff.ts b/src/jsonDiff.ts index c31fdb3..58f0b01 100644 --- a/src/jsonDiff.ts +++ b/src/jsonDiff.ts @@ -28,6 +28,9 @@ interface IAtomicChange { } interface Options { + /** Identify array elements by a stable key instead of index. */ + arrayIdentityKeys?: EmbeddedObjKeysType | EmbeddedObjKeysMapType; + /** @deprecated Use `arrayIdentityKeys` instead. */ embeddedObjKeys?: EmbeddedObjKeysType | EmbeddedObjKeysMapType; keysToSkip?: readonly string[]; treatTypeChangeAsReplace?: boolean; @@ -42,7 +45,7 @@ interface Options { * @returns {IChange[]} - An array of changes that transform the old object into the new object. */ function diff(oldObj: any, newObj: any, options: Options = {}): IChange[] { - let { embeddedObjKeys } = options; + let embeddedObjKeys = options.arrayIdentityKeys ?? options.embeddedObjKeys; const { keysToSkip, treatTypeChangeAsReplace } = options; // Trim leading '.' from keys in embeddedObjKeys diff --git a/tests/__fixtures__/json-delta/basic-replace.json b/tests/__fixtures__/json-delta/basic-replace.json new file mode 100644 index 0000000..ac86a11 --- /dev/null +++ b/tests/__fixtures__/json-delta/basic-replace.json @@ -0,0 +1,25 @@ +{ + "name": "basic-replace", + "description": "Replace a top-level string property", + "source": { + "name": "Alice", + "age": 30 + }, + "target": { + "name": "Bob", + "age": 30 + }, + "delta": { + "format": "json-delta", + "version": 1, + "operations": [ + { + "op": "replace", + "path": "$.name", + "value": "Bob", + "oldValue": "Alice" + } + ] + }, + "level": 2 +} diff --git a/tests/__fixtures__/json-delta/keyed-array-update.json b/tests/__fixtures__/json-delta/keyed-array-update.json new file mode 100644 index 0000000..51750f8 --- /dev/null +++ b/tests/__fixtures__/json-delta/keyed-array-update.json @@ -0,0 +1,52 @@ +{ + "name": "keyed-array-update", + "description": "Update, add, and remove elements in an array identified by key", + "source": { + "items": [ + { "id": "1", "name": "Widget", "price": 10 }, + { "id": "2", "name": "Gadget", "price": 20 }, + { "id": "3", "name": "Doohickey", "price": 30 } + ] + }, + "target": { + "items": [ + { "id": "1", "name": "Widget Pro", "price": 15 }, + { "id": "2", "name": "Gadget", "price": 20 }, + { "id": "4", "name": "Thingamajig", "price": 40 } + ] + }, + "delta": { + "format": "json-delta", + "version": 1, + "operations": [ + { + "op": "replace", + "path": "$.items[?(@.id=='1')].name", + "value": "Widget Pro", + "oldValue": "Widget" + }, + { + "op": "replace", + "path": "$.items[?(@.id=='1')].price", + "value": 15, + "oldValue": 10 + }, + { + "op": "remove", + "path": "$.items[?(@.id=='3')]", + "oldValue": { "id": "3", "name": "Doohickey", "price": 30 } + }, + { + "op": "add", + "path": "$.items[?(@.id=='4')]", + "value": { "id": "4", "name": "Thingamajig", "price": 40 } + } + ] + }, + "computeHints": { + "arrayKeys": { + "items": "id" + } + }, + "level": 2 +} diff --git a/tests/deltaPath.test.ts b/tests/deltaPath.test.ts new file mode 100644 index 0000000..6ec9664 --- /dev/null +++ b/tests/deltaPath.test.ts @@ -0,0 +1,482 @@ +import { + parseDeltaPath, + buildDeltaPath, + formatFilterLiteral, + parseFilterLiteral, + atomicPathToDeltaPath, + deltaPathToAtomicPath, + extractKeyFromAtomicPath, +} from '../src/deltaPath'; + +describe('formatFilterLiteral', () => { + it('formats string values with single quotes', () => { + expect(formatFilterLiteral('Alice')).toBe("'Alice'"); + }); + + it('escapes single quotes by doubling', () => { + expect(formatFilterLiteral("O'Brien")).toBe("'O''Brien'"); + }); + + it('formats integers', () => { + expect(formatFilterLiteral(42)).toBe('42'); + }); + + it('formats negative numbers', () => { + expect(formatFilterLiteral(-7)).toBe('-7'); + }); + + it('formats floating point numbers', () => { + expect(formatFilterLiteral(3.14)).toBe('3.14'); + }); + + it('formats booleans', () => { + expect(formatFilterLiteral(true)).toBe('true'); + expect(formatFilterLiteral(false)).toBe('false'); + }); + + it('formats null', () => { + expect(formatFilterLiteral(null)).toBe('null'); + }); + + it('throws for unsupported types', () => { + expect(() => formatFilterLiteral({})).toThrow(); + expect(() => formatFilterLiteral(undefined)).toThrow(); + }); + + it('throws for NaN', () => { + expect(() => formatFilterLiteral(NaN)).toThrow(/non-finite/); + }); + + it('throws for Infinity', () => { + expect(() => formatFilterLiteral(Infinity)).toThrow(/non-finite/); + expect(() => formatFilterLiteral(-Infinity)).toThrow(/non-finite/); + }); +}); + +describe('parseFilterLiteral', () => { + it('parses single-quoted strings', () => { + expect(parseFilterLiteral("'Alice'")).toBe('Alice'); + }); + + it('unescapes doubled quotes', () => { + expect(parseFilterLiteral("'O''Brien'")).toBe("O'Brien"); + }); + + it('parses integers', () => { + expect(parseFilterLiteral('42')).toBe(42); + }); + + it('parses negative numbers', () => { + expect(parseFilterLiteral('-7')).toBe(-7); + }); + + it('parses floating point numbers', () => { + expect(parseFilterLiteral('3.14')).toBe(3.14); + }); + + it('parses scientific notation', () => { + expect(parseFilterLiteral('1e3')).toBe(1000); + expect(parseFilterLiteral('1.5E-2')).toBe(0.015); + }); + + it('parses booleans', () => { + expect(parseFilterLiteral('true')).toBe(true); + expect(parseFilterLiteral('false')).toBe(false); + }); + + it('parses null', () => { + expect(parseFilterLiteral('null')).toBe(null); + }); + + it('throws for invalid literals', () => { + expect(() => parseFilterLiteral('abc')).toThrow(); + }); + + it('rejects non-JSON numeric formats', () => { + expect(() => parseFilterLiteral('')).toThrow(); + expect(() => parseFilterLiteral('0x10')).toThrow(); + expect(() => parseFilterLiteral('0o7')).toThrow(); + expect(() => parseFilterLiteral('0b101')).toThrow(); + expect(() => parseFilterLiteral(' ')).toThrow(); + expect(() => parseFilterLiteral('01')).toThrow(); // leading zero + }); + + it('round-trips with formatFilterLiteral', () => { + const values: unknown[] = ['hello', "O'Brien", 42, -7, 3.14, true, false, null]; + for (const val of values) { + expect(parseFilterLiteral(formatFilterLiteral(val))).toEqual(val); + } + }); +}); + +describe('parseDeltaPath', () => { + it('parses root-only path', () => { + expect(parseDeltaPath('$')).toEqual([{ type: 'root' }]); + }); + + it('parses dot property', () => { + expect(parseDeltaPath('$.name')).toEqual([ + { type: 'root' }, + { type: 'property', name: 'name' }, + ]); + }); + + it('parses chained dot properties', () => { + expect(parseDeltaPath('$.user.address.city')).toEqual([ + { type: 'root' }, + { type: 'property', name: 'user' }, + { type: 'property', name: 'address' }, + { type: 'property', name: 'city' }, + ]); + }); + + it('parses bracket property', () => { + expect(parseDeltaPath("$['a.b']")).toEqual([ + { type: 'root' }, + { type: 'property', name: 'a.b' }, + ]); + }); + + it('parses bracket property with escaped quotes', () => { + expect(parseDeltaPath("$['O''Brien']")).toEqual([ + { type: 'root' }, + { type: 'property', name: "O'Brien" }, + ]); + }); + + it('parses array index', () => { + expect(parseDeltaPath('$.items[0]')).toEqual([ + { type: 'root' }, + { type: 'property', name: 'items' }, + { type: 'index', index: 0 }, + ]); + }); + + it('parses key filter with dot property and number', () => { + expect(parseDeltaPath('$.items[?(@.id==42)]')).toEqual([ + { type: 'root' }, + { type: 'property', name: 'items' }, + { type: 'keyFilter', property: 'id', value: 42 }, + ]); + }); + + it('parses key filter with string literal', () => { + expect(parseDeltaPath("$.items[?(@.name=='Widget')]")).toEqual([ + { type: 'root' }, + { type: 'property', name: 'items' }, + { type: 'keyFilter', property: 'name', value: 'Widget' }, + ]); + }); + + it('parses key filter with bracket property', () => { + expect(parseDeltaPath("$.items[?(@['a.b']==42)]")).toEqual([ + { type: 'root' }, + { type: 'property', name: 'items' }, + { type: 'keyFilter', property: 'a.b', value: 42 }, + ]); + }); + + it('parses key filter with boolean literal', () => { + expect(parseDeltaPath('$.items[?(@.active==true)]')).toEqual([ + { type: 'root' }, + { type: 'property', name: 'items' }, + { type: 'keyFilter', property: 'active', value: true }, + ]); + }); + + it('parses key filter with null literal', () => { + expect(parseDeltaPath('$.items[?(@.status==null)]')).toEqual([ + { type: 'root' }, + { type: 'property', name: 'items' }, + { type: 'keyFilter', property: 'status', value: null }, + ]); + }); + + it('parses value filter with string', () => { + expect(parseDeltaPath("$.tags[?(@=='urgent')]")).toEqual([ + { type: 'root' }, + { type: 'property', name: 'tags' }, + { type: 'valueFilter', value: 'urgent' }, + ]); + }); + + it('parses value filter with number', () => { + expect(parseDeltaPath('$.scores[?(@==100)]')).toEqual([ + { type: 'root' }, + { type: 'property', name: 'scores' }, + { type: 'valueFilter', value: 100 }, + ]); + }); + + it('parses deep path after key filter', () => { + expect(parseDeltaPath('$.items[?(@.id==1)].name')).toEqual([ + { type: 'root' }, + { type: 'property', name: 'items' }, + { type: 'keyFilter', property: 'id', value: 1 }, + { type: 'property', name: 'name' }, + ]); + }); + + it('parses non-canonical bracket-for-everything form', () => { + expect(parseDeltaPath("$['user']['name']")).toEqual([ + { type: 'root' }, + { type: 'property', name: 'user' }, + { type: 'property', name: 'name' }, + ]); + }); + + it('throws on invalid paths', () => { + expect(() => parseDeltaPath('')).toThrow(); + expect(() => parseDeltaPath('name')).toThrow(); + expect(() => parseDeltaPath('$[01]')).toThrow(); // leading zero + }); + + it('throws on unexpected character after [', () => { + expect(() => parseDeltaPath('$[!invalid]')).toThrow(/Unexpected character after/); + }); + + it('throws on unexpected character in path', () => { + expect(() => parseDeltaPath('$!name')).toThrow(/Unexpected character/); + }); + + it('throws on unterminated quoted string', () => { + expect(() => parseDeltaPath("$['unterminated")).toThrow(/Unterminated quoted string/); + }); + + it('throws on invalid filter expression', () => { + expect(() => parseDeltaPath('$[?(invalid==1)]')).toThrow(/Invalid filter expression/); + }); + + it('handles filter literal containing )]', () => { + const result = parseDeltaPath("$.items[?(@.name=='val)]ue')]"); + expect(result).toEqual([ + { type: 'root' }, + { type: 'property', name: 'items' }, + { type: 'keyFilter', property: 'name', value: 'val)]ue' }, + ]); + }); +}); + +describe('buildDeltaPath', () => { + it('builds root-only path', () => { + expect(buildDeltaPath([{ type: 'root' }])).toBe('$'); + }); + + it('builds simple dot property path', () => { + expect(buildDeltaPath([ + { type: 'root' }, + { type: 'property', name: 'user' }, + { type: 'property', name: 'name' }, + ])).toBe('$.user.name'); + }); + + it('uses bracket notation for special property names', () => { + expect(buildDeltaPath([ + { type: 'root' }, + { type: 'property', name: 'a.b' }, + ])).toBe("$['a.b']"); + }); + + it('uses bracket notation for properties starting with digits', () => { + expect(buildDeltaPath([ + { type: 'root' }, + { type: 'property', name: '0key' }, + ])).toBe("$['0key']"); + }); + + it('builds array index', () => { + expect(buildDeltaPath([ + { type: 'root' }, + { type: 'property', name: 'items' }, + { type: 'index', index: 3 }, + ])).toBe('$.items[3]'); + }); + + it('builds key filter with number', () => { + expect(buildDeltaPath([ + { type: 'root' }, + { type: 'property', name: 'items' }, + { type: 'keyFilter', property: 'id', value: 42 }, + ])).toBe('$.items[?(@.id==42)]'); + }); + + it('builds key filter with string', () => { + expect(buildDeltaPath([ + { type: 'root' }, + { type: 'property', name: 'items' }, + { type: 'keyFilter', property: 'id', value: 'abc' }, + ])).toBe("$.items[?(@.id=='abc')]"); + }); + + it('builds key filter with bracket-notation property', () => { + expect(buildDeltaPath([ + { type: 'root' }, + { type: 'property', name: 'items' }, + { type: 'keyFilter', property: 'a.b', value: 42 }, + ])).toBe("$.items[?(@['a.b']==42)]"); + }); + + it('builds value filter', () => { + expect(buildDeltaPath([ + { type: 'root' }, + { type: 'property', name: 'tags' }, + { type: 'valueFilter', value: 'urgent' }, + ])).toBe("$.tags[?(@=='urgent')]"); + }); + + it('round-trips with parseDeltaPath for canonical paths', () => { + const paths = [ + '$', + '$.name', + '$.user.address.city', + "$.config['a.b']", + '$.items[0]', + '$.items[?(@.id==42)]', + "$.items[?(@.name=='Widget')]", + "$.tags[?(@=='urgent')]", + '$.items[?(@.id==1)].name', + ]; + for (const path of paths) { + expect(buildDeltaPath(parseDeltaPath(path))).toBe(path); + } + }); +}); + +describe('atomicPathToDeltaPath', () => { + it('converts $.$root to $', () => { + expect(atomicPathToDeltaPath('$.$root')).toBe('$'); + }); + + it('converts simple dot paths unchanged', () => { + expect(atomicPathToDeltaPath('$.name')).toBe('$.name'); + expect(atomicPathToDeltaPath('$.user.name')).toBe('$.user.name'); + }); + + it('quotes unquoted bracket properties', () => { + expect(atomicPathToDeltaPath('$[a.b]')).toBe("$['a.b']"); + }); + + it('preserves already-quoted bracket properties', () => { + expect(atomicPathToDeltaPath("$['a.b']")).toBe("$['a.b']"); + }); + + it('preserves array indices', () => { + expect(atomicPathToDeltaPath('$.items[0]')).toBe('$.items[0]'); + }); + + it('preserves filter expressions', () => { + expect(atomicPathToDeltaPath("$.items[?(@.id=='1')]")).toBe("$.items[?(@.id=='1')]"); + }); + + it('handles bracket property with special characters', () => { + expect(atomicPathToDeltaPath('$[foo-bar]')).toBe("$['foo-bar']"); + }); + + it('throws when path does not start with $', () => { + expect(() => atomicPathToDeltaPath('invalid')).toThrow(/must start with/); + }); + + it('throws on unexpected character', () => { + expect(() => atomicPathToDeltaPath('$!bad')).toThrow(/Unexpected character/); + }); + + it('handles paths with filters and deep properties', () => { + expect(atomicPathToDeltaPath("$.items[?(@.id=='1')].name")).toBe("$.items[?(@.id=='1')].name"); + }); + + it('handles filter literal containing )]', () => { + expect(atomicPathToDeltaPath("$.items[?(@.name=='val)]ue')]")).toBe("$.items[?(@.name=='val)]ue')]"); + }); +}); + +describe('deltaPathToAtomicPath', () => { + it('converts $ to $.$root', () => { + expect(deltaPathToAtomicPath('$')).toBe('$.$root'); + }); + + it('passes through simple dot paths', () => { + expect(deltaPathToAtomicPath('$.name')).toBe('$.name'); + }); + + it('strips bracket-property quotes', () => { + expect(deltaPathToAtomicPath("$['a.b']")).toBe('$[a.b]'); + }); + + it('re-quotes numeric filter literals as strings', () => { + expect(deltaPathToAtomicPath('$.items[?(@.id==42)]')).toBe("$.items[?(@.id=='42')]"); + }); + + it('re-quotes boolean filter literals as strings', () => { + expect(deltaPathToAtomicPath('$.items[?(@.active==true)]')).toBe("$.items[?(@.active=='true')]"); + }); + + it('re-quotes null filter literals as strings', () => { + expect(deltaPathToAtomicPath('$.items[?(@.status==null)]')).toBe("$.items[?(@.status=='null')]"); + }); + + it('preserves already string-quoted filter literals', () => { + expect(deltaPathToAtomicPath("$.items[?(@.id=='42')]")).toBe("$.items[?(@.id=='42')]"); + }); + + it('handles value filter re-quoting', () => { + expect(deltaPathToAtomicPath('$.scores[?(@==100)]')).toBe("$.scores[?(@=='100')]"); + }); + + it('handles deep path after filter with re-quoting', () => { + expect(deltaPathToAtomicPath('$.items[?(@.id==1)].name')).toBe("$.items[?(@.id=='1')].name"); + }); + + it('preserves array indices', () => { + expect(deltaPathToAtomicPath('$.items[0]')).toBe('$.items[0]'); + }); + + it('throws when path does not start with $', () => { + expect(() => deltaPathToAtomicPath('invalid')).toThrow(/must start with/); + }); + + it('throws on unexpected character after [', () => { + expect(() => deltaPathToAtomicPath('$[!bad]')).toThrow(/Unexpected character after/); + }); + + it('throws on unexpected character in path', () => { + expect(() => deltaPathToAtomicPath('$!bad')).toThrow(/Unexpected character/); + }); + + it('handles filter literal containing )]', () => { + expect(deltaPathToAtomicPath("$.items[?(@.name=='val)]ue')]")).toBe("$.items[?(@.name=='val)]ue')]"); + }); +}); + +describe('extractKeyFromAtomicPath', () => { + it('extracts $root from root path', () => { + expect(extractKeyFromAtomicPath('$.$root')).toBe('$root'); + }); + + it('extracts last dot property', () => { + expect(extractKeyFromAtomicPath('$.user.name')).toBe('name'); + }); + + it('extracts array index', () => { + expect(extractKeyFromAtomicPath('$.items[0]')).toBe('0'); + }); + + it('extracts filter key value', () => { + expect(extractKeyFromAtomicPath("$.items[?(@.id=='42')]")).toBe('42'); + }); + + it('extracts value filter key', () => { + expect(extractKeyFromAtomicPath("$.tags[?(@=='urgent')]")).toBe('urgent'); + }); + + it('extracts property after filter (deep path)', () => { + expect(extractKeyFromAtomicPath("$.items[?(@.id=='1')].name")).toBe('name'); + }); + + it('extracts bracket property key (non-numeric, non-filter)', () => { + expect(extractKeyFromAtomicPath('$[a.b]')).toBe('a.b'); + }); + + it('returns the path itself as fallback', () => { + expect(extractKeyFromAtomicPath('$')).toBe('$'); + }); +}); diff --git a/tests/jsonDelta.test.ts b/tests/jsonDelta.test.ts new file mode 100644 index 0000000..dadbc88 --- /dev/null +++ b/tests/jsonDelta.test.ts @@ -0,0 +1,951 @@ +import { + diffDelta, + toDelta, + fromDelta, + applyDelta, + revertDelta, + invertDelta, + validateDelta, + IJsonDelta, +} from '../src/jsonDelta'; +import { + diff, + atomizeChangeset, + unatomizeChangeset, + applyChangeset, + Operation, + IAtomicChange, +} from '../src/jsonDiff'; +import * as fs from 'fs'; +import * as path from 'path'; + +// ─── Helpers ──────────────────────────────────────────────────────────────── + +function loadFixture(name: string): any { + const filePath = path.join(__dirname, '__fixtures__', 'json-delta', `${name}.json`); + return JSON.parse(fs.readFileSync(filePath, 'utf-8')); +} + +function deepClone(obj: T): T { + return JSON.parse(JSON.stringify(obj)); +} + +// ─── validateDelta ────────────────────────────────────────────────────────── + +describe('validateDelta', () => { + it('validates a correct delta', () => { + const delta: IJsonDelta = { + format: 'json-delta', + version: 1, + operations: [{ op: 'replace', path: '$.name', value: 'Bob', oldValue: 'Alice' }], + }; + expect(validateDelta(delta)).toEqual({ valid: true, errors: [] }); + }); + + it('validates delta with x_ extension properties', () => { + const delta = { + format: 'json-delta', + version: 1, + operations: [{ op: 'replace', path: '$.name', value: 'Bob', x_author: 'system' }], + x_metadata: { timestamp: 123 }, + }; + expect(validateDelta(delta)).toEqual({ valid: true, errors: [] }); + }); + + it('validates delta with empty operations', () => { + const delta = { format: 'json-delta', version: 1, operations: [] as any[] }; + expect(validateDelta(delta)).toEqual({ valid: true, errors: [] }); + }); + + it('rejects missing format', () => { + const result = validateDelta({ version: 1, operations: [] }); + expect(result.valid).toBe(false); + expect(result.errors[0]).toMatch(/format/); + }); + + it('rejects wrong format', () => { + const result = validateDelta({ format: 'json-patch', version: 1, operations: [] }); + expect(result.valid).toBe(false); + }); + + it('rejects missing version', () => { + const result = validateDelta({ format: 'json-delta', operations: [] }); + expect(result.valid).toBe(false); + expect(result.errors[0]).toMatch(/version/); + }); + + it('rejects missing operations', () => { + const result = validateDelta({ format: 'json-delta', version: 1 }); + expect(result.valid).toBe(false); + expect(result.errors[0]).toMatch(/operations/); + }); + + it('rejects invalid op', () => { + const result = validateDelta({ + format: 'json-delta', + version: 1, + operations: [{ op: 'move', path: '$.x' }], + }); + expect(result.valid).toBe(false); + }); + + it('rejects add with oldValue', () => { + const result = validateDelta({ + format: 'json-delta', + version: 1, + operations: [{ op: 'add', path: '$.x', value: 1, oldValue: 0 }], + }); + expect(result.valid).toBe(false); + }); + + it('rejects remove with value', () => { + const result = validateDelta({ + format: 'json-delta', + version: 1, + operations: [{ op: 'remove', path: '$.x', value: 1 }], + }); + expect(result.valid).toBe(false); + }); + + it('rejects add without value', () => { + const result = validateDelta({ + format: 'json-delta', + version: 1, + operations: [{ op: 'add', path: '$.x' }], + }); + expect(result.valid).toBe(false); + }); + + it('rejects replace without value', () => { + const result = validateDelta({ + format: 'json-delta', + version: 1, + operations: [{ op: 'replace', path: '$.x', oldValue: 1 }], + }); + expect(result.valid).toBe(false); + }); + + it('rejects non-object', () => { + expect(validateDelta(null).valid).toBe(false); + expect(validateDelta('string').valid).toBe(false); + expect(validateDelta(42).valid).toBe(false); + }); + + it('rejects non-object operation entries', () => { + const result = validateDelta({ + format: 'json-delta', + version: 1, + operations: [null, 'not-an-object'], + }); + expect(result.valid).toBe(false); + expect(result.errors[0]).toMatch(/must be an object/); + }); + + it('rejects operation with non-string path', () => { + const result = validateDelta({ + format: 'json-delta', + version: 1, + operations: [{ op: 'add', path: 123, value: 'x' }], + }); + expect(result.valid).toBe(false); + expect(result.errors[0]).toMatch(/path must be a string/); + }); +}); + +// ─── diffDelta ────────────────────────────────────────────────────────────── + +describe('diffDelta', () => { + it('produces empty operations for identical objects', () => { + const obj = { a: 1, b: 'hello' }; + const delta = diffDelta(obj, deepClone(obj)); + expect(delta.format).toBe('json-delta'); + expect(delta.version).toBe(1); + expect(delta.operations).toEqual([]); + }); + + it('detects simple property replace', () => { + const delta = diffDelta({ name: 'Alice' }, { name: 'Bob' }); + expect(delta.operations).toEqual([ + { op: 'replace', path: '$.name', value: 'Bob', oldValue: 'Alice' }, + ]); + }); + + it('detects property add', () => { + const delta = diffDelta({ a: 1 }, { a: 1, b: 2 }); + expect(delta.operations).toHaveLength(1); + expect(delta.operations[0]).toEqual({ op: 'add', path: '$.b', value: 2 }); + }); + + it('detects property remove', () => { + const delta = diffDelta({ a: 1, b: 2 }, { a: 1 }); + expect(delta.operations).toHaveLength(1); + expect(delta.operations[0]).toMatchObject({ op: 'remove', path: '$.b', oldValue: 2 }); + }); + + it('handles nested object changes', () => { + const delta = diffDelta( + { user: { name: 'Alice', address: { city: 'Portland' } } }, + { user: { name: 'Alice', address: { city: 'Seattle' } } } + ); + expect(delta.operations).toEqual([ + { op: 'replace', path: '$.user.address.city', value: 'Seattle', oldValue: 'Portland' }, + ]); + }); + + it('handles arrays with $index (default)', () => { + const delta = diffDelta({ items: [1, 2, 3] }, { items: [1, 2, 4] }); + expect(delta.operations).toHaveLength(1); + expect(delta.operations[0]).toMatchObject({ + op: 'replace', + path: '$.items[2]', + value: 4, + oldValue: 3, + }); + }); + + it('handles arrays with named key (string IDs)', () => { + const delta = diffDelta( + { items: [{ id: '1', name: 'Widget' }] }, + { items: [{ id: '1', name: 'Gadget' }] }, + { embeddedObjKeys: { items: 'id' } } + ); + expect(delta.operations).toEqual([ + { op: 'replace', path: "$.items[?(@.id=='1')].name", value: 'Gadget', oldValue: 'Widget' }, + ]); + }); + + it('handles arrays with named key (numeric IDs) β€” canonical typed literals', () => { + const delta = diffDelta( + { items: [{ id: 1, name: 'Widget' }] }, + { items: [{ id: 1, name: 'Gadget' }] }, + { embeddedObjKeys: { items: 'id' } } + ); + expect(delta.operations).toEqual([ + { op: 'replace', path: '$.items[?(@.id==1)].name', value: 'Gadget', oldValue: 'Widget' }, + ]); + }); + + it('handles keyed array add and remove', () => { + const delta = diffDelta( + { items: [{ id: '1', name: 'Widget' }] }, + { items: [{ id: '1', name: 'Widget' }, { id: '2', name: 'Gadget' }] }, + { embeddedObjKeys: { items: 'id' } } + ); + expect(delta.operations).toHaveLength(1); + expect(delta.operations[0]).toMatchObject({ + op: 'add', + path: "$.items[?(@.id=='2')]", + value: { id: '2', name: 'Gadget' }, + }); + }); + + it('handles $value arrays with string values', () => { + const delta = diffDelta( + { tags: ['urgent', 'review'] }, + { tags: ['urgent', 'draft'] }, + { embeddedObjKeys: { tags: '$value' } } + ); + expect(delta.operations).toHaveLength(2); + // Remove 'review' and add 'draft' + const removeOp = delta.operations.find(op => op.op === 'remove'); + const addOp = delta.operations.find(op => op.op === 'add'); + expect(removeOp?.path).toBe("$.tags[?(@=='review')]"); + expect(addOp?.path).toBe("$.tags[?(@=='draft')]"); + }); + + it('handles $value arrays with numeric values', () => { + const delta = diffDelta( + { scores: [10, 20, 30] }, + { scores: [10, 25, 30] }, + { embeddedObjKeys: { scores: '$value' } } + ); + expect(delta.operations).toHaveLength(2); + const removeOp = delta.operations.find(op => op.op === 'remove'); + const addOp = delta.operations.find(op => op.op === 'add'); + expect(removeOp?.path).toBe('$.scores[?(@==20)]'); + expect(addOp?.path).toBe('$.scores[?(@==25)]'); + }); + + it('type changes produce single replace (not REMOVE+ADD)', () => { + const delta = diffDelta({ a: 'hello' }, { a: 42 }); + expect(delta.operations).toHaveLength(1); + expect(delta.operations[0]).toMatchObject({ + op: 'replace', + path: '$.a', + value: 42, + oldValue: 'hello', + }); + }); + + it('Objectβ†’Array type change produces single replace', () => { + const delta = diffDelta({ a: { x: 1 } }, { a: [1, 2] }); + expect(delta.operations).toHaveLength(1); + expect(delta.operations[0]).toMatchObject({ + op: 'replace', + path: '$.a', + value: [1, 2], + oldValue: { x: 1 }, + }); + }); + + it('Arrayβ†’Object type change produces single replace', () => { + const delta = diffDelta({ a: [1, 2] }, { a: { x: 1 } }); + expect(delta.operations).toHaveLength(1); + expect(delta.operations[0]).toMatchObject({ + op: 'replace', + path: '$.a', + }); + }); + + it('nullβ†’Object produces single replace', () => { + const delta = diffDelta({ a: null }, { a: { x: 1 } }); + expect(delta.operations).toHaveLength(1); + expect(delta.operations[0]).toMatchObject({ + op: 'replace', + path: '$.a', + value: { x: 1 }, + oldValue: null, + }); + }); + + it('replace with null value', () => { + const delta = diffDelta({ a: 42 }, { a: null }); + expect(delta.operations).toHaveLength(1); + expect(delta.operations[0]).toMatchObject({ + op: 'replace', + path: '$.a', + value: null, + oldValue: 42, + }); + }); + + it('omits oldValue when reversible is false', () => { + const delta = diffDelta({ name: 'Alice' }, { name: 'Bob' }, { reversible: false }); + expect(delta.operations[0]).toEqual({ op: 'replace', path: '$.name', value: 'Bob' }); + expect(delta.operations[0]).not.toHaveProperty('oldValue'); + }); + + it('passes keysToSkip through', () => { + const delta = diffDelta( + { a: 1, b: 2, c: 3 }, + { a: 10, b: 20, c: 30 }, + { keysToSkip: ['b'] } + ); + const paths = delta.operations.map(op => op.path); + expect(paths).toContain('$.a'); + expect(paths).toContain('$.c'); + expect(paths).not.toContain('$.b'); + }); + + it('handles function keys with canonical typed literals', () => { + const delta = diffDelta( + { items: [{ id: 1, name: 'Widget' }] }, + { items: [{ id: 1, name: 'Gadget' }] }, + { + embeddedObjKeys: { + items: ((item: any, returnKeyName?: boolean) => + returnKeyName ? 'id' : item.id) as any, + }, + } + ); + expect(delta.operations).toHaveLength(1); + expect(delta.operations[0].path).toBe('$.items[?(@.id==1)].name'); + }); + + it('handles function keys with add operations', () => { + const delta = diffDelta( + { items: [{ id: 1, name: 'Widget' }] }, + { items: [{ id: 1, name: 'Widget' }, { id: 2, name: 'Gadget' }] }, + { + embeddedObjKeys: { + items: ((item: any, returnKeyName?: boolean) => + returnKeyName ? 'id' : item.id) as any, + }, + } + ); + expect(delta.operations).toHaveLength(1); + expect(delta.operations[0].op).toBe('add'); + expect(delta.operations[0].path).toBe('$.items[?(@.id==2)]'); + }); + + it('handles function keys with remove operations', () => { + const delta = diffDelta( + { items: [{ id: 1, name: 'Widget' }, { id: 2, name: 'Gadget' }] }, + { items: [{ id: 1, name: 'Widget' }] }, + { + embeddedObjKeys: { + items: ((item: any, returnKeyName?: boolean) => + returnKeyName ? 'id' : item.id) as any, + }, + } + ); + expect(delta.operations).toHaveLength(1); + expect(delta.operations[0].op).toBe('remove'); + expect(delta.operations[0].path).toBe('$.items[?(@.id==2)]'); + }); + + it('handles nested property names with dots (bracket notation)', () => { + const delta = diffDelta( + { 'a.b': 1 }, + { 'a.b': 2 } + ); + expect(delta.operations).toHaveLength(1); + expect(delta.operations[0].path).toBe("$['a.b']"); + }); + + it('handles deep path in $index arrays', () => { + const delta = diffDelta( + { items: [{ name: 'Widget', color: 'red' }] }, + { items: [{ name: 'Widget', color: 'blue' }] } + ); + expect(delta.operations).toHaveLength(1); + expect(delta.operations[0].path).toBe('$.items[0].color'); + }); +}); + +// ─── toDelta ──────────────────────────────────────────────────────────────── + +describe('toDelta', () => { + it('converts hierarchical Changeset to delta', () => { + const changeset = diff({ name: 'Alice' }, { name: 'Bob' }); + const delta = toDelta(changeset); + expect(delta.format).toBe('json-delta'); + expect(delta.version).toBe(1); + expect(delta.operations).toHaveLength(1); + expect(delta.operations[0].op).toBe('replace'); + expect(delta.operations[0].value).toBe('Bob'); + }); + + it('converts flat IAtomicChange[] to delta', () => { + const changeset = diff({ name: 'Alice' }, { name: 'Bob' }); + const atoms = atomizeChangeset(changeset); + const delta = toDelta(atoms); + expect(delta.operations).toHaveLength(1); + expect(delta.operations[0].op).toBe('replace'); + }); + + it('merges REMOVE+ADD pairs into single replace', () => { + const changeset = diff({ a: 'hello' }, { a: 42 }, { treatTypeChangeAsReplace: true }); + const delta = toDelta(changeset); + // Should be a single replace, not separate remove+add + expect(delta.operations).toHaveLength(1); + expect(delta.operations[0].op).toBe('replace'); + expect(delta.operations[0].value).toBe(42); + }); + + it('canonicalizes paths (bracket quotes)', () => { + const atoms: IAtomicChange[] = [{ + type: Operation.UPDATE, + key: 'a.b', + path: '$[a.b]', + valueType: 'Number', + value: 2, + oldValue: 1, + }]; + const delta = toDelta(atoms); + expect(delta.operations[0].path).toBe("$['a.b']"); + }); + + it('normalizes root operations ($.$root β†’ $)', () => { + const atoms: IAtomicChange[] = [{ + type: Operation.UPDATE, + key: '$root', + path: '$.$root', + valueType: 'String', + value: 'new', + oldValue: 'old', + }]; + const delta = toDelta(atoms); + expect(delta.operations[0].path).toBe('$'); + }); + + it('handles empty changeset', () => { + const delta = toDelta([]); + expect(delta.operations).toEqual([]); + }); +}); + +// ─── fromDelta ────────────────────────────────────────────────────────────── + +describe('fromDelta', () => { + it('returns IAtomicChange[] with correct 1:1 mapping', () => { + const delta: IJsonDelta = { + format: 'json-delta', + version: 1, + operations: [ + { op: 'replace', path: '$.name', value: 'Bob', oldValue: 'Alice' }, + { op: 'add', path: '$.age', value: 30 }, + ], + }; + const atoms = fromDelta(delta); + expect(atoms).toHaveLength(2); + expect(atoms[0].type).toBe(Operation.UPDATE); + expect(atoms[0].key).toBe('name'); + expect(atoms[0].path).toBe('$.name'); + expect(atoms[0].value).toBe('Bob'); + expect(atoms[0].oldValue).toBe('Alice'); + expect(atoms[1].type).toBe(Operation.ADD); + expect(atoms[1].key).toBe('age'); + expect(atoms[1].value).toBe(30); + }); + + it('converts remove op correctly (oldValue β†’ value)', () => { + const delta: IJsonDelta = { + format: 'json-delta', + version: 1, + operations: [{ op: 'remove', path: '$.x', oldValue: 42 }], + }; + const atoms = fromDelta(delta); + expect(atoms[0].type).toBe(Operation.REMOVE); + expect(atoms[0].value).toBe(42); + }); + + it('normalizes root path ($ β†’ $.$root)', () => { + const delta: IJsonDelta = { + format: 'json-delta', + version: 1, + operations: [{ op: 'replace', path: '$', value: { new: true }, oldValue: { old: true } }], + }; + const atoms = fromDelta(delta); + expect(atoms[0].path).toBe('$.$root'); + expect(atoms[0].key).toBe('$root'); + }); + + it('normalizes non-string filter literals to string-quoted', () => { + const delta: IJsonDelta = { + format: 'json-delta', + version: 1, + operations: [{ op: 'replace', path: '$.items[?(@.id==42)].name', value: 'X', oldValue: 'Y' }], + }; + const atoms = fromDelta(delta); + expect(atoms[0].path).toBe("$.items[?(@.id=='42')].name"); + }); + + it('round-trips: diffDelta β†’ fromDelta β†’ unatomize β†’ applyChangeset', () => { + const source = { name: 'Alice', age: 30, active: true }; + const target = { name: 'Bob', age: 30, active: false }; + const delta = diffDelta(source, target); + const atoms = fromDelta(delta); + const changeset = unatomizeChangeset(atoms); + const result = deepClone(source); + applyChangeset(result, changeset); + expect(result).toEqual(target); + }); + + it('derives valueType from value', () => { + const delta: IJsonDelta = { + format: 'json-delta', + version: 1, + operations: [ + { op: 'add', path: '$.s', value: 'hello' }, + { op: 'add', path: '$.n', value: 42 }, + { op: 'add', path: '$.b', value: true }, + { op: 'add', path: '$.o', value: { x: 1 } }, + { op: 'add', path: '$.a', value: [1, 2] }, + { op: 'add', path: '$.null', value: null }, + ], + }; + const atoms = fromDelta(delta); + expect(atoms[0].valueType).toBe('String'); + expect(atoms[1].valueType).toBe('Number'); + expect(atoms[2].valueType).toBe('Boolean'); + expect(atoms[3].valueType).toBe('Object'); + expect(atoms[4].valueType).toBe('Array'); + expect(atoms[5].valueType).toBe(null); + }); + + it('throws on invalid delta', () => { + expect(() => fromDelta({ format: 'wrong' } as any)).toThrow(/Invalid delta/); + }); +}); + +// ─── applyDelta ───────────────────────────────────────────────────────────── + +describe('applyDelta', () => { + it('applies simple property changes', () => { + const obj = { name: 'Alice', age: 30 }; + const delta: IJsonDelta = { + format: 'json-delta', + version: 1, + operations: [ + { op: 'replace', path: '$.name', value: 'Bob', oldValue: 'Alice' }, + ], + }; + const result = applyDelta(obj, delta); + expect(result).toEqual({ name: 'Bob', age: 30 }); + }); + + it('applies add and remove', () => { + const obj = { a: 1, b: 2 }; + const delta: IJsonDelta = { + format: 'json-delta', + version: 1, + operations: [ + { op: 'remove', path: '$.b', oldValue: 2 }, + { op: 'add', path: '$.c', value: 3 }, + ], + }; + const result = applyDelta(obj, delta); + expect(result).toEqual({ a: 1, c: 3 }); + }); + + it('applies keyed array operations', () => { + const obj = { + items: [ + { id: '1', name: 'Widget', price: 10 }, + { id: '2', name: 'Gadget', price: 20 }, + ], + }; + const delta: IJsonDelta = { + format: 'json-delta', + version: 1, + operations: [ + { op: 'replace', path: "$.items[?(@.id=='1')].name", value: 'Widget Pro', oldValue: 'Widget' }, + ], + }; + const result = applyDelta(obj, delta); + expect(result.items[0].name).toBe('Widget Pro'); + }); + + it('applies root add (from null)', () => { + const result = applyDelta(null, { + format: 'json-delta', + version: 1, + operations: [{ op: 'add', path: '$', value: { hello: 'world' } }], + }); + expect(result).toEqual({ hello: 'world' }); + }); + + it('applies root remove (to null)', () => { + const result = applyDelta({ hello: 'world' }, { + format: 'json-delta', + version: 1, + operations: [{ op: 'remove', path: '$', oldValue: { hello: 'world' } }], + }); + expect(result).toBe(null); + }); + + it('applies root replace', () => { + const result = applyDelta( + { old: true }, + { + format: 'json-delta', + version: 1, + operations: [{ op: 'replace', path: '$', value: { new: true }, oldValue: { old: true } }], + } + ); + expect(result).toEqual({ new: true }); + }); + + it('root replace with primitive returns new value', () => { + const result = applyDelta( + 'old', + { + format: 'json-delta', + version: 1, + operations: [{ op: 'replace', path: '$', value: 'new', oldValue: 'old' }], + } + ); + expect(result).toBe('new'); + }); + + it('throws on invalid delta', () => { + expect(() => applyDelta({}, { format: 'wrong' } as any)).toThrow(); + }); + + it('root replace object with array returns array', () => { + const result = applyDelta( + { old: true }, + { + format: 'json-delta', + version: 1, + operations: [{ op: 'replace', path: '$', value: [1, 2, 3], oldValue: { old: true } }], + } + ); + expect(result).toEqual([1, 2, 3]); + expect(Array.isArray(result)).toBe(true); + }); + + it('root replace array with object returns plain object', () => { + const result = applyDelta( + [1, 2, 3], + { + format: 'json-delta', + version: 1, + operations: [{ op: 'replace', path: '$', value: { new: true }, oldValue: [1, 2, 3] }], + } + ); + expect(result).toEqual({ new: true }); + expect(Array.isArray(result)).toBe(false); + }); + + it('root replace array with array returns new array', () => { + const result = applyDelta( + [1, 2], + { + format: 'json-delta', + version: 1, + operations: [{ op: 'replace', path: '$', value: [3, 4, 5], oldValue: [1, 2] }], + } + ); + expect(result).toEqual([3, 4, 5]); + expect(Array.isArray(result)).toBe(true); + }); + + it('applies operations sequentially (order matters)', () => { + const obj = { items: ['a', 'b', 'c'] }; + // Remove index 1, then the array becomes ['a', 'c'] + // Then replace index 1 (which is now 'c') with 'd' + const delta: IJsonDelta = { + format: 'json-delta', + version: 1, + operations: [ + { op: 'remove', path: '$.items[1]', oldValue: 'b' }, + { op: 'replace', path: '$.items[1]', value: 'd', oldValue: 'c' }, + ], + }; + const result = applyDelta(obj, delta); + expect(result.items).toEqual(['a', 'd']); + }); +}); + +// ─── revertDelta ──────────────────────────────────────────────────────────── + +describe('revertDelta', () => { + it('full round-trip: source β†’ applyDelta β†’ revertDelta == source', () => { + const source = { name: 'Alice', age: 30, tags: ['admin'] }; + const target = { name: 'Bob', age: 31, tags: ['admin', 'user'] }; + const delta = diffDelta(source, target, { embeddedObjKeys: { tags: '$value' } }); + + const applied = applyDelta(deepClone(source), delta); + expect(applied).toEqual(target); + + const reverted = revertDelta(deepClone(applied), delta); + expect(reverted).toEqual(source); + }); + + it('throws on non-reversible delta (missing oldValue)', () => { + const delta: IJsonDelta = { + format: 'json-delta', + version: 1, + operations: [{ op: 'replace', path: '$.name', value: 'Bob' }], + }; + expect(() => revertDelta({ name: 'Alice' }, delta)).toThrow(/not reversible/); + }); +}); + +// ─── invertDelta ──────────────────────────────────────────────────────────── + +describe('invertDelta', () => { + it('inverts add β†’ remove', () => { + const delta: IJsonDelta = { + format: 'json-delta', + version: 1, + operations: [{ op: 'add', path: '$.x', value: 42 }], + }; + const inverse = invertDelta(delta); + expect(inverse.operations).toEqual([{ op: 'remove', path: '$.x', oldValue: 42 }]); + }); + + it('inverts remove β†’ add', () => { + const delta: IJsonDelta = { + format: 'json-delta', + version: 1, + operations: [{ op: 'remove', path: '$.x', oldValue: 42 }], + }; + const inverse = invertDelta(delta); + expect(inverse.operations).toEqual([{ op: 'add', path: '$.x', value: 42 }]); + }); + + it('inverts replace (swaps value and oldValue)', () => { + const delta: IJsonDelta = { + format: 'json-delta', + version: 1, + operations: [{ op: 'replace', path: '$.name', value: 'Bob', oldValue: 'Alice' }], + }; + const inverse = invertDelta(delta); + expect(inverse.operations).toEqual([ + { op: 'replace', path: '$.name', value: 'Alice', oldValue: 'Bob' }, + ]); + }); + + it('reverses operation order', () => { + const delta: IJsonDelta = { + format: 'json-delta', + version: 1, + operations: [ + { op: 'add', path: '$.a', value: 1 }, + { op: 'add', path: '$.b', value: 2 }, + ], + }; + const inverse = invertDelta(delta); + expect(inverse.operations[0].path).toBe('$.b'); + expect(inverse.operations[1].path).toBe('$.a'); + }); + + it('throws when replace missing oldValue', () => { + const delta: IJsonDelta = { + format: 'json-delta', + version: 1, + operations: [{ op: 'replace', path: '$.x', value: 42 }], + }; + expect(() => invertDelta(delta)).toThrow(/not reversible/); + }); + + it('throws when remove missing oldValue', () => { + const delta: IJsonDelta = { + format: 'json-delta', + version: 1, + operations: [{ op: 'remove', path: '$.x' }], + }; + expect(() => invertDelta(delta)).toThrow(/not reversible/); + }); + + it('preserves envelope extension properties', () => { + const delta: IJsonDelta = { + format: 'json-delta', + version: 1, + operations: [{ op: 'add', path: '$.x', value: 1 }], + x_source: 'test', + }; + const inverse = invertDelta(delta); + expect(inverse.x_source).toBe('test'); + expect(inverse.format).toBe('json-delta'); + }); + + it('preserves operation-level extension properties', () => { + const delta: IJsonDelta = { + format: 'json-delta', + version: 1, + operations: [{ op: 'add', path: '$.x', value: 1, x_author: 'alice' }], + }; + const inverse = invertDelta(delta); + expect(inverse.operations[0].x_author).toBe('alice'); + }); + + it('throws on invalid delta input', () => { + expect(() => invertDelta({ format: 'wrong' } as any)).toThrow(/Invalid delta/); + }); +}); + +// ─── Extension property preservation ──────────────────────────────────────── + +describe('extension property preservation', () => { + it('applyDelta ignores extension properties without error', () => { + const delta: IJsonDelta = { + format: 'json-delta', + version: 1, + operations: [{ op: 'replace', path: '$.name', value: 'Bob', oldValue: 'Alice', x_reason: 'rename' }], + x_metadata: { ts: 123 }, + }; + const result = applyDelta({ name: 'Alice' }, delta); + expect(result).toEqual({ name: 'Bob' }); + }); +}); + +// ─── Conformance Fixtures ─────────────────────────────────────────────────── + +describe('conformance fixtures', () => { + describe('basic-replace', () => { + const fixture = loadFixture('basic-replace'); + + it('Level 1: applyDelta(source, delta) == target', () => { + const result = applyDelta(deepClone(fixture.source), fixture.delta); + expect(result).toEqual(fixture.target); + }); + + it('Level 2: applyDelta(target, inverse(delta)) == source', () => { + const inverse = invertDelta(fixture.delta); + const result = applyDelta(deepClone(fixture.target), inverse); + expect(result).toEqual(fixture.source); + }); + + it('diffDelta produces equivalent delta (verified by apply)', () => { + const computed = diffDelta(fixture.source, fixture.target); + const result = applyDelta(deepClone(fixture.source), computed); + expect(result).toEqual(fixture.target); + }); + }); + + describe('keyed-array-update', () => { + const fixture = loadFixture('keyed-array-update'); + + it('Level 1: applyDelta(source, delta) == target', () => { + const result = applyDelta(deepClone(fixture.source), fixture.delta); + expect(result).toEqual(fixture.target); + }); + + it('Level 2: applyDelta(target, inverse(delta)) == source', () => { + const inverse = invertDelta(fixture.delta); + const result = applyDelta(deepClone(fixture.target), inverse); + expect(result).toEqual(fixture.source); + }); + + it('diffDelta produces equivalent delta (verified by apply)', () => { + const opts = { + embeddedObjKeys: fixture.computeHints?.arrayKeys || {}, + }; + const computed = diffDelta(fixture.source, fixture.target, opts); + const result = applyDelta(deepClone(fixture.source), computed); + expect(result).toEqual(fixture.target); + }); + }); +}); + +// ─── Integration: full round-trip scenarios ───────────────────────────────── + +describe('integration round-trips', () => { + it('nested objects with add/remove/replace', () => { + const source = { + user: { name: 'Alice', age: 30 }, + settings: { theme: 'light', lang: 'en' }, + }; + const target = { + user: { name: 'Bob', age: 31 }, + settings: { theme: 'dark' }, + newField: true, + }; + const delta = diffDelta(source, target); + expect(applyDelta(deepClone(source), delta)).toEqual(target); + expect(revertDelta(deepClone(target), delta)).toEqual(source); + }); + + it('keyed arrays with deep property changes', () => { + const source = { + items: [ + { id: 1, name: 'Widget', details: { color: 'red' } }, + { id: 2, name: 'Gadget', details: { color: 'blue' } }, + ], + }; + const target = { + items: [ + { id: 1, name: 'Widget', details: { color: 'green' } }, + { id: 2, name: 'Gadget', details: { color: 'blue' } }, + ], + }; + const delta = diffDelta(source, target, { embeddedObjKeys: { items: 'id' } }); + expect(delta.operations).toHaveLength(1); + expect(delta.operations[0].path).toBe('$.items[?(@.id==1)].details.color'); + expect(applyDelta(deepClone(source), delta)).toEqual(target); + expect(revertDelta(deepClone(target), delta)).toEqual(source); + }); + + it('toDelta bridge: diff β†’ toDelta β†’ applyDelta', () => { + const source = { a: 1, b: 'hello' }; + const target = { a: 2, b: 'world', c: true }; + const changeset = diff(source, target); + const delta = toDelta(changeset); + expect(applyDelta(deepClone(source), delta)).toEqual(target); + }); + + it('fromDelta bridge: diffDelta β†’ fromDelta β†’ unatomize β†’ apply', () => { + const source = { x: 10, y: 20 }; + const target = { x: 10, y: 30, z: 40 }; + const delta = diffDelta(source, target); + const atoms = fromDelta(delta); + const changeset = unatomizeChangeset(atoms); + const result = deepClone(source); + applyChangeset(result, changeset); + expect(result).toEqual(target); + }); +});