j-templates 8.1.2 → 9.0.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/SYNTAX_PRIMER.md +3 -4
- package/Store/Diff/diffTree.d.ts +39 -33
- package/Store/Diff/diffTree.js +147 -435
- package/Store/Diff/diffTree.types.d.ts +5 -79
- package/Store/Diff/diffTree.types.js +1 -3
- package/Store/Diff/diffTreeWorker.d.ts +4 -0
- package/Store/Diff/diffTreeWorker.js +17 -39
- package/Store/Diff/diffTreeWriter.d.ts +27 -38
- package/Store/Diff/diffTreeWriter.js +44 -95
- package/Store/Diff/index.d.ts +2 -0
- package/Store/Diff/index.js +2 -0
- package/Store/Diff/utils/diffTree.types.d.ts +113 -0
- package/Store/Diff/utils/diffTree.types.js +1 -0
- package/Store/Diff/utils/homes.d.ts +46 -0
- package/Store/Diff/utils/homes.js +213 -0
- package/Store/Diff/utils/paths.d.ts +42 -0
- package/Store/Diff/utils/paths.js +87 -0
- package/Store/Diff/utils/projections.d.ts +55 -0
- package/Store/Diff/utils/projections.js +290 -0
- package/Store/Diff/utils/routes.d.ts +27 -0
- package/Store/Diff/utils/routes.js +168 -0
- package/Store/Diff/utils/state.d.ts +56 -0
- package/Store/Diff/utils/state.js +155 -0
- package/Store/Store/pushStore.d.ts +1 -5
- package/Store/Store/pushStore.js +3 -22
- package/Store/Store/store.d.ts +10 -4
- package/Store/Store/store.js +25 -11
- package/Store/Store/storeAsync.d.ts +4 -5
- package/Store/Store/storeAsync.js +19 -36
- package/Store/Store/storeSync.d.ts +14 -9
- package/Store/Store/storeSync.js +27 -22
- package/Store/Tree/observableNode.d.ts +2 -23
- package/Store/Tree/observableNode.js +46 -183
- package/Store/Tree/snapshot.d.ts +68 -0
- package/Store/Tree/snapshot.js +147 -0
- package/Utils/json.d.ts +180 -10
- package/Utils/json.js +532 -67
- package/package.json +1 -1
- package/Store/Diff/diffSync.d.ts +0 -36
- package/Store/Diff/diffSync.js +0 -43
package/Utils/json.js
CHANGED
|
@@ -1,3 +1,15 @@
|
|
|
1
|
+
/** How an inserted splice item gets its value */
|
|
2
|
+
export var SPLICE_ACTION;
|
|
3
|
+
(function (SPLICE_ACTION) {
|
|
4
|
+
/** The element at `from` in the array before the splice is applied */
|
|
5
|
+
SPLICE_ACTION[SPLICE_ACTION["MOVE"] = 1] = "MOVE";
|
|
6
|
+
/** A new value */
|
|
7
|
+
SPLICE_ACTION[SPLICE_ACTION["INSERT"] = 2] = "INSERT";
|
|
8
|
+
})(SPLICE_ACTION || (SPLICE_ACTION = {}));
|
|
9
|
+
/** The default strategy: every changed object and array is diffed into, arrays by position */
|
|
10
|
+
export const JsonDiffDefaultStrategy = () => "diff";
|
|
11
|
+
/** The default identity: an element is its own identity, so elements are compared by === */
|
|
12
|
+
export const JsonDiffDefaultIdentity = (value) => value;
|
|
1
13
|
const jsonProto = Object.getPrototypeOf({});
|
|
2
14
|
const arrayProto = Object.getPrototypeOf([]);
|
|
3
15
|
const strProto = Object.getPrototypeOf("");
|
|
@@ -94,126 +106,579 @@ export function JsonDeepClone(value, type = JsonType(value)) {
|
|
|
94
106
|
return value;
|
|
95
107
|
}
|
|
96
108
|
}
|
|
109
|
+
/**
|
|
110
|
+
* Applies a diff result to a root value in place, one record at a time in order. A diff doesn't own
|
|
111
|
+
* its values (see JsonDiff), so each value written or inserted is deep-cloned first: the root never
|
|
112
|
+
* shares an object with the diff, the value it was computed from, or another record, unless
|
|
113
|
+
* `clone` says otherwise.
|
|
114
|
+
* - A write assigns its value at its path
|
|
115
|
+
* - A delete removes the property its path ends with
|
|
116
|
+
* - A splice record splices the array at its path in place (see JsonApplySplices)
|
|
117
|
+
* @param root - The value to update; must be an object or array
|
|
118
|
+
* @param diff - The diff result to apply
|
|
119
|
+
* @param assign - Assigns a written value (already cloned) to its parent
|
|
120
|
+
* @param remove - Removes a deleted property from its parent
|
|
121
|
+
* @param spliced - Called with each array a splice record changed, after it's changed
|
|
122
|
+
* @param clone - Copies each value before it's written or inserted. A caller that already owns the
|
|
123
|
+
* diff's values can pass one that keeps them, or parts of them, as they are.
|
|
124
|
+
* @throws For a write or delete with an empty path: the root can't be replaced or removed, only
|
|
125
|
+
* changed inside. JsonDiff records one only when the root's JSON type changes.
|
|
126
|
+
*/
|
|
127
|
+
export function JsonApplyDiff(root, diff, assign = DefaultAssign, remove = DefaultRemove, spliced, clone = JsonDeepClone) {
|
|
128
|
+
// The objects along the last path walked, so records under the same parent walk it once:
|
|
129
|
+
// walked[y] is [path[y - 1], the object at path[0..y)]
|
|
130
|
+
const walked = [["", root]];
|
|
131
|
+
for (let x = 0; x < diff.length; x++) {
|
|
132
|
+
const record = diff[x];
|
|
133
|
+
const path = record.path;
|
|
134
|
+
if (path.length === 0 && record.type !== "splice")
|
|
135
|
+
throw new Error("Unable to replace the root of a diff: apply it to an object holding the root");
|
|
136
|
+
if (record.type !== "splice") {
|
|
137
|
+
const parent = Walk(walked, path, path.length - 1);
|
|
138
|
+
const prop = path[path.length - 1];
|
|
139
|
+
record.type === "write" ? assign(parent, prop, clone(record.value)) : remove(parent, prop);
|
|
140
|
+
// The value at the path was replaced or removed, so anything walked through it is gone
|
|
141
|
+
walked.length = Math.min(walked.length, path.length);
|
|
142
|
+
}
|
|
143
|
+
else {
|
|
144
|
+
const array = Walk(walked, path, path.length);
|
|
145
|
+
if (!Array.isArray(array))
|
|
146
|
+
throw new Error(`Unable to splice ${path.join(".")}: not an array`);
|
|
147
|
+
JsonApplySplices(array, record.splices, clone);
|
|
148
|
+
// The array is the same object, but its elements moved
|
|
149
|
+
walked.length = Math.min(walked.length, path.length + 1);
|
|
150
|
+
spliced?.(array);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
function DefaultAssign(parent, prop, value) {
|
|
155
|
+
parent[prop] = value;
|
|
156
|
+
}
|
|
157
|
+
function DefaultRemove(parent, prop) {
|
|
158
|
+
delete parent[prop];
|
|
159
|
+
}
|
|
160
|
+
/** The object at `path[0..depth)`, walking from the deepest object in `walked` still on the path */
|
|
161
|
+
function Walk(walked, path, depth) {
|
|
162
|
+
for (let y = 0; y < depth; y++) {
|
|
163
|
+
const property = path[y];
|
|
164
|
+
const index = y + 1;
|
|
165
|
+
if (index < walked.length && walked[index][0] === property)
|
|
166
|
+
continue;
|
|
167
|
+
const child = walked[y][1][property];
|
|
168
|
+
if (index < walked.length) {
|
|
169
|
+
walked[index] = [property, child];
|
|
170
|
+
// Deeper entries were under the old object; clearing the next one makes the walk refresh each
|
|
171
|
+
if (index + 1 < walked.length)
|
|
172
|
+
walked[index + 1][0] = null;
|
|
173
|
+
}
|
|
174
|
+
else
|
|
175
|
+
walked.push([property, child]);
|
|
176
|
+
}
|
|
177
|
+
return walked[depth][1];
|
|
178
|
+
}
|
|
179
|
+
/** Sets an array's elements to `items` in place. Unlike `splice(0, length, ...items)`, any length works. */
|
|
180
|
+
function ReplaceArrayContents(array, items) {
|
|
181
|
+
for (let x = 0; x < items.length; x++)
|
|
182
|
+
array[x] = items[x];
|
|
183
|
+
array.length = items.length;
|
|
184
|
+
}
|
|
185
|
+
/**
|
|
186
|
+
* Applies splices to an array in place, in one pass however many there are: the new contents are
|
|
187
|
+
* built from the old ones, then written back into the same array. A moved item is the old element
|
|
188
|
+
* at its `from`, as is; an inserted item's value is deep-cloned.
|
|
189
|
+
* @param splices - In ascending, non-overlapping order, as JsonDiffArraySplice returns them: each
|
|
190
|
+
* one's index is at or after where the one before it ended
|
|
191
|
+
* @param clone - Copies each inserted value; see JsonApplyDiff
|
|
192
|
+
* @throws When a splice starts before the one before it ended
|
|
193
|
+
*/
|
|
194
|
+
export function JsonApplySplices(array, splices, clone = JsonDeepClone) {
|
|
195
|
+
if (splices.length === 0)
|
|
196
|
+
return;
|
|
197
|
+
// `array` isn't changed until the end, so it's read as the original throughout
|
|
198
|
+
const result = [];
|
|
199
|
+
// The next old element to copy. The array as the splices so far left it is `result`, then the old
|
|
200
|
+
// elements from `read` on.
|
|
201
|
+
let read = 0;
|
|
202
|
+
for (let x = 0; x < splices.length; x++) {
|
|
203
|
+
const splice = splices[x];
|
|
204
|
+
if (splice.index < result.length)
|
|
205
|
+
throw new Error(`Unable to apply splice at ${splice.index}: splices must be in ascending, non-overlapping order`);
|
|
206
|
+
// Copy the old elements up to the splice; an index past the end splices at the end, as
|
|
207
|
+
// Array.prototype.splice does
|
|
208
|
+
for (; result.length < splice.index && read < array.length; read++)
|
|
209
|
+
result.push(array[read]);
|
|
210
|
+
read = Math.min(read + splice.delete, array.length);
|
|
211
|
+
for (let y = 0; y < splice.insert.length; y++) {
|
|
212
|
+
const item = splice.insert[y];
|
|
213
|
+
result.push(item.type === SPLICE_ACTION.MOVE ? array[item.from] : clone(item.value));
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
for (; read < array.length; read++)
|
|
217
|
+
result.push(array[read]);
|
|
218
|
+
ReplaceArrayContents(array, result);
|
|
219
|
+
}
|
|
220
|
+
/**
|
|
221
|
+
* Whether two JSON values differ, without recording how: it stops at the first difference and
|
|
222
|
+
* skips any subtree both values share, so it costs no more than walking what differs. An
|
|
223
|
+
* undefined property counts as absent, as in JSON. Arrays are compared by position.
|
|
224
|
+
* @param newValue - The new value
|
|
225
|
+
* @param oldValue - The old value to compare against
|
|
226
|
+
* @returns true when the values differ
|
|
227
|
+
*/
|
|
228
|
+
export function JsonHasChanges(newValue, oldValue) {
|
|
229
|
+
if (newValue === oldValue)
|
|
230
|
+
return false;
|
|
231
|
+
const type = JsonType(newValue);
|
|
232
|
+
if (type !== JsonType(oldValue))
|
|
233
|
+
return true;
|
|
234
|
+
switch (type) {
|
|
235
|
+
case "array": {
|
|
236
|
+
const newArray = newValue;
|
|
237
|
+
const oldArray = oldValue;
|
|
238
|
+
if (newArray.length !== oldArray.length)
|
|
239
|
+
return true;
|
|
240
|
+
for (let x = 0; x < newArray.length; x++)
|
|
241
|
+
if (JsonHasChanges(newArray[x], oldArray[x]))
|
|
242
|
+
return true;
|
|
243
|
+
return false;
|
|
244
|
+
}
|
|
245
|
+
case "object": {
|
|
246
|
+
const newObject = newValue;
|
|
247
|
+
const oldObject = oldValue;
|
|
248
|
+
const newKeys = Object.keys(newObject);
|
|
249
|
+
let defined = 0;
|
|
250
|
+
for (let x = 0; x < newKeys.length; x++) {
|
|
251
|
+
const value = newObject[newKeys[x]];
|
|
252
|
+
if (value === undefined)
|
|
253
|
+
continue;
|
|
254
|
+
defined++;
|
|
255
|
+
if (!Object.hasOwn(oldObject, newKeys[x]) || JsonHasChanges(value, oldObject[newKeys[x]]))
|
|
256
|
+
return true;
|
|
257
|
+
}
|
|
258
|
+
// Every defined key of the new value is in the old one, so they differ only when the old
|
|
259
|
+
// one defines more
|
|
260
|
+
const oldKeys = Object.keys(oldObject);
|
|
261
|
+
if (oldKeys.length === defined)
|
|
262
|
+
return false;
|
|
263
|
+
let oldDefined = 0;
|
|
264
|
+
for (let x = 0; x < oldKeys.length; x++)
|
|
265
|
+
if (oldObject[oldKeys[x]] !== undefined)
|
|
266
|
+
oldDefined++;
|
|
267
|
+
return oldDefined !== defined;
|
|
268
|
+
}
|
|
269
|
+
default:
|
|
270
|
+
return true;
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
/**
|
|
274
|
+
* `newValue`, with every part that has no changes from the same place in `oldValue` (as
|
|
275
|
+
* JsonHasChanges compares them) replaced by `oldValue`'s object, so unchanged parts keep their
|
|
276
|
+
* identity. Neither value is changed: a container of `newValue` is copied only when one of its
|
|
277
|
+
* children is replaced, and kept as it is otherwise. Arrays are compared by position.
|
|
278
|
+
*
|
|
279
|
+
* old: { a: { x: 1 }, b: { y: 1 } }
|
|
280
|
+
* new: { a: { x: 1 }, b: { y: 2 } }
|
|
281
|
+
* result: { a: old.a, b: new.b }, a new object
|
|
282
|
+
*
|
|
283
|
+
* @param newValue - The new value
|
|
284
|
+
* @param oldValue - The value whose objects are kept where nothing changed
|
|
285
|
+
* @returns `oldValue` itself when nothing changed, otherwise `newValue` or a copy of it holding
|
|
286
|
+
* `oldValue`'s unchanged parts
|
|
287
|
+
*/
|
|
288
|
+
export function JsonMaintainUnchanged(newValue, oldValue) {
|
|
289
|
+
if (newValue === oldValue)
|
|
290
|
+
return newValue;
|
|
291
|
+
const type = JsonType(newValue);
|
|
292
|
+
if (type !== JsonType(oldValue))
|
|
293
|
+
return newValue;
|
|
294
|
+
switch (type) {
|
|
295
|
+
case "array": {
|
|
296
|
+
const newArray = newValue;
|
|
297
|
+
const oldArray = oldValue;
|
|
298
|
+
let copy;
|
|
299
|
+
let unchanged = newArray.length === oldArray.length;
|
|
300
|
+
for (let x = 0; x < newArray.length; x++) {
|
|
301
|
+
const value = x < oldArray.length ? JsonMaintainUnchanged(newArray[x], oldArray[x]) : newArray[x];
|
|
302
|
+
unchanged &&= value === oldArray[x];
|
|
303
|
+
if (value !== newArray[x])
|
|
304
|
+
(copy ??= newArray.slice())[x] = value;
|
|
305
|
+
}
|
|
306
|
+
return unchanged ? oldValue : (copy ?? newValue);
|
|
307
|
+
}
|
|
308
|
+
case "object": {
|
|
309
|
+
const newObject = newValue;
|
|
310
|
+
const oldObject = oldValue;
|
|
311
|
+
const newKeys = Object.keys(newObject);
|
|
312
|
+
let copy;
|
|
313
|
+
let unchanged = true;
|
|
314
|
+
let defined = 0;
|
|
315
|
+
for (let x = 0; x < newKeys.length; x++) {
|
|
316
|
+
const key = newKeys[x];
|
|
317
|
+
const newProp = newObject[key];
|
|
318
|
+
if (newProp === undefined)
|
|
319
|
+
continue;
|
|
320
|
+
defined++;
|
|
321
|
+
if (!Object.hasOwn(oldObject, key)) {
|
|
322
|
+
unchanged = false;
|
|
323
|
+
continue;
|
|
324
|
+
}
|
|
325
|
+
const value = JsonMaintainUnchanged(newProp, oldObject[key]);
|
|
326
|
+
unchanged &&= value === oldObject[key];
|
|
327
|
+
if (value !== newProp)
|
|
328
|
+
SetOwnProperty(copy ??= { ...newObject }, key, value);
|
|
329
|
+
}
|
|
330
|
+
// Every defined key of the new value matched, so the old one is kept unless it defines more
|
|
331
|
+
if (unchanged) {
|
|
332
|
+
const oldKeys = Object.keys(oldObject);
|
|
333
|
+
let oldDefined = 0;
|
|
334
|
+
for (let x = 0; x < oldKeys.length && oldDefined <= defined; x++)
|
|
335
|
+
if (oldObject[oldKeys[x]] !== undefined)
|
|
336
|
+
oldDefined++;
|
|
337
|
+
if (oldDefined === defined)
|
|
338
|
+
return oldValue;
|
|
339
|
+
}
|
|
340
|
+
return (copy ?? newValue);
|
|
341
|
+
}
|
|
342
|
+
default:
|
|
343
|
+
return newValue;
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
/** Sets a key as an own property, even `__proto__`, which plain assignment would make the prototype */
|
|
347
|
+
export function SetOwnProperty(target, key, value) {
|
|
348
|
+
if (key === "__proto__")
|
|
349
|
+
Object.defineProperty(target, key, { value, enumerable: true, writable: true, configurable: true });
|
|
350
|
+
else
|
|
351
|
+
target[key] = value;
|
|
352
|
+
}
|
|
97
353
|
/**
|
|
98
354
|
* Computes the differences between two JSON values.
|
|
99
|
-
* Returns an array of paths and values that changed from oldValue to newValue.
|
|
355
|
+
* Returns an array of paths and values that changed from oldValue to newValue. A value is only
|
|
356
|
+
* written whole where its JSON type changes (or a strategy says so); an object or array of the
|
|
357
|
+
* same type is always diffed into, however much of it changed. So the root itself is only ever
|
|
358
|
+
* written when its type changes, and a diff of values of the same type can always be applied
|
|
359
|
+
* in place. A record doesn't
|
|
360
|
+
* own its value: it references `newValue`'s objects, so two records can share one, and
|
|
361
|
+
* JsonApplyDiff clones each value as it's applied.
|
|
100
362
|
* @template T - The type of the JSON values being compared
|
|
101
363
|
* @param newValue - The new value
|
|
102
364
|
* @param oldValue - The old value to compare against
|
|
103
|
-
* @param rootPath - Optional dot-separated string for the base path
|
|
104
|
-
* @param
|
|
365
|
+
* @param rootPath - Optional dot-separated string for the base path
|
|
366
|
+
* @param strategy - Optional choice of how each changed object or array is diffed; without one,
|
|
367
|
+
* everything is diffed by walking into it, arrays by position
|
|
368
|
+
* @param identity - Optional identity a spliced array's elements are compared by; without one,
|
|
369
|
+
* elements are compared by ===. See JsonDiffIdentityFunc
|
|
105
370
|
* @returns Array of change records with path and new value
|
|
106
371
|
*/
|
|
107
|
-
export function JsonDiff(newValue, oldValue, rootPath,
|
|
108
|
-
const result =
|
|
109
|
-
|
|
110
|
-
JsonDiffRecursive(startPath, newValue, oldValue, result);
|
|
372
|
+
export function JsonDiff(newValue, oldValue, rootPath, strategy, identity) {
|
|
373
|
+
const result = [];
|
|
374
|
+
JsonDiffInto(newValue, oldValue, rootPath ? rootPath.split(".") : [], result, strategy, identity);
|
|
111
375
|
return result;
|
|
112
376
|
}
|
|
113
377
|
/**
|
|
114
|
-
*
|
|
378
|
+
* Appends the differences between two JSON values to `result`, so several values can be diffed into
|
|
379
|
+
* one result.
|
|
380
|
+
* @param path - The path the values are at
|
|
381
|
+
* @param strategy - See JsonDiff
|
|
382
|
+
* @param identity - See JsonDiff
|
|
383
|
+
* @returns The result
|
|
384
|
+
*/
|
|
385
|
+
export function JsonDiffInto(newValue, oldValue, path, result, strategy = JsonDiffDefaultStrategy, identity = JsonDiffDefaultIdentity) {
|
|
386
|
+
JsonDiffRecursive(path, newValue, oldValue, result, { strategy, identity });
|
|
387
|
+
return result;
|
|
388
|
+
}
|
|
389
|
+
/**
|
|
390
|
+
* Recursive helper for JsonDiff that traverses and compares values. Values of the same JSON type
|
|
391
|
+
* are diffed into; anything else is written.
|
|
115
392
|
* @internal
|
|
116
393
|
* @param path - Current path being evaluated
|
|
117
394
|
* @param newValue - New value at this path
|
|
118
395
|
* @param oldValue - Old value at this path
|
|
119
396
|
* @param resp - Result array to populate with changes
|
|
120
|
-
* @
|
|
397
|
+
* @param options - The diff's strategy and identity, see JsonDiff
|
|
121
398
|
*/
|
|
122
|
-
function JsonDiffRecursive(path, newValue, oldValue, resp) {
|
|
399
|
+
function JsonDiffRecursive(path, newValue, oldValue, resp, options) {
|
|
123
400
|
if (newValue === oldValue)
|
|
124
|
-
return
|
|
401
|
+
return;
|
|
125
402
|
const newType = JsonType(newValue);
|
|
126
403
|
const oldType = JsonType(oldValue);
|
|
127
|
-
const
|
|
128
|
-
|
|
129
|
-
|
|
404
|
+
const choice = newType !== "value" ? options.strategy(newValue, oldValue, path) : undefined;
|
|
405
|
+
if (choice === "skip")
|
|
406
|
+
return;
|
|
407
|
+
if (newType === oldType && choice !== "write")
|
|
130
408
|
switch (newType) {
|
|
131
409
|
case "array": {
|
|
132
|
-
|
|
133
|
-
|
|
410
|
+
if (choice === "splice") {
|
|
411
|
+
const splices = JsonDiffArraySplice(newValue, oldValue, options.identity);
|
|
412
|
+
splices.length > 0 && resp.push({ type: "splice", path, splices });
|
|
413
|
+
}
|
|
414
|
+
else
|
|
415
|
+
JsonDiffArrays(path, newValue, oldValue, resp, options);
|
|
416
|
+
return;
|
|
134
417
|
}
|
|
135
418
|
case "object": {
|
|
136
|
-
|
|
137
|
-
|
|
419
|
+
JsonDiffObjects(path, newValue, oldValue, resp, options);
|
|
420
|
+
return;
|
|
138
421
|
}
|
|
139
422
|
}
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
423
|
+
resp.push({
|
|
424
|
+
type: "write",
|
|
425
|
+
path,
|
|
426
|
+
value: newValue,
|
|
427
|
+
});
|
|
428
|
+
}
|
|
429
|
+
/**
|
|
430
|
+
* The splices that turn `oldValue` into `newValue`, matching elements by identity without walking
|
|
431
|
+
* into them: two elements are the same when their identities are ===. With the default identity,
|
|
432
|
+
* that's the elements themselves. A matched element is never diffed into, even when it isn't === to
|
|
433
|
+
* its match. An identity that repeats is matched in order, and a repeat with no old element left to
|
|
434
|
+
* match is inserted.
|
|
435
|
+
*
|
|
436
|
+
* It works in four stages, followed through this example in the comments below:
|
|
437
|
+
*
|
|
438
|
+
* old: [A, B, C, D, E, F]
|
|
439
|
+
* new: [A, E, B, X, D, F]
|
|
440
|
+
*
|
|
441
|
+
* 1. Trim the elements identical at the start and end (A and F): they can't need a change.
|
|
442
|
+
* 2. Match each remaining new element to the old index holding the same element, or -1.
|
|
443
|
+
* 3. Choose which matched elements stay in place: the longest run of them still in their old
|
|
444
|
+
* order (B, D). Keeping the longest run is what keeps the number of moves as small as possible.
|
|
445
|
+
* 4. Walk both arrays from kept element to kept element. Between two kept elements, delete the
|
|
446
|
+
* old elements that aren't kept (C, E) and insert the new ones that aren't (E as a move, X as
|
|
447
|
+
* a new value).
|
|
448
|
+
*
|
|
449
|
+
* @returns The splices, in the order to apply them; none when the arrays hold the same elements.
|
|
450
|
+
* Applied in order to [A, B, C, D, E, F], with each move read from the array before any splice:
|
|
451
|
+
*
|
|
452
|
+
* splice(1, 0, E) [A, E, B, C, D, E, F]
|
|
453
|
+
* splice(3, 1, X) [A, E, B, X, D, E, F]
|
|
454
|
+
* splice(5, 1) [A, E, B, X, D, F]
|
|
455
|
+
*
|
|
456
|
+
* @param identity - What elements are compared by; see JsonDiffIdentityFunc. Elements that are
|
|
457
|
+
* === are the same element without calling it, and it's called at most a few times for each
|
|
458
|
+
* other element, so it must give the same identity for the same value.
|
|
459
|
+
*/
|
|
460
|
+
export function JsonDiffArraySplice(newValue, oldValue, identity = JsonDiffDefaultIdentity) {
|
|
461
|
+
const newLength = newValue.length;
|
|
462
|
+
const oldLength = oldValue.length;
|
|
463
|
+
// Every comparison below is between identities. Inserted values are still read from newValue.
|
|
464
|
+
const same = (a, b) => a === b || identity(a) === identity(b);
|
|
465
|
+
// Stage 1: trim the elements with the same identity at the start and end. An append, a delete or a single
|
|
466
|
+
// replaced element leaves little or nothing in the middle, so the stages after this cost little.
|
|
467
|
+
//
|
|
468
|
+
// start: A === A, B !== E → start = 1
|
|
469
|
+
// end: F === F, E !== D → newEnd = oldEnd = 5
|
|
470
|
+
// middle old (indexes 1-4): [B, C, D, E]
|
|
471
|
+
// middle new (indexes 1-4): [E, B, X, D]
|
|
472
|
+
const shared = Math.min(newLength, oldLength);
|
|
473
|
+
let start = 0;
|
|
474
|
+
for (; start < shared && same(newValue[start], oldValue[start]); start++) { }
|
|
475
|
+
if (start === newLength && start === oldLength)
|
|
476
|
+
return [];
|
|
477
|
+
let newEnd = newLength;
|
|
478
|
+
let oldEnd = oldLength;
|
|
479
|
+
for (; newEnd > start && oldEnd > start && same(newValue[newEnd - 1], oldValue[oldEnd - 1]); newEnd--, oldEnd--) { }
|
|
480
|
+
// Stage 2: match each new element in the middle to an old index holding an element with the same
|
|
481
|
+
// identity.
|
|
482
|
+
//
|
|
483
|
+
// oldIndexes maps each old identity to every index it's at. The lists are filled from the end, so
|
|
484
|
+
// each holds its indexes highest first and pop() hands them out lowest first: the first copy of a
|
|
485
|
+
// repeated value in the new array matches its first copy in the old array, and so on. Once a
|
|
486
|
+
// value's indexes run out, any further copy has no match.
|
|
487
|
+
//
|
|
488
|
+
// oldIndexes: B → [1], C → [2], D → [3], E → [4]
|
|
489
|
+
const oldIndexes = new Map();
|
|
490
|
+
for (let x = oldEnd - 1; x >= start; x--) {
|
|
491
|
+
const id = identity(oldValue[x]);
|
|
492
|
+
const indexes = oldIndexes.get(id);
|
|
493
|
+
indexes ? indexes.push(x) : oldIndexes.set(id, [x]);
|
|
494
|
+
}
|
|
495
|
+
// matched[x] is the old index the new element at start + x matched, or -1 when it has none.
|
|
496
|
+
//
|
|
497
|
+
// new: E B X D
|
|
498
|
+
// matched: 4 1 -1 3
|
|
499
|
+
const middle = newEnd - start;
|
|
500
|
+
const matched = new Int32Array(middle);
|
|
501
|
+
for (let x = 0; x < middle; x++)
|
|
502
|
+
matched[x] = oldIndexes.get(identity(newValue[start + x]))?.pop() ?? -1;
|
|
503
|
+
// Stage 3: choose which matched elements stay in place. They're the longest run whose old
|
|
504
|
+
// indexes increase from left to right, because only those can keep their order without moving.
|
|
505
|
+
//
|
|
506
|
+
// matched: 4 1 -1 3
|
|
507
|
+
// longest increasing run: 1, 3 → B and D are kept
|
|
508
|
+
// E (4) is matched but out of order → it will be moved
|
|
509
|
+
// X (-1) has no match → it will be inserted
|
|
510
|
+
//
|
|
511
|
+
// keptNew is indexed by position in the new middle; keptOld marks the same kept elements by
|
|
512
|
+
// their position in the old middle.
|
|
513
|
+
//
|
|
514
|
+
// keptNew (E, B, X, D): 0 1 0 1
|
|
515
|
+
// keptOld (B, C, D, E): 1 0 1 0 → C and E aren't kept, so they will be deleted
|
|
516
|
+
const keptNew = KeptMatches(matched);
|
|
517
|
+
const keptOld = new Uint8Array(oldEnd - start);
|
|
518
|
+
for (let x = 0; x < middle; x++)
|
|
519
|
+
if (keptNew[x])
|
|
520
|
+
keptOld[matched[x] - start] = 1;
|
|
521
|
+
// Stage 4: walk both arrays at once. The kept elements appear in the same order in both, so after
|
|
522
|
+
// skipping the elements that aren't kept, both cursors stop at the same kept element (or both
|
|
523
|
+
// reach the end). Each stop emits one splice:
|
|
524
|
+
// - delete: the old elements skipped, which aren't kept
|
|
525
|
+
// - insert: the new elements skipped, as a move when they were matched, otherwise as a new value
|
|
526
|
+
//
|
|
527
|
+
// A splice's index is the new cursor's position when it starts. The splices before it have already
|
|
528
|
+
// made the array match the new array up to that position, so that's where this one goes.
|
|
529
|
+
//
|
|
530
|
+
// index 1: old skips nothing before B; new skips E (matched 4) → { 1, delete 0, [MOVE 4] }
|
|
531
|
+
// index 3: old skips C before D; new skips X (no match) → { 3, delete 1, [INSERT X] }
|
|
532
|
+
// index 5: old skips E to the end; new has nothing left → { 5, delete 1, [] }
|
|
533
|
+
//
|
|
534
|
+
// The last stop is what deletes old elements left over at the end of the array.
|
|
535
|
+
const splices = [];
|
|
536
|
+
let oldIndex = start;
|
|
537
|
+
let newIndex = start;
|
|
538
|
+
while (oldIndex < oldEnd || newIndex < newEnd) {
|
|
539
|
+
const index = newIndex;
|
|
540
|
+
let deleteCount = 0;
|
|
541
|
+
for (; oldIndex < oldEnd && !keptOld[oldIndex - start]; oldIndex++)
|
|
542
|
+
deleteCount++;
|
|
543
|
+
const insert = [];
|
|
544
|
+
for (; newIndex < newEnd && !keptNew[newIndex - start]; newIndex++) {
|
|
545
|
+
const from = matched[newIndex - start];
|
|
546
|
+
insert.push(from < 0 ? { type: SPLICE_ACTION.INSERT, value: newValue[newIndex] } : { type: SPLICE_ACTION.MOVE, from });
|
|
547
|
+
}
|
|
548
|
+
// Two kept elements next to each other in both arrays leave nothing to splice between them
|
|
549
|
+
if (deleteCount > 0 || insert.length > 0)
|
|
550
|
+
splices.push({ index, delete: deleteCount, insert });
|
|
551
|
+
// Step past the kept element both cursors stopped at. At the end, this takes both past the end
|
|
552
|
+
// and the loop stops.
|
|
553
|
+
oldIndex++;
|
|
554
|
+
newIndex++;
|
|
555
|
+
}
|
|
556
|
+
return splices;
|
|
557
|
+
}
|
|
558
|
+
/**
|
|
559
|
+
* Which matched elements to keep in place: the longest run of them, from left to right, whose old
|
|
560
|
+
* indexes increase. That's the longest increasing subsequence of `matched`, ignoring the -1s, and
|
|
561
|
+
* every matched element left out of it has to move. O(n log n).
|
|
562
|
+
*
|
|
563
|
+
* For example, matched [4, 1, -1, 3] keeps the elements at 1 and 3 (old indexes 1 and 3), and a
|
|
564
|
+
* rotate, matched [4, 0, 1, 2, 3], keeps everything but the first, so only that one moves.
|
|
565
|
+
*
|
|
566
|
+
* @param matched - Each new element's matched old index, or -1 when it has none
|
|
567
|
+
* @returns 1 for each element kept, by position in `matched`
|
|
568
|
+
*/
|
|
569
|
+
function KeptMatches(matched) {
|
|
570
|
+
const length = matched.length;
|
|
571
|
+
// previous[x]: the element before x in the longest run found that ends at x, or -1. Following it
|
|
572
|
+
// back from the end of the longest run gives the whole run.
|
|
573
|
+
const previous = new Int32Array(length);
|
|
574
|
+
// tails[k]: of every increasing run of length k + 1 found so far, the element ending the one with
|
|
575
|
+
// the smallest old index. A smaller ending is better, since more elements can extend it. Their old
|
|
576
|
+
// indexes always increase along tails, which is what lets the search below be a binary search.
|
|
577
|
+
const tails = [];
|
|
578
|
+
for (let x = 0; x < length; x++) {
|
|
579
|
+
const value = matched[x];
|
|
580
|
+
// An unmatched element can't be kept
|
|
581
|
+
if (value < 0)
|
|
582
|
+
continue;
|
|
583
|
+
// Find the first run in tails whose ending old index isn't below this element's. This element
|
|
584
|
+
// can extend the run before it (every ending before it is lower), so it ends a run of length
|
|
585
|
+
// low + 1: either a better ending for that length, or, past the end of tails, a new longest run.
|
|
586
|
+
let low = 0;
|
|
587
|
+
let high = tails.length;
|
|
588
|
+
while (low < high) {
|
|
589
|
+
const mid = (low + high) >> 1;
|
|
590
|
+
if (matched[tails[mid]] < value)
|
|
591
|
+
low = mid + 1;
|
|
592
|
+
else
|
|
593
|
+
high = mid;
|
|
594
|
+
}
|
|
595
|
+
previous[x] = low > 0 ? tails[low - 1] : -1;
|
|
596
|
+
tails[low] = x;
|
|
147
597
|
}
|
|
148
|
-
|
|
598
|
+
// The last entry of tails ends a longest run; follow previous back from it to mark the whole run
|
|
599
|
+
//
|
|
600
|
+
// matched [4, 1, -1, 3]:
|
|
601
|
+
// x=0 (4): tails [0] (run: 4)
|
|
602
|
+
// x=1 (1): tails [1] (1 is a better ending than 4 for length 1)
|
|
603
|
+
// x=3 (3): tails [1, 3] (3 extends the run ending at 1; previous[3] = 1)
|
|
604
|
+
// back from 3 → 1 → done: kept = [0, 1, 0, 1]
|
|
605
|
+
const kept = new Uint8Array(length);
|
|
606
|
+
for (let x = tails.length > 0 ? tails[tails.length - 1] : -1; x >= 0; x = previous[x])
|
|
607
|
+
kept[x] = 1;
|
|
608
|
+
return kept;
|
|
149
609
|
}
|
|
150
610
|
/**
|
|
151
|
-
* Compares two arrays and records differences
|
|
611
|
+
* Compares two arrays by position and records differences: each changed position, then the
|
|
612
|
+
* length when it changed. Elements identical at the start, and at the end when the lengths are the
|
|
613
|
+
* same, aren't diffed.
|
|
152
614
|
* @internal
|
|
153
615
|
* @param path - Current path being evaluated
|
|
154
616
|
* @param newValue - New array
|
|
155
617
|
* @param oldValue - Old array to compare against
|
|
156
618
|
* @param resp - Result array to populate with changes
|
|
157
|
-
* @
|
|
619
|
+
* @param options - The diff's strategy and identity, see JsonDiff
|
|
158
620
|
*/
|
|
159
|
-
function JsonDiffArrays(path, newValue, oldValue, resp) {
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
allChildrenChanged = false;
|
|
175
|
-
if (newValue.length !== oldValue.length)
|
|
621
|
+
function JsonDiffArrays(path, newValue, oldValue, resp, options) {
|
|
622
|
+
const sameLength = newValue.length === oldValue.length;
|
|
623
|
+
const shared = Math.min(newValue.length, oldValue.length);
|
|
624
|
+
let start = 0;
|
|
625
|
+
for (; start < shared && newValue[start] === oldValue[start]; start++) { }
|
|
626
|
+
// Arrays are equal, no diffing required
|
|
627
|
+
if (sameLength && start === newValue.length)
|
|
628
|
+
return;
|
|
629
|
+
let end = newValue.length;
|
|
630
|
+
if (sameLength)
|
|
631
|
+
for (; end > start && newValue[end - 1] === oldValue[end - 1]; end--) { }
|
|
632
|
+
for (let x = start; x < end; x++)
|
|
633
|
+
// Checked before the path is built: most elements of a diffed array are often unchanged
|
|
634
|
+
newValue[x] !== oldValue[x] && JsonDiffRecursive(path.concat(x), newValue[x], oldValue[x], resp, options);
|
|
635
|
+
if (!sameLength)
|
|
176
636
|
resp.push({
|
|
637
|
+
type: "write",
|
|
177
638
|
path: path.concat("length"),
|
|
178
639
|
value: newValue.length,
|
|
179
640
|
});
|
|
180
|
-
return allChildrenChanged;
|
|
181
641
|
}
|
|
182
642
|
/**
|
|
183
|
-
* Compares two objects and records differences
|
|
184
|
-
*
|
|
643
|
+
* Compares two objects and records differences, walking their sorted keys together: a key in
|
|
644
|
+
* both is diffed, a key only the new value has is written, and a key only the old value has is
|
|
645
|
+
* deleted. A new key holding undefined counts as absent and isn't written.
|
|
185
646
|
* @internal
|
|
186
647
|
* @param path - Current path being evaluated
|
|
187
648
|
* @param newValue - New object
|
|
188
649
|
* @param oldValue - Old object to compare against
|
|
189
650
|
* @param resp - Result array to populate with changes
|
|
190
|
-
* @
|
|
651
|
+
* @param options - The diff's strategy and identity, see JsonDiff
|
|
191
652
|
*/
|
|
192
|
-
function JsonDiffObjects(path, newValue, oldValue, resp) {
|
|
653
|
+
function JsonDiffObjects(path, newValue, oldValue, resp, options) {
|
|
193
654
|
const newKeys = Object.keys(newValue).sort();
|
|
194
655
|
const oldKeys = Object.keys(oldValue).sort();
|
|
195
|
-
if (newKeys.length === 0 && oldKeys.length === 0)
|
|
196
|
-
return false;
|
|
197
|
-
if (newKeys.length < oldKeys.length)
|
|
198
|
-
return true;
|
|
199
656
|
let newKeyIndex = 0;
|
|
200
657
|
let oldKeyIndex = 0;
|
|
201
|
-
while (newKeyIndex < newKeys.length) {
|
|
202
|
-
const
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
658
|
+
while (newKeyIndex < newKeys.length || oldKeyIndex < oldKeys.length) {
|
|
659
|
+
const newKey = newKeys[newKeyIndex];
|
|
660
|
+
const oldKey = oldKeys[oldKeyIndex];
|
|
661
|
+
// Both lists are sorted the same way `<` compares strings
|
|
662
|
+
if (oldKeyIndex >= oldKeys.length || (newKeyIndex < newKeys.length && newKey < oldKey)) {
|
|
663
|
+
newValue[newKey] !== undefined && resp.push({
|
|
664
|
+
type: "write",
|
|
665
|
+
path: path.concat(newKey),
|
|
666
|
+
value: newValue[newKey],
|
|
667
|
+
});
|
|
668
|
+
newKeyIndex++;
|
|
207
669
|
}
|
|
208
|
-
else if (
|
|
670
|
+
else if (newKeyIndex >= newKeys.length || oldKey < newKey) {
|
|
209
671
|
resp.push({
|
|
210
|
-
|
|
211
|
-
|
|
672
|
+
type: "delete",
|
|
673
|
+
path: path.concat(oldKey),
|
|
212
674
|
});
|
|
675
|
+
oldKeyIndex++;
|
|
676
|
+
}
|
|
677
|
+
else {
|
|
678
|
+
// Checked before the path is built: most properties of a diffed object are often unchanged
|
|
679
|
+
newValue[newKey] !== oldValue[oldKey] && JsonDiffRecursive(path.concat(newKey), newValue[newKey], oldValue[oldKey], resp, options);
|
|
680
|
+
newKeyIndex++;
|
|
681
|
+
oldKeyIndex++;
|
|
213
682
|
}
|
|
214
|
-
newKeyIndex++;
|
|
215
683
|
}
|
|
216
|
-
if (oldKeyIndex < oldKeys.length)
|
|
217
|
-
return true;
|
|
218
|
-
return false;
|
|
219
684
|
}
|