json-diff-ts 4.9.1 → 5.0.0-alpha.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -12,33 +12,106 @@
12
12
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
13
13
  [![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)
14
14
 
15
- ## Overview
15
+ **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.
16
16
 
17
- **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.
17
+ Zero dependencies. TypeScript-first. ESM + CommonJS. Trusted by thousands of developers ([500K+ weekly npm downloads](https://www.npmjs.com/package/json-diff-ts)).
18
18
 
19
- ### 🚀 **Why Choose json-diff-ts?**
19
+ ## Why Index-Based Diffing Breaks
20
20
 
21
- - **🔥 Zero dependencies** - Lightweight bundle size
22
- - **⚡ High performance** - Optimized algorithms for fast JSON diffing and patching
23
- - **🎯 95%+ test coverage** - Thoroughly tested with comprehensive test suite
24
- - **📦 Modern ES modules** - Full TypeScript support with tree-shaking
25
- - **🔧 Flexible API** - Compare, diff, patch, and atomic operations
26
- - **🌐 Universal** - Works in browsers, Node.js, and edge environments
27
- - **✅ Production ready** - Used in enterprise applications worldwide
28
- - **🎯 TypeScript-first** - Full type safety and IntelliSense support
29
- - **🔧 Modern features** - ESM + CommonJS, JSONPath, atomic operations
30
- - **📦 Production ready** - Battle-tested with comprehensive test suite
21
+ Most JSON diff libraries track array changes by position. Insert one element at the start and every path shifts:
31
22
 
32
- ### ✨ **Key Features**
23
+ ```text
24
+ Remove /items/0 ← was actually "Widget"
25
+ Add /items/0 ← now it's "NewItem"
26
+ Update /items/1 ← this used to be /items/0
27
+ ...
28
+ ```
29
+
30
+ 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.
31
+
32
+ **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:
33
+
34
+ ```typescript
35
+ import { diffDelta, applyDelta, revertDelta } from 'json-diff-ts';
36
+
37
+ const before = {
38
+ items: [
39
+ { id: 1, name: 'Widget', price: 9.99 },
40
+ { id: 2, name: 'Gadget', price: 24.99 },
41
+ ],
42
+ };
43
+
44
+ const after = {
45
+ items: [
46
+ { id: 2, name: 'Gadget', price: 24.99 }, // reordered
47
+ { id: 1, name: 'Widget Pro', price: 14.99 }, // renamed + repriced
48
+ { id: 3, name: 'Doohickey', price: 4.99 }, // added
49
+ ],
50
+ };
51
+
52
+ const delta = diffDelta(before, after, { arrayIdentityKeys: { items: 'id' } });
53
+ ```
54
+
55
+ The delta tracks _what_ changed, not _where_ it moved:
56
+
57
+ ```json
58
+ {
59
+ "format": "json-delta",
60
+ "version": 1,
61
+ "operations": [
62
+ { "op": "replace", "path": "$.items[?(@.id==1)].name", "value": "Widget Pro", "oldValue": "Widget" },
63
+ { "op": "replace", "path": "$.items[?(@.id==1)].price", "value": 14.99, "oldValue": 9.99 },
64
+ { "op": "add", "path": "$.items[?(@.id==3)]", "value": { "id": 3, "name": "Doohickey", "price": 4.99 } }
65
+ ]
66
+ }
67
+ ```
68
+
69
+ Apply forward to get the new state, or revert to restore the original:
70
+
71
+ ```typescript
72
+ // Clone before applying — applyDelta mutates the input object
73
+ const updated = applyDelta(structuredClone(before), delta); // updated === after
74
+ const restored = revertDelta(structuredClone(updated), delta); // restored === before
75
+ ```
76
+
77
+ ## Quick Start
78
+
79
+ ```typescript
80
+ import { diffDelta, applyDelta, revertDelta } from 'json-diff-ts';
81
+
82
+ const oldObj = {
83
+ items: [
84
+ { id: 1, name: 'Widget', price: 9.99 },
85
+ { id: 2, name: 'Gadget', price: 24.99 },
86
+ ],
87
+ };
33
88
 
34
- - **Key-based array identification**: Compare array elements using keys instead of indices for more intuitive diffing
35
- - **JSONPath support**: Target specific parts of JSON documents with precision
36
- - **Atomic changesets**: Transform changes into granular, independently applicable operations
37
- - **Dual module support**: Works with both ECMAScript Modules and CommonJS
38
- - **Type change handling**: Flexible options for handling data type changes
39
- - **Path skipping**: Skip nested paths during comparison for performance
89
+ const newObj = {
90
+ items: [
91
+ { id: 1, name: 'Widget Pro', price: 9.99 },
92
+ { id: 2, name: 'Gadget', price: 24.99 },
93
+ { id: 3, name: 'Doohickey', price: 4.99 },
94
+ ],
95
+ };
40
96
 
41
- 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.
97
+ // 1. Compute a delta between two JSON objects
98
+ const delta = diffDelta(oldObj, newObj, {
99
+ arrayIdentityKeys: { items: 'id' }, // match array elements by 'id' field
100
+ });
101
+ // delta.operations =>
102
+ // [
103
+ // { op: 'replace', path: '$.items[?(@.id==1)].name', value: 'Widget Pro', oldValue: 'Widget' },
104
+ // { op: 'add', path: '$.items[?(@.id==3)]', value: { id: 3, name: 'Doohickey', price: 4.99 } }
105
+ // ]
106
+
107
+ // 2. Apply the delta to produce the new state
108
+ const updated = applyDelta(structuredClone(oldObj), delta);
109
+
110
+ // 3. Revert the delta to restore the original state
111
+ const reverted = revertDelta(structuredClone(updated), delta);
112
+ ```
113
+
114
+ That's it. `delta` is a plain JSON object you can store in a database, send over HTTP, or consume in any language.
42
115
 
43
116
  ## Installation
44
117
 
@@ -46,289 +119,455 @@ This library is particularly valuable for applications where tracking changes in
46
119
  npm install json-diff-ts
47
120
  ```
48
121
 
49
- ## Quick Start
122
+ ```typescript
123
+ // ESM / TypeScript
124
+ import { diffDelta, applyDelta, revertDelta } from 'json-diff-ts';
125
+
126
+ // CommonJS
127
+ const { diffDelta, applyDelta, revertDelta } = require('json-diff-ts');
128
+ ```
129
+
130
+ ## What is JSON Delta?
131
+
132
+ [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 Python implementation is also available: [json-delta-py](https://github.com/ltwlf/json-delta-py).
133
+
134
+ A delta is a self-describing JSON document you can store, transmit, and consume in any language:
135
+
136
+ - **Three operations** -- `add`, `remove`, `replace`. Nothing else to learn.
137
+ - **JSONPath-based paths** -- `$.items[?(@.id==1)].name` identifies elements by key, not index.
138
+ - **Reversible by default** -- every `replace` and `remove` includes `oldValue` for undo.
139
+ - **Self-identifying** -- the `format` field makes deltas discoverable without external context.
140
+ - **Extension-friendly** -- unknown properties are preserved; `x_`-prefixed properties are future-safe.
141
+
142
+ ### JSON Delta vs JSON Patch (RFC 6902)
143
+
144
+ 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.
145
+
146
+ 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.
147
+
148
+ | | JSON Delta | JSON Patch (RFC 6902) |
149
+ | --- | --- | --- |
150
+ | Path syntax | JSONPath (`$.items[?(@.id==1)]`) | JSON Pointer (`/items/0`) |
151
+ | Array identity | Key-based -- survives reorder | Index-based -- breaks on insert/delete |
152
+ | Reversibility | Built-in `oldValue` | Not supported |
153
+ | Self-describing | `format` field in envelope | No envelope |
154
+ | Specification | [json-delta-format](https://github.com/ltwlf/json-delta-format) | [RFC 6902](https://tools.ietf.org/html/rfc6902) |
155
+
156
+ ---
157
+
158
+ ## JSON Delta API
159
+
160
+ ### `diffDelta` -- Compute a Delta
50
161
 
51
162
  ```typescript
52
- import { diff, applyChangeset } from 'json-diff-ts';
53
-
54
- // Two versions of data
55
- const oldData = { name: 'Luke', level: 1, skills: ['piloting'] };
56
- const newData = { name: 'Luke Skywalker', level: 5, skills: ['piloting', 'force'] };
57
-
58
- // Calculate differences
59
- const changes = diff(oldData, newData);
60
- console.log(changes);
61
- // Output: [
62
- // { type: 'UPDATE', key: 'name', value: 'Luke Skywalker', oldValue: 'Luke' },
63
- // { type: 'UPDATE', key: 'level', value: 5, oldValue: 1 },
64
- // { type: 'ADD', key: 'skills', value: 'force', embeddedKey: '1' }
65
- // ]
163
+ const delta = diffDelta(
164
+ { user: { name: 'Alice', role: 'viewer' } },
165
+ { user: { name: 'Alice', role: 'admin' } }
166
+ );
167
+ // delta.operations [{ op: 'replace', path: '$.user.role', value: 'admin', oldValue: 'viewer' }]
168
+ ```
169
+
170
+ #### Keyed Arrays
66
171
 
67
- // Apply changes to get the new object
68
- const result = applyChangeset(oldData, changes);
69
- console.log(result); // { name: 'Luke Skywalker', level: 5, skills: ['piloting', 'force'] }
172
+ Match array elements by identity key. Filter paths use canonical typed literals per the spec:
173
+
174
+ ```typescript
175
+ const delta = diffDelta(
176
+ { users: [{ id: 1, role: 'viewer' }, { id: 2, role: 'editor' }] },
177
+ { users: [{ id: 1, role: 'admin' }, { id: 2, role: 'editor' }] },
178
+ { arrayIdentityKeys: { users: 'id' } }
179
+ );
180
+ // delta.operations → [{ op: 'replace', path: '$.users[?(@.id==1)].role', value: 'admin', oldValue: 'viewer' }]
70
181
  ```
71
182
 
72
- ### Import Options
183
+ #### Non-reversible Mode
184
+
185
+ Omit `oldValue` fields when you don't need undo:
73
186
 
74
- **TypeScript / ES Modules:**
75
187
  ```typescript
76
- import { diff } from 'json-diff-ts';
188
+ const delta = diffDelta(source, target, { reversible: false });
77
189
  ```
78
190
 
79
- **CommonJS:**
80
- ```javascript
81
- const { diff } = require('json-diff-ts');
191
+ ### `applyDelta` -- Apply a Delta
192
+
193
+ Applies operations sequentially. Always use the return value (required for root-level replacements):
194
+
195
+ ```typescript
196
+ const result = applyDelta(structuredClone(source), delta);
82
197
  ```
83
198
 
84
- ## Core Features
199
+ ### `revertDelta` -- Revert a Delta
85
200
 
86
- ### `diff`
201
+ Computes the inverse and applies it. Requires `oldValue` on all `replace` and `remove` operations:
87
202
 
88
- 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.
203
+ ```typescript
204
+ const original = revertDelta(structuredClone(target), delta);
205
+ ```
206
+
207
+ ### `invertDelta` -- Compute the Inverse
89
208
 
90
- #### Basic Example with Star Wars Data
209
+ Returns a new delta that undoes the original (spec Section 9.2):
91
210
 
92
211
  ```typescript
93
- import { diff } from 'json-diff-ts';
212
+ const inverse = invertDelta(delta);
213
+ // add ↔ remove, replace swaps value/oldValue, order reversed
214
+ ```
94
215
 
95
- // State during A New Hope - Desert planet, small rebel cell
96
- const oldData = {
97
- location: 'Tatooine',
98
- mission: 'Rescue Princess',
99
- status: 'In Progress',
100
- characters: [
101
- { id: 'LUKE_SKYWALKER', name: 'Luke Skywalker', role: 'Farm Boy', forceTraining: false },
102
- { id: 'LEIA_ORGANA', name: 'Princess Leia', role: 'Prisoner', forceTraining: false }
103
- ],
104
- equipment: ['Lightsaber', 'Blaster']
105
- };
216
+ ### `validateDelta` -- Validate Structure
106
217
 
107
- // State after successful rescue - Base established, characters evolved
108
- const newData = {
109
- location: 'Yavin Base',
110
- mission: 'Destroy Death Star',
111
- status: 'Complete',
112
- characters: [
113
- { id: 'LUKE_SKYWALKER', name: 'Luke Skywalker', role: 'Pilot', forceTraining: true, rank: 'Commander' },
114
- { id: 'HAN_SOLO', name: 'Han Solo', role: 'Smuggler', forceTraining: false, ship: 'Millennium Falcon' }
218
+ ```typescript
219
+ const { valid, errors } = validateDelta(maybeDelta);
220
+ ```
221
+
222
+ ### API Reference
223
+
224
+ | Function | Signature | Description |
225
+ | --- | --- | --- |
226
+ | `diffDelta` | `(oldObj, newObj, options?) => IJsonDelta` | Compute a canonical JSON Delta |
227
+ | `applyDelta` | `(obj, delta) => any` | Apply a delta sequentially. Returns the result |
228
+ | `revertDelta` | `(obj, delta) => any` | Revert a reversible delta |
229
+ | `invertDelta` | `(delta) => IJsonDelta` | Compute the inverse delta |
230
+ | `validateDelta` | `(delta) => { valid, errors }` | Structural validation |
231
+ | `toDelta` | `(changeset, options?) => IJsonDelta` | Bridge: v4 changeset to JSON Delta |
232
+ | `fromDelta` | `(delta) => IAtomicChange[]` | Bridge: JSON Delta to v4 atomic changes |
233
+
234
+ ### DeltaOptions
235
+
236
+ Extends the base `Options` interface:
237
+
238
+ ```typescript
239
+ interface DeltaOptions extends Options {
240
+ reversible?: boolean; // Include oldValue for undo. Default: true
241
+ arrayIdentityKeys?: Record<string, string | FunctionKey>;
242
+ keysToSkip?: readonly string[];
243
+ }
244
+ ```
245
+
246
+ ---
247
+
248
+ ## Practical Examples
249
+
250
+ ### Audit Log
251
+
252
+ 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:
253
+
254
+ ```typescript
255
+ import { diffDelta, applyDelta, revertDelta, IJsonDelta } from 'json-diff-ts';
256
+
257
+ interface AuditEntry {
258
+ timestamp: string;
259
+ userId: string;
260
+ delta: IJsonDelta;
261
+ }
262
+
263
+ const auditLog: AuditEntry[] = [];
264
+ let doc = {
265
+ title: 'Project Plan',
266
+ status: 'draft',
267
+ items: [
268
+ { id: 1, task: 'Design', done: false },
269
+ { id: 2, task: 'Build', done: false },
115
270
  ],
116
- equipment: ['Lightsaber', 'Blaster', 'Bowcaster', 'X-wing Fighter']
117
271
  };
118
272
 
119
- const diffs = diff(oldData, newData, { embeddedObjKeys: { characters: 'id' } });
120
- console.log(diffs);
121
- // First operations:
273
+ function updateDocument(newDoc: typeof doc, userId: string) {
274
+ const delta = diffDelta(doc, newDoc, {
275
+ arrayIdentityKeys: { items: 'id' },
276
+ });
277
+
278
+ if (delta.operations.length > 0) {
279
+ auditLog.push({ timestamp: new Date().toISOString(), userId, delta });
280
+ doc = applyDelta(structuredClone(doc), delta);
281
+ }
282
+
283
+ return doc;
284
+ }
285
+
286
+ // Revert the last change
287
+ function undo(): typeof doc {
288
+ const last = auditLog.pop();
289
+ if (!last) return doc;
290
+ doc = revertDelta(structuredClone(doc), last.delta);
291
+ return doc;
292
+ }
293
+
294
+ // Example usage:
295
+ updateDocument(
296
+ { ...doc, status: 'active', items: [{ id: 1, task: 'Design', done: true }, ...doc.items.slice(1)] },
297
+ 'alice'
298
+ );
299
+ // auditLog[0].delta.operations =>
122
300
  // [
123
- // { type: 'UPDATE', key: 'location', value: 'Yavin Base', oldValue: 'Tatooine' },
124
- // { type: 'UPDATE', key: 'mission', value: 'Destroy Death Star', oldValue: 'Rescue Princess' },
125
- // { type: 'UPDATE', key: 'status', value: 'Complete', oldValue: 'In Progress' },
126
- // ...
301
+ // { op: 'replace', path: '$.status', value: 'active', oldValue: 'draft' },
302
+ // { op: 'replace', path: '$.items[?(@.id==1)].done', value: true, oldValue: false }
127
303
  // ]
128
304
  ```
129
305
 
130
- #### Advanced Options
306
+ Because every delta is self-describing JSON, your audit log is queryable, storable in any database, and readable from any language.
131
307
 
132
- ##### Path-based Key Identification
308
+ ### Undo / Redo Stack
133
309
 
134
- ```javascript
135
- import { diff } from 'json-diff-ts';
310
+ Build undo/redo for any JSON state object. Deltas are small (only changed fields), reversible, and serializable:
136
311
 
137
- // Using nested paths for sub-arrays
138
- const diffs = diff(oldData, newData, { embeddedObjKeys: { 'characters.equipment': 'id' } });
312
+ ```typescript
313
+ import { diffDelta, applyDelta, revertDelta, IJsonDelta } from 'json-diff-ts';
139
314
 
140
- // Designating root with '.' - useful for complex nested structures
141
- const diffs = diff(oldData, newData, { embeddedObjKeys: { '.characters.allies': 'id' } });
142
- ```
315
+ class UndoManager<T extends object> {
316
+ private undoStack: IJsonDelta[] = [];
317
+ private redoStack: IJsonDelta[] = [];
143
318
 
144
- ##### Type Change Handling
319
+ constructor(private state: T) {}
145
320
 
146
- ```javascript
147
- import { diff } from 'json-diff-ts';
321
+ apply(newState: T): T {
322
+ const delta = diffDelta(this.state, newState);
323
+ if (delta.operations.length === 0) return this.state;
324
+ this.undoStack.push(delta);
325
+ this.redoStack = [];
326
+ this.state = applyDelta(structuredClone(this.state), delta);
327
+ return this.state;
328
+ }
329
+
330
+ undo(): T {
331
+ const delta = this.undoStack.pop();
332
+ if (!delta) return this.state;
333
+ this.redoStack.push(delta);
334
+ this.state = revertDelta(structuredClone(this.state), delta);
335
+ return this.state;
336
+ }
148
337
 
149
- // Control how type changes are treated
150
- const diffs = diff(oldData, newData, { treatTypeChangeAsReplace: false });
338
+ redo(): T {
339
+ const delta = this.redoStack.pop();
340
+ if (!delta) return this.state;
341
+ this.undoStack.push(delta);
342
+ this.state = applyDelta(structuredClone(this.state), delta);
343
+ return this.state;
344
+ }
345
+ }
151
346
  ```
152
347
 
153
- Date objects can now be updated to primitive values without errors when `treatTypeChangeAsReplace` is set to `false`.
348
+ ### Data Synchronization
154
349
 
155
- ##### Skip Nested Paths
350
+ 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:
156
351
 
157
- ```javascript
158
- import { diff } from 'json-diff-ts';
352
+ ```typescript
353
+ import { diffDelta, applyDelta, validateDelta } from 'json-diff-ts';
354
+
355
+ // Client side: compute and send delta
356
+ const delta = diffDelta(localState, updatedState, {
357
+ arrayIdentityKeys: { records: 'id' },
358
+ });
359
+ await fetch('/api/sync', {
360
+ method: 'POST',
361
+ body: JSON.stringify(delta),
362
+ });
159
363
 
160
- // Skip specific nested paths from comparison - useful for ignoring metadata
161
- const diffs = diff(oldData, newData, { keysToSkip: ['characters.metadata'] });
364
+ // Server side: validate and apply
365
+ const result = validateDelta(req.body);
366
+ if (!result.valid) return res.status(400).json(result.errors);
367
+ // ⚠️ In production, sanitize paths/values to prevent prototype pollution
368
+ // (e.g. reject paths containing "__proto__" or "constructor")
369
+ currentState = applyDelta(structuredClone(currentState), req.body);
162
370
  ```
163
371
 
164
- ##### Dynamic Key Resolution
372
+ ---
165
373
 
166
- ```javascript
167
- import { diff } from 'json-diff-ts';
374
+ ## Bridge: v4 Changeset <-> JSON Delta
168
375
 
169
- // Use function to resolve object keys dynamically
170
- const diffs = diff(oldData, newData, {
171
- embeddedObjKeys: {
172
- characters: (obj, shouldReturnKeyName) => (shouldReturnKeyName ? 'id' : obj.id)
173
- }
174
- });
376
+ Convert between the legacy internal format and JSON Delta:
175
377
 
176
- // Access index for array elements
177
- const rebels = [
178
- { name: 'Luke Skywalker', faction: 'Jedi' },
179
- { name: 'Yoda', faction: 'Jedi' },
180
- { name: 'Princess Leia', faction: 'Rebellion' }
181
- ];
182
-
183
- const diffs = diff(oldRebels, newRebels, {
184
- embeddedObjKeys: {
185
- rebels: (obj, shouldReturnKeyName, index) => {
186
- if (shouldReturnKeyName) return 'faction';
187
- // Use index to differentiate rebels in the same faction
188
- return `faction.${obj.faction}.${index}`;
189
- }
190
- }
191
- });
378
+ ```typescript
379
+ import { diff, toDelta, fromDelta, unatomizeChangeset } from 'json-diff-ts';
380
+
381
+ // v4 changeset JSON Delta
382
+ const changeset = diff(source, target, { arrayIdentityKeys: { items: 'id' } });
383
+ const delta = toDelta(changeset);
384
+
385
+ // JSON Delta v4 atomic changes
386
+ const atoms = fromDelta(delta);
387
+
388
+ // v4 atomic changes → hierarchical changeset (if needed)
389
+ const cs = unatomizeChangeset(atoms);
192
390
  ```
193
391
 
194
- ##### Regular Expression Paths
392
+ **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.
393
+
394
+ ---
395
+
396
+ ## Legacy Changeset API (v4 Compatibility)
397
+
398
+ All v4 APIs remain fully supported. Existing code continues to work without changes. For new projects, prefer the JSON Delta API above.
399
+
400
+ ### `diff`
195
401
 
196
- ```javascript
402
+ Generates a hierarchical changeset between two objects:
403
+
404
+ ```typescript
197
405
  import { diff } from 'json-diff-ts';
198
406
 
199
- // Use regex for path matching - powerful for dynamic property names
200
- const embeddedObjKeys = new Map();
201
- embeddedObjKeys.set(/^characters/, 'id'); // Match any property starting with 'characters'
202
- const diffs = diff(oldData, newData, { embeddedObjKeys });
407
+ const oldData = {
408
+ location: 'Tatooine',
409
+ characters: [
410
+ { id: 'LUKE', name: 'Luke Skywalker', role: 'Farm Boy' },
411
+ { id: 'LEIA', name: 'Princess Leia', role: 'Prisoner' }
412
+ ],
413
+ };
414
+
415
+ const newData = {
416
+ location: 'Yavin Base',
417
+ characters: [
418
+ { id: 'LUKE', name: 'Luke Skywalker', role: 'Pilot', rank: 'Commander' },
419
+ { id: 'HAN', name: 'Han Solo', role: 'Smuggler' }
420
+ ],
421
+ };
422
+
423
+ const changes = diff(oldData, newData, { arrayIdentityKeys: { characters: 'id' } });
203
424
  ```
204
425
 
205
- ##### String Array Comparison
426
+ ### `applyChangeset` and `revertChangeset`
206
427
 
207
- ```javascript
208
- import { diff } from 'json-diff-ts';
428
+ ```typescript
429
+ import { applyChangeset, revertChangeset } from 'json-diff-ts';
209
430
 
210
- // Compare string arrays by value instead of index - useful for tags, categories
211
- const diffs = diff(oldData, newData, { embeddedObjKeys: { equipment: '$value' } });
431
+ const updated = applyChangeset(structuredClone(oldData), changes);
432
+ const reverted = revertChangeset(structuredClone(newData), changes);
212
433
  ```
213
434
 
214
435
  ### `atomizeChangeset` and `unatomizeChangeset`
215
436
 
216
- Transform complex changesets into a list of atomic changes (and back), each describable by a JSONPath.
437
+ Flatten a hierarchical changeset into atomic changes addressable by JSONPath, or reconstruct the hierarchy:
217
438
 
218
- ```javascript
439
+ ```typescript
219
440
  import { atomizeChangeset, unatomizeChangeset } from 'json-diff-ts';
220
441
 
221
- // Create atomic changes
222
- const atomicChanges = atomizeChangeset(diffs);
442
+ const atoms = atomizeChangeset(changes);
443
+ // [
444
+ // { type: 'UPDATE', key: 'location', value: 'Yavin Base', oldValue: 'Tatooine',
445
+ // path: '$.location', valueType: 'String' },
446
+ // { type: 'ADD', key: 'rank', value: 'Commander',
447
+ // path: "$.characters[?(@.id=='LUKE')].rank", valueType: 'String' },
448
+ // ...
449
+ // ]
223
450
 
224
- // Restore the changeset from a selection of atomic changes
225
- const changeset = unatomizeChangeset(atomicChanges.slice(0, 3));
451
+ const restored = unatomizeChangeset(atoms.slice(0, 2));
226
452
  ```
227
453
 
228
- **Atomic Changes Structure:**
229
-
230
- ```javascript
231
- [
232
- {
233
- type: 'UPDATE',
234
- key: 'location',
235
- value: 'Yavin Base',
236
- oldValue: 'Tatooine',
237
- path: '$.location',
238
- valueType: 'String'
239
- },
240
- {
241
- type: 'UPDATE',
242
- key: 'mission',
243
- value: 'Destroy Death Star',
244
- oldValue: 'Rescue Princess',
245
- path: '$.mission',
246
- valueType: 'String'
247
- },
248
- {
249
- type: 'ADD',
250
- key: 'rank',
251
- value: 'Commander',
252
- path: "$.characters[?(@.id=='LUKE_SKYWALKER')].rank",
253
- valueType: 'String'
254
- },
255
- {
256
- type: 'ADD',
257
- key: 'HAN_SOLO',
258
- value: { id: 'HAN_SOLO', name: 'Han Solo', role: 'Smuggler', forceTraining: false, ship: 'Millennium Falcon' },
259
- path: "$.characters[?(@.id=='HAN_SOLO')]",
260
- valueType: 'Object'
261
- }
262
- ]
263
- ```
454
+ ### Advanced Options
264
455
 
265
- ### `applyChangeset` and `revertChangeset`
456
+ #### Key-based Array Matching
266
457
 
267
- Apply or revert changes to JSON objects.
458
+ ```typescript
459
+ // Named key
460
+ diff(old, new, { arrayIdentityKeys: { characters: 'id' } });
268
461
 
269
- ```javascript
270
- import { applyChangeset, revertChangeset } from 'json-diff-ts';
462
+ // Function key
463
+ diff(old, new, {
464
+ arrayIdentityKeys: {
465
+ characters: (obj, shouldReturnKeyName) => (shouldReturnKeyName ? 'id' : obj.id)
466
+ }
467
+ });
271
468
 
272
- // Apply changes
273
- const updated = applyChangeset(oldData, diffs);
274
- console.log(updated);
275
- // { location: 'Yavin Base', mission: 'Destroy Death Star', status: 'Complete', ... }
469
+ // Regex path matching
470
+ const keys = new Map();
471
+ keys.set(/^characters/, 'id');
472
+ diff(old, new, { arrayIdentityKeys: keys });
276
473
 
277
- // Revert changes
278
- const reverted = revertChangeset(newData, diffs);
279
- console.log(reverted);
280
- // { location: 'Tatooine', mission: 'Rescue Princess', status: 'In Progress', ... }
474
+ // Value-based identity for primitive arrays
475
+ diff(old, new, { arrayIdentityKeys: { tags: '$value' } });
281
476
  ```
282
477
 
283
- ## API Reference
478
+ #### Path Skipping
284
479
 
285
- ### Core Functions
480
+ ```typescript
481
+ diff(old, new, { keysToSkip: ['characters.metadata'] });
482
+ ```
483
+
484
+ #### Type Change Handling
485
+
486
+ ```typescript
487
+ diff(old, new, { treatTypeChangeAsReplace: false });
488
+ ```
489
+
490
+ ### Legacy API Reference
286
491
 
287
- | Function | Description | Parameters |
288
- |----------|-------------|------------|
289
- | `diff(oldObj, newObj, options?)` | Generate differences between two objects | `oldObj`: Original object<br>`newObj`: Updated object<br>`options`: Optional configuration |
290
- | `applyChangeset(obj, changeset)` | Apply changes to an object | `obj`: Object to modify<br>`changeset`: Changes to apply |
291
- | `revertChangeset(obj, changeset)` | Revert changes from an object | `obj`: Object to modify<br>`changeset`: Changes to revert |
292
- | `atomizeChangeset(changeset)` | Convert changeset to atomic changes | `changeset`: Nested changeset |
293
- | `unatomizeChangeset(atomicChanges)` | Convert atomic changes back to nested changeset | `atomicChanges`: Array of atomic changes |
492
+ | Function | Description |
493
+ | --- | --- |
494
+ | `diff(oldObj, newObj, options?)` | Compute hierarchical changeset |
495
+ | `applyChangeset(obj, changeset)` | Apply a changeset to an object |
496
+ | `revertChangeset(obj, changeset)` | Revert a changeset from an object |
497
+ | `atomizeChangeset(changeset)` | Flatten to atomic changes with JSONPath |
498
+ | `unatomizeChangeset(atoms)` | Reconstruct hierarchy from atomic changes |
294
499
 
295
500
  ### Comparison Functions
296
501
 
297
- | Function | Description | Parameters |
298
- |----------|-------------|------------|
299
- | `compare(oldObj, newObj)` | Create enriched comparison object | `oldObj`: Original object<br>`newObj`: Updated object |
300
- | `enrich(obj)` | Create enriched representation of object | `obj`: Object to enrich |
301
- | `createValue(value)` | Create value node for comparison | `value`: Any value |
302
- | `createContainer(value)` | Create container node for comparison | `value`: Object or Array |
502
+ | Function | Description |
503
+ | --- | --- |
504
+ | `compare(oldObj, newObj)` | Create enriched comparison object |
505
+ | `enrich(obj)` | Create enriched representation |
303
506
 
304
- ### Options Interface
507
+ ### Options
305
508
 
306
509
  ```typescript
307
510
  interface Options {
511
+ arrayIdentityKeys?: Record<string, string | FunctionKey> | Map<string | RegExp, string | FunctionKey>;
512
+ /** @deprecated Use arrayIdentityKeys instead */
308
513
  embeddedObjKeys?: Record<string, string | FunctionKey> | Map<string | RegExp, string | FunctionKey>;
309
514
  keysToSkip?: readonly string[];
310
- treatTypeChangeAsReplace?: boolean;
515
+ treatTypeChangeAsReplace?: boolean; // default: true
311
516
  }
312
517
  ```
313
518
 
314
- | Option | Type | Description |
315
- | ------ | ---- | ----------- |
316
- | `embeddedObjKeys` | `Record<string, string \| FunctionKey>` or `Map<string \| RegExp, string \| FunctionKey>` | Map paths of arrays to a key or resolver function used to match elements when diffing. Use a `Map` for regex paths. |
317
- | `keysToSkip` | `readonly string[]` | Dotted paths to exclude from comparison, e.g. `"meta.info"`. |
318
- | `treatTypeChangeAsReplace` | `boolean` | When `true` (default), a type change results in a REMOVE/ADD pair. Set to `false` to treat it as an UPDATE. |
519
+ ---
319
520
 
320
- ### Change Types
521
+ ## Migration from v4
321
522
 
322
- ```typescript
323
- enum Operation {
324
- REMOVE = 'REMOVE',
325
- ADD = 'ADD',
326
- UPDATE = 'UPDATE'
327
- }
328
- ```
523
+ 1. **No action required** -- all v4 APIs work identically in v5.
524
+ 2. **Adopt JSON Delta** -- use `diffDelta()` / `applyDelta()` for new code.
525
+ 3. **Bridge existing data** -- `toDelta()` / `fromDelta()` for interop with stored v4 changesets.
526
+ 4. **Rename `embeddedObjKeys` to `arrayIdentityKeys`** -- the old name still works, but `arrayIdentityKeys` is the preferred name going forward.
527
+ 5. Both formats coexist. No forced migration.
528
+
529
+ ---
530
+
531
+ ## Why json-diff-ts?
532
+
533
+ | Feature | json-diff-ts | deep-diff | jsondiffpatch | RFC 6902 |
534
+ | --- | --- | --- | --- | --- |
535
+ | TypeScript | Native | Partial | Definitions only | Varies |
536
+ | Bundle Size | ~21KB | ~45KB | ~120KB+ | Varies |
537
+ | Dependencies | Zero | Few | Many | Varies |
538
+ | ESM Support | Native | CJS only | CJS only | Varies |
539
+ | Array Identity | Key-based | Index only | Configurable | Index only |
540
+ | Wire Format | JSON Delta (standardized) | Proprietary | Proprietary | JSON Pointer |
541
+ | Reversibility | Built-in (`oldValue`) | Manual | Plugin | Not built-in |
542
+
543
+ ## FAQ
544
+
545
+ **Q: How does JSON Delta compare to JSON Patch (RFC 6902)?**
546
+ 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`.
547
+
548
+ **Q: Can I use this with React / Vue / Angular?**
549
+ Yes. json-diff-ts works in any JavaScript runtime -- browsers, Node.js, Deno, Bun, edge workers.
550
+
551
+ **Q: Is it suitable for large objects?**
552
+ Yes. The library handles large, deeply nested JSON structures efficiently with zero dependencies and a ~6KB gzipped footprint.
553
+
554
+ **Q: Can I use the v4 API alongside JSON Delta?**
555
+ Yes. Both APIs coexist. Use `toDelta()` / `fromDelta()` to convert between formats.
556
+
557
+ **Q: What about arrays of primitives?**
558
+ Use `$value` as the identity key: `{ arrayIdentityKeys: { tags: '$value' } }`. Elements are matched by value identity.
559
+
560
+ ---
329
561
 
330
562
  ## Release Notes
331
563
 
564
+ - **v5.0.0-alpha.0:**
565
+ - JSON Delta API: `diffDelta`, `applyDelta`, `revertDelta`, `invertDelta`, `toDelta`, `fromDelta`, `validateDelta`
566
+ - Canonical path production with typed filter literals
567
+ - Conformance with the [JSON Delta Specification](https://github.com/ltwlf/json-delta-format) v0
568
+ - Renamed `embeddedObjKeys` to `arrayIdentityKeys` (old name still works as deprecated alias)
569
+ - All v4 APIs preserved unchanged
570
+
332
571
  - **v4.9.0:**
333
572
  - Fixed `applyChangeset` and `revertChangeset` for root-level arrays containing objects (fixes #362)
334
573
  - Fixed `compare` on root-level arrays producing unexpected UNCHANGED entries (fixes #358)
@@ -340,83 +579,23 @@ enum Operation {
340
579
  - Fixed README Options Interface formatting (#360)
341
580
  - **v4.8.2:** Fixed array handling in `applyChangeset` for null, undefined, and deleted elements (fixes issue #316)
342
581
  - **v4.8.1:** Improved documentation with working examples and detailed options.
343
- - **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.
344
-
582
+ - **v4.8.0:** Significantly reduced bundle size by completely removing es-toolkit dependency and implementing custom utility functions.
345
583
  - **v4.7.0:** Optimized bundle size and performance by replacing es-toolkit/compat with es-toolkit for difference, intersection, and keyBy functions
346
-
347
584
  - **v4.6.3:** Fixed null comparison returning update when values are both null (fixes issue #284)
348
-
349
- - **v4.6.2:** Fixed updating to null when `treatTypeChangeAsReplace` is false and bumped Jest dev dependencies
585
+ - **v4.6.2:** Fixed updating to null when `treatTypeChangeAsReplace` is false
350
586
  - **v4.6.1:** Consistent JSONPath format for array items (fixes issue #269)
351
587
  - **v4.6.0:** Fixed filter path regex to avoid polynomial complexity
352
- - **v4.5.1:** Updated package dependencies
353
588
  - **v4.5.0:** Switched internal utilities from lodash to es-toolkit/compat for a smaller bundle size
354
589
  - **v4.4.0:** Fixed Date-to-string diff when `treatTypeChangeAsReplace` is false
355
- - **v4.3.0:** Enhanced functionality:
356
- - Added support for nested keys to skip using dotted path notation in the keysToSkip option
357
- - This allows excluding specific nested object paths from comparison (fixes #242)
358
- - **v4.2.0:** Improved stability with multiple fixes:
359
- - Fixed object handling in atomizeChangeset and unatomizeChangeset
360
- - Fixed array handling in applyChangeset and revertChangeset
361
- - Fixed handling of null values in applyChangeset
362
- - Fixed handling of empty REMOVE operations when diffing from undefined
590
+ - **v4.3.0:** Added support for nested keys to skip using dotted path notation (fixes #242)
591
+ - **v4.2.0:** Improved stability with multiple fixes for atomize/unatomize, apply/revert, null handling
363
592
  - **v4.1.0:** Full support for ES modules while maintaining CommonJS compatibility
364
- - **v4.0.0:** Changed naming of flattenChangeset and unflattenChanges to atomizeChangeset and unatomizeChangeset; added option to set treatTypeChangeAsReplace
365
- - **v3.0.1:** Fixed issue with unflattenChanges when a key has periods
366
- - **v3.0.0:** Added support for both CommonJS and ECMAScript Modules. Replaced lodash-es with lodash to support both module formats
367
- - **v2.2.0:** Fixed lodash-es dependency, added exclude keys option, added string array comparison by value
368
- - **v2.1.0:** Fixed JSON Path filters by replacing single equal sign (=) with double equal sign (==). Added support for using '.' as root in paths
369
- - **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)
370
- - **v1.2.6:** Enhanced JSON Path handling for period-inclusive segments
371
- - **v1.2.5:** Added key name resolution support for key functions
372
- - **v1.2.4:** Documentation updates and dependency upgrades
373
- - **v1.2.3:** Updated dependencies and TypeScript
593
+ - **v4.0.0:** Renamed flattenChangeset/unflattenChanges to atomizeChangeset/unatomizeChangeset; added treatTypeChangeAsReplace option
374
594
 
375
595
  ## Contributing
376
596
 
377
597
  Contributions are welcome! Please follow the provided issue templates and code of conduct.
378
598
 
379
- ## Performance & Bundle Size
380
-
381
- - **Zero dependencies**: No external runtime dependencies
382
- - **Lightweight**: ~21KB minified, ~6KB gzipped
383
- - **Tree-shakable**: Use only what you need with ES modules
384
- - **High performance**: Optimized for large JSON objects and arrays
385
-
386
- ## Use Cases
387
-
388
- - **State Management**: Track changes in Redux, Zustand, or custom state stores
389
- - **Form Handling**: Detect field changes in React, Vue, or Angular forms
390
- - **Data Synchronization**: Sync data between client and server efficiently
391
- - **Version Control**: Implement undo/redo functionality
392
- - **API Optimization**: Send only changed data to reduce bandwidth
393
- - **Real-time Updates**: Track changes in collaborative applications
394
-
395
- ## Comparison with Alternatives
396
-
397
- | Feature | json-diff-ts | deep-diff | jsondiffpatch |
398
- |---------|--------------|-----------|---------------|
399
- | TypeScript | ✅ Native | ❌ Partial | ❌ Definitions only |
400
- | Bundle Size | 🟢 21KB | 🟡 45KB | 🔴 120KB+ |
401
- | Dependencies | 🟢 Zero | 🟡 Few | 🔴 Many |
402
- | ESM Support | ✅ Native | ❌ CJS only | ❌ CJS only |
403
- | Array Key Matching | ✅ Advanced | ❌ Basic | ✅ Advanced |
404
- | JSONPath Support | ✅ Full | ❌ None | ❌ Limited |
405
-
406
- ## FAQ
407
-
408
- **Q: Can I use this with React/Vue/Angular?**
409
- A: Yes! json-diff-ts works with any JavaScript framework or vanilla JS.
410
-
411
- **Q: Does it work with Node.js?**
412
- A: Absolutely! Supports Node.js 18+ with both CommonJS and ES modules.
413
-
414
- **Q: How does it compare to JSON Patch (RFC 6902)?**
415
- A: json-diff-ts provides a more flexible format with advanced array handling, while JSON Patch is a standardized format.
416
-
417
- **Q: Is it suitable for large objects?**
418
- A: Yes, the library is optimized for performance and can handle large, complex JSON structures efficiently.
419
-
420
599
  ## Support
421
600
 
422
601
  If you find this library useful, consider supporting its development:
@@ -425,8 +604,6 @@ If you find this library useful, consider supporting its development:
425
604
 
426
605
  ## Contact
427
606
 
428
- Reach out to the maintainer:
429
-
430
607
  - LinkedIn: [Christian Glessner](https://www.linkedin.com/in/christian-glessner/)
431
608
  - Twitter: [@leitwolf_io](https://twitter.com/leitwolf_io)
432
609