@jay-framework/json-patch 0.5.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.
@@ -0,0 +1,38 @@
1
+ declare const ADD = "add";
2
+ declare const REPLACE = "replace";
3
+ declare const REMOVE = "remove";
4
+ declare const MOVE = "move";
5
+ type JSONPointer = (string | number)[];
6
+ interface JSONPatchAdd {
7
+ op: typeof ADD;
8
+ path: JSONPointer;
9
+ value: any;
10
+ }
11
+ interface JSONPatchReplace {
12
+ op: typeof REPLACE;
13
+ path: JSONPointer;
14
+ value: any;
15
+ }
16
+ interface JSONPatchRemove {
17
+ op: typeof REMOVE;
18
+ path: JSONPointer;
19
+ }
20
+ interface JSONPatchMove {
21
+ op: typeof MOVE;
22
+ from: JSONPointer;
23
+ path: JSONPointer;
24
+ }
25
+ type JSONPatchOperation = JSONPatchAdd | JSONPatchReplace | JSONPatchRemove | JSONPatchMove;
26
+ type JSONPatch = JSONPatchOperation[];
27
+
28
+ type MeasureOfChange = number;
29
+ type DataFields = number;
30
+ type ArrayContext = {
31
+ matchBy: string;
32
+ };
33
+ type ArrayContexts = [JSONPointer, ArrayContext][];
34
+ declare function diff<T>(newValue: T, oldValue: T, contexts?: ArrayContexts, path?: JSONPointer): [JSONPatch, MeasureOfChange, DataFields];
35
+
36
+ declare function patch<T>(target: T, jsonPatch: JSONPatch, level?: number): T;
37
+
38
+ export { ADD, type ArrayContexts, type JSONPatch, type JSONPatchAdd, type JSONPatchMove, type JSONPatchOperation, type JSONPatchRemove, type JSONPatchReplace, type JSONPointer, MOVE, REMOVE, REPLACE, diff, patch };
package/dist/index.js ADDED
@@ -0,0 +1,163 @@
1
+ import { RandomAccessLinkedList, listCompare, ITEM_ADDED, ITEM_MOVED, ITEM_REMOVED } from "@jay-framework/list-compare";
2
+ const ADD = "add";
3
+ const REPLACE = "replace";
4
+ const REMOVE = "remove";
5
+ const MOVE = "move";
6
+ const LIST_COMPARE_RESULT_TO_JSON_PATCH = {};
7
+ LIST_COMPARE_RESULT_TO_JSON_PATCH[ITEM_ADDED] = (instruction, path) => ({
8
+ op: ADD,
9
+ value: instruction.item,
10
+ path: [...path, instruction.pos]
11
+ });
12
+ LIST_COMPARE_RESULT_TO_JSON_PATCH[ITEM_MOVED] = (instruction, path) => ({
13
+ op: MOVE,
14
+ from: [...path, instruction.fromPos],
15
+ path: [...path, instruction.pos]
16
+ });
17
+ LIST_COMPARE_RESULT_TO_JSON_PATCH[ITEM_REMOVED] = (instruction, path) => ({
18
+ op: REMOVE,
19
+ path: [...path, instruction.pos]
20
+ });
21
+ function findArrayContext(contexts, path) {
22
+ let foundContext = contexts?.find(([pointer]) => {
23
+ return path.length === pointer.length && path.reduce((prev, curr, index) => {
24
+ return prev && curr === "*" ? true : curr === path[index];
25
+ }, true);
26
+ });
27
+ return foundContext ? foundContext[1] : void 0;
28
+ }
29
+ function diffObjectOrArray(newValue, oldValue, contexts, path) {
30
+ let diffResults = [];
31
+ let keys, i, length;
32
+ keys = Object.keys(newValue);
33
+ let oldKeys = Object.keys(oldValue);
34
+ length = keys.length;
35
+ for (i = length; i-- !== 0; ) {
36
+ const key = keys[i];
37
+ diffResults.push(
38
+ diff(
39
+ newValue[key],
40
+ oldValue[key],
41
+ contexts,
42
+ [...path, key]
43
+ )
44
+ );
45
+ }
46
+ for (i = oldKeys.length; i-- !== 0; ) {
47
+ const key = oldKeys[i];
48
+ if (!(key in newValue))
49
+ diffResults.push([[{ op: REMOVE, path: [...path, key] }], 1, 1]);
50
+ }
51
+ return flattenPatch(diffResults, path, newValue);
52
+ }
53
+ function flattenPatch(diffResults, path, newValue) {
54
+ let [measureOfChange, dataFields] = diffResults.reduce(
55
+ (prev, curr) => [prev[0] + curr[1], prev[1] + curr[2]],
56
+ [0, 0]
57
+ );
58
+ if (measureOfChange / dataFields > 0.5)
59
+ return [[{ op: REPLACE, path, value: newValue }], 1, 1];
60
+ else
61
+ return [diffResults.map((_) => _[0]).flat(), measureOfChange, dataFields];
62
+ }
63
+ function diffArrayWithContext(context, oldValue, newValue, path, contexts) {
64
+ let { matchBy } = context;
65
+ const lastArray = new RandomAccessLinkedList(oldValue, matchBy);
66
+ const newArray = new RandomAccessLinkedList(newValue, matchBy);
67
+ const instructions = listCompare(lastArray, newArray, () => {
68
+ });
69
+ const arrayPatch = instructions.map(
70
+ (instruction) => LIST_COMPARE_RESULT_TO_JSON_PATCH[instruction.action](instruction, path)
71
+ );
72
+ const arrayItemPatches = [
73
+ [arrayPatch, instructions.length, newValue.length]
74
+ ];
75
+ newArray.forEach((newArrayItem, _, index) => {
76
+ let oldArrayItem = lastArray.get(newArrayItem[matchBy]);
77
+ if (oldArrayItem)
78
+ arrayItemPatches.push(
79
+ diff(newArrayItem, oldArrayItem.value, contexts, [...path, "" + index])
80
+ );
81
+ });
82
+ return flattenPatch(arrayItemPatches, path, newValue);
83
+ }
84
+ function diff(newValue, oldValue, contexts, path = []) {
85
+ if (oldValue === void 0 || oldValue === null)
86
+ return [[{ op: ADD, path, value: newValue }], 1, 1];
87
+ if (newValue === oldValue)
88
+ return [[], 0, 1];
89
+ if (newValue && oldValue && typeof newValue === "object" && typeof oldValue === "object") {
90
+ if (Array.isArray(newValue) && Array.isArray(oldValue)) {
91
+ let context = findArrayContext(contexts, path);
92
+ if (context) {
93
+ return diffArrayWithContext(context, oldValue, newValue, path, contexts);
94
+ }
95
+ }
96
+ if (Array.isArray(newValue) !== Array.isArray(oldValue))
97
+ return [[{ op: REPLACE, path, value: newValue }], 1, 1];
98
+ return diffObjectOrArray(
99
+ newValue,
100
+ oldValue,
101
+ contexts,
102
+ path
103
+ );
104
+ }
105
+ return [[{ op: REPLACE, path, value: newValue }], 1, 1];
106
+ }
107
+ function validateMove({ from, path }) {
108
+ let valid = from.length === path.length;
109
+ for (let i = 0, length = from.length - 1; i < length; i++)
110
+ valid = valid && from[i] === path[i];
111
+ return valid;
112
+ }
113
+ function patch(target, jsonPatch, level = 0) {
114
+ const copy = Array.isArray(target) ? [...target] : { ...target };
115
+ let equalCount = 0;
116
+ const patchesGroupedByProp = jsonPatch.reduce((prev, patchOperation) => {
117
+ const pathItem = patchOperation.path[level];
118
+ const op = patchOperation.op;
119
+ if (patchOperation.path.length - 1 === level) {
120
+ if (op === REPLACE || op === ADD) {
121
+ if (Array.isArray(copy) && op === ADD)
122
+ copy.splice(pathItem, 0, patchOperation.value);
123
+ else if (copy[pathItem] === patchOperation.value)
124
+ equalCount += 1;
125
+ else
126
+ copy[pathItem] = patchOperation.value;
127
+ } else if (op === REMOVE) {
128
+ if (Array.isArray(copy))
129
+ copy.splice(pathItem, 1);
130
+ else
131
+ delete copy[pathItem];
132
+ } else if (op === MOVE && Array.isArray(copy) && validateMove(patchOperation)) {
133
+ let [item] = copy.splice(patchOperation.from[level], 1);
134
+ copy.splice(pathItem, 0, item);
135
+ }
136
+ } else if (!prev[pathItem])
137
+ prev[pathItem] = [patchOperation];
138
+ else
139
+ prev[pathItem].push(patchOperation);
140
+ return prev;
141
+ }, {});
142
+ Object.keys(patchesGroupedByProp).forEach((key) => {
143
+ if (copy[key]) {
144
+ const patched = patch(copy[key], patchesGroupedByProp[key], level + 1);
145
+ if (target[key] === patched)
146
+ equalCount += patchesGroupedByProp[key].length;
147
+ else
148
+ copy[key] = patch(copy[key], patchesGroupedByProp[key], level + 1);
149
+ }
150
+ });
151
+ if (equalCount === jsonPatch.length)
152
+ return target;
153
+ else
154
+ return copy;
155
+ }
156
+ export {
157
+ ADD,
158
+ MOVE,
159
+ REMOVE,
160
+ REPLACE,
161
+ diff,
162
+ patch
163
+ };
package/package.json ADDED
@@ -0,0 +1,34 @@
1
+ {
2
+ "name": "@jay-framework/json-patch",
3
+ "version": "0.5.0",
4
+ "type": "module",
5
+ "license": "Apache-2.0",
6
+ "main": "./dist/index.js",
7
+ "files": [
8
+ "dist",
9
+ "readme.md"
10
+ ],
11
+ "scripts": {
12
+ "build": "npm run build:js && npm run build:types",
13
+ "build:watch": "npm run build:js -- --watch & npm run build:types -- --watch",
14
+ "build:js": "vite build",
15
+ "build:types": "tsup lib/index.ts --dts-only --format esm",
16
+ "build:check-types": "tsc",
17
+ "clean": "rimraf dist",
18
+ "confirm": "npm run clean && npm run build && npm run build:check-types && npm run test",
19
+ "test": "vitest run",
20
+ "test:watch": "vitest"
21
+ },
22
+ "dependencies": {
23
+ "@jay-framework/list-compare": "workspace:^"
24
+ },
25
+ "devDependencies": {
26
+ "@jay-framework/dev-environment": "workspace:^",
27
+ "@types/node": "^20.11.5",
28
+ "rimraf": "^5.0.5",
29
+ "tsup": "^8.0.1",
30
+ "typescript": "^5.3.3",
31
+ "vite": "^5.0.11",
32
+ "vitest": "^1.2.1"
33
+ }
34
+ }
package/readme.md ADDED
@@ -0,0 +1,126 @@
1
+ # JSON Patch algorithm (diff, patch)
2
+
3
+ JSON Patch compatible with RFC 6902 from the IETF, with support for **item movement** and **immutable** objects,
4
+ [JSON Patch page](https://jsonpatch.com/)
5
+
6
+ This JSON Patch algorithm is unique by it's support for array item movement (`move` operation).
7
+ Most other JSON Patch algorithm, when faced with a new item in an array, will resort for `add`, `replace`
8
+ and `remove` operations
9
+ [See overview of other implementations](../../../design-log/23%20-%20JSON%20compare%20and%20patch.md)
10
+
11
+ ## The diff signature
12
+
13
+ ```typescript
14
+ type MeasureOfChange = number;
15
+ type DataFields = number;
16
+ type ArrayContext = {
17
+ matchBy: string;
18
+ };
19
+ export type ArrayContexts = [JSONPointer, ArrayContext][];
20
+
21
+ declare function diff<T>(
22
+ newValue: T,
23
+ oldValue: T,
24
+ contexts?: ArrayContexts,
25
+ path: JSONPointer = [],
26
+ ): [JSONPatch, MeasureOfChange, DataFields];
27
+ ```
28
+
29
+ The `diff` function accepts two values and computes the difference between.
30
+
31
+ In default mode, it only supports `add`, `replace` and `remove` operations.
32
+ In order to support `move` a `contexts: ArrayContexts` 3rd parameter has to be provided.
33
+ The `ArrayContexts` is an array of tuples including `JSONPointer` and `ArrayContext` which instruct the algorithm
34
+ how to compare the array at the `JSONPointer` location. The `ArrayContext` has the name of an attribute to match object by,
35
+ the same `id` used by `@jay-framework/list-compare`.
36
+
37
+ The `path` parameter is internal for the function working recursively.
38
+
39
+ The function returns a tuple of 3 values -
40
+
41
+ - `JSONPatch` - the computed patch
42
+ - `MeasureOfChange = nummber` - the number of atomic data fields that have changed
43
+ - `DataFields = number` - the number of atomic data fields that have been compared
44
+
45
+ ## The patch signature
46
+
47
+ ```typescript
48
+ declare function patch<T>(target: T, jsonPatch: JSONPatch, level = 0): T;
49
+ ```
50
+
51
+ The function receives a `target` object and a `jsonPatch` and returns a new object only if there is actual change.
52
+ The `level` parameter is internal for the function working recursively.
53
+
54
+ On patches of type `REPLACE` the function validates the replacement value is different from the original value.
55
+ If the replacement value is the same by the `===` operator, the operation is ignored.
56
+
57
+ ## Algorithm notes
58
+
59
+ - This algorithm is using the `@jay-framework/list-compare` package to compute array mutations.
60
+ - This algorithm is using the `MeasureOfChange` and `DataFields` at each level to decide if to
61
+ use detailed patch of sub-objects, or to just replace the whole object.
62
+ The threshold is `measureOfChange / dataFields > 0.5` (not configurable for now).
63
+
64
+ ## Example diff
65
+
66
+ ```typescript
67
+ const NESTED_ARRAY_CONTEXT: ArrayContexts = [[['b'], { matchBy: 'id' }]];
68
+ const patch = diff(
69
+ {
70
+ a: 1,
71
+ b: [
72
+ { id: 1, c: '1' },
73
+ { id: 2, c: '2' },
74
+ { id: 3, c: '3' },
75
+ { id: 5, c: '5' },
76
+ { id: 7, c: '7' },
77
+ { id: 6, c: '6' },
78
+ ],
79
+ },
80
+ {
81
+ a: 1,
82
+ b: [
83
+ { id: 1, c: '1' },
84
+ { id: 3, c: '3' },
85
+ { id: 4, c: '4' },
86
+ { id: 2, c: '2' },
87
+ { id: 5, c: '5' },
88
+ { id: 6, c: '6' },
89
+ ],
90
+ },
91
+ NESTED_ARRAY_CONTEXT,
92
+ );
93
+
94
+ expect(patch[0]).toEqual([
95
+ { op: MOVE, from: ['b', 3], path: ['b', 1] },
96
+ { op: REMOVE, path: ['b', 3] },
97
+ { op: ADD, path: ['b', 4], value: { id: 7, c: '7' } },
98
+ ]);
99
+ ```
100
+
101
+ ## Examples patch
102
+
103
+ add example
104
+
105
+ ```typescript
106
+ const obj = patch({ a: 1, b: 2, c: 3 }, [{ op: ADD, path: ['d'], value: 4 }]);
107
+
108
+ expect(obj).toEqual({ a: 1, b: 2, c: 3, d: 4 });
109
+ ```
110
+
111
+ move example
112
+
113
+ ```typescript
114
+ const original = [
115
+ { id: 1, c: '1' },
116
+ { id: 2, c: '2' },
117
+ { id: 3, c: '3' },
118
+ ];
119
+ const patched = patch(original, [{ op: MOVE, path: [1], from: [2] }]);
120
+
121
+ expect(patched).toEqual([
122
+ { id: 1, c: '1' },
123
+ { id: 3, c: '3' },
124
+ { id: 2, c: '2' },
125
+ ]);
126
+ ```