json-diff-ts 5.0.0-alpha.1 → 5.0.0-alpha.2
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 +613 -318
- package/dist/index.cjs +1008 -107
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +188 -10
- package/dist/index.d.ts +188 -10
- package/dist/index.js +985 -106
- package/dist/index.js.map +1 -1
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -10,34 +10,108 @@
|
|
|
10
10
|
[](https://www.typescriptlang.org/)
|
|
11
11
|
[](http://makeapullrequest.com)
|
|
12
12
|
[](https://opensource.org/licenses/MIT)
|
|
13
|
+
[](https://buymeacoffee.com/leitwolf)
|
|
13
14
|
|
|
14
|
-
|
|
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.
|
|
15
16
|
|
|
16
|
-
|
|
17
|
+
Zero dependencies. TypeScript-first. ESM + CommonJS. Trusted by thousands of developers ([500K+ weekly npm downloads](https://www.npmjs.com/package/json-diff-ts)).
|
|
17
18
|
|
|
18
|
-
|
|
19
|
+
## Why Index-Based Diffing Breaks
|
|
19
20
|
|
|
20
|
-
|
|
21
|
-
- **⚡ High performance** - Optimized algorithms for fast JSON diffing and patching
|
|
22
|
-
- **🎯 95%+ test coverage** - Thoroughly tested with comprehensive test suite
|
|
23
|
-
- **📦 Modern ES modules** - Full TypeScript support with tree-shaking
|
|
24
|
-
- **🔧 Flexible API** - Compare, diff, patch, and atomic operations
|
|
25
|
-
- **🌐 Universal** - Works in browsers, Node.js, and edge environments
|
|
26
|
-
- **✅ Production ready** - Used in enterprise applications worldwide
|
|
27
|
-
- **🎯 TypeScript-first** - Full type safety and IntelliSense support
|
|
28
|
-
- **🔧 Modern features** - ESM + CommonJS, JSONPath, atomic operations
|
|
29
|
-
- **📦 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:
|
|
30
22
|
|
|
31
|
-
|
|
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
|
+
};
|
|
32
88
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
+
};
|
|
96
|
+
|
|
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
|
+
```
|
|
39
113
|
|
|
40
|
-
|
|
114
|
+
That's it. `delta` is a plain JSON object you can store in a database, send over HTTP, or consume in any language.
|
|
41
115
|
|
|
42
116
|
## Installation
|
|
43
117
|
|
|
@@ -45,413 +119,634 @@ This library is particularly valuable for applications where tracking changes in
|
|
|
45
119
|
npm install json-diff-ts
|
|
46
120
|
```
|
|
47
121
|
|
|
48
|
-
|
|
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.
|
|
133
|
+
|
|
134
|
+
```text
|
|
135
|
+
json-delta-format (specification)
|
|
136
|
+
├── json-diff-ts (TypeScript implementation) ← this package
|
|
137
|
+
└── json-delta-py (Python implementation)
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
The specification defines the wire format. Each language implementation produces and consumes compatible deltas.
|
|
141
|
+
|
|
142
|
+
A delta is a self-describing JSON document you can store, transmit, and consume in any language:
|
|
143
|
+
|
|
144
|
+
- **Three operations** -- `add`, `remove`, `replace`. Nothing else to learn.
|
|
145
|
+
- **JSONPath-based paths** -- `$.items[?(@.id==1)].name` identifies elements by key, not index.
|
|
146
|
+
- **Reversible by default** -- every `replace` and `remove` includes `oldValue` for undo.
|
|
147
|
+
- **Self-identifying** -- the `format` field makes deltas discoverable without external context.
|
|
148
|
+
- **Extension-friendly** -- unknown properties are preserved; `x_`-prefixed properties are future-safe.
|
|
149
|
+
|
|
150
|
+
### JSON Delta vs JSON Patch (RFC 6902)
|
|
151
|
+
|
|
152
|
+
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.
|
|
153
|
+
|
|
154
|
+
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.
|
|
155
|
+
|
|
156
|
+
| | JSON Delta | JSON Patch (RFC 6902) |
|
|
157
|
+
| --- | --- | --- |
|
|
158
|
+
| Path syntax | JSONPath (`$.items[?(@.id==1)]`) | JSON Pointer (`/items/0`) |
|
|
159
|
+
| Array identity | Key-based -- survives reorder | Index-based -- breaks on insert/delete |
|
|
160
|
+
| Reversibility | Built-in `oldValue` | Not supported |
|
|
161
|
+
| Self-describing | `format` field in envelope | No envelope |
|
|
162
|
+
| Specification | [json-delta-format](https://github.com/ltwlf/json-delta-format) | [RFC 6902](https://tools.ietf.org/html/rfc6902) |
|
|
163
|
+
|
|
164
|
+
---
|
|
165
|
+
|
|
166
|
+
## JSON Delta API
|
|
167
|
+
|
|
168
|
+
### `diffDelta` -- Compute a Delta
|
|
49
169
|
|
|
50
170
|
```typescript
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
// { type: 'UPDATE', key: 'name', value: 'Luke Skywalker', oldValue: 'Luke' },
|
|
62
|
-
// { type: 'UPDATE', key: 'level', value: 5, oldValue: 1 },
|
|
63
|
-
// { type: 'ADD', key: 'skills', value: 'force', embeddedKey: '1' }
|
|
64
|
-
// ]
|
|
171
|
+
const delta = diffDelta(
|
|
172
|
+
{ user: { name: 'Alice', role: 'viewer' } },
|
|
173
|
+
{ user: { name: 'Alice', role: 'admin' } }
|
|
174
|
+
);
|
|
175
|
+
// delta.operations → [{ op: 'replace', path: '$.user.role', value: 'admin', oldValue: 'viewer' }]
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
#### Keyed Arrays
|
|
179
|
+
|
|
180
|
+
Match array elements by identity key. Filter paths use canonical typed literals per the spec:
|
|
65
181
|
|
|
66
|
-
|
|
67
|
-
const
|
|
68
|
-
|
|
182
|
+
```typescript
|
|
183
|
+
const delta = diffDelta(
|
|
184
|
+
{ users: [{ id: 1, role: 'viewer' }, { id: 2, role: 'editor' }] },
|
|
185
|
+
{ users: [{ id: 1, role: 'admin' }, { id: 2, role: 'editor' }] },
|
|
186
|
+
{ arrayIdentityKeys: { users: 'id' } }
|
|
187
|
+
);
|
|
188
|
+
// delta.operations → [{ op: 'replace', path: '$.users[?(@.id==1)].role', value: 'admin', oldValue: 'viewer' }]
|
|
69
189
|
```
|
|
70
190
|
|
|
71
|
-
|
|
191
|
+
#### Non-reversible Mode
|
|
192
|
+
|
|
193
|
+
Omit `oldValue` fields when you don't need undo:
|
|
72
194
|
|
|
73
|
-
**TypeScript / ES Modules:**
|
|
74
195
|
```typescript
|
|
75
|
-
|
|
196
|
+
const delta = diffDelta(source, target, { reversible: false });
|
|
76
197
|
```
|
|
77
198
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
199
|
+
### `applyDelta` -- Apply a Delta
|
|
200
|
+
|
|
201
|
+
Applies operations sequentially. Always use the return value (required for root-level replacements):
|
|
202
|
+
|
|
203
|
+
```typescript
|
|
204
|
+
const result = applyDelta(structuredClone(source), delta);
|
|
81
205
|
```
|
|
82
206
|
|
|
83
|
-
|
|
207
|
+
### `revertDelta` -- Revert a Delta
|
|
84
208
|
|
|
85
|
-
|
|
209
|
+
Computes the inverse and applies it. Requires `oldValue` on all `replace` and `remove` operations:
|
|
210
|
+
|
|
211
|
+
```typescript
|
|
212
|
+
const original = revertDelta(structuredClone(target), delta);
|
|
213
|
+
```
|
|
86
214
|
|
|
87
|
-
|
|
215
|
+
### `invertDelta` -- Compute the Inverse
|
|
88
216
|
|
|
89
|
-
|
|
217
|
+
Returns a new delta that undoes the original (spec Section 9.2):
|
|
90
218
|
|
|
91
219
|
```typescript
|
|
92
|
-
|
|
220
|
+
const inverse = invertDelta(delta);
|
|
221
|
+
// add ↔ remove, replace swaps value/oldValue, order reversed
|
|
222
|
+
```
|
|
93
223
|
|
|
94
|
-
|
|
95
|
-
const oldData = {
|
|
96
|
-
location: 'Tatooine',
|
|
97
|
-
mission: 'Rescue Princess',
|
|
98
|
-
status: 'In Progress',
|
|
99
|
-
characters: [
|
|
100
|
-
{ id: 'LUKE_SKYWALKER', name: 'Luke Skywalker', role: 'Farm Boy', forceTraining: false },
|
|
101
|
-
{ id: 'LEIA_ORGANA', name: 'Princess Leia', role: 'Prisoner', forceTraining: false }
|
|
102
|
-
],
|
|
103
|
-
equipment: ['Lightsaber', 'Blaster']
|
|
104
|
-
};
|
|
224
|
+
### `validateDelta` -- Validate Structure
|
|
105
225
|
|
|
106
|
-
|
|
107
|
-
const
|
|
108
|
-
|
|
109
|
-
mission: 'Destroy Death Star',
|
|
110
|
-
status: 'Complete',
|
|
111
|
-
characters: [
|
|
112
|
-
{ id: 'LUKE_SKYWALKER', name: 'Luke Skywalker', role: 'Pilot', forceTraining: true, rank: 'Commander' },
|
|
113
|
-
{ id: 'HAN_SOLO', name: 'Han Solo', role: 'Smuggler', forceTraining: false, ship: 'Millennium Falcon' }
|
|
114
|
-
],
|
|
115
|
-
equipment: ['Lightsaber', 'Blaster', 'Bowcaster', 'X-wing Fighter']
|
|
116
|
-
};
|
|
226
|
+
```typescript
|
|
227
|
+
const { valid, errors } = validateDelta(maybeDelta);
|
|
228
|
+
```
|
|
117
229
|
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
230
|
+
### API Reference
|
|
231
|
+
|
|
232
|
+
| Function | Signature | Description |
|
|
233
|
+
| --- | --- | --- |
|
|
234
|
+
| `diffDelta` | `(oldObj, newObj, options?) => IJsonDelta` | Compute a canonical JSON Delta |
|
|
235
|
+
| `applyDelta` | `(obj, delta) => any` | Apply a delta sequentially. Returns the result |
|
|
236
|
+
| `revertDelta` | `(obj, delta) => any` | Revert a reversible delta |
|
|
237
|
+
| `invertDelta` | `(delta) => IJsonDelta` | Compute the inverse delta |
|
|
238
|
+
| `validateDelta` | `(delta) => { valid, errors }` | Structural validation |
|
|
239
|
+
| `toDelta` | `(changeset, options?) => IJsonDelta` | Bridge: v4 changeset to JSON Delta |
|
|
240
|
+
| `fromDelta` | `(delta) => IAtomicChange[]` | Bridge: JSON Delta to v4 atomic changes |
|
|
241
|
+
| `squashDeltas` | `(source, deltas, options?) => IJsonDelta` | Compact multiple deltas into one net-effect delta |
|
|
242
|
+
| `deltaMap` | `(delta, fn) => IJsonDelta` | Transform each operation in a delta |
|
|
243
|
+
| `deltaStamp` | `(delta, extensions) => IJsonDelta` | Set extension properties on all operations |
|
|
244
|
+
| `deltaGroupBy` | `(delta, keyFn) => Record<string, IJsonDelta>` | Group operations into sub-deltas |
|
|
245
|
+
| `operationSpecDict` | `(op) => IDeltaOperation` | Strip extension properties from operation |
|
|
246
|
+
| `operationExtensions` | `(op) => Record<string, any>` | Get extension properties from operation |
|
|
247
|
+
| `deltaSpecDict` | `(delta) => IJsonDelta` | Strip all extensions from delta |
|
|
248
|
+
| `deltaExtensions` | `(delta) => Record<string, any>` | Get envelope extensions from delta |
|
|
249
|
+
| `leafProperty` | `(op) => string \| null` | Terminal property name from operation path |
|
|
250
|
+
|
|
251
|
+
### DeltaOptions
|
|
252
|
+
|
|
253
|
+
Extends the base `Options` interface:
|
|
254
|
+
|
|
255
|
+
```typescript
|
|
256
|
+
interface DeltaOptions extends Options {
|
|
257
|
+
reversible?: boolean; // Include oldValue for undo. Default: true
|
|
258
|
+
arrayIdentityKeys?: Record<string, string | FunctionKey>;
|
|
259
|
+
keysToSkip?: readonly string[];
|
|
260
|
+
}
|
|
261
|
+
```
|
|
262
|
+
|
|
263
|
+
### Delta Workflow Helpers
|
|
264
|
+
|
|
265
|
+
Transform, inspect, and compact deltas for workflow automation.
|
|
266
|
+
|
|
267
|
+
#### `squashDeltas` -- Compact Multiple Deltas
|
|
268
|
+
|
|
269
|
+
Combine a sequence of deltas into a single net-effect delta. Useful for compacting audit logs or collapsing undo history:
|
|
270
|
+
|
|
271
|
+
```typescript
|
|
272
|
+
import { diffDelta, applyDelta, squashDeltas } from 'json-diff-ts';
|
|
273
|
+
|
|
274
|
+
const source = { name: 'Alice', role: 'viewer' };
|
|
275
|
+
const d1 = diffDelta(source, { name: 'Bob', role: 'viewer' });
|
|
276
|
+
const d2 = diffDelta({ name: 'Bob', role: 'viewer' }, { name: 'Bob', role: 'admin' });
|
|
277
|
+
|
|
278
|
+
const squashed = squashDeltas(source, [d1, d2]);
|
|
279
|
+
// squashed.operations => [
|
|
280
|
+
// { op: 'replace', path: '$.name', value: 'Bob', oldValue: 'Alice' },
|
|
281
|
+
// { op: 'replace', path: '$.role', value: 'admin', oldValue: 'viewer' }
|
|
126
282
|
// ]
|
|
283
|
+
|
|
284
|
+
// Verify: applying the squashed delta equals applying both sequentially
|
|
285
|
+
const result = applyDelta(structuredClone(source), squashed);
|
|
286
|
+
// result => { name: 'Bob', role: 'admin' }
|
|
127
287
|
```
|
|
128
288
|
|
|
129
|
-
|
|
289
|
+
Options: `reversible`, `arrayIdentityKeys`, `target` (pre-computed final state), `verifyTarget` (default: true).
|
|
130
290
|
|
|
131
|
-
|
|
291
|
+
#### `deltaMap` / `deltaStamp` / `deltaGroupBy` -- Delta Transformations
|
|
132
292
|
|
|
133
|
-
|
|
134
|
-
|
|
293
|
+
All transforms are immutable — they return new deltas without modifying the original:
|
|
294
|
+
|
|
295
|
+
```typescript
|
|
296
|
+
import { diffDelta, deltaMap, deltaStamp, deltaGroupBy } from 'json-diff-ts';
|
|
297
|
+
|
|
298
|
+
const delta = diffDelta(
|
|
299
|
+
{ name: 'Alice', age: 30, role: 'viewer' },
|
|
300
|
+
{ name: 'Bob', age: 31, status: 'active' }
|
|
301
|
+
);
|
|
135
302
|
|
|
136
|
-
//
|
|
137
|
-
const
|
|
303
|
+
// Stamp metadata onto every operation
|
|
304
|
+
const stamped = deltaStamp(delta, { x_author: 'system', x_ts: Date.now() });
|
|
138
305
|
|
|
139
|
-
//
|
|
140
|
-
const
|
|
306
|
+
// Transform operations
|
|
307
|
+
const prefixed = deltaMap(delta, (op) => ({
|
|
308
|
+
...op,
|
|
309
|
+
path: op.path.replace('$', '$.data'),
|
|
310
|
+
}));
|
|
311
|
+
|
|
312
|
+
// Group by operation type
|
|
313
|
+
const groups = deltaGroupBy(delta, (op) => op.op);
|
|
314
|
+
// groups => { replace: IJsonDelta, add: IJsonDelta, remove: IJsonDelta }
|
|
141
315
|
```
|
|
142
316
|
|
|
143
|
-
|
|
317
|
+
#### `operationSpecDict` / `deltaSpecDict` -- Spec Introspection
|
|
144
318
|
|
|
145
|
-
|
|
146
|
-
|
|
319
|
+
Separate spec-defined fields from extension properties:
|
|
320
|
+
|
|
321
|
+
```typescript
|
|
322
|
+
import { operationSpecDict, operationExtensions, deltaSpecDict } from 'json-diff-ts';
|
|
323
|
+
|
|
324
|
+
const op = { op: 'replace', path: '$.name', value: 'Bob', x_author: 'system' };
|
|
325
|
+
operationSpecDict(op); // { op: 'replace', path: '$.name', value: 'Bob' }
|
|
326
|
+
operationExtensions(op); // { x_author: 'system' }
|
|
147
327
|
|
|
148
|
-
//
|
|
149
|
-
const
|
|
328
|
+
// Strip all extensions from a delta
|
|
329
|
+
const clean = deltaSpecDict(delta);
|
|
150
330
|
```
|
|
151
331
|
|
|
152
|
-
|
|
332
|
+
#### `leafProperty` -- Path Introspection
|
|
153
333
|
|
|
154
|
-
|
|
334
|
+
Extract the terminal property name from an operation's path:
|
|
155
335
|
|
|
156
|
-
```
|
|
157
|
-
import {
|
|
336
|
+
```typescript
|
|
337
|
+
import { leafProperty } from 'json-diff-ts';
|
|
158
338
|
|
|
159
|
-
|
|
160
|
-
|
|
339
|
+
leafProperty({ op: 'replace', path: '$.user.name' }); // 'name'
|
|
340
|
+
leafProperty({ op: 'add', path: '$.items[?(@.id==1)]' }); // null (filter)
|
|
341
|
+
leafProperty({ op: 'replace', path: '$' }); // null (root)
|
|
161
342
|
```
|
|
162
343
|
|
|
163
|
-
|
|
344
|
+
---
|
|
164
345
|
|
|
165
|
-
|
|
166
|
-
import { diff } from 'json-diff-ts';
|
|
346
|
+
## Comparison Serialization
|
|
167
347
|
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
348
|
+
Serialize enriched comparison trees to plain objects or flat change lists.
|
|
349
|
+
|
|
350
|
+
```typescript
|
|
351
|
+
import { compare, comparisonToDict, comparisonToFlatList } from 'json-diff-ts';
|
|
352
|
+
|
|
353
|
+
const result = compare(
|
|
354
|
+
{ name: 'Alice', age: 30, role: 'viewer' },
|
|
355
|
+
{ name: 'Bob', age: 30, status: 'active' }
|
|
356
|
+
);
|
|
357
|
+
|
|
358
|
+
// Recursive plain object
|
|
359
|
+
const dict = comparisonToDict(result);
|
|
360
|
+
// {
|
|
361
|
+
// type: 'CONTAINER',
|
|
362
|
+
// value: {
|
|
363
|
+
// name: { type: 'UPDATE', value: 'Bob', oldValue: 'Alice' },
|
|
364
|
+
// age: { type: 'UNCHANGED', value: 30 },
|
|
365
|
+
// role: { type: 'REMOVE', oldValue: 'viewer' },
|
|
366
|
+
// status: { type: 'ADD', value: 'active' }
|
|
367
|
+
// }
|
|
368
|
+
// }
|
|
369
|
+
|
|
370
|
+
// Flat list of leaf changes with paths
|
|
371
|
+
const flat = comparisonToFlatList(result);
|
|
372
|
+
// [
|
|
373
|
+
// { path: '$.name', type: 'UPDATE', value: 'Bob', oldValue: 'Alice' },
|
|
374
|
+
// { path: '$.role', type: 'REMOVE', oldValue: 'viewer' },
|
|
375
|
+
// { path: '$.status', type: 'ADD', value: 'active' }
|
|
376
|
+
// ]
|
|
377
|
+
|
|
378
|
+
// Include unchanged entries
|
|
379
|
+
const all = comparisonToFlatList(result, { includeUnchanged: true });
|
|
380
|
+
```
|
|
381
|
+
|
|
382
|
+
---
|
|
383
|
+
|
|
384
|
+
## Practical Examples
|
|
385
|
+
|
|
386
|
+
### Audit Log
|
|
387
|
+
|
|
388
|
+
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:
|
|
389
|
+
|
|
390
|
+
```typescript
|
|
391
|
+
import { diffDelta, applyDelta, revertDelta, IJsonDelta } from 'json-diff-ts';
|
|
392
|
+
|
|
393
|
+
interface AuditEntry {
|
|
394
|
+
timestamp: string;
|
|
395
|
+
userId: string;
|
|
396
|
+
delta: IJsonDelta;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
const auditLog: AuditEntry[] = [];
|
|
400
|
+
let doc = {
|
|
401
|
+
title: 'Project Plan',
|
|
402
|
+
status: 'draft',
|
|
403
|
+
items: [
|
|
404
|
+
{ id: 1, task: 'Design', done: false },
|
|
405
|
+
{ id: 2, task: 'Build', done: false },
|
|
406
|
+
],
|
|
407
|
+
};
|
|
408
|
+
|
|
409
|
+
function updateDocument(newDoc: typeof doc, userId: string) {
|
|
410
|
+
const delta = diffDelta(doc, newDoc, {
|
|
411
|
+
arrayIdentityKeys: { items: 'id' },
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
if (delta.operations.length > 0) {
|
|
415
|
+
auditLog.push({ timestamp: new Date().toISOString(), userId, delta });
|
|
416
|
+
doc = applyDelta(structuredClone(doc), delta);
|
|
172
417
|
}
|
|
173
|
-
|
|
418
|
+
|
|
419
|
+
return doc;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
// Revert the last change
|
|
423
|
+
function undo(): typeof doc {
|
|
424
|
+
const last = auditLog.pop();
|
|
425
|
+
if (!last) return doc;
|
|
426
|
+
doc = revertDelta(structuredClone(doc), last.delta);
|
|
427
|
+
return doc;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
// Example usage:
|
|
431
|
+
updateDocument(
|
|
432
|
+
{ ...doc, status: 'active', items: [{ id: 1, task: 'Design', done: true }, ...doc.items.slice(1)] },
|
|
433
|
+
'alice'
|
|
434
|
+
);
|
|
435
|
+
// auditLog[0].delta.operations =>
|
|
436
|
+
// [
|
|
437
|
+
// { op: 'replace', path: '$.status', value: 'active', oldValue: 'draft' },
|
|
438
|
+
// { op: 'replace', path: '$.items[?(@.id==1)].done', value: true, oldValue: false }
|
|
439
|
+
// ]
|
|
174
440
|
```
|
|
175
441
|
|
|
176
|
-
|
|
442
|
+
Because every delta is self-describing JSON, your audit log is queryable, storable in any database, and readable from any language.
|
|
177
443
|
|
|
178
|
-
|
|
179
|
-
|
|
444
|
+
### Undo / Redo Stack
|
|
445
|
+
|
|
446
|
+
Build undo/redo for any JSON state object. Deltas are small (only changed fields), reversible, and serializable:
|
|
447
|
+
|
|
448
|
+
```typescript
|
|
449
|
+
import { diffDelta, applyDelta, revertDelta, IJsonDelta } from 'json-diff-ts';
|
|
450
|
+
|
|
451
|
+
class UndoManager<T extends object> {
|
|
452
|
+
private undoStack: IJsonDelta[] = [];
|
|
453
|
+
private redoStack: IJsonDelta[] = [];
|
|
454
|
+
|
|
455
|
+
constructor(private state: T) {}
|
|
456
|
+
|
|
457
|
+
apply(newState: T): T {
|
|
458
|
+
const delta = diffDelta(this.state, newState);
|
|
459
|
+
if (delta.operations.length === 0) return this.state;
|
|
460
|
+
this.undoStack.push(delta);
|
|
461
|
+
this.redoStack = [];
|
|
462
|
+
this.state = applyDelta(structuredClone(this.state), delta);
|
|
463
|
+
return this.state;
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
undo(): T {
|
|
467
|
+
const delta = this.undoStack.pop();
|
|
468
|
+
if (!delta) return this.state;
|
|
469
|
+
this.redoStack.push(delta);
|
|
470
|
+
this.state = revertDelta(structuredClone(this.state), delta);
|
|
471
|
+
return this.state;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
redo(): T {
|
|
475
|
+
const delta = this.redoStack.pop();
|
|
476
|
+
if (!delta) return this.state;
|
|
477
|
+
this.undoStack.push(delta);
|
|
478
|
+
this.state = applyDelta(structuredClone(this.state), delta);
|
|
479
|
+
return this.state;
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
```
|
|
483
|
+
|
|
484
|
+
### Data Synchronization
|
|
485
|
+
|
|
486
|
+
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:
|
|
487
|
+
|
|
488
|
+
```typescript
|
|
489
|
+
import { diffDelta, applyDelta, validateDelta } from 'json-diff-ts';
|
|
490
|
+
|
|
491
|
+
// Client side: compute and send delta
|
|
492
|
+
const delta = diffDelta(localState, updatedState, {
|
|
493
|
+
arrayIdentityKeys: { records: 'id' },
|
|
494
|
+
});
|
|
495
|
+
await fetch('/api/sync', {
|
|
496
|
+
method: 'POST',
|
|
497
|
+
body: JSON.stringify(delta),
|
|
498
|
+
});
|
|
180
499
|
|
|
181
|
-
//
|
|
182
|
-
const
|
|
183
|
-
|
|
184
|
-
|
|
500
|
+
// Server side: validate and apply
|
|
501
|
+
const result = validateDelta(req.body);
|
|
502
|
+
if (!result.valid) return res.status(400).json(result.errors);
|
|
503
|
+
// ⚠️ In production, sanitize paths/values to prevent prototype pollution
|
|
504
|
+
// (e.g. reject paths containing "__proto__" or "constructor")
|
|
505
|
+
currentState = applyDelta(structuredClone(currentState), req.body);
|
|
185
506
|
```
|
|
186
507
|
|
|
187
|
-
|
|
508
|
+
---
|
|
188
509
|
|
|
189
|
-
|
|
190
|
-
|
|
510
|
+
## Bridge: v4 Changeset <-> JSON Delta
|
|
511
|
+
|
|
512
|
+
Convert between the legacy internal format and JSON Delta:
|
|
513
|
+
|
|
514
|
+
```typescript
|
|
515
|
+
import { diff, toDelta, fromDelta, unatomizeChangeset } from 'json-diff-ts';
|
|
516
|
+
|
|
517
|
+
// v4 changeset → JSON Delta
|
|
518
|
+
const changeset = diff(source, target, { arrayIdentityKeys: { items: 'id' } });
|
|
519
|
+
const delta = toDelta(changeset);
|
|
520
|
+
|
|
521
|
+
// JSON Delta → v4 atomic changes
|
|
522
|
+
const atoms = fromDelta(delta);
|
|
191
523
|
|
|
192
|
-
//
|
|
193
|
-
const
|
|
524
|
+
// v4 atomic changes → hierarchical changeset (if needed)
|
|
525
|
+
const cs = unatomizeChangeset(atoms);
|
|
194
526
|
```
|
|
195
527
|
|
|
196
|
-
|
|
528
|
+
**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.
|
|
197
529
|
|
|
198
|
-
|
|
530
|
+
---
|
|
199
531
|
|
|
200
|
-
|
|
532
|
+
## Legacy Changeset API (v4 Compatibility)
|
|
533
|
+
|
|
534
|
+
All v4 APIs remain fully supported. Existing code continues to work without changes. For new projects, prefer the JSON Delta API above.
|
|
535
|
+
|
|
536
|
+
### `diff`
|
|
537
|
+
|
|
538
|
+
Generates a hierarchical changeset between two objects:
|
|
539
|
+
|
|
540
|
+
```typescript
|
|
201
541
|
import { diff } from 'json-diff-ts';
|
|
202
542
|
|
|
203
|
-
const
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
{ id:
|
|
207
|
-
{ id:
|
|
208
|
-
]
|
|
543
|
+
const oldData = {
|
|
544
|
+
location: 'Tatooine',
|
|
545
|
+
characters: [
|
|
546
|
+
{ id: 'LUKE', name: 'Luke Skywalker', role: 'Farm Boy' },
|
|
547
|
+
{ id: 'LEIA', name: 'Princess Leia', role: 'Prisoner' }
|
|
548
|
+
],
|
|
209
549
|
};
|
|
210
550
|
|
|
211
|
-
const
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
{ id:
|
|
215
|
-
{ id:
|
|
216
|
-
]
|
|
551
|
+
const newData = {
|
|
552
|
+
location: 'Yavin Base',
|
|
553
|
+
characters: [
|
|
554
|
+
{ id: 'LUKE', name: 'Luke Skywalker', role: 'Pilot', rank: 'Commander' },
|
|
555
|
+
{ id: 'HAN', name: 'Han Solo', role: 'Smuggler' }
|
|
556
|
+
],
|
|
217
557
|
};
|
|
218
558
|
|
|
219
|
-
|
|
220
|
-
const diffsNoMoves = diff(before, after, { embeddedObjKeys: { users: 'id' } });
|
|
221
|
-
console.log(diffsNoMoves); // [] - no changes detected since elements are unchanged
|
|
222
|
-
|
|
223
|
-
// With move detection enabled
|
|
224
|
-
const diffsWithMoves = diff(before, after, {
|
|
225
|
-
embeddedObjKeys: { users: 'id' },
|
|
226
|
-
detectArrayMoves: true
|
|
227
|
-
});
|
|
228
|
-
console.log(diffsWithMoves);
|
|
229
|
-
// [
|
|
230
|
-
// {
|
|
231
|
-
// type: 'UPDATE',
|
|
232
|
-
// key: 'users',
|
|
233
|
-
// embeddedKey: 'id',
|
|
234
|
-
// changes: [
|
|
235
|
-
// { type: 'MOVE', key: 2, oldIndex: 1, newIndex: 0, value: { id: 2, name: 'Bob' } },
|
|
236
|
-
// { type: 'MOVE', key: 3, oldIndex: 2, newIndex: 1, value: { id: 3, name: 'Charlie' } },
|
|
237
|
-
// { type: 'MOVE', key: 1, oldIndex: 0, newIndex: 2, value: { id: 1, name: 'Alice' } }
|
|
238
|
-
// ]
|
|
239
|
-
// }
|
|
240
|
-
// ]
|
|
559
|
+
const changes = diff(oldData, newData, { arrayIdentityKeys: { characters: 'id' } });
|
|
241
560
|
```
|
|
242
561
|
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
562
|
+
### `applyChangeset` and `revertChangeset`
|
|
563
|
+
|
|
564
|
+
```typescript
|
|
565
|
+
import { applyChangeset, revertChangeset } from 'json-diff-ts';
|
|
566
|
+
|
|
567
|
+
const updated = applyChangeset(structuredClone(oldData), changes);
|
|
568
|
+
const reverted = revertChangeset(structuredClone(newData), changes);
|
|
569
|
+
```
|
|
247
570
|
|
|
248
571
|
### `atomizeChangeset` and `unatomizeChangeset`
|
|
249
572
|
|
|
250
|
-
|
|
573
|
+
Flatten a hierarchical changeset into atomic changes addressable by JSONPath, or reconstruct the hierarchy:
|
|
251
574
|
|
|
252
|
-
```
|
|
575
|
+
```typescript
|
|
253
576
|
import { atomizeChangeset, unatomizeChangeset } from 'json-diff-ts';
|
|
254
577
|
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
//
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
[
|
|
266
|
-
{
|
|
267
|
-
type: 'UPDATE',
|
|
268
|
-
key: 'location',
|
|
269
|
-
value: 'Yavin Base',
|
|
270
|
-
oldValue: 'Tatooine',
|
|
271
|
-
path: '$.location',
|
|
272
|
-
valueType: 'String'
|
|
273
|
-
},
|
|
274
|
-
{
|
|
275
|
-
type: 'UPDATE',
|
|
276
|
-
key: 'mission',
|
|
277
|
-
value: 'Destroy Death Star',
|
|
278
|
-
oldValue: 'Rescue Princess',
|
|
279
|
-
path: '$.mission',
|
|
280
|
-
valueType: 'String'
|
|
281
|
-
},
|
|
282
|
-
{
|
|
283
|
-
type: 'ADD',
|
|
284
|
-
key: 'rank',
|
|
285
|
-
value: 'Commander',
|
|
286
|
-
path: "$.characters[?(@.id=='LUKE_SKYWALKER')].rank",
|
|
287
|
-
valueType: 'String'
|
|
288
|
-
},
|
|
289
|
-
{
|
|
290
|
-
type: 'ADD',
|
|
291
|
-
key: 'HAN_SOLO',
|
|
292
|
-
value: { id: 'HAN_SOLO', name: 'Han Solo', role: 'Smuggler', forceTraining: false, ship: 'Millennium Falcon' },
|
|
293
|
-
path: "$.characters[?(@.id=='HAN_SOLO')]",
|
|
294
|
-
valueType: 'Object'
|
|
295
|
-
}
|
|
296
|
-
]
|
|
578
|
+
const atoms = atomizeChangeset(changes);
|
|
579
|
+
// [
|
|
580
|
+
// { type: 'UPDATE', key: 'location', value: 'Yavin Base', oldValue: 'Tatooine',
|
|
581
|
+
// path: '$.location', valueType: 'String' },
|
|
582
|
+
// { type: 'ADD', key: 'rank', value: 'Commander',
|
|
583
|
+
// path: "$.characters[?(@.id=='LUKE')].rank", valueType: 'String' },
|
|
584
|
+
// ...
|
|
585
|
+
// ]
|
|
586
|
+
|
|
587
|
+
const restored = unatomizeChangeset(atoms.slice(0, 2));
|
|
297
588
|
```
|
|
298
589
|
|
|
299
|
-
###
|
|
590
|
+
### Advanced Options
|
|
300
591
|
|
|
301
|
-
|
|
592
|
+
#### Key-based Array Matching
|
|
302
593
|
|
|
303
|
-
```
|
|
304
|
-
|
|
594
|
+
```typescript
|
|
595
|
+
// Named key
|
|
596
|
+
diff(old, new, { arrayIdentityKeys: { characters: 'id' } });
|
|
305
597
|
|
|
306
|
-
//
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
598
|
+
// Function key
|
|
599
|
+
diff(old, new, {
|
|
600
|
+
arrayIdentityKeys: {
|
|
601
|
+
characters: (obj, shouldReturnKeyName) => (shouldReturnKeyName ? 'id' : obj.id)
|
|
602
|
+
}
|
|
603
|
+
});
|
|
310
604
|
|
|
311
|
-
//
|
|
312
|
-
const
|
|
313
|
-
|
|
314
|
-
|
|
605
|
+
// Regex path matching
|
|
606
|
+
const keys = new Map();
|
|
607
|
+
keys.set(/^characters/, 'id');
|
|
608
|
+
diff(old, new, { arrayIdentityKeys: keys });
|
|
609
|
+
|
|
610
|
+
// Value-based identity for primitive arrays
|
|
611
|
+
diff(old, new, { arrayIdentityKeys: { tags: '$value' } });
|
|
315
612
|
```
|
|
316
613
|
|
|
317
|
-
|
|
614
|
+
#### Path Skipping
|
|
318
615
|
|
|
319
|
-
|
|
616
|
+
```typescript
|
|
617
|
+
diff(old, new, { keysToSkip: ['characters.metadata'] });
|
|
618
|
+
```
|
|
320
619
|
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
620
|
+
#### Type Change Handling
|
|
621
|
+
|
|
622
|
+
```typescript
|
|
623
|
+
diff(old, new, { treatTypeChangeAsReplace: false });
|
|
624
|
+
```
|
|
625
|
+
|
|
626
|
+
### Legacy API Reference
|
|
627
|
+
|
|
628
|
+
| Function | Description |
|
|
629
|
+
| --- | --- |
|
|
630
|
+
| `diff(oldObj, newObj, options?)` | Compute hierarchical changeset |
|
|
631
|
+
| `applyChangeset(obj, changeset)` | Apply a changeset to an object |
|
|
632
|
+
| `revertChangeset(obj, changeset)` | Revert a changeset from an object |
|
|
633
|
+
| `atomizeChangeset(changeset)` | Flatten to atomic changes with JSONPath |
|
|
634
|
+
| `unatomizeChangeset(atoms)` | Reconstruct hierarchy from atomic changes |
|
|
328
635
|
|
|
329
636
|
### Comparison Functions
|
|
330
637
|
|
|
331
|
-
| Function | Description |
|
|
332
|
-
|
|
333
|
-
| `compare(oldObj, newObj)` | Create enriched comparison object |
|
|
334
|
-
| `enrich(obj)` | Create enriched representation
|
|
335
|
-
| `
|
|
336
|
-
| `
|
|
638
|
+
| Function | Description |
|
|
639
|
+
| --- | --- |
|
|
640
|
+
| `compare(oldObj, newObj)` | Create enriched comparison object |
|
|
641
|
+
| `enrich(obj)` | Create enriched representation |
|
|
642
|
+
| `comparisonToDict(node)` | Serialize comparison tree to plain object |
|
|
643
|
+
| `comparisonToFlatList(node, options?)` | Flatten comparison to leaf change list |
|
|
337
644
|
|
|
338
|
-
### Options
|
|
645
|
+
### Options
|
|
339
646
|
|
|
340
647
|
```typescript
|
|
341
648
|
interface Options {
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
649
|
+
arrayIdentityKeys?: Record<string, string | FunctionKey> | Map<string | RegExp, string | FunctionKey>;
|
|
650
|
+
/** @deprecated Use arrayIdentityKeys instead */
|
|
651
|
+
embeddedObjKeys?: Record<string, string | FunctionKey> | Map<string | RegExp, string | FunctionKey>;
|
|
652
|
+
keysToSkip?: readonly string[];
|
|
653
|
+
treatTypeChangeAsReplace?: boolean; // default: true
|
|
346
654
|
}
|
|
347
655
|
```
|
|
348
656
|
|
|
349
|
-
|
|
350
|
-
| ------ | ---- | ----------- |
|
|
351
|
-
| `embeddedObjKeys` | `Record<string, string | Function>` or `Map<string | RegExp, string | Function>` | Map paths of arrays to a key or resolver function used to match elements when diffing. Use a `Map` for regex paths. |
|
|
352
|
-
| `keysToSkip` | `string[]` | Dotted paths to exclude from comparison, e.g. `"meta.info"`. |
|
|
353
|
-
| `treatTypeChangeAsReplace` | `boolean` | When `true` (default), a type change results in a REMOVE/ADD pair. Set to `false` to treat it as an UPDATE. |
|
|
354
|
-
| `detectArrayMoves` | `boolean` | When `true`, detect array element reordering as MOVE operations. Only works with `embeddedObjKeys`. Default: `false`. |
|
|
657
|
+
---
|
|
355
658
|
|
|
356
|
-
|
|
659
|
+
## Migration from v4
|
|
357
660
|
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
661
|
+
1. **No action required** -- all v4 APIs work identically in v5.
|
|
662
|
+
2. **Adopt JSON Delta** -- use `diffDelta()` / `applyDelta()` for new code.
|
|
663
|
+
3. **Bridge existing data** -- `toDelta()` / `fromDelta()` for interop with stored v4 changesets.
|
|
664
|
+
4. **Rename `embeddedObjKeys` to `arrayIdentityKeys`** -- the old name still works, but `arrayIdentityKeys` is the preferred name going forward.
|
|
665
|
+
5. Both formats coexist. No forced migration.
|
|
666
|
+
|
|
667
|
+
---
|
|
668
|
+
|
|
669
|
+
## Why json-diff-ts?
|
|
670
|
+
|
|
671
|
+
| Feature | json-diff-ts | deep-diff | jsondiffpatch | RFC 6902 |
|
|
672
|
+
| --- | --- | --- | --- | --- |
|
|
673
|
+
| TypeScript | Native | Partial | Definitions only | Varies |
|
|
674
|
+
| Bundle Size | ~21KB | ~45KB | ~120KB+ | Varies |
|
|
675
|
+
| Dependencies | Zero | Few | Many | Varies |
|
|
676
|
+
| ESM Support | Native | CJS only | CJS only | Varies |
|
|
677
|
+
| Array Identity | Key-based | Index only | Configurable | Index only |
|
|
678
|
+
| Wire Format | JSON Delta (standardized) | Proprietary | Proprietary | JSON Pointer |
|
|
679
|
+
| Reversibility | Built-in (`oldValue`) | Manual | Plugin | Not built-in |
|
|
680
|
+
|
|
681
|
+
## FAQ
|
|
682
|
+
|
|
683
|
+
**Q: How does JSON Delta compare to JSON Patch (RFC 6902)?**
|
|
684
|
+
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`.
|
|
685
|
+
|
|
686
|
+
**Q: Can I use this with React / Vue / Angular?**
|
|
687
|
+
Yes. json-diff-ts works in any JavaScript runtime -- browsers, Node.js, Deno, Bun, edge workers.
|
|
688
|
+
|
|
689
|
+
**Q: Is it suitable for large objects?**
|
|
690
|
+
Yes. The library handles large, deeply nested JSON structures efficiently with zero dependencies and a ~6KB gzipped footprint.
|
|
691
|
+
|
|
692
|
+
**Q: Can I use the v4 API alongside JSON Delta?**
|
|
693
|
+
Yes. Both APIs coexist. Use `toDelta()` / `fromDelta()` to convert between formats.
|
|
694
|
+
|
|
695
|
+
**Q: What about arrays of primitives?**
|
|
696
|
+
Use `$value` as the identity key: `{ arrayIdentityKeys: { tags: '$value' } }`. Elements are matched by value identity.
|
|
697
|
+
|
|
698
|
+
---
|
|
366
699
|
|
|
367
700
|
## Release Notes
|
|
368
701
|
|
|
369
|
-
- **
|
|
702
|
+
- **v5.0.0-alpha.2:**
|
|
703
|
+
- Delta workflow helpers: `squashDeltas`, `deltaMap`, `deltaStamp`, `deltaGroupBy`
|
|
704
|
+
- Delta/operation introspection: `operationSpecDict`, `operationExtensions`, `deltaSpecDict`, `deltaExtensions`, `leafProperty`
|
|
705
|
+
- Comparison serialization: `comparisonToDict`, `comparisonToFlatList`
|
|
706
|
+
|
|
707
|
+
- **v5.0.0-alpha.0:**
|
|
708
|
+
- JSON Delta API: `diffDelta`, `applyDelta`, `revertDelta`, `invertDelta`, `toDelta`, `fromDelta`, `validateDelta`
|
|
709
|
+
- Canonical path production with typed filter literals
|
|
710
|
+
- Conformance with the [JSON Delta Specification](https://github.com/ltwlf/json-delta-format) v0
|
|
711
|
+
- Renamed `embeddedObjKeys` to `arrayIdentityKeys` (old name still works as deprecated alias)
|
|
712
|
+
- All v4 APIs preserved unchanged
|
|
713
|
+
|
|
714
|
+
- **v4.9.0:**
|
|
715
|
+
- Fixed `applyChangeset` and `revertChangeset` for root-level arrays containing objects (fixes #362)
|
|
716
|
+
- Fixed `compare` on root-level arrays producing unexpected UNCHANGED entries (fixes #358)
|
|
717
|
+
- Refactored `applyChangelist` path resolution for correctness with terminal array indices
|
|
718
|
+
- `keysToSkip` now accepts `readonly string[]` (fixes #359)
|
|
719
|
+
- `keyBy` callback now receives the element index (PR #365)
|
|
720
|
+
- Enhanced array handling for `undefined` values (fixes #316)
|
|
721
|
+
- Fixed typo in warning message (#361)
|
|
722
|
+
- Fixed README Options Interface formatting (#360)
|
|
370
723
|
- **v4.8.2:** Fixed array handling in `applyChangeset` for null, undefined, and deleted elements (fixes issue #316)
|
|
371
724
|
- **v4.8.1:** Improved documentation with working examples and detailed options.
|
|
372
|
-
- **v4.8.0:** Significantly reduced bundle size by completely removing es-toolkit dependency and implementing custom utility functions.
|
|
373
|
-
|
|
725
|
+
- **v4.8.0:** Significantly reduced bundle size by completely removing es-toolkit dependency and implementing custom utility functions.
|
|
374
726
|
- **v4.7.0:** Optimized bundle size and performance by replacing es-toolkit/compat with es-toolkit for difference, intersection, and keyBy functions
|
|
375
|
-
|
|
376
727
|
- **v4.6.3:** Fixed null comparison returning update when values are both null (fixes issue #284)
|
|
377
|
-
|
|
378
|
-
- **v4.6.2:** Fixed updating to null when `treatTypeChangeAsReplace` is false and bumped Jest dev dependencies
|
|
728
|
+
- **v4.6.2:** Fixed updating to null when `treatTypeChangeAsReplace` is false
|
|
379
729
|
- **v4.6.1:** Consistent JSONPath format for array items (fixes issue #269)
|
|
380
730
|
- **v4.6.0:** Fixed filter path regex to avoid polynomial complexity
|
|
381
|
-
- **v4.5.1:** Updated package dependencies
|
|
382
731
|
- **v4.5.0:** Switched internal utilities from lodash to es-toolkit/compat for a smaller bundle size
|
|
383
732
|
- **v4.4.0:** Fixed Date-to-string diff when `treatTypeChangeAsReplace` is false
|
|
384
|
-
- **v4.3.0:**
|
|
385
|
-
|
|
386
|
-
- This allows excluding specific nested object paths from comparison (fixes #242)
|
|
387
|
-
- **v4.2.0:** Improved stability with multiple fixes:
|
|
388
|
-
- Fixed object handling in atomizeChangeset and unatomizeChangeset
|
|
389
|
-
- Fixed array handling in applyChangeset and revertChangeset
|
|
390
|
-
- Fixed handling of null values in applyChangeset
|
|
391
|
-
- Fixed handling of empty REMOVE operations when diffing from undefined
|
|
733
|
+
- **v4.3.0:** Added support for nested keys to skip using dotted path notation (fixes #242)
|
|
734
|
+
- **v4.2.0:** Improved stability with multiple fixes for atomize/unatomize, apply/revert, null handling
|
|
392
735
|
- **v4.1.0:** Full support for ES modules while maintaining CommonJS compatibility
|
|
393
|
-
- **v4.0.0:**
|
|
394
|
-
- **v3.0.1:** Fixed issue with unflattenChanges when a key has periods
|
|
395
|
-
- **v3.0.0:** Added support for both CommonJS and ECMAScript Modules. Replaced lodash-es with lodash to support both module formats
|
|
396
|
-
- **v5.0.0-alpha.1:** Added MOVE operation for array reordering detection with embeddedObjKeys. New `detectArrayMoves` option enables tracking of element position changes within arrays when using key-based identification. Fully backward compatible.
|
|
397
|
-
- **v4.8.2:** Latest stable release
|
|
398
|
-
- **v2.2.0:** Fixed lodash-es dependency, added exclude keys option, added string array comparison by value
|
|
399
|
-
- **v2.1.0:** Fixed JSON Path filters by replacing single equal sign (=) with double equal sign (==). Added support for using '.' as root in paths
|
|
400
|
-
- **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)
|
|
401
|
-
- **v1.2.6:** Enhanced JSON Path handling for period-inclusive segments
|
|
402
|
-
- **v1.2.5:** Added key name resolution support for key functions
|
|
403
|
-
- **v1.2.4:** Documentation updates and dependency upgrades
|
|
404
|
-
- **v1.2.3:** Updated dependencies and TypeScript
|
|
736
|
+
- **v4.0.0:** Renamed flattenChangeset/unflattenChanges to atomizeChangeset/unatomizeChangeset; added treatTypeChangeAsReplace option
|
|
405
737
|
|
|
406
738
|
## Contributing
|
|
407
739
|
|
|
408
740
|
Contributions are welcome! Please follow the provided issue templates and code of conduct.
|
|
409
741
|
|
|
410
|
-
##
|
|
411
|
-
|
|
412
|
-
- **Zero dependencies**: No external runtime dependencies
|
|
413
|
-
- **Lightweight**: ~21KB minified, ~6KB gzipped
|
|
414
|
-
- **Tree-shakable**: Use only what you need with ES modules
|
|
415
|
-
- **High performance**: Optimized for large JSON objects and arrays
|
|
416
|
-
|
|
417
|
-
## Use Cases
|
|
418
|
-
|
|
419
|
-
- **State Management**: Track changes in Redux, Zustand, or custom state stores
|
|
420
|
-
- **Form Handling**: Detect field changes in React, Vue, or Angular forms
|
|
421
|
-
- **Data Synchronization**: Sync data between client and server efficiently
|
|
422
|
-
- **Version Control**: Implement undo/redo functionality
|
|
423
|
-
- **API Optimization**: Send only changed data to reduce bandwidth
|
|
424
|
-
- **Real-time Updates**: Track changes in collaborative applications
|
|
742
|
+
## Support
|
|
425
743
|
|
|
426
|
-
|
|
744
|
+
If you find this library useful, consider supporting its development:
|
|
427
745
|
|
|
428
|
-
|
|
429
|
-
|---------|--------------|-----------|---------------|
|
|
430
|
-
| TypeScript | ✅ Native | ❌ Partial | ❌ Definitions only |
|
|
431
|
-
| Bundle Size | 🟢 21KB | 🟡 45KB | 🔴 120KB+ |
|
|
432
|
-
| Dependencies | 🟢 Zero | 🟡 Few | 🔴 Many |
|
|
433
|
-
| ESM Support | ✅ Native | ❌ CJS only | ❌ CJS only |
|
|
434
|
-
| Array Key Matching | ✅ Advanced | ❌ Basic | ✅ Advanced |
|
|
435
|
-
| JSONPath Support | ✅ Full | ❌ None | ❌ Limited |
|
|
436
|
-
|
|
437
|
-
## FAQ
|
|
438
|
-
|
|
439
|
-
**Q: Can I use this with React/Vue/Angular?**
|
|
440
|
-
A: Yes! json-diff-ts works with any JavaScript framework or vanilla JS.
|
|
441
|
-
|
|
442
|
-
**Q: Does it work with Node.js?**
|
|
443
|
-
A: Absolutely! Supports Node.js 18+ with both CommonJS and ES modules.
|
|
444
|
-
|
|
445
|
-
**Q: How does it compare to JSON Patch (RFC 6902)?**
|
|
446
|
-
A: json-diff-ts provides a more flexible format with advanced array handling, while JSON Patch is a standardized format.
|
|
447
|
-
|
|
448
|
-
**Q: Is it suitable for large objects?**
|
|
449
|
-
A: Yes, the library is optimized for performance and can handle large, complex JSON structures efficiently.
|
|
746
|
+
[](https://buymeacoffee.com/leitwolf)
|
|
450
747
|
|
|
451
748
|
## Contact
|
|
452
749
|
|
|
453
|
-
Reach out to the maintainer:
|
|
454
|
-
|
|
455
750
|
- LinkedIn: [Christian Glessner](https://www.linkedin.com/in/christian-glessner/)
|
|
456
751
|
- Twitter: [@leitwolf_io](https://twitter.com/leitwolf_io)
|
|
457
752
|
|