@fgv/ts-json 5.0.0-21 → 5.0.0-23

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.
@@ -1,585 +0,0 @@
1
- /*
2
- * Copyright (c) 2025 Erik Fortune
3
- *
4
- * Permission is hereby granted, free of charge, to any person obtaining a copy
5
- * of this software and associated documentation files (the "Software"), to deal
6
- * in the Software without restriction, including without limitation the rights
7
- * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
8
- * copies of the Software, and to permit persons to whom the Software is
9
- * furnished to do so, subject to the following conditions:
10
- *
11
- * The above copyright notice and this permission notice shall be included in all
12
- * copies or substantial portions of the Software.
13
- *
14
- * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
15
- * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
16
- * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
17
- * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
18
- * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
19
- * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
20
- * SOFTWARE.
21
- */
22
-
23
- import { JsonValue, isJsonObject, isJsonArray, isJsonPrimitive } from '@fgv/ts-json-base';
24
- import { Result, succeed } from '@fgv/ts-utils';
25
- import { deepEquals } from './utils';
26
-
27
- /**
28
- * JSON Diff Utilities for TypeScript
29
- *
30
- * This module provides comprehensive tools for comparing JSON values and identifying
31
- * differences between them. It offers two complementary approaches:
32
- *
33
- * ## **Detailed Diff API** (`jsonDiff`)
34
- * Best for analysis, debugging, and understanding specific changes:
35
- * - Returns a list of individual changes with exact paths and values
36
- * - Ideal for logging, change tracking, and detailed analysis
37
- * - Configurable options for array handling and path notation
38
- *
39
- * ## **Three-Way Diff API** (`jsonThreeWayDiff`)
40
- * Best for applying changes and programmatic manipulation:
41
- * - Returns three objects representing removed, unchanged, and added data
42
- * - Perfect for merging, UI displays, and actionable results
43
- * - Treats arrays as atomic units for simpler handling
44
- *
45
- * ## **Simple Equality** (`jsonEquals`)
46
- * Fast boolean check for JSON equality without change details.
47
- *
48
- * **Key Features:**
49
- * - Deep recursive comparison of nested structures
50
- * - Support for all JSON types: objects, arrays, primitives, null
51
- * - TypeScript-first with comprehensive type safety
52
- * - Result pattern for consistent error handling
53
- * - Extensive TSDoc documentation with practical examples
54
- *
55
- * @example Quick comparison of the two main APIs
56
- * ```typescript
57
- * const before = { name: "John", age: 30, city: "NYC" };
58
- * const after = { name: "Jane", age: 30, country: "USA" };
59
- *
60
- * // Detailed analysis
61
- * const detailed = jsonDiff(before, after);
62
- * // Returns: [
63
- * // { path: "name", type: "modified", oldValue: "John", newValue: "Jane" },
64
- * // { path: "city", type: "removed", oldValue: "NYC" },
65
- * // { path: "country", type: "added", newValue: "USA" }
66
- * // ]
67
- *
68
- * // Actionable objects
69
- * const actionable = jsonThreeWayDiff(before, after);
70
- * // Returns: {
71
- * // onlyInA: { name: "John", city: "NYC" },
72
- * // unchanged: { age: 30 },
73
- * // onlyInB: { name: "Jane", country: "USA" }
74
- * // }
75
- *
76
- * // Apply changes: { ...unchanged, ...onlyInB }
77
- * // Revert changes: { ...unchanged, ...onlyInA }
78
- * ```
79
- */
80
-
81
- /**
82
- * Type of change detected in a JSON diff operation.
83
- *
84
- * - `'added'` - Property exists only in the second object
85
- * - `'removed'` - Property exists only in the first object
86
- * - `'modified'` - Property exists in both objects but with different values
87
- * - `'unchanged'` - Property exists in both objects with identical values (only included when `includeUnchanged` is true)
88
- *
89
- * @public
90
- */
91
- export type DiffChangeType = 'added' | 'removed' | 'modified' | 'unchanged';
92
-
93
- /**
94
- * Represents a single change in a JSON diff operation.
95
- *
96
- * Each change describes a specific difference between two JSON values, including
97
- * the location of the change and the old/new values involved.
98
- *
99
- * @example
100
- * ```typescript
101
- * // Example changes from diffing { name: "John", age: 30 } vs { name: "Jane", city: "NYC" }
102
- * const changes: IDiffChange[] = [
103
- * { path: "name", type: "modified", oldValue: "John", newValue: "Jane" },
104
- * { path: "age", type: "removed", oldValue: 30 },
105
- * { path: "city", type: "added", newValue: "NYC" }
106
- * ];
107
- * ```
108
- *
109
- * @public
110
- */
111
- export interface IDiffChange {
112
- /**
113
- * The path to the changed value using dot notation.
114
- *
115
- * For nested objects, uses dots to separate levels (e.g., "user.profile.name").
116
- * For arrays, uses numeric indices (e.g., "items.0.id", "tags.2").
117
- * Empty string indicates the root value itself changed.
118
- *
119
- * @example "user.name", "items.0.id", "settings.theme", ""
120
- */
121
- path: string;
122
-
123
- /**
124
- * The type of change that occurred.
125
- *
126
- * @see {@link DiffChangeType} for detailed descriptions of each change type.
127
- */
128
- type: DiffChangeType;
129
-
130
- /**
131
- * The value in the first object.
132
- *
133
- * - Present for `'removed'` and `'modified'` changes
134
- * - Present for `'unchanged'` changes when `includeUnchanged` is true
135
- * - Undefined for `'added'` changes
136
- */
137
- oldValue?: JsonValue;
138
-
139
- /**
140
- * The value in the second object.
141
- *
142
- * - Present for `'added'` and `'modified'` changes
143
- * - Present for `'unchanged'` changes when `includeUnchanged` is true
144
- * - Undefined for `'removed'` changes
145
- */
146
- newValue?: JsonValue;
147
- }
148
-
149
- /**
150
- * Result of a JSON diff operation containing all detected changes.
151
- *
152
- * This interface provides detailed information about every difference found
153
- * between two JSON values, making it ideal for analysis, debugging, and
154
- * understanding exactly what changed.
155
- *
156
- * @example
157
- * ```typescript
158
- * const result: IDiffResult = {
159
- * changes: [
160
- * { path: "name", type: "modified", oldValue: "John", newValue: "Jane" },
161
- * { path: "hobbies.1", type: "added", newValue: "gaming" }
162
- * ],
163
- * identical: false
164
- * };
165
- * ```
166
- *
167
- * @see {@link IDiffChange} for details about individual change objects
168
- * @see {@link Diff.jsonDiff} for the function that produces this result
169
- * @public
170
- */
171
- export interface IDiffResult {
172
- /**
173
- * Array of all changes detected between the two JSON objects.
174
- *
175
- * Changes are ordered by the path where they occur. For nested structures,
176
- * parent changes appear before child changes.
177
- */
178
- changes: IDiffChange[];
179
-
180
- /**
181
- * True if the objects are identical, false otherwise.
182
- *
183
- * When true, the `changes` array will be empty (unless `includeUnchanged`
184
- * option was used, in which case it may contain 'unchanged' entries).
185
- */
186
- identical: boolean;
187
- }
188
-
189
- /**
190
- * Options for customizing JSON diff behavior.
191
- *
192
- * These options allow you to control how the diff algorithm processes
193
- * different types of JSON structures and what information is included
194
- * in the results.
195
- *
196
- * @example
197
- * ```typescript
198
- * // Include unchanged values and use custom path separator
199
- * const options: IJsonDiffOptions = {
200
- * includeUnchanged: true,
201
- * pathSeparator: '/',
202
- * arrayOrderMatters: false
203
- * };
204
- *
205
- * const result = jsonDiff(obj1, obj2, options);
206
- * ```
207
- *
208
- * @public
209
- */
210
- export interface IJsonDiffOptions {
211
- /**
212
- * If true, includes unchanged values in the result.
213
- *
214
- * When enabled, the diff result will include entries with `type: 'unchanged'`
215
- * for properties that exist in both objects with identical values. This can
216
- * be useful for displaying complete side-by-side comparisons.
217
- *
218
- * @defaultValue false
219
- */
220
- includeUnchanged?: boolean;
221
-
222
- /**
223
- * Custom path separator for nested property paths.
224
- *
225
- * Controls the character used to separate levels in nested object paths.
226
- * For example, with separator `'/'`, a nested property would be reported
227
- * as `"user/profile/name"` instead of `"user.profile.name"`.
228
- *
229
- * @defaultValue "."
230
- * @example "/", "-\>", "::"
231
- */
232
- pathSeparator?: string;
233
-
234
- /**
235
- * If true, treats arrays as ordered lists where position matters.
236
- * If false, treats arrays as unordered sets.
237
- *
238
- * When `true` (default), array changes are reported by index position:
239
- * `[1,2,3]` vs `[1,3,2]` shows modifications at indices 1 and 2.
240
- *
241
- * When `false`, arrays are compared as sets: `[1,2,3]` vs `[1,3,2]`
242
- * may be considered equivalent (simplified unordered comparison).
243
- *
244
- * @defaultValue true
245
- */
246
- arrayOrderMatters?: boolean;
247
- }
248
-
249
- /**
250
- * Internal recursive diff function that builds the change list.
251
- */
252
- function diffRecursive(
253
- obj1: JsonValue,
254
- obj2: JsonValue,
255
- path: string,
256
- options: Required<IJsonDiffOptions>,
257
- changes: IDiffChange[]
258
- ): void {
259
- const pathPrefix = path ? `${path}${options.pathSeparator}` : '';
260
-
261
- // Handle primitive values
262
- if (isJsonPrimitive(obj1) || isJsonPrimitive(obj2)) {
263
- if (!deepEquals(obj1, obj2)) {
264
- changes.push({
265
- path,
266
- type: 'modified',
267
- oldValue: obj1,
268
- newValue: obj2
269
- });
270
- } else if (options.includeUnchanged) {
271
- changes.push({
272
- path,
273
- type: 'unchanged',
274
- oldValue: obj1,
275
- newValue: obj2
276
- });
277
- }
278
- return;
279
- }
280
-
281
- // Handle arrays
282
- if (isJsonArray(obj1) && isJsonArray(obj2)) {
283
- if (options.arrayOrderMatters) {
284
- // Ordered array comparison
285
- const maxLength = Math.max(obj1.length, obj2.length);
286
- for (let i = 0; i < maxLength; i++) {
287
- const itemPath = `${pathPrefix}${i}`;
288
-
289
- if (i >= obj1.length) {
290
- changes.push({
291
- path: itemPath,
292
- type: 'added',
293
- newValue: obj2[i]
294
- });
295
- } else if (i >= obj2.length) {
296
- changes.push({
297
- path: itemPath,
298
- type: 'removed',
299
- oldValue: obj1[i]
300
- });
301
- } else {
302
- diffRecursive(obj1[i], obj2[i], itemPath, options, changes);
303
- }
304
- }
305
- } else {
306
- // Unordered array comparison - simplified approach
307
- // This is a basic implementation; a more sophisticated approach would use
308
- // algorithms like longest common subsequence for better matching
309
- const processed = new Set<number>();
310
-
311
- // Find matching elements
312
- for (let i = 0; i < obj1.length; i++) {
313
- let found = false;
314
- for (let j = 0; j < obj2.length; j++) {
315
- if (!processed.has(j) && deepEquals(obj1[i], obj2[j])) {
316
- processed.add(j);
317
- found = true;
318
- if (options.includeUnchanged) {
319
- changes.push({
320
- path: `${pathPrefix}${i}`,
321
- type: 'unchanged',
322
- oldValue: obj1[i],
323
- newValue: obj2[j]
324
- });
325
- }
326
- break;
327
- }
328
- }
329
- if (!found) {
330
- changes.push({
331
- path: `${pathPrefix}${i}`,
332
- type: 'removed',
333
- oldValue: obj1[i]
334
- });
335
- }
336
- }
337
-
338
- // Find added elements
339
- for (let j = 0; j < obj2.length; j++) {
340
- if (!processed.has(j)) {
341
- changes.push({
342
- path: `${pathPrefix}${j}`,
343
- type: 'added',
344
- newValue: obj2[j]
345
- });
346
- }
347
- }
348
- }
349
- return;
350
- }
351
-
352
- // Handle one array, one non-array
353
- if (isJsonArray(obj1) || isJsonArray(obj2)) {
354
- changes.push({
355
- path,
356
- type: 'modified',
357
- oldValue: obj1,
358
- newValue: obj2
359
- });
360
- return;
361
- }
362
-
363
- // Handle objects
364
- if (isJsonObject(obj1) && isJsonObject(obj2)) {
365
- const keys1 = new Set(Object.keys(obj1));
366
- const keys2 = new Set(Object.keys(obj2));
367
- const allKeys = new Set([...keys1, ...keys2]);
368
-
369
- for (const key of allKeys) {
370
- const keyPath = path ? `${path}${options.pathSeparator}${key}` : key;
371
-
372
- if (!keys1.has(key)) {
373
- changes.push({
374
- path: keyPath,
375
- type: 'added',
376
- newValue: obj2[key]
377
- });
378
- } else if (!keys2.has(key)) {
379
- changes.push({
380
- path: keyPath,
381
- type: 'removed',
382
- oldValue: obj1[key]
383
- });
384
- } else {
385
- diffRecursive(obj1[key], obj2[key], keyPath, options, changes);
386
- }
387
- }
388
- return;
389
- }
390
- /* c8 ignore next 9 - defensive code path that should never be reached with valid JsonValue types */
391
-
392
- // Handle mixed object types
393
- changes.push({
394
- path,
395
- type: 'modified',
396
- oldValue: obj1,
397
- newValue: obj2
398
- });
399
- }
400
-
401
- /**
402
- * Performs a deep diff comparison between two JSON values.
403
- *
404
- * This function provides detailed change tracking by analyzing every difference
405
- * between two JSON structures. It returns a list of specific changes with paths,
406
- * making it ideal for debugging, logging, change analysis, and understanding
407
- * exactly what has changed between two data states.
408
- *
409
- * **Key Features:**
410
- * - Deep recursive comparison of nested objects and arrays
411
- * - Precise path tracking using dot notation (e.g., "user.profile.name")
412
- * - Support for all JSON value types: objects, arrays, primitives, null
413
- * - Configurable array comparison (ordered vs unordered)
414
- * - Optional inclusion of unchanged values for complete comparisons
415
- *
416
- * **Use Cases:**
417
- * - Debugging data changes in applications
418
- * - Generating change logs or audit trails
419
- * - Validating API responses against expected data
420
- * - Creating detailed diff reports for data synchronization
421
- *
422
- * @param obj1 - The first JSON value to compare (often the "before" state)
423
- * @param obj2 - The second JSON value to compare (often the "after" state)
424
- * @param options - Optional configuration for customizing diff behavior
425
- * @returns A Result containing the diff result with all detected changes
426
- *
427
- * @example Basic usage with objects
428
- * ```typescript
429
- * const before = { name: "John", age: 30, city: "NYC" };
430
- * const after = { name: "Jane", age: 30, country: "USA" };
431
- *
432
- * const result = jsonDiff(before, after);
433
- * if (result.success) {
434
- * result.value.changes.forEach(change => {
435
- * console.log(`${change.path}: ${change.type}`);
436
- * // Output:
437
- * // name: modified
438
- * // city: removed
439
- * // country: added
440
- * });
441
- * }
442
- * ```
443
- *
444
- * @example With arrays and nested structures
445
- * ```typescript
446
- * const user1 = {
447
- * profile: { name: "John", hobbies: ["reading"] },
448
- * settings: { theme: "dark" }
449
- * };
450
- * const user2 = {
451
- * profile: { name: "John", hobbies: ["reading", "gaming"] },
452
- * settings: { theme: "light", notifications: true }
453
- * };
454
- *
455
- * const result = jsonDiff(user1, user2);
456
- * if (result.success) {
457
- * console.log(result.value.changes);
458
- * // [
459
- * // { path: "profile.hobbies.1", type: "added", newValue: "gaming" },
460
- * // { path: "settings.theme", type: "modified", oldValue: "dark", newValue: "light" },
461
- * // { path: "settings.notifications", type: "added", newValue: true }
462
- * // ]
463
- * }
464
- * ```
465
- *
466
- * @example Using options for custom behavior
467
- * ```typescript
468
- * const options: IJsonDiffOptions = {
469
- * includeUnchanged: true, // Include unchanged properties
470
- * pathSeparator: '/', // Use '/' instead of '.' in paths
471
- * arrayOrderMatters: false // Treat arrays as unordered sets
472
- * };
473
- *
474
- * const result = jsonDiff(obj1, obj2, options);
475
- * ```
476
- *
477
- * @see {@link IDiffResult} for the structure of returned results
478
- * @see {@link IDiffChange} for details about individual changes
479
- * @see {@link IJsonDiffOptions} for available configuration options
480
- * @see {@link jsonThreeWayDiff} for an alternative API focused on actionable results
481
- * @see {@link jsonEquals} for simple equality checking without change details
482
- *
483
- * @public
484
- */
485
- export function jsonDiff(
486
- obj1: JsonValue,
487
- obj2: JsonValue,
488
- options: IJsonDiffOptions = {}
489
- ): Result<IDiffResult> {
490
- const opts: Required<IJsonDiffOptions> = {
491
- includeUnchanged: false,
492
- pathSeparator: '.',
493
- arrayOrderMatters: true,
494
- ...options
495
- };
496
-
497
- const changes: IDiffChange[] = [];
498
- diffRecursive(obj1, obj2, '', opts, changes);
499
-
500
- const result: IDiffResult = {
501
- changes,
502
- identical: changes.length === 0 || changes.every((c) => c.type === 'unchanged')
503
- };
504
-
505
- return succeed(result);
506
- }
507
-
508
- /**
509
- * A simpler helper function that returns true if two JSON values are deeply equal.
510
- *
511
- * This function provides a fast boolean check for JSON equality without the overhead
512
- * of tracking individual changes. It performs the same deep comparison logic as
513
- * {@link Diff.jsonDiff} but returns only a true/false result, making it ideal for
514
- * conditional logic and validation scenarios.
515
- *
516
- * **Key Features:**
517
- * - Deep recursive comparison of all nested structures
518
- * - Handles all JSON types: objects, arrays, primitives, null
519
- * - Object property order independence
520
- * - Array order significance (index positions matter)
521
- * - Performance optimized for equality checking
522
- *
523
- * **Use Cases:**
524
- * - Conditional logic based on data equality
525
- * - Input validation and testing assertions
526
- * - Caching and memoization keys
527
- * - Quick checks before expensive diff operations
528
- *
529
- * @param obj1 - The first JSON value to compare
530
- * @param obj2 - The second JSON value to compare
531
- * @returns True if the values are deeply equal, false otherwise
532
- *
533
- * @example Basic equality checking
534
- * ```typescript
535
- * // Objects with same structure and values
536
- * const user1 = { name: "John", hobbies: ["reading", "gaming"] };
537
- * const user2 = { name: "John", hobbies: ["reading", "gaming"] };
538
- * console.log(jsonEquals(user1, user2)); // true
539
- *
540
- * // Different property order (still equal)
541
- * const obj1 = { a: 1, b: 2 };
542
- * const obj2 = { b: 2, a: 1 };
543
- * console.log(jsonEquals(obj1, obj2)); // true
544
- *
545
- * // Different values
546
- * const before = { status: "pending" };
547
- * const after = { status: "completed" };
548
- * console.log(jsonEquals(before, after)); // false
549
- * ```
550
- *
551
- * @example With nested structures
552
- * ```typescript
553
- * const config1 = {
554
- * database: { host: "localhost", port: 5432 },
555
- * features: ["auth", "cache"]
556
- * };
557
- * const config2 = {
558
- * database: { host: "localhost", port: 5432 },
559
- * features: ["auth", "cache"]
560
- * };
561
- *
562
- * if (jsonEquals(config1, config2)) {
563
- * console.log("Configurations are identical");
564
- * }
565
- * ```
566
- *
567
- * @example Array order sensitivity
568
- * ```typescript
569
- * const list1 = [1, 2, 3];
570
- * const list2 = [3, 2, 1];
571
- * console.log(jsonEquals(list1, list2)); // false - order matters
572
- *
573
- * const list3 = [1, 2, 3];
574
- * const list4 = [1, 2, 3];
575
- * console.log(jsonEquals(list3, list4)); // true - same order
576
- * ```
577
- *
578
- * @see {@link Diff.jsonDiff} for detailed change analysis when equality fails
579
- * @see {@link jsonThreeWayDiff} for actionable difference results
580
- *
581
- * @public
582
- */
583
- export function jsonEquals(obj1: JsonValue, obj2: JsonValue): boolean {
584
- return deepEquals(obj1, obj2);
585
- }
@@ -1,24 +0,0 @@
1
- /*
2
- * Copyright (c) 2025 Erik Fortune
3
- *
4
- * Permission is hereby granted, free of charge, to any person obtaining a copy
5
- * of this software and associated documentation files (the "Software"), to deal
6
- * in the Software without restriction, including without limitation the rights
7
- * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
8
- * copies of the Software, and to permit persons to whom the Software is
9
- * furnished to do so, subject to the following conditions:
10
- *
11
- * The above copyright notice and this permission notice shall be included in all
12
- * copies or substantial portions of the Software.
13
- *
14
- * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
15
- * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
16
- * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
17
- * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
18
- * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
19
- * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
20
- * SOFTWARE.
21
- */
22
-
23
- export * from './detailedDiff';
24
- export * from './threeWayDiff';