From 6509144d59ac6ea01725f5e30576470e71e9a3e8 Mon Sep 17 00:00:00 2001 From: Christian Glessner Date: Sat, 7 Mar 2026 23:56:02 +0100 Subject: [PATCH 1/4] Add JSON Delta API for v5.0.0-alpha.0 Implement first-class JSON Delta support as an adapter layer over the existing v4 internals. New APIs produce, consume, apply, and revert spec-conformant JSON Delta documents while preserving all v4 APIs unchanged. New public functions: diffDelta, applyDelta, revertDelta, invertDelta, toDelta, fromDelta, validateDelta. New files: src/jsonDelta.ts, src/deltaPath.ts, CLAUDE.md, conformance fixtures, and comprehensive tests (143 new tests, all 245 pass). README rewritten to position JSON Delta as the primary API with practical examples for audit logging, undo/redo, and state sync. --- CLAUDE.md | 54 ++ README.md | 739 +++++++++------ package.json | 2 +- src/deltaPath.ts | 405 +++++++++ src/index.ts | 1 + src/jsonDelta.ts | 642 ++++++++++++++ .../json-delta/basic-replace.json | 25 + .../json-delta/keyed-array-update.json | 52 ++ tests/deltaPath.test.ts | 403 +++++++++ tests/jsonDelta.test.ts | 838 ++++++++++++++++++ 10 files changed, 2876 insertions(+), 285 deletions(-) create mode 100644 CLAUDE.md create mode 100644 src/deltaPath.ts create mode 100644 src/jsonDelta.ts create mode 100644 tests/__fixtures__/json-delta/basic-replace.json create mode 100644 tests/__fixtures__/json-delta/keyed-array-update.json create mode 100644 tests/deltaPath.test.ts create mode 100644 tests/jsonDelta.test.ts diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..ee12d6e --- /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/spec/v0.md`. +- **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..34978a7 100644 --- a/README.md +++ b/README.md @@ -12,33 +12,94 @@ [![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, { embeddedObjKeys: { items: 'id' } }); +``` -- **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 +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 or revert: + +```typescript +const result = applyDelta(structuredClone(before), delta); // β†’ after +const original = revertDelta(structuredClone(result), delta); // β†’ before +``` + +## Quick Start + +```typescript +import { diffDelta, applyDelta, revertDelta } from 'json-diff-ts'; + +// 1. Compute a delta between two JSON objects +const delta = diffDelta(oldObj, newObj, { + embeddedObjKeys: { items: 'id' }, // match array elements by 'id' field +}); +// delta => +// { +// format: 'json-delta', +// version: 1, +// operations: [ +// { op: 'replace', path: '$.items[?(@.id==1)].name', value: 'New Name', oldValue: 'Old Name' }, +// { op: 'add', path: '$.items[?(@.id==3)]', value: { id: 3, name: 'New Item' } } +// ] +// } + +// 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); +``` -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. +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 +107,461 @@ This library is particularly valuable for applications where tracking changes in npm install json-diff-ts ``` -## Quick Start - ```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' } -// ] +// ESM / TypeScript +import { diffDelta, applyDelta, revertDelta } from 'json-diff-ts'; -// Apply changes to get the new object -const result = applyChangeset(oldData, changes); -console.log(result); // { name: 'Luke Skywalker', level: 5, skills: ['piloting', 'force'] } +// CommonJS +const { diffDelta, applyDelta, revertDelta } = require('json-diff-ts'); ``` -### Import Options +## 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 / ES Modules:** ```typescript -import { diff } from 'json-diff-ts'; +const delta = diffDelta( + { user: { name: 'Alice', role: 'viewer' } }, + { user: { name: 'Alice', role: 'admin' } } +); +// β†’ { op: 'replace', path: '$.user.role', value: 'admin', oldValue: 'viewer' } ``` -**CommonJS:** -```javascript -const { diff } = require('json-diff-ts'); +#### Keyed Arrays + +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' }] }, + { embeddedObjKeys: { users: 'id' } } +); +// β†’ { op: 'replace', path: '$.users[?(@.id==1)].role', value: 'admin', oldValue: 'viewer' } ``` -## Core Features +#### Non-reversible Mode -### `diff` +Omit `oldValue` fields when you don't need undo: + +```typescript +const delta = diffDelta(source, target, { reversible: false }); +``` -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. +### `applyDelta` -- Apply a Delta -#### Basic Example with Star Wars Data +Applies operations sequentially. Always use the return value (required for root-level replacements): ```typescript -import { diff } from 'json-diff-ts'; +const result = applyDelta(structuredClone(source), delta); +``` -// 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'] -}; +### `revertDelta` -- Revert a Delta -// 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' } - ], - equipment: ['Lightsaber', 'Blaster', 'Bowcaster', 'X-wing Fighter'] -}; +Computes the inverse and applies it. Requires `oldValue` on all `replace` and `remove` operations: -const diffs = diff(oldData, newData, { embeddedObjKeys: { characters: 'id' } }); -console.log(diffs); -// First 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' }, -// ... -// ] +```typescript +const original = revertDelta(structuredClone(target), delta); ``` -#### Advanced Options +### `invertDelta` -- Compute the Inverse -##### Path-based Key Identification +Returns a new delta that undoes the original (spec Section 9.2): -```javascript -import { diff } from 'json-diff-ts'; +```typescript +const inverse = invertDelta(delta); +// add ↔ remove, replace swaps value/oldValue, order reversed +``` -// Using nested paths for sub-arrays -const diffs = diff(oldData, newData, { embeddedObjKeys: { 'characters.equipment': 'id' } }); +### `validateDelta` -- Validate Structure -// Designating root with '.' - useful for complex nested structures -const diffs = diff(oldData, newData, { embeddedObjKeys: { '.characters.allies': 'id' } }); +```typescript +const { valid, errors } = validateDelta(maybeDelta); ``` -##### Type Change Handling +### API Reference -```javascript -import { diff } from 'json-diff-ts'; +| 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: -// Control how type changes are treated -const diffs = diff(oldData, newData, { treatTypeChangeAsReplace: false }); +```typescript +interface DeltaOptions extends Options { + reversible?: boolean; // Include oldValue for undo. Default: true + embeddedObjKeys?: Record; + keysToSkip?: readonly string[]; +} ``` -Date objects can now be updated to primitive values without errors when `treatTypeChangeAsReplace` is set to `false`. +--- -##### Skip Nested Paths +## Practical Examples -```javascript -import { diff } from 'json-diff-ts'; +### 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[] = []; + +function updateDocument(doc: any, changes: any, userId: string) { + const delta = diffDelta(doc, { ...doc, ...changes }, { + embeddedObjKeys: { items: 'id', members: 'email' }, + }); + + if (delta.operations.length > 0) { + auditLog.push({ timestamp: new Date().toISOString(), userId, delta }); + } -// Skip specific nested paths from comparison - useful for ignoring metadata -const diffs = diff(oldData, newData, { keysToSkip: ['characters.metadata'] }); + return applyDelta(doc, delta); +} + +// Revert the last change +function undo(doc: any): any { + const last = auditLog.pop(); + if (!last) return doc; + return revertDelta(doc, last.delta); +} ``` -##### Dynamic Key Resolution +Because every delta is self-describing JSON, your audit log is queryable, storable in any database, and readable from any language. -```javascript -import { diff } from 'json-diff-ts'; +### Undo / Redo Stack -// Use function to resolve object keys dynamically -const diffs = diff(oldData, newData, { - embeddedObjKeys: { - characters: (obj, shouldReturnKeyName) => (shouldReturnKeyName ? 'id' : obj.id) +Build undo/redo for any JSON state object. Deltas are small (only changed fields), reversible, and serializable: + +```typescript +import { diffDelta, applyDelta, revertDelta, IJsonDelta } from 'json-diff-ts'; + +class UndoManager { + private undoStack: IJsonDelta[] = []; + private redoStack: IJsonDelta[] = []; + + constructor(private state: T) {} + + 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; } -}); -// Access index for array elements -const rebels = [ - { name: 'Luke Skywalker', faction: 'Jedi' }, - { name: 'Yoda', faction: 'Jedi' }, - { name: 'Princess Leia', faction: 'Rebellion' } -]; + 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; + } -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}`; - } + 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; + } +} +``` + +### AI Agent State Tracking + +Track tool calls, context updates, and reasoning steps in an AI agent pipeline. Each state transition is a delta you can inspect, replay, or roll back: + +```typescript +import { diffDelta, applyDelta, IJsonDelta } from 'json-diff-ts'; + +interface AgentState { + context: Record; + messages: Array<{ role: string; content: string }>; + tools: Array<{ name: string; status: string; result?: any }>; +} + +const transitions: IJsonDelta[] = []; + +function recordTransition(before: AgentState, after: AgentState) { + const delta = diffDelta(before, after, { + embeddedObjKeys: { tools: 'name' }, + }); + transitions.push(delta); + return delta; +} + +// Replay from initial state to any point +function replayTo(initial: AgentState, stepIndex: number): AgentState { + let state = structuredClone(initial); + for (let i = 0; i <= stepIndex; i++) { + state = applyDelta(state, transitions[i]); } + return state; +} +``` + +### Data Synchronization + +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: + +```typescript +import { diffDelta, applyDelta, validateDelta } from 'json-diff-ts'; + +// Client side: compute and send delta +const delta = diffDelta(localState, updatedState, { + embeddedObjKeys: { records: 'id' }, +}); +await fetch('/api/sync', { + method: 'POST', + body: JSON.stringify(delta), }); + +// Server side: validate and apply +const result = validateDelta(req.body); +if (!result.valid) return res.status(400).json(result.errors); +const newState = applyDelta(currentState, req.body); ``` -##### Regular Expression Paths +--- -```javascript -import { diff } from 'json-diff-ts'; +## Bridge: v4 Changeset <-> JSON Delta + +Convert between the legacy internal format and JSON Delta: -// 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 }); +```typescript +import { diff, toDelta, fromDelta, unatomizeChangeset } from 'json-diff-ts'; + +// v4 changeset β†’ JSON Delta +const changeset = diff(source, target, { embeddedObjKeys: { 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); ``` -##### String Array Comparison +**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) -```javascript +All v4 APIs remain fully supported. Existing code continues to work without changes. For new projects, prefer the JSON Delta API above. + +### `diff` + +Generates a hierarchical changeset between two objects: + +```typescript import { diff } 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 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, { embeddedObjKeys: { characters: 'id' } }); +``` + +### `applyChangeset` and `revertChangeset` + +```typescript +import { applyChangeset, revertChangeset } from 'json-diff-ts'; + +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, { embeddedObjKeys: { characters: 'id' } }); -```javascript -import { applyChangeset, revertChangeset } from 'json-diff-ts'; +// Function key +diff(old, new, { + embeddedObjKeys: { + 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, { embeddedObjKeys: 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, { embeddedObjKeys: { 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 { 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. 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 embedded key: `{ embeddedObjKeys: { 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 + - 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 +573,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 +598,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..65407c1 --- /dev/null +++ b/src/deltaPath.ts @@ -0,0 +1,405 @@ +// ─── 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') 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 + const n = Number(s); + if (!Number.isNaN(n)) return n; + 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 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 = path.indexOf(')]', i); + 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 = atomicPath.indexOf(')]', i); + 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 = deltaPath.indexOf(')]', i); + 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..414424e --- /dev/null +++ b/src/jsonDelta.ts @@ -0,0 +1,642 @@ +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)})]`; + } + + if (typeof embeddedKey === 'function') { + // Function key β€” get the property name and find the element + 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)})]`; + } + // Fallback: string-quoted + 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; + } + } + // 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)]; + } + if (embeddedKey === '$value') { + return arr.find((item) => String(item) === changeKey); + } + 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; +} + +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; + } + 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; + } + 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 }; + 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: T, delta: IJsonDelta): T { + 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': { + if (typeof obj === 'object' && obj !== null && typeof op.value === 'object' && op.value !== null) { + // Clear existing properties and assign new ones + for (const key of Object.keys(obj)) { + delete obj[key]; + } + if (Array.isArray(op.value)) { + // Converting to array requires returning new value + return [...op.value]; + } + Object.assign(obj, op.value); + return obj; + } + return op.value; + } + 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, + }; + 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: T, delta: IJsonDelta): T { + const inverse = invertDelta(delta); + return applyDelta(obj, inverse); +} + +// ─── Re-exports for convenience ───────────────────────────────────────────── + +export { DeltaPathSegment, formatFilterLiteral, parseFilterLiteral, parseDeltaPath, buildDeltaPath } from './deltaPath.js'; 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..34743ca --- /dev/null +++ b/tests/deltaPath.test.ts @@ -0,0 +1,403 @@ +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(); + }); +}); + +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('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 + }); +}); + +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('handles paths with filters and deep properties', () => { + expect(atomicPathToDeltaPath("$.items[?(@.id=='1')].name")).toBe("$.items[?(@.id=='1')].name"); + }); +}); + +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]'); + }); +}); + +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'); + }); +}); diff --git a/tests/jsonDelta.test.ts b/tests/jsonDelta.test.ts new file mode 100644 index 0000000..c280aef --- /dev/null +++ b/tests/jsonDelta.test.ts @@ -0,0 +1,838 @@ +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); + }); +}); + +// ─── 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'); + }); +}); + +// ─── 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('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'); + }); +}); + +// ─── 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); + }); +}); From 19267d72a5f21778424fd9052866bfc6e26e63b6 Mon Sep 17 00:00:00 2001 From: Christian Glessner Date: Sun, 8 Mar 2026 00:05:37 +0100 Subject: [PATCH 2/4] Improve test coverage to meet codecov target Add 19 new tests covering error paths, edge cases, and validation branches in deltaPath.ts and jsonDelta.ts. Add istanbul ignore comments to unreachable defensive branches (exhaustive switch defaults, function-key paths that diff() never produces). Coverage: deltaPath.ts 100%, jsonDelta.ts 97.8%, overall 96.96%. --- src/jsonDelta.ts | 12 +++++- tests/deltaPath.test.ts | 44 +++++++++++++++++++++ tests/jsonDelta.test.ts | 86 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 140 insertions(+), 2 deletions(-) diff --git a/src/jsonDelta.ts b/src/jsonDelta.ts index 414424e..4b67955 100644 --- a/src/jsonDelta.ts +++ b/src/jsonDelta.ts @@ -265,8 +265,8 @@ function buildCanonicalFilterPath( return `${basePath}[?(@==${formatFilterLiteral(typedVal)})]`; } + /* istanbul ignore next -- diff() always resolves function keys to strings in embeddedKey */ if (typeof embeddedKey === 'function') { - // Function key β€” get the property name and find the element 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); @@ -275,7 +275,6 @@ function buildCanonicalFilterPath( const memberAccess = SIMPLE_PROPERTY_RE.test(keyName) ? `.${keyName}` : `['${keyName.replace(/'/g, "''")}']`; return `${basePath}[?(@${memberAccess}==${formatFilterLiteral(typedVal)})]`; } - // Fallback: string-quoted const memberAccess = typeof keyName === 'string' && SIMPLE_PROPERTY_RE.test(keyName) ? `.${keyName}` : `.${changeKey}`; return `${basePath}[?(@${memberAccess}=='${changeKey}')]`; } @@ -300,6 +299,7 @@ function findActualValue(oldArr: any[], newArr: any[], stringKey: string, opType 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) { @@ -320,9 +320,11 @@ function findElement(arr: any[], embeddedKey: string | FunctionKey, changeKey: s 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); } @@ -348,6 +350,7 @@ function findElementByKey( return undefined; } +/* istanbul ignore next -- only reachable if embeddedKey is a function, which diff() never stores */ function findElementByFn( oldArr: any[], newArr: any[], @@ -406,6 +409,7 @@ export function toDelta(changeset: Changeset | IAtomicChange[], options: { rever } return op; } + /* istanbul ignore next -- exhaustive switch */ default: throw new Error(`Unknown operation type: ${atom.type}`); } @@ -479,6 +483,7 @@ export function fromDelta(delta: IJsonDelta): IAtomicChange[] { } return atom; } + /* istanbul ignore next -- exhaustive switch */ default: throw new Error(`Unknown operation: ${op.op}`); } @@ -533,6 +538,7 @@ export function invertDelta(delta: IJsonDelta): IJsonDelta { 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}`); } @@ -598,6 +604,7 @@ function applyRootOp(obj: any, op: IDeltaOperation): any { } return op.value; } + /* istanbul ignore next -- exhaustive switch */ default: throw new Error(`Unknown operation: ${op.op}`); } @@ -621,6 +628,7 @@ function deltaOpToAtomicChange(op: IDeltaOperation): IAtomicChange { value: op.value, oldValue: op.oldValue, }; + /* istanbul ignore next -- exhaustive switch */ default: throw new Error(`Unknown operation: ${op.op}`); } diff --git a/tests/deltaPath.test.ts b/tests/deltaPath.test.ts index 34743ca..a9ba200 100644 --- a/tests/deltaPath.test.ts +++ b/tests/deltaPath.test.ts @@ -212,6 +212,22 @@ describe('parseDeltaPath', () => { 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/); + }); }); describe('buildDeltaPath', () => { @@ -329,6 +345,14 @@ describe('atomicPathToDeltaPath', () => { 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"); }); @@ -374,6 +398,18 @@ describe('deltaPathToAtomicPath', () => { 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/); + }); }); describe('extractKeyFromAtomicPath', () => { @@ -400,4 +436,12 @@ describe('extractKeyFromAtomicPath', () => { 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 index c280aef..5a33ae9 100644 --- a/tests/jsonDelta.test.ts +++ b/tests/jsonDelta.test.ts @@ -130,6 +130,26 @@ describe('validateDelta', () => { 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 ────────────────────────────────────────────────────────────── @@ -331,6 +351,56 @@ describe('diffDelta', () => { 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 ──────────────────────────────────────────────────────────────── @@ -583,6 +653,18 @@ describe('applyDelta', () => { 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]); + }); + it('applies operations sequentially (order matters)', () => { const obj = { items: ['a', 'b', 'c'] }; // Remove index 1, then the array becomes ['a', 'c'] @@ -713,6 +795,10 @@ describe('invertDelta', () => { 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 ──────────────────────────────────────── From 480ce3e6ecd145505de7d9c8d11e59163d41421e Mon Sep 17 00:00:00 2001 From: Christian Glessner Date: Sun, 8 Mar 2026 00:26:08 +0100 Subject: [PATCH 3/4] feat(v5): introduce arrayIdentityKeys option (embeddedObjKeys deprecated alias) --- README.md | 138 +++++++++++++++++++++++++----------------------- src/jsonDiff.ts | 5 +- 2 files changed, 75 insertions(+), 68 deletions(-) diff --git a/README.md b/README.md index 34978a7..5dcbf3a 100644 --- a/README.md +++ b/README.md @@ -49,7 +49,7 @@ const after = { ], }; -const delta = diffDelta(before, after, { embeddedObjKeys: { items: 'id' } }); +const delta = diffDelta(before, after, { arrayIdentityKeys: { items: 'id' } }); ``` The delta tracks _what_ changed, not _where_ it moved: @@ -66,11 +66,12 @@ The delta tracks _what_ changed, not _where_ it moved: } ``` -Apply forward or revert: +Apply forward to get the new state, or revert to restore the original: ```typescript -const result = applyDelta(structuredClone(before), delta); // β†’ after -const original = revertDelta(structuredClone(result), delta); // β†’ before +// 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 @@ -78,19 +79,30 @@ const original = revertDelta(structuredClone(result), delta); // β†’ before ```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 }, + ], +}; + +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 }, + ], +}; + // 1. Compute a delta between two JSON objects const delta = diffDelta(oldObj, newObj, { - embeddedObjKeys: { items: 'id' }, // match array elements by 'id' field + arrayIdentityKeys: { items: 'id' }, // match array elements by 'id' field }); -// delta => -// { -// format: 'json-delta', -// version: 1, -// operations: [ -// { op: 'replace', path: '$.items[?(@.id==1)].name', value: 'New Name', oldValue: 'Old Name' }, -// { op: 'add', path: '$.items[?(@.id==3)]', value: { id: 3, name: 'New Item' } } -// ] -// } +// 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); @@ -152,7 +164,7 @@ const delta = diffDelta( { user: { name: 'Alice', role: 'viewer' } }, { user: { name: 'Alice', role: 'admin' } } ); -// β†’ { op: 'replace', path: '$.user.role', value: 'admin', oldValue: 'viewer' } +// delta.operations β†’ [{ op: 'replace', path: '$.user.role', value: 'admin', oldValue: 'viewer' }] ``` #### Keyed Arrays @@ -163,9 +175,9 @@ Match array elements by identity key. Filter paths use canonical typed literals const delta = diffDelta( { users: [{ id: 1, role: 'viewer' }, { id: 2, role: 'editor' }] }, { users: [{ id: 1, role: 'admin' }, { id: 2, role: 'editor' }] }, - { embeddedObjKeys: { users: 'id' } } + { arrayIdentityKeys: { users: 'id' } } ); -// β†’ { op: 'replace', path: '$.users[?(@.id==1)].role', value: 'admin', oldValue: 'viewer' } +// delta.operations β†’ [{ op: 'replace', path: '$.users[?(@.id==1)].role', value: 'admin', oldValue: 'viewer' }] ``` #### Non-reversible Mode @@ -226,7 +238,7 @@ Extends the base `Options` interface: ```typescript interface DeltaOptions extends Options { reversible?: boolean; // Include oldValue for undo. Default: true - embeddedObjKeys?: Record; + arrayIdentityKeys?: Record; keysToSkip?: readonly string[]; } ``` @@ -249,25 +261,46 @@ interface AuditEntry { } const auditLog: AuditEntry[] = []; +let doc = { + title: 'Project Plan', + status: 'draft', + items: [ + { id: 1, task: 'Design', done: false }, + { id: 2, task: 'Build', done: false }, + ], +}; -function updateDocument(doc: any, changes: any, userId: string) { - const delta = diffDelta(doc, { ...doc, ...changes }, { - embeddedObjKeys: { items: 'id', members: 'email' }, +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 applyDelta(doc, delta); + return doc; } // Revert the last change -function undo(doc: any): any { +function undo(): typeof doc { const last = auditLog.pop(); if (!last) return doc; - return revertDelta(doc, last.delta); + 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 => +// [ +// { op: 'replace', path: '$.status', value: 'active', oldValue: 'draft' }, +// { op: 'replace', path: '$.items[?(@.id==1)].done', value: true, oldValue: false } +// ] ``` Because every delta is self-describing JSON, your audit log is queryable, storable in any database, and readable from any language. @@ -312,39 +345,6 @@ class UndoManager { } ``` -### AI Agent State Tracking - -Track tool calls, context updates, and reasoning steps in an AI agent pipeline. Each state transition is a delta you can inspect, replay, or roll back: - -```typescript -import { diffDelta, applyDelta, IJsonDelta } from 'json-diff-ts'; - -interface AgentState { - context: Record; - messages: Array<{ role: string; content: string }>; - tools: Array<{ name: string; status: string; result?: any }>; -} - -const transitions: IJsonDelta[] = []; - -function recordTransition(before: AgentState, after: AgentState) { - const delta = diffDelta(before, after, { - embeddedObjKeys: { tools: 'name' }, - }); - transitions.push(delta); - return delta; -} - -// Replay from initial state to any point -function replayTo(initial: AgentState, stepIndex: number): AgentState { - let state = structuredClone(initial); - for (let i = 0; i <= stepIndex; i++) { - state = applyDelta(state, transitions[i]); - } - return state; -} -``` - ### Data Synchronization 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: @@ -354,7 +354,7 @@ import { diffDelta, applyDelta, validateDelta } from 'json-diff-ts'; // Client side: compute and send delta const delta = diffDelta(localState, updatedState, { - embeddedObjKeys: { records: 'id' }, + arrayIdentityKeys: { records: 'id' }, }); await fetch('/api/sync', { method: 'POST', @@ -364,7 +364,7 @@ await fetch('/api/sync', { // Server side: validate and apply const result = validateDelta(req.body); if (!result.valid) return res.status(400).json(result.errors); -const newState = applyDelta(currentState, req.body); +currentState = applyDelta(structuredClone(currentState), req.body); ``` --- @@ -377,7 +377,7 @@ Convert between the legacy internal format and JSON Delta: import { diff, toDelta, fromDelta, unatomizeChangeset } from 'json-diff-ts'; // v4 changeset β†’ JSON Delta -const changeset = diff(source, target, { embeddedObjKeys: { items: 'id' } }); +const changeset = diff(source, target, { arrayIdentityKeys: { items: 'id' } }); const delta = toDelta(changeset); // JSON Delta β†’ v4 atomic changes @@ -418,7 +418,7 @@ const newData = { ], }; -const changes = diff(oldData, newData, { embeddedObjKeys: { characters: 'id' } }); +const changes = diff(oldData, newData, { arrayIdentityKeys: { characters: 'id' } }); ``` ### `applyChangeset` and `revertChangeset` @@ -455,11 +455,11 @@ const restored = unatomizeChangeset(atoms.slice(0, 2)); ```typescript // Named key -diff(old, new, { embeddedObjKeys: { characters: 'id' } }); +diff(old, new, { arrayIdentityKeys: { characters: 'id' } }); // Function key diff(old, new, { - embeddedObjKeys: { + arrayIdentityKeys: { characters: (obj, shouldReturnKeyName) => (shouldReturnKeyName ? 'id' : obj.id) } }); @@ -467,10 +467,10 @@ diff(old, new, { // Regex path matching const keys = new Map(); keys.set(/^characters/, 'id'); -diff(old, new, { embeddedObjKeys: keys }); +diff(old, new, { arrayIdentityKeys: keys }); // Value-based identity for primitive arrays -diff(old, new, { embeddedObjKeys: { tags: '$value' } }); +diff(old, new, { arrayIdentityKeys: { tags: '$value' } }); ``` #### Path Skipping @@ -506,6 +506,8 @@ diff(old, new, { treatTypeChangeAsReplace: false }); ```typescript interface Options { + arrayIdentityKeys?: Record | Map; + /** @deprecated Use arrayIdentityKeys instead */ embeddedObjKeys?: Record | Map; keysToSkip?: readonly string[]; treatTypeChangeAsReplace?: boolean; // default: true @@ -519,7 +521,8 @@ interface Options { 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. Both formats coexist. No forced migration. +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. --- @@ -550,7 +553,7 @@ Yes. The library handles large, deeply nested JSON structures efficiently with z Yes. Both APIs coexist. Use `toDelta()` / `fromDelta()` to convert between formats. **Q: What about arrays of primitives?** -Use `$value` as the embedded key: `{ embeddedObjKeys: { tags: '$value' } }`. Elements are matched by value identity. +Use `$value` as the identity key: `{ arrayIdentityKeys: { tags: '$value' } }`. Elements are matched by value identity. --- @@ -560,6 +563,7 @@ Use `$value` as the embedded key: `{ embeddedObjKeys: { tags: '$value' } }`. Ele - 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:** 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 From c9107fffe2a94675105fdcaf59bf7b73fbaf9b8a Mon Sep 17 00:00:00 2001 From: Christian Glessner Date: Sun, 8 Mar 2026 08:11:13 +0100 Subject: [PATCH 4/4] fix: address Copilot review findings on PR #379 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix applyRootOp Array↔Object root replace bug (only mutate plain objβ†’obj) - Reject NaN/Infinity in formatFilterLiteral - Fix )] in quoted strings breaking filter parsing (new findFilterClose helper) - Change applyDelta/revertDelta return type from T to any (root ops change type) - Reject non-JSON numeric formats in parseFilterLiteral (hex, octal, etc.) - Fix CLAUDE.md spec path reference to GitHub URL - Add prototype pollution warning to README Data Sync example - Add 8 new tests covering all fixes (272 total) --- CLAUDE.md | 2 +- README.md | 2 ++ src/deltaPath.ts | 52 +++++++++++++++++++++++++++++++++++------ src/jsonDelta.ts | 16 ++++++------- tests/deltaPath.test.ts | 35 +++++++++++++++++++++++++++ tests/jsonDelta.test.ts | 27 +++++++++++++++++++++ 6 files changed, 118 insertions(+), 16 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index ee12d6e..5ec53d4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -30,7 +30,7 @@ tests/ ## 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/spec/v0.md`. +- **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. diff --git a/README.md b/README.md index 5dcbf3a..24c84c1 100644 --- a/README.md +++ b/README.md @@ -364,6 +364,8 @@ await fetch('/api/sync', { // 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); ``` diff --git a/src/deltaPath.ts b/src/deltaPath.ts index 65407c1..ddefa65 100644 --- a/src/deltaPath.ts +++ b/src/deltaPath.ts @@ -17,7 +17,10 @@ export type DeltaPathSegment = export function formatFilterLiteral(value: unknown): string { if (value === null) return 'null'; if (typeof value === 'boolean') return String(value); - if (typeof value === 'number') 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}`); } @@ -33,9 +36,10 @@ export function parseFilterLiteral(s: string): unknown { if (s.startsWith("'") && s.endsWith("'")) { return s.slice(1, -1).replace(/''/g, "'"); } - // Number - const n = Number(s); - if (!Number.isNaN(n)) return n; + // 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}`); } @@ -64,6 +68,40 @@ function extractQuotedString(s: string, start: number): [string, number] { 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 { @@ -117,7 +155,7 @@ export function parseDeltaPath(path: string): DeltaPathSegment[] { if (path[i + 1] === '?') { // Filter expression: [?(@...==...)] - const closingIdx = path.indexOf(')]', i); + 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)); @@ -228,7 +266,7 @@ export function atomicPathToDeltaPath(atomicPath: string): string { } else if (atomicPath[i] === '[') { if (atomicPath[i + 1] === '?') { // Filter expression β€” pass through as-is until ')]' - const closingIdx = atomicPath.indexOf(')]', i); + 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; @@ -287,7 +325,7 @@ export function deltaPathToAtomicPath(deltaPath: string): string { } else if (deltaPath[i] === '[') { if (deltaPath[i + 1] === '?') { // Filter expression β€” need to re-quote non-string literals to strings - const closingIdx = deltaPath.indexOf(')]', i); + 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); diff --git a/src/jsonDelta.ts b/src/jsonDelta.ts index 4b67955..530fecb 100644 --- a/src/jsonDelta.ts +++ b/src/jsonDelta.ts @@ -562,7 +562,7 @@ export function invertDelta(delta: IJsonDelta): IJsonDelta { * Processes operations sequentially. Handles root operations directly. * Returns the result (MUST use return value for root primitive replacements). */ -export function applyDelta(obj: T, delta: IJsonDelta): T { +export function applyDelta(obj: any, delta: IJsonDelta): any { const validation = validateDelta(delta); if (!validation.valid) { throw new Error(`Invalid delta: ${validation.errors.join(', ')}`); @@ -590,18 +590,18 @@ function applyRootOp(obj: any, op: IDeltaOperation): any { case 'remove': return null; case 'replace': { - if (typeof obj === 'object' && obj !== null && typeof op.value === 'object' && op.value !== null) { - // Clear existing properties and assign new ones + // 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]; } - if (Array.isArray(op.value)) { - // Converting to array requires returning new value - return [...op.value]; - } 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 */ @@ -640,7 +640,7 @@ function deltaOpToAtomicChange(op: IDeltaOperation): IAtomicChange { * Revert a JSON Delta by computing its inverse and applying it. * Requires all replace/remove operations to have oldValue. */ -export function revertDelta(obj: T, delta: IJsonDelta): T { +export function revertDelta(obj: any, delta: IJsonDelta): any { const inverse = invertDelta(delta); return applyDelta(obj, inverse); } diff --git a/tests/deltaPath.test.ts b/tests/deltaPath.test.ts index a9ba200..6ec9664 100644 --- a/tests/deltaPath.test.ts +++ b/tests/deltaPath.test.ts @@ -42,6 +42,15 @@ describe('formatFilterLiteral', () => { 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', () => { @@ -83,6 +92,15 @@ describe('parseFilterLiteral', () => { 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) { @@ -228,6 +246,15 @@ describe('parseDeltaPath', () => { 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', () => { @@ -356,6 +383,10 @@ describe('atomicPathToDeltaPath', () => { 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', () => { @@ -410,6 +441,10 @@ describe('deltaPathToAtomicPath', () => { 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', () => { diff --git a/tests/jsonDelta.test.ts b/tests/jsonDelta.test.ts index 5a33ae9..dadbc88 100644 --- a/tests/jsonDelta.test.ts +++ b/tests/jsonDelta.test.ts @@ -663,6 +663,33 @@ describe('applyDelta', () => { } ); 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)', () => {