@sanity/diff-patch 5.0.0 → 7.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/README.md +256 -96
- package/dist/index.d.ts +85 -222
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +400 -239
- package/dist/index.js.map +1 -1
- package/package.json +59 -76
- package/src/diffPatch.ts +454 -361
- package/src/index.ts +3 -15
- package/src/patches.ts +54 -33
- package/src/paths.ts +12 -4
- package/src/setOperations.ts +29 -0
- package/dist/index.cjs +0 -251
- package/dist/index.cjs.map +0 -1
- package/dist/index.d.cts +0 -299
package/src/diffPatch.ts
CHANGED
|
@@ -1,41 +1,44 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {makePatches, stringifyPatches} from '@sanity/diff-match-patch'
|
|
2
2
|
import {DiffError} from './diffError.js'
|
|
3
|
-
import {type Path, pathToString} from './paths.js'
|
|
3
|
+
import {isKeyedObject, type KeyedSanityObject, type Path, pathToString} from './paths.js'
|
|
4
4
|
import {validateProperty} from './validate.js'
|
|
5
5
|
import {
|
|
6
6
|
type Patch,
|
|
7
|
-
type SetPatch,
|
|
8
7
|
type UnsetPatch,
|
|
9
|
-
type InsertAfterPatch,
|
|
10
8
|
type DiffMatchPatch,
|
|
11
|
-
type SanityInsertPatch,
|
|
12
|
-
type SanityPatch,
|
|
13
|
-
type SanitySetPatch,
|
|
14
|
-
type SanityUnsetPatch,
|
|
15
|
-
type SanityDiffMatchPatch,
|
|
16
9
|
type SanityPatchMutation,
|
|
10
|
+
type SanityPatchOperations,
|
|
11
|
+
type SanitySetPatchOperation,
|
|
12
|
+
type SanityUnsetPatchOperation,
|
|
13
|
+
type SanityInsertPatchOperation,
|
|
14
|
+
type SanityDiffMatchPatchOperation,
|
|
17
15
|
} from './patches.js'
|
|
16
|
+
import {difference, intersection} from './setOperations.js'
|
|
18
17
|
|
|
19
|
-
|
|
18
|
+
/**
|
|
19
|
+
* Document keys that are ignored during diff operations.
|
|
20
|
+
* These are system-managed fields that should not be included in patches on
|
|
21
|
+
* top-level documents and should not be diffed with diff-match-patch.
|
|
22
|
+
*/
|
|
23
|
+
const SYSTEM_KEYS = ['_id', '_type', '_createdAt', '_updatedAt', '_rev']
|
|
20
24
|
|
|
21
|
-
|
|
25
|
+
/**
|
|
26
|
+
* Maximum size of strings to consider for diff-match-patch (1MB)
|
|
27
|
+
* Based on testing showing consistently good performance up to this size
|
|
28
|
+
*/
|
|
29
|
+
const DMP_MAX_STRING_SIZE = 1_000_000
|
|
22
30
|
|
|
23
31
|
/**
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
* @internal
|
|
32
|
+
* Maximum difference in string length before falling back to set operations (40%)
|
|
33
|
+
* Above this threshold, likely indicates text replacement which can be slow
|
|
27
34
|
*/
|
|
28
|
-
|
|
29
|
-
[key: string]: unknown
|
|
30
|
-
_key: string
|
|
31
|
-
}
|
|
35
|
+
const DMP_MAX_STRING_LENGTH_CHANGE_RATIO = 0.4
|
|
32
36
|
|
|
33
37
|
/**
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
* @internal
|
|
38
|
+
* Minimum string size to apply change ratio check (10KB)
|
|
39
|
+
* Small strings are always fast regardless of change ratio
|
|
37
40
|
*/
|
|
38
|
-
|
|
41
|
+
const DMP_MIN_SIZE_FOR_RATIO_CHECK = 10_000
|
|
39
42
|
|
|
40
43
|
/**
|
|
41
44
|
* Represents a partial Sanity document (eg a "stub").
|
|
@@ -51,37 +54,6 @@ export interface DocumentStub {
|
|
|
51
54
|
[key: string]: unknown
|
|
52
55
|
}
|
|
53
56
|
|
|
54
|
-
/**
|
|
55
|
-
* Options for the diff-match-patch algorithm.
|
|
56
|
-
*
|
|
57
|
-
* @public
|
|
58
|
-
*/
|
|
59
|
-
export interface DiffMatchPatchOptions {
|
|
60
|
-
/**
|
|
61
|
-
* Whether or not to use diff-match-patch at all
|
|
62
|
-
*
|
|
63
|
-
* @defaultValue `true`
|
|
64
|
-
*/
|
|
65
|
-
enabled: boolean
|
|
66
|
-
|
|
67
|
-
/**
|
|
68
|
-
* Threshold at which to start using diff-match-patch instead of a regular `set` patch.
|
|
69
|
-
*
|
|
70
|
-
* @defaultValue `30`
|
|
71
|
-
*/
|
|
72
|
-
lengthThresholdAbsolute: number
|
|
73
|
-
|
|
74
|
-
/**
|
|
75
|
-
* Only use generated diff-match-patch if the patch length is less than or equal to
|
|
76
|
-
* (targetString * relative). Example: A 100 character target with a relative factor
|
|
77
|
-
* of 1.2 will allow a 120 character diff-match-patch. If larger than this number,
|
|
78
|
-
* it will fall back to a regular `set` patch.
|
|
79
|
-
*
|
|
80
|
-
* @defaultValue `1.2`
|
|
81
|
-
*/
|
|
82
|
-
lengthThresholdRelative: number
|
|
83
|
-
}
|
|
84
|
-
|
|
85
57
|
/**
|
|
86
58
|
* Options for the patch generator
|
|
87
59
|
*
|
|
@@ -110,80 +82,30 @@ export interface PatchOptions {
|
|
|
110
82
|
* @defaultValue `undefined` (do not apply revision check)
|
|
111
83
|
*/
|
|
112
84
|
ifRevisionID?: string | true
|
|
113
|
-
|
|
114
|
-
/**
|
|
115
|
-
* Whether or not to hide warnings during the diff process.
|
|
116
|
-
*
|
|
117
|
-
* @defaultValue `false`
|
|
118
|
-
*/
|
|
119
|
-
hideWarnings?: boolean
|
|
120
|
-
|
|
121
|
-
/**
|
|
122
|
-
* Options for the diff-match-patch algorithm.
|
|
123
|
-
*/
|
|
124
|
-
diffMatchPatch?: Partial<DiffMatchPatchOptions>
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
/**
|
|
128
|
-
* Options for diff generation, where all DMP properties are required
|
|
129
|
-
*
|
|
130
|
-
* @public
|
|
131
|
-
*/
|
|
132
|
-
export type DiffOptions = PatchOptions & {diffMatchPatch: Required<DiffMatchPatchOptions>}
|
|
133
|
-
|
|
134
|
-
const defaultOptions = {
|
|
135
|
-
hideWarnings: false,
|
|
136
|
-
diffMatchPatch: {
|
|
137
|
-
enabled: true,
|
|
138
|
-
|
|
139
|
-
// Only use diff-match-patch if target string is longer than this threshold
|
|
140
|
-
lengthThresholdAbsolute: 30,
|
|
141
|
-
|
|
142
|
-
// Only use generated diff-match-patch if the patch length is less than or equal to
|
|
143
|
-
// (targetString * relative). Example: A 100 character target with a relative factor
|
|
144
|
-
// of 1.2 will allow a 120 character diff-match-patch. If larger than this number,
|
|
145
|
-
// it will fall back to a regular `set` patch.
|
|
146
|
-
lengthThresholdRelative: 1.2,
|
|
147
|
-
},
|
|
148
|
-
} satisfies DiffOptions
|
|
149
|
-
|
|
150
|
-
/**
|
|
151
|
-
* Merges the default options with the passed in options.
|
|
152
|
-
*
|
|
153
|
-
* @param options - Options to merge with the defaults
|
|
154
|
-
* @returns Merged options
|
|
155
|
-
*/
|
|
156
|
-
function mergeOptions(options: PatchOptions): DiffOptions {
|
|
157
|
-
return {
|
|
158
|
-
...defaultOptions,
|
|
159
|
-
...options,
|
|
160
|
-
diffMatchPatch: {...defaultOptions.diffMatchPatch, ...(options.diffMatchPatch || {})},
|
|
161
|
-
}
|
|
162
85
|
}
|
|
163
86
|
|
|
164
87
|
/**
|
|
165
88
|
* Generates an array of mutations for Sanity, based on the differences between
|
|
166
89
|
* the two passed documents/trees.
|
|
167
90
|
*
|
|
168
|
-
* @param
|
|
169
|
-
* @param
|
|
91
|
+
* @param source - The first document/tree to compare
|
|
92
|
+
* @param target - The second document/tree to compare
|
|
170
93
|
* @param opts - Options for the diff generation
|
|
171
94
|
* @returns Array of mutations
|
|
172
95
|
* @public
|
|
173
96
|
*/
|
|
174
97
|
export function diffPatch(
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
98
|
+
source: DocumentStub,
|
|
99
|
+
target: DocumentStub,
|
|
100
|
+
options: PatchOptions = {},
|
|
178
101
|
): SanityPatchMutation[] {
|
|
179
|
-
const
|
|
180
|
-
const id = options.id || (itemA._id === itemB._id && itemA._id)
|
|
102
|
+
const id = options.id || (source._id === target._id && source._id)
|
|
181
103
|
const revisionLocked = options.ifRevisionID
|
|
182
|
-
const ifRevisionID = typeof revisionLocked === 'boolean' ?
|
|
104
|
+
const ifRevisionID = typeof revisionLocked === 'boolean' ? source._rev : revisionLocked
|
|
183
105
|
const basePath = options.basePath || []
|
|
184
106
|
if (!id) {
|
|
185
107
|
throw new Error(
|
|
186
|
-
'_id on
|
|
108
|
+
'_id on source and target not present or differs, specify document id the mutations should be applied to',
|
|
187
109
|
)
|
|
188
110
|
}
|
|
189
111
|
|
|
@@ -193,94 +115,95 @@ export function diffPatch(
|
|
|
193
115
|
)
|
|
194
116
|
}
|
|
195
117
|
|
|
196
|
-
if (basePath.length === 0 &&
|
|
197
|
-
throw new Error(`_type is immutable and cannot be changed (${
|
|
118
|
+
if (basePath.length === 0 && source._type !== target._type) {
|
|
119
|
+
throw new Error(`_type is immutable and cannot be changed (${source._type} => ${target._type})`)
|
|
198
120
|
}
|
|
199
121
|
|
|
200
|
-
const operations = diffItem(
|
|
201
|
-
return serializePatches(operations,
|
|
122
|
+
const operations = diffItem(source, target, basePath, [])
|
|
123
|
+
return serializePatches(operations).map((patchOperations, i) => ({
|
|
124
|
+
patch: {
|
|
125
|
+
id,
|
|
126
|
+
// only add `ifRevisionID` to the first patch
|
|
127
|
+
...(i === 0 && ifRevisionID && {ifRevisionID}),
|
|
128
|
+
...patchOperations,
|
|
129
|
+
},
|
|
130
|
+
}))
|
|
202
131
|
}
|
|
203
132
|
|
|
204
133
|
/**
|
|
205
|
-
*
|
|
206
|
-
*
|
|
134
|
+
* Generates an array of patch operation objects for Sanity, based on the
|
|
135
|
+
* differences between the two passed values
|
|
207
136
|
*
|
|
208
|
-
* @param
|
|
209
|
-
* @param
|
|
210
|
-
* @param
|
|
211
|
-
* @
|
|
212
|
-
* @param patches - Array of patches to append the results to. Note that this is MUTATED.
|
|
213
|
-
* @returns Array of patches
|
|
137
|
+
* @param source - The source value to start off with
|
|
138
|
+
* @param target - The target value that the patch operations will aim to create
|
|
139
|
+
* @param basePath - An optional path that will be prefixed to all subsequent patch operations
|
|
140
|
+
* @returns Array of mutations
|
|
214
141
|
* @public
|
|
215
142
|
*/
|
|
216
|
-
export function
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
143
|
+
export function diffValue(
|
|
144
|
+
source: unknown,
|
|
145
|
+
target: unknown,
|
|
146
|
+
basePath: Path = [],
|
|
147
|
+
): SanityPatchOperations[] {
|
|
148
|
+
return serializePatches(diffItem(source, target, basePath))
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function diffItem(
|
|
152
|
+
source: unknown,
|
|
153
|
+
target: unknown,
|
|
220
154
|
path: Path = [],
|
|
221
155
|
patches: Patch[] = [],
|
|
222
156
|
): Patch[] {
|
|
223
|
-
if (
|
|
157
|
+
if (source === target) {
|
|
224
158
|
return patches
|
|
225
159
|
}
|
|
226
160
|
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
const aIsUndefined = aType === 'undefined'
|
|
231
|
-
const bIsUndefined = bType === 'undefined'
|
|
232
|
-
|
|
233
|
-
if (aIsUndefined && !bIsUndefined) {
|
|
234
|
-
patches.push({op: 'set', path, value: itemB})
|
|
161
|
+
if (typeof source === 'string' && typeof target === 'string') {
|
|
162
|
+
diffString(source, target, path, patches)
|
|
235
163
|
return patches
|
|
236
164
|
}
|
|
237
165
|
|
|
238
|
-
if (
|
|
239
|
-
|
|
166
|
+
if (Array.isArray(source) && Array.isArray(target)) {
|
|
167
|
+
diffArray(source, target, path, patches)
|
|
240
168
|
return patches
|
|
241
169
|
}
|
|
242
170
|
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
if (!isContainer) {
|
|
247
|
-
return diffPrimitive(itemA as PrimitiveValue, itemB as PrimitiveValue, options, path, patches)
|
|
171
|
+
if (isRecord(source) && isRecord(target)) {
|
|
172
|
+
diffObject(source, target, path, patches)
|
|
173
|
+
return patches
|
|
248
174
|
}
|
|
249
175
|
|
|
250
|
-
if (
|
|
251
|
-
|
|
252
|
-
patches.push({op: 'set', path, value: itemB})
|
|
176
|
+
if (target === undefined) {
|
|
177
|
+
patches.push({op: 'unset', path})
|
|
253
178
|
return patches
|
|
254
179
|
}
|
|
255
180
|
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
: diffObject(itemA as object, itemB as object, options, path, patches)
|
|
181
|
+
patches.push({op: 'set', path, value: target})
|
|
182
|
+
return patches
|
|
259
183
|
}
|
|
260
184
|
|
|
261
185
|
function diffObject(
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
options: DiffOptions,
|
|
186
|
+
source: Record<string, unknown>,
|
|
187
|
+
target: Record<string, unknown>,
|
|
265
188
|
path: Path,
|
|
266
189
|
patches: Patch[],
|
|
267
190
|
) {
|
|
268
191
|
const atRoot = path.length === 0
|
|
269
|
-
const aKeys = Object.keys(
|
|
192
|
+
const aKeys = Object.keys(source)
|
|
270
193
|
.filter(atRoot ? isNotIgnoredKey : yes)
|
|
271
|
-
.map((key) => validateProperty(key,
|
|
194
|
+
.map((key) => validateProperty(key, source[key], path))
|
|
272
195
|
|
|
273
196
|
const aKeysLength = aKeys.length
|
|
274
|
-
const bKeys = Object.keys(
|
|
197
|
+
const bKeys = Object.keys(target)
|
|
275
198
|
.filter(atRoot ? isNotIgnoredKey : yes)
|
|
276
|
-
.map((key) => validateProperty(key,
|
|
199
|
+
.map((key) => validateProperty(key, target[key], path))
|
|
277
200
|
|
|
278
201
|
const bKeysLength = bKeys.length
|
|
279
202
|
|
|
280
203
|
// Check for deleted items
|
|
281
204
|
for (let i = 0; i < aKeysLength; i++) {
|
|
282
205
|
const key = aKeys[i]
|
|
283
|
-
if (!(key in
|
|
206
|
+
if (!(key in target)) {
|
|
284
207
|
patches.push({op: 'unset', path: path.concat(key)})
|
|
285
208
|
}
|
|
286
209
|
}
|
|
@@ -288,288 +211,458 @@ function diffObject(
|
|
|
288
211
|
// Check for changed items
|
|
289
212
|
for (let i = 0; i < bKeysLength; i++) {
|
|
290
213
|
const key = bKeys[i]
|
|
291
|
-
diffItem(
|
|
214
|
+
diffItem(source[key], target[key], path.concat([key]), patches)
|
|
292
215
|
}
|
|
293
216
|
|
|
294
217
|
return patches
|
|
295
218
|
}
|
|
296
219
|
|
|
297
|
-
function diffArray(
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
220
|
+
function diffArray(source: unknown[], target: unknown[], path: Path, patches: Patch[]) {
|
|
221
|
+
if (isUniquelyKeyed(source) && isUniquelyKeyed(target)) {
|
|
222
|
+
return diffArrayByKey(source, target, path, patches)
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
return diffArrayByIndex(source, target, path, patches)
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function diffArrayByIndex(source: unknown[], target: unknown[], path: Path, patches: Patch[]) {
|
|
304
229
|
// Check for new items
|
|
305
|
-
if (
|
|
230
|
+
if (target.length > source.length) {
|
|
306
231
|
patches.push({
|
|
307
232
|
op: 'insert',
|
|
308
|
-
|
|
309
|
-
|
|
233
|
+
position: 'after',
|
|
234
|
+
path: path.concat([-1]),
|
|
235
|
+
items: target.slice(source.length).map(nullifyUndefined),
|
|
310
236
|
})
|
|
311
237
|
}
|
|
312
238
|
|
|
313
239
|
// Check for deleted items
|
|
314
|
-
if (
|
|
315
|
-
const isSingle =
|
|
316
|
-
const unsetItems =
|
|
317
|
-
|
|
318
|
-
// If we
|
|
319
|
-
//
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
240
|
+
if (target.length < source.length) {
|
|
241
|
+
const isSingle = source.length - target.length === 1
|
|
242
|
+
const unsetItems = source.slice(target.length)
|
|
243
|
+
|
|
244
|
+
// If we have unique array keys, we'll want to unset by key, as this is
|
|
245
|
+
// safer in a realtime, collaborative setting
|
|
246
|
+
if (isUniquelyKeyed(unsetItems)) {
|
|
247
|
+
patches.push(
|
|
248
|
+
...unsetItems.map((item): UnsetPatch => ({
|
|
249
|
+
op: 'unset',
|
|
250
|
+
path: path.concat({_key: item._key}),
|
|
251
|
+
})),
|
|
252
|
+
)
|
|
253
|
+
} else {
|
|
323
254
|
patches.push({
|
|
324
255
|
op: 'unset',
|
|
325
|
-
path: path.concat([isSingle ?
|
|
256
|
+
path: path.concat([isSingle ? target.length : [target.length, '']]),
|
|
326
257
|
})
|
|
327
|
-
} else {
|
|
328
|
-
patches.push(
|
|
329
|
-
...unsetItems.map(
|
|
330
|
-
(item): UnsetPatch => ({op: 'unset', path: path.concat({_key: item._key})}),
|
|
331
|
-
),
|
|
332
|
-
)
|
|
333
258
|
}
|
|
334
259
|
}
|
|
335
260
|
|
|
336
261
|
// Check for illegal array contents
|
|
337
|
-
for (let i = 0; i <
|
|
338
|
-
if (Array.isArray(
|
|
339
|
-
throw new DiffError('Multi-dimensional arrays not supported', path.concat(i),
|
|
262
|
+
for (let i = 0; i < target.length; i++) {
|
|
263
|
+
if (Array.isArray(target[i])) {
|
|
264
|
+
throw new DiffError('Multi-dimensional arrays not supported', path.concat(i), target[i])
|
|
340
265
|
}
|
|
341
266
|
}
|
|
342
267
|
|
|
343
|
-
const overlapping = Math.min(
|
|
344
|
-
const segmentA =
|
|
345
|
-
const segmentB =
|
|
268
|
+
const overlapping = Math.min(source.length, target.length)
|
|
269
|
+
const segmentA = source.slice(0, overlapping)
|
|
270
|
+
const segmentB = target.slice(0, overlapping)
|
|
346
271
|
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
: diffArrayByIndex(segmentA, segmentB, options, path, patches)
|
|
350
|
-
}
|
|
351
|
-
|
|
352
|
-
function diffArrayByIndex(
|
|
353
|
-
itemA: unknown[],
|
|
354
|
-
itemB: unknown[],
|
|
355
|
-
options: DiffOptions,
|
|
356
|
-
path: Path,
|
|
357
|
-
patches: Patch[],
|
|
358
|
-
) {
|
|
359
|
-
for (let i = 0; i < itemA.length; i++) {
|
|
360
|
-
diffItem(
|
|
361
|
-
itemA[i],
|
|
362
|
-
nullifyUndefined(itemB[i], path, i, options),
|
|
363
|
-
options,
|
|
364
|
-
path.concat(i),
|
|
365
|
-
patches,
|
|
366
|
-
)
|
|
272
|
+
for (let i = 0; i < segmentA.length; i++) {
|
|
273
|
+
diffItem(segmentA[i], nullifyUndefined(segmentB[i]), path.concat(i), patches)
|
|
367
274
|
}
|
|
368
275
|
|
|
369
276
|
return patches
|
|
370
277
|
}
|
|
371
278
|
|
|
279
|
+
/**
|
|
280
|
+
* Diffs two arrays of keyed objects by their `_key` properties.
|
|
281
|
+
*
|
|
282
|
+
* This approach is preferred over index-based diffing for collaborative editing scenarios
|
|
283
|
+
* because it generates patches that are more resilient to concurrent modifications.
|
|
284
|
+
* When multiple users edit the same array simultaneously, key-based patches have better
|
|
285
|
+
* conflict resolution characteristics than index-based patches.
|
|
286
|
+
*
|
|
287
|
+
* The function handles three main operations:
|
|
288
|
+
* 1. **Reordering**: When existing items change positions within the array
|
|
289
|
+
* 2. **Content changes**: When the content of existing items is modified
|
|
290
|
+
* 3. **Insertions/Deletions**: When items are added or removed from the array
|
|
291
|
+
*
|
|
292
|
+
* @param source - The original array with keyed objects
|
|
293
|
+
* @param target - The target array with keyed objects
|
|
294
|
+
* @param path - The path to this array within the document
|
|
295
|
+
* @param patches - Array to accumulate generated patches
|
|
296
|
+
* @returns The patches array with new patches appended
|
|
297
|
+
*/
|
|
372
298
|
function diffArrayByKey(
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
options: DiffOptions,
|
|
299
|
+
source: KeyedSanityObject[],
|
|
300
|
+
target: KeyedSanityObject[],
|
|
376
301
|
path: Path,
|
|
377
302
|
patches: Patch[],
|
|
378
303
|
) {
|
|
379
|
-
|
|
380
|
-
const
|
|
304
|
+
// Create lookup maps for efficient key-based access to array items
|
|
305
|
+
const sourceItemsByKey = new Map(source.map((item) => [item._key, item]))
|
|
306
|
+
const targetItemsByKey = new Map(target.map((item) => [item._key, item]))
|
|
307
|
+
|
|
308
|
+
// Categorize keys by their presence in source vs target arrays
|
|
309
|
+
const sourceKeys = new Set(sourceItemsByKey.keys())
|
|
310
|
+
const targetKeys = new Set(targetItemsByKey.keys())
|
|
311
|
+
const keysRemovedFromSource = difference(sourceKeys, targetKeys)
|
|
312
|
+
const keysAddedToTarget = difference(targetKeys, sourceKeys)
|
|
313
|
+
const keysInBothArrays = intersection(sourceKeys, targetKeys)
|
|
314
|
+
|
|
315
|
+
// Handle reordering of existing items within the array.
|
|
316
|
+
// We detect reordering by comparing the relative positions of keys that exist in both arrays,
|
|
317
|
+
// excluding keys that were added or removed (since they don't participate in reordering).
|
|
318
|
+
const sourceKeysStillPresent = Array.from(difference(sourceKeys, keysRemovedFromSource))
|
|
319
|
+
const targetKeysAlreadyPresent = Array.from(difference(targetKeys, keysAddedToTarget))
|
|
320
|
+
|
|
321
|
+
// Track which keys need to be reordered by comparing their relative positions
|
|
322
|
+
const keyReorderOperations: {sourceKey: string; targetKey: string}[] = []
|
|
323
|
+
|
|
324
|
+
for (let i = 0; i < keysInBothArrays.size; i++) {
|
|
325
|
+
const keyAtPositionInSource = sourceKeysStillPresent[i]
|
|
326
|
+
const keyAtPositionInTarget = targetKeysAlreadyPresent[i]
|
|
327
|
+
|
|
328
|
+
// If different keys occupy the same relative position, a reorder is needed
|
|
329
|
+
if (keyAtPositionInSource !== keyAtPositionInTarget) {
|
|
330
|
+
keyReorderOperations.push({
|
|
331
|
+
sourceKey: keyAtPositionInSource,
|
|
332
|
+
targetKey: keyAtPositionInTarget,
|
|
333
|
+
})
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// Generate reorder patch if any items changed positions
|
|
338
|
+
if (keyReorderOperations.length) {
|
|
339
|
+
patches.push({
|
|
340
|
+
op: 'reorder',
|
|
341
|
+
path,
|
|
342
|
+
snapshot: source,
|
|
343
|
+
reorders: keyReorderOperations,
|
|
344
|
+
})
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// Process content changes for items that exist in both arrays
|
|
348
|
+
for (const key of keysInBothArrays) {
|
|
349
|
+
diffItem(sourceItemsByKey.get(key), targetItemsByKey.get(key), [...path, {_key: key}], patches)
|
|
350
|
+
}
|
|
381
351
|
|
|
382
|
-
//
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
return diffArrayByIndex(itemA, itemB, options, path, patches)
|
|
352
|
+
// Remove items that no longer exist in the target array
|
|
353
|
+
for (const keyToRemove of keysRemovedFromSource) {
|
|
354
|
+
patches.push({op: 'unset', path: [...path, {_key: keyToRemove}]})
|
|
386
355
|
}
|
|
387
356
|
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
357
|
+
// Insert new items that were added to the target array
|
|
358
|
+
// We batch consecutive insertions for efficiency and insert them at the correct positions
|
|
359
|
+
if (keysAddedToTarget.size) {
|
|
360
|
+
let insertionAnchorKey: string // The key after which we'll insert pending items
|
|
361
|
+
let itemsPendingInsertion: unknown[] = []
|
|
362
|
+
|
|
363
|
+
const flushPendingInsertions = () => {
|
|
364
|
+
if (itemsPendingInsertion.length) {
|
|
365
|
+
patches.push({
|
|
366
|
+
op: 'insert',
|
|
367
|
+
// Insert after the anchor key if we have one, otherwise insert at the beginning
|
|
368
|
+
...(insertionAnchorKey
|
|
369
|
+
? {position: 'after', path: [...path, {_key: insertionAnchorKey}]}
|
|
370
|
+
: {position: 'before', path: [...path, 0]}),
|
|
371
|
+
items: itemsPendingInsertion,
|
|
372
|
+
})
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// Walk through the target array to determine where new items should be inserted
|
|
377
|
+
for (const key of targetKeys) {
|
|
378
|
+
if (keysAddedToTarget.has(key)) {
|
|
379
|
+
// This is a new item - add it to the pending insertion batch
|
|
380
|
+
itemsPendingInsertion.push(targetItemsByKey.get(key)!)
|
|
381
|
+
} else if (keysInBothArrays.has(key)) {
|
|
382
|
+
// This is an existing item - flush any pending insertions before it
|
|
383
|
+
flushPendingInsertions()
|
|
384
|
+
insertionAnchorKey = key
|
|
385
|
+
itemsPendingInsertion = []
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// Flush any remaining insertions at the end
|
|
390
|
+
flushPendingInsertions()
|
|
393
391
|
}
|
|
394
392
|
|
|
395
393
|
return patches
|
|
396
394
|
}
|
|
397
395
|
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
396
|
+
/**
|
|
397
|
+
* Determines whether to use diff-match-patch or fallback to a `set` operation
|
|
398
|
+
* when creating a patch to transform a `source` string to `target` string.
|
|
399
|
+
*
|
|
400
|
+
* `diffMatchPatch` patches are typically preferred to `set` operations because
|
|
401
|
+
* they handle conflicts better (when multiple editors work simultaneously) by
|
|
402
|
+
* preserving the user's intended and allowing for 3-way merges.
|
|
403
|
+
*
|
|
404
|
+
* **Heuristic rationale:**
|
|
405
|
+
*
|
|
406
|
+
* Perf analysis revealed that string length has minimal impact on small,
|
|
407
|
+
* keystroke-level changes, but large text replacements (high change ratio) can
|
|
408
|
+
* trigger worst-case algorithm behavior. The 40% change ratio threshold is a
|
|
409
|
+
* simple heuristic that catches problematic replacement scenarios while
|
|
410
|
+
* allowing the algorithm to excel at insertions and deletions.
|
|
411
|
+
*
|
|
412
|
+
* **Performance characteristics (tested on M2 MacBook Pro):**
|
|
413
|
+
*
|
|
414
|
+
* *Keystroke-level editing (most common use case):*
|
|
415
|
+
* - Small strings (1KB-10KB): 0ms for 1-5 keystrokes, consistently sub-millisecond
|
|
416
|
+
* - Medium strings (50KB-200KB): 0ms for 1-5 keystrokes, consistently sub-millisecond
|
|
417
|
+
* - 10 simultaneous keystrokes: ~12ms on 100KB strings
|
|
418
|
+
*
|
|
419
|
+
* *Copy-paste operations (less frequent):*
|
|
420
|
+
* - Small copy-paste operations (<50KB): 0-10ms regardless of string length
|
|
421
|
+
* - Large insertions/deletions (50KB+): 0-50ms (excellent performance)
|
|
422
|
+
* - Large text replacements (50KB+): 70ms-2s+ (can be slow due to algorithm complexity)
|
|
423
|
+
*
|
|
424
|
+
* **Algorithm details:**
|
|
425
|
+
* Uses Myers' diff algorithm with O(ND) time complexity where N=text length and D=edit distance.
|
|
426
|
+
* Includes optimizations: common prefix/suffix removal, line-mode processing, and timeout protection.
|
|
427
|
+
*
|
|
428
|
+
*
|
|
429
|
+
* **Test methodology:**
|
|
430
|
+
* - Generated realistic word-based text patterns
|
|
431
|
+
* - Simulated actual editing behaviors (keystrokes vs copy-paste)
|
|
432
|
+
* - Measured performance across string sizes from 1KB to 10MB
|
|
433
|
+
* - Validated against edge cases including repetitive text and scattered changes
|
|
434
|
+
*
|
|
435
|
+
* @param source - The previous version of the text
|
|
436
|
+
* @param target - The new version of the text
|
|
437
|
+
* @returns true if diff-match-patch should be used, false if fallback to set operation
|
|
438
|
+
*
|
|
439
|
+
* @example
|
|
440
|
+
* ```typescript
|
|
441
|
+
* // Keystroke editing - always fast
|
|
442
|
+
* shouldUseDiffMatchPatch(largeDoc, largeDocWithTypo) // true, ~0ms
|
|
443
|
+
*
|
|
444
|
+
* // Small paste - always fast
|
|
445
|
+
* shouldUseDiffMatchPatch(doc, docWithSmallInsertion) // true, ~0ms
|
|
446
|
+
*
|
|
447
|
+
* // Large replacement - potentially slow
|
|
448
|
+
* shouldUseDiffMatchPatch(article, completelyDifferentArticle) // false, use set
|
|
449
|
+
* ```
|
|
450
|
+
*
|
|
451
|
+
* Compatible with @sanity/diff-match-patch@3.2.0
|
|
452
|
+
*/
|
|
453
|
+
function shouldUseDiffMatchPatch(source: string, target: string): boolean {
|
|
454
|
+
const maxLength = Math.max(source.length, target.length)
|
|
455
|
+
|
|
456
|
+
// Always reject strings larger than our tested size limit
|
|
457
|
+
if (maxLength > DMP_MAX_STRING_SIZE) {
|
|
458
|
+
return false
|
|
417
459
|
}
|
|
418
460
|
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
461
|
+
// For small strings, always use diff-match-patch regardless of change ratio
|
|
462
|
+
// Performance testing showed these are always fast (<10ms)
|
|
463
|
+
if (maxLength < DMP_MIN_SIZE_FOR_RATIO_CHECK) {
|
|
464
|
+
return true
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
// Calculate the change ratio to detect large text replacements
|
|
468
|
+
// High ratios indicate replacement scenarios which can trigger slow algorithm paths
|
|
469
|
+
const lengthDifference = Math.abs(target.length - source.length)
|
|
470
|
+
const changeRatio = lengthDifference / maxLength
|
|
471
|
+
|
|
472
|
+
// If change ratio is high, likely a replacement operation that could be slow
|
|
473
|
+
// Fall back to set operation for better user experience
|
|
474
|
+
if (changeRatio > DMP_MAX_STRING_LENGTH_CHANGE_RATIO) {
|
|
475
|
+
return false
|
|
427
476
|
}
|
|
428
477
|
|
|
429
|
-
//
|
|
430
|
-
//
|
|
431
|
-
|
|
432
|
-
return strPatch.length > itemB.length * lengthThresholdRelative
|
|
433
|
-
? undefined
|
|
434
|
-
: {op: 'diffMatchPatch', path, value: strPatch}
|
|
478
|
+
// All other cases: use diff-match-patch
|
|
479
|
+
// This covers keystroke editing and insertion/deletion scenarios which perform excellently
|
|
480
|
+
return true
|
|
435
481
|
}
|
|
436
482
|
|
|
437
|
-
function
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
patches: Patch[],
|
|
443
|
-
): Patch[] {
|
|
444
|
-
const dmp = getDiffMatchPatch(itemA, itemB, options, path)
|
|
483
|
+
function getDiffMatchPatch(source: string, target: string, path: Path): DiffMatchPatch | undefined {
|
|
484
|
+
const last = path.at(-1)
|
|
485
|
+
// don't use diff-match-patch for system keys
|
|
486
|
+
if (typeof last === 'string' && last.startsWith('_')) return undefined
|
|
487
|
+
if (!shouldUseDiffMatchPatch(source, target)) return undefined
|
|
445
488
|
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
489
|
+
try {
|
|
490
|
+
// Using `makePatches(string, string)` directly instead of the multi-step approach e.g.
|
|
491
|
+
// `stringifyPatches(makePatches(cleanupEfficiency(makeDiff(source, target))))`.
|
|
492
|
+
// this is because `makePatches` internally handles diff generation and
|
|
493
|
+
// automatically applies both `cleanupSemantic()` and `cleanupEfficiency()`
|
|
494
|
+
// when beneficial, resulting in cleaner code with near identical performance and
|
|
495
|
+
// better error handling.
|
|
496
|
+
// [source](https://github.com/sanity-io/diff-match-patch/blob/v3.2.0/src/patch/make.ts#L67-L76)
|
|
497
|
+
//
|
|
498
|
+
// Performance validation (M2 MacBook Pro):
|
|
499
|
+
// Both approaches measured at identical performance:
|
|
500
|
+
// - 10KB strings: 0-1ms total processing time
|
|
501
|
+
// - 100KB strings: 0-1ms total processing time
|
|
502
|
+
// - Individual step breakdown: makeDiff(0ms) + cleanup(0ms) + makePatches(0ms) + stringify(~1ms)
|
|
503
|
+
const strPatch = stringifyPatches(makePatches(source, target))
|
|
504
|
+
return {op: 'diffMatchPatch', path, value: strPatch}
|
|
505
|
+
} catch {
|
|
506
|
+
// Fall back to using regular set patch
|
|
507
|
+
return undefined
|
|
508
|
+
}
|
|
509
|
+
}
|
|
453
510
|
|
|
511
|
+
function diffString(source: string, target: string, path: Path, patches: Patch[]) {
|
|
512
|
+
const dmp = getDiffMatchPatch(source, target, path)
|
|
513
|
+
patches.push(dmp ?? {op: 'set', path, value: target})
|
|
454
514
|
return patches
|
|
455
515
|
}
|
|
456
516
|
|
|
457
517
|
function isNotIgnoredKey(key: string) {
|
|
458
|
-
return
|
|
518
|
+
return SYSTEM_KEYS.indexOf(key) === -1
|
|
459
519
|
}
|
|
460
520
|
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
}
|
|
521
|
+
// mutually exclusive operations
|
|
522
|
+
type SanityPatchOperation =
|
|
523
|
+
| SanitySetPatchOperation
|
|
524
|
+
| SanityUnsetPatchOperation
|
|
525
|
+
| SanityInsertPatchOperation
|
|
526
|
+
| SanityDiffMatchPatchOperation
|
|
468
527
|
|
|
469
|
-
|
|
470
|
-
const
|
|
471
|
-
|
|
472
|
-
const insert = patches.filter((patch): patch is InsertAfterPatch => patch.op === 'insert')
|
|
473
|
-
const dmp = patches.filter((patch): patch is DiffMatchPatch => patch.op === 'diffMatchPatch')
|
|
474
|
-
|
|
475
|
-
const withSet =
|
|
476
|
-
set.length > 0 &&
|
|
477
|
-
set.reduce(
|
|
478
|
-
(patch: SanitySetPatch, item: SetPatch) => {
|
|
479
|
-
const path = pathToString(item.path)
|
|
480
|
-
patch.set[path] = item.value
|
|
481
|
-
return patch
|
|
482
|
-
},
|
|
483
|
-
{id, set: {}},
|
|
484
|
-
)
|
|
528
|
+
function serializePatches(patches: Patch[], curr?: SanityPatchOperation): SanityPatchOperations[] {
|
|
529
|
+
const [patch, ...rest] = patches
|
|
530
|
+
if (!patch) return curr ? [curr] : []
|
|
485
531
|
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
patch.unset.push(path)
|
|
492
|
-
return patch
|
|
493
|
-
},
|
|
494
|
-
{id, unset: []},
|
|
495
|
-
)
|
|
532
|
+
switch (patch.op) {
|
|
533
|
+
case 'set':
|
|
534
|
+
case 'diffMatchPatch': {
|
|
535
|
+
type CurrentOp = Extract<SanityPatchOperation, {[K in typeof patch.op]: {}}>
|
|
536
|
+
const emptyOp = {[patch.op]: {}} as CurrentOp
|
|
496
537
|
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
return acc.concat({id, insert: {after, items: item.items}})
|
|
500
|
-
}, [])
|
|
501
|
-
|
|
502
|
-
const withDmp =
|
|
503
|
-
dmp.length > 0 &&
|
|
504
|
-
dmp.reduce(
|
|
505
|
-
(patch: SanityDiffMatchPatch, item: DiffMatchPatch) => {
|
|
506
|
-
const path = pathToString(item.path)
|
|
507
|
-
patch.diffMatchPatch[path] = item.value
|
|
508
|
-
return patch
|
|
509
|
-
},
|
|
510
|
-
{id, diffMatchPatch: {}},
|
|
511
|
-
)
|
|
538
|
+
if (!curr) return serializePatches(patches, emptyOp)
|
|
539
|
+
if (!(patch.op in curr)) return [curr, ...serializePatches(patches, emptyOp)]
|
|
512
540
|
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
541
|
+
Object.assign((curr as CurrentOp)[patch.op], {[pathToString(patch.path)]: patch.value})
|
|
542
|
+
return serializePatches(rest, curr)
|
|
543
|
+
}
|
|
544
|
+
case 'unset': {
|
|
545
|
+
const emptyOp = {unset: []}
|
|
546
|
+
if (!curr) return serializePatches(patches, emptyOp)
|
|
547
|
+
if (!('unset' in curr)) return [curr, ...serializePatches(patches, emptyOp)]
|
|
516
548
|
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
549
|
+
curr.unset.push(pathToString(patch.path))
|
|
550
|
+
return serializePatches(rest, curr)
|
|
551
|
+
}
|
|
552
|
+
case 'insert': {
|
|
553
|
+
if (curr) return [curr, ...serializePatches(patches)]
|
|
554
|
+
|
|
555
|
+
return [
|
|
556
|
+
{
|
|
557
|
+
insert: {
|
|
558
|
+
[patch.position]: pathToString(patch.path),
|
|
559
|
+
items: patch.items,
|
|
560
|
+
},
|
|
561
|
+
} as SanityInsertPatchOperation,
|
|
562
|
+
...serializePatches(rest),
|
|
563
|
+
]
|
|
564
|
+
}
|
|
565
|
+
case 'reorder': {
|
|
566
|
+
if (curr) return [curr, ...serializePatches(patches)]
|
|
567
|
+
|
|
568
|
+
// REORDER STRATEGY: Two-phase approach to avoid key collisions
|
|
569
|
+
//
|
|
570
|
+
// Problem: Direct key swaps can cause collisions. For example, swapping A↔B:
|
|
571
|
+
// - Set A's content to B: ✓
|
|
572
|
+
// - Set B's content to A: ✗ (A's content was already overwritten)
|
|
573
|
+
//
|
|
574
|
+
// Solution: Use temporary keys as an intermediate step
|
|
575
|
+
// Phase 1: Move all items to temporary keys with their final content
|
|
576
|
+
// Phase 2: Update just the _key property to restore the final keys
|
|
577
|
+
|
|
578
|
+
// Phase 1: Move items to collision-safe temporary keys
|
|
579
|
+
const tempKeyOperations: SanityPatchOperations = {}
|
|
580
|
+
tempKeyOperations.set = {}
|
|
581
|
+
|
|
582
|
+
for (const {sourceKey, targetKey} of patch.reorders) {
|
|
583
|
+
const temporaryKey = `__temp_reorder_${sourceKey}__`
|
|
584
|
+
const finalContentForThisPosition =
|
|
585
|
+
patch.snapshot[getIndexForKey(patch.snapshot, targetKey)]
|
|
586
|
+
|
|
587
|
+
Object.assign(tempKeyOperations.set, {
|
|
588
|
+
[pathToString([...patch.path, {_key: sourceKey}])]: {
|
|
589
|
+
...finalContentForThisPosition,
|
|
590
|
+
_key: temporaryKey,
|
|
591
|
+
},
|
|
592
|
+
})
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
// Phase 2: Update _key properties to restore the intended final keys
|
|
596
|
+
const finalKeyOperations: SanityPatchOperations = {}
|
|
597
|
+
finalKeyOperations.set = {}
|
|
598
|
+
|
|
599
|
+
for (const {sourceKey, targetKey} of patch.reorders) {
|
|
600
|
+
const temporaryKey = `__temp_reorder_${sourceKey}__`
|
|
601
|
+
|
|
602
|
+
Object.assign(finalKeyOperations.set, {
|
|
603
|
+
[pathToString([...patch.path, {_key: temporaryKey}, '_key'])]: targetKey,
|
|
604
|
+
})
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
return [tempKeyOperations, finalKeyOperations, ...serializePatches(rest)]
|
|
608
|
+
}
|
|
609
|
+
default: {
|
|
610
|
+
return []
|
|
611
|
+
}
|
|
612
|
+
}
|
|
520
613
|
}
|
|
521
614
|
|
|
522
615
|
function isUniquelyKeyed(arr: unknown[]): arr is KeyedSanityObject[] {
|
|
523
|
-
const
|
|
616
|
+
const seenKeys = new Set<string>()
|
|
524
617
|
|
|
525
|
-
for (
|
|
526
|
-
|
|
527
|
-
if (!
|
|
528
|
-
|
|
529
|
-
|
|
618
|
+
for (const item of arr) {
|
|
619
|
+
// Each item must be a keyed object with a _key property
|
|
620
|
+
if (!isKeyedObject(item)) return false
|
|
621
|
+
|
|
622
|
+
// Each _key must be unique within the array
|
|
623
|
+
if (seenKeys.has(item._key)) return false
|
|
530
624
|
|
|
531
|
-
|
|
625
|
+
seenKeys.add(item._key)
|
|
532
626
|
}
|
|
533
627
|
|
|
534
628
|
return true
|
|
535
629
|
}
|
|
536
630
|
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
631
|
+
// Cache to avoid recomputing key-to-index mappings for the same array
|
|
632
|
+
const keyToIndexCache = new WeakMap<KeyedSanityObject[], Record<string, number>>()
|
|
633
|
+
|
|
634
|
+
function getIndexForKey(keyedArray: KeyedSanityObject[], targetKey: string) {
|
|
635
|
+
const cachedMapping = keyToIndexCache.get(keyedArray)
|
|
636
|
+
if (cachedMapping) return cachedMapping[targetKey]
|
|
540
637
|
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
(
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
return acc
|
|
638
|
+
// Build a mapping from _key to array index
|
|
639
|
+
const keyToIndexMapping = keyedArray.reduce<Record<string, number>>(
|
|
640
|
+
(mapping, {_key}, arrayIndex) => {
|
|
641
|
+
mapping[_key] = arrayIndex
|
|
642
|
+
return mapping
|
|
547
643
|
},
|
|
548
|
-
{
|
|
644
|
+
{},
|
|
549
645
|
)
|
|
550
|
-
}
|
|
551
|
-
|
|
552
|
-
function arrayIsEqual(itemA: unknown[], itemB: unknown[]) {
|
|
553
|
-
return itemA.length === itemB.length && itemA.every((item, i) => itemB[i] === item)
|
|
554
|
-
}
|
|
555
646
|
|
|
556
|
-
|
|
557
|
-
if (typeof item !== 'undefined') {
|
|
558
|
-
return item
|
|
559
|
-
}
|
|
647
|
+
keyToIndexCache.set(keyedArray, keyToIndexMapping)
|
|
560
648
|
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
console.warn(`undefined value in array converted to null (at '${serializedPath}')`)
|
|
564
|
-
}
|
|
649
|
+
return keyToIndexMapping[targetKey]
|
|
650
|
+
}
|
|
565
651
|
|
|
566
|
-
|
|
652
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
653
|
+
return typeof value === 'object' && !!value && !Array.isArray(value)
|
|
567
654
|
}
|
|
568
655
|
|
|
569
|
-
|
|
570
|
-
|
|
656
|
+
/**
|
|
657
|
+
* Simplify returns `null` if the value given was `undefined`. This behavior
|
|
658
|
+
* is the same as how `JSON.stringify` works so this is relatively expected
|
|
659
|
+
* behavior.
|
|
660
|
+
*/
|
|
661
|
+
function nullifyUndefined(item: unknown) {
|
|
662
|
+
if (item === undefined) return null
|
|
663
|
+
return item
|
|
571
664
|
}
|
|
572
665
|
|
|
573
|
-
function yes(
|
|
666
|
+
function yes() {
|
|
574
667
|
return true
|
|
575
668
|
}
|