lucid-extension-sdk 0.0.299 → 0.0.301
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/commandtypes.d.ts +4 -0
- package/core/cardintegration/lucidcardintegrationregistry.js +6 -0
- package/core/cardintegration/lucidcardintegrationstandardimportmodal.d.ts +5 -0
- package/dataconnector/actions/getmergedpatches.d.ts +14 -0
- package/dataconnector/actions/getmergedpatches.js +322 -0
- package/dataconnector/actions/patch.d.ts +2 -0
- package/dataconnector/actions/patch.js +11 -1
- package/dataconnector/actions/patch.test.d.ts +27 -0
- package/dataconnector/actions/patch.test.js +240 -0
- package/package.json +1 -1
package/commandtypes.d.ts
CHANGED
|
@@ -715,6 +715,10 @@ export type AddCardIntegrationQuery = {
|
|
|
715
715
|
'gdc': string;
|
|
716
716
|
/** If specified, import modal settings */
|
|
717
717
|
'im'?: {
|
|
718
|
+
/** Import Modal Heading */
|
|
719
|
+
'imh'?: string;
|
|
720
|
+
/** Use Isolated Search Bar */
|
|
721
|
+
'uisbui'?: boolean;
|
|
718
722
|
/** Get search fields action */
|
|
719
723
|
'gsf': string;
|
|
720
724
|
/** Search action */
|
|
@@ -87,6 +87,12 @@ class LucidCardIntegrationRegistry {
|
|
|
87
87
|
'i': LucidCardIntegrationRegistry.nextHookName(),
|
|
88
88
|
'os': importModal.onSetup ? LucidCardIntegrationRegistry.nextHookName() : undefined,
|
|
89
89
|
};
|
|
90
|
+
if (importModal.importModalHeading) {
|
|
91
|
+
serialized['im']['imh'] = importModal.importModalHeading;
|
|
92
|
+
}
|
|
93
|
+
if (importModal.useIsolatedSearchBarUI) {
|
|
94
|
+
serialized['im']['uisbui'] = importModal.useIsolatedSearchBarUI;
|
|
95
|
+
}
|
|
90
96
|
client.registerAction(serialized['im']['gsf'], async ({ 's': searchSoFar }) => {
|
|
91
97
|
const result = await importModal.getSearchFields(new Map(searchSoFar));
|
|
92
98
|
return (0, cardintegrationdefinitions_1.serializeCardFieldArrayDefinition)(result);
|
|
@@ -33,6 +33,11 @@ import { ExtensionCardFieldDefinition } from './cardintegrationdefinitions';
|
|
|
33
33
|
*
|
|
34
34
|
*/
|
|
35
35
|
export interface LucidCardIntegrationStandardImportModal {
|
|
36
|
+
/**
|
|
37
|
+
* Heading used in the import modal.
|
|
38
|
+
*/
|
|
39
|
+
importModalHeading?: string;
|
|
40
|
+
useIsolatedSearchBarUI?: boolean;
|
|
36
41
|
getSearchFields: (searchSoFar: Map<string, SerializedFieldType>) => Promise<ExtensionCardFieldDefinition[]>;
|
|
37
42
|
search: (fields: Map<string, SerializedFieldType>) => Promise<SearchResult>;
|
|
38
43
|
import: (primaryKeys: string[], searchFields: Map<string, SerializedFieldType>) => Promise<ImportResult>;
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { ItemPatch } from './patch';
|
|
2
|
+
/**
|
|
3
|
+
* Take an optional cumulative patch "patch up to this point" and combine it with the new patch this is being
|
|
4
|
+
* applied.
|
|
5
|
+
*
|
|
6
|
+
* Note that this function assumes that the patches are both being applied to the exact same collection. We do
|
|
7
|
+
* not validate that this is the case, and breaking that assumption could result in unexpected bad behavior. That
|
|
8
|
+
* needs to have been checked or otherwise contrained by the calling code.
|
|
9
|
+
*
|
|
10
|
+
* @param cumulativePatch The previous patches merged in to a single patch up to this point
|
|
11
|
+
* @param newPatch The new patches which are now being applied at this point
|
|
12
|
+
* @returns The combined patch
|
|
13
|
+
*/
|
|
14
|
+
export declare function getMergedPatches(prevCumulativePatch: ItemPatch | undefined, newPatch: ItemPatch): ItemPatch;
|
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.getMergedPatches = void 0;
|
|
4
|
+
const object_1 = require("../../core/object");
|
|
5
|
+
const patch_1 = require("./patch");
|
|
6
|
+
/**
|
|
7
|
+
* A utility function that hasn't been moved in to the lucid-extension-sdk yet. It takes something that satisfies the
|
|
8
|
+
* map interface, `mapLike`, and finds if `maplike` contains the existing key. If it does, it returns the key. If it
|
|
9
|
+
* does not, it creates a new instance of the key value from the `fallback` function, adds it to the map, and then
|
|
10
|
+
* returns that.
|
|
11
|
+
*
|
|
12
|
+
* @param mapLike The map-like object we are getting/setting on.
|
|
13
|
+
* @param key The key we are looking up.
|
|
14
|
+
* @param fallback A function with provides a default value for when the map doesn't have the given key.
|
|
15
|
+
* @returns The value of the map at the given key, having created the entry in the map if it didn't already exist.
|
|
16
|
+
*/
|
|
17
|
+
function setIfNotPresentAndGet(mapLike, key, fallback) {
|
|
18
|
+
const valIfPresent = mapLike.get(key);
|
|
19
|
+
if (valIfPresent !== undefined) {
|
|
20
|
+
return valIfPresent;
|
|
21
|
+
}
|
|
22
|
+
const newVal = fallback();
|
|
23
|
+
mapLike.set(key, newVal);
|
|
24
|
+
return newVal;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Turn an array of raw moves into an equivalent set of connected chains. It also returns utility maps which track
|
|
28
|
+
* which elements end up in the tail of which returned chains (`backMapping`) and which anchors each chain starts with,
|
|
29
|
+
* `anchorMapping`.
|
|
30
|
+
*
|
|
31
|
+
* @param itemOrderChanged The original sequence of raw moves that we are consolidating into chains.
|
|
32
|
+
* @returns an object containing the relevant chains and some lookup utility values. The contents of the response are:
|
|
33
|
+
*
|
|
34
|
+
* connectedChains: an array of chains representing the original reordering as described by the ConnectedChain type.
|
|
35
|
+
* backMapping: An array from primary keys to the index of the chains that contain those keys in their tails. Note that
|
|
36
|
+
* the arrays are in reverse order, largest indexes first.
|
|
37
|
+
* anchorMapping: An array from priamry keys to the index of the chains that have the key as their anchor. These are
|
|
38
|
+
* also in reverse order, largest indexes first.
|
|
39
|
+
*
|
|
40
|
+
*/
|
|
41
|
+
function getConnectedChains(itemOrderChanged) {
|
|
42
|
+
const connectedChains = [];
|
|
43
|
+
const backMapping = new Map();
|
|
44
|
+
const anchorMapping = new Map();
|
|
45
|
+
let lastMoved = undefined;
|
|
46
|
+
let currentTail = new Set();
|
|
47
|
+
let currentAnchor = undefined;
|
|
48
|
+
itemOrderChanged === null || itemOrderChanged === void 0 ? void 0 : itemOrderChanged.forEach(([moved, next]) => {
|
|
49
|
+
if (lastMoved !== next) {
|
|
50
|
+
if (currentAnchor !== undefined) {
|
|
51
|
+
setIfNotPresentAndGet(anchorMapping, currentAnchor, () => []).unshift(connectedChains.length);
|
|
52
|
+
connectedChains.push({
|
|
53
|
+
anchor: currentAnchor,
|
|
54
|
+
tail: currentTail,
|
|
55
|
+
anchorBlockers: new Set([...currentTail]
|
|
56
|
+
.filter((tailKey) => anchorMapping.has(tailKey))
|
|
57
|
+
.map((key) => { var _a, _b; return (_b = (_a = anchorMapping.get(key)) === null || _a === void 0 ? void 0 : _a[0]) !== null && _b !== void 0 ? _b : -1; })),
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
currentAnchor = next;
|
|
61
|
+
currentTail = new Set();
|
|
62
|
+
}
|
|
63
|
+
currentTail.add(moved);
|
|
64
|
+
setIfNotPresentAndGet(backMapping, moved, () => []).unshift(connectedChains.length);
|
|
65
|
+
lastMoved = moved;
|
|
66
|
+
});
|
|
67
|
+
if (lastMoved !== undefined) {
|
|
68
|
+
if (currentAnchor !== undefined) {
|
|
69
|
+
setIfNotPresentAndGet(anchorMapping, currentAnchor, () => []).unshift(connectedChains.length);
|
|
70
|
+
connectedChains.push({
|
|
71
|
+
anchor: currentAnchor,
|
|
72
|
+
tail: currentTail,
|
|
73
|
+
anchorBlockers: new Set([...currentTail]
|
|
74
|
+
.filter((tailKey) => anchorMapping.has(tailKey))
|
|
75
|
+
.map((key) => { var _a, _b; return (_b = (_a = anchorMapping.get(key)) === null || _a === void 0 ? void 0 : _a[0]) !== null && _b !== void 0 ? _b : -1; })),
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return { connectedChains, backMapping, anchorMapping };
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Take an array of connected chains (plus auxillary maps for efficient access) and remove all possible moves involving
|
|
83
|
+
* the given key. Note that if the key was used as an anchor for a chain somewhere, we can't delete references to it
|
|
84
|
+
* from that chain or any chains before, because we don't necessarily know where the key was moved from when used as
|
|
85
|
+
* an anchor.
|
|
86
|
+
*
|
|
87
|
+
* @param connectedChains The connected chains of the type returned from getConnectedChains
|
|
88
|
+
* @param backMapping The map from the non-anchor elements of the chains to which chains they appear in in connected chains
|
|
89
|
+
* @param anchorMapping The map from the anchor elements of the chains to the chains of which they are anchor
|
|
90
|
+
*
|
|
91
|
+
* @returns Doesn't explicitly return anything, but does modify all input parameters in place to do the expected
|
|
92
|
+
* update.
|
|
93
|
+
*/
|
|
94
|
+
function trimChains(connectedChains, backMapping, anchorMapping, key) {
|
|
95
|
+
var _a, _b, _c;
|
|
96
|
+
const keyBarrier = (_b = (_a = anchorMapping.get(key)) === null || _a === void 0 ? void 0 : _a[0]) !== null && _b !== void 0 ? _b : -1;
|
|
97
|
+
const backMappingList = (_c = backMapping.get(key)) !== null && _c !== void 0 ? _c : [];
|
|
98
|
+
[...backMappingList].every((previousInstanceRow) => {
|
|
99
|
+
if (previousInstanceRow < keyBarrier) {
|
|
100
|
+
return false;
|
|
101
|
+
}
|
|
102
|
+
connectedChains[previousInstanceRow].tail.delete(key);
|
|
103
|
+
backMappingList.shift();
|
|
104
|
+
return true;
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Our goal when combining moves is to minimize the number of chains produced. When we are adding new moves, we
|
|
109
|
+
* thus first search for existing chains they can be inserted into. It sometimes happens that there is a target
|
|
110
|
+
* chain that we can add the new move to, but that the new move cannot "reach" the target chain because one of the
|
|
111
|
+
* intermediate chains block the insertion (the move operators do not commute and cannot be modified to have an
|
|
112
|
+
* equivalent move). When that happens, it is sometimes possible to instead move the target chain down through the
|
|
113
|
+
* list of chains to a location where the new move actually can be inserted correctly. This function is about moving
|
|
114
|
+
* the chains around so that the insertion can be performed consistently, if possible.
|
|
115
|
+
*
|
|
116
|
+
*
|
|
117
|
+
* @param connectedChains See the description on `getConnectedChains`.
|
|
118
|
+
* @param backMapping See the description on `getConnectedChains`.
|
|
119
|
+
* @param anchorMapping See the description on `getConnectedChains`.
|
|
120
|
+
* @param mostRecentBlocker The index of the chain that stops the new move from being added.
|
|
121
|
+
* @param targetIndex The index of the chain that we are trying to insert the new move into.
|
|
122
|
+
* @returns An object containing the target index of the actual chain that we can insert the move into, if it exists. If
|
|
123
|
+
* we can not insert the move into the original target, it returns undefined. Note that if `newTargetIndex` is different
|
|
124
|
+
* from `targetIndex`, this function has changed `connectedChains`, `backMapping` and `anchorMapping` in place
|
|
125
|
+
* in order to actually move the target.
|
|
126
|
+
*/
|
|
127
|
+
function rearrangeAroundBlockersIfPossible(connectedChains, backMapping, anchorMapping, mostRecentBlocker, targetIndex) {
|
|
128
|
+
if (mostRecentBlocker <= targetIndex) {
|
|
129
|
+
return { newTargetIndex: targetIndex };
|
|
130
|
+
}
|
|
131
|
+
if (targetIndex < 0 || targetIndex >= connectedChains.length) {
|
|
132
|
+
return undefined;
|
|
133
|
+
}
|
|
134
|
+
let blocked = false;
|
|
135
|
+
for (let i = targetIndex + 1; !blocked && i <= mostRecentBlocker; i++) {
|
|
136
|
+
blocked = connectedChains[i].anchorBlockers.has(targetIndex);
|
|
137
|
+
}
|
|
138
|
+
if (blocked) {
|
|
139
|
+
return undefined;
|
|
140
|
+
}
|
|
141
|
+
function indexMapper(index) {
|
|
142
|
+
if (index < targetIndex || index > mostRecentBlocker) {
|
|
143
|
+
return index;
|
|
144
|
+
}
|
|
145
|
+
if (index === targetIndex) {
|
|
146
|
+
return mostRecentBlocker;
|
|
147
|
+
}
|
|
148
|
+
return index - 1;
|
|
149
|
+
}
|
|
150
|
+
connectedChains.forEach((chain) => {
|
|
151
|
+
chain.anchorBlockers = new Set([...chain.anchorBlockers].map(indexMapper));
|
|
152
|
+
});
|
|
153
|
+
[...backMapping].forEach(([key, rows]) => {
|
|
154
|
+
backMapping.set(key, rows.map(indexMapper));
|
|
155
|
+
});
|
|
156
|
+
[...anchorMapping].forEach(([anchorKey, rows]) => {
|
|
157
|
+
anchorMapping.set(anchorKey, rows.map(indexMapper));
|
|
158
|
+
});
|
|
159
|
+
const targetRow = connectedChains.splice(targetIndex, 1)[0];
|
|
160
|
+
connectedChains.splice(mostRecentBlocker, 0, targetRow);
|
|
161
|
+
return { newTargetIndex: mostRecentBlocker };
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* Function which modifies the chain information to add a new chain to the current list. Note this this aggresively
|
|
165
|
+
* tries to combine a new chain with existing chains, if possible, but will add a new chain if necessary.
|
|
166
|
+
*
|
|
167
|
+
* @param connectedChains See the description on `getConnectedChains`.
|
|
168
|
+
* @param backMapping See the description on `getConnectedChains`.
|
|
169
|
+
* @param anchorMapping See the description on `getConnectedChains`.
|
|
170
|
+
* @param anchor The anchor for the new chain.
|
|
171
|
+
* @param chain The tail for the new chain.
|
|
172
|
+
* @returns Nothing, but modifies `connectedChains`, `backMapping` and `anchorMapping` in place to incorporate the
|
|
173
|
+
* new chain.
|
|
174
|
+
*/
|
|
175
|
+
function insertNewChain(connectedChains, backMapping, anchorMapping, anchor, chain) {
|
|
176
|
+
var _a, _b, _c, _d;
|
|
177
|
+
const anchorBlockers = new Set([...chain].filter((key) => anchorMapping.has(key)).map((key) => { var _a, _b; return (_b = (_a = anchorMapping.get(key)) === null || _a === void 0 ? void 0 : _a[0]) !== null && _b !== void 0 ? _b : -1; }));
|
|
178
|
+
const anchorMergeTarget = (_b = (_a = anchorMapping.get(anchor)) === null || _a === void 0 ? void 0 : _a[0]) !== null && _b !== void 0 ? _b : -1;
|
|
179
|
+
const nonAnchorMergeTarget = anchor !== null ? (_d = (_c = backMapping.get(anchor)) === null || _c === void 0 ? void 0 : _c[0]) !== null && _d !== void 0 ? _d : -1 : -1;
|
|
180
|
+
const targetIndex = Math.max(anchorMergeTarget, nonAnchorMergeTarget);
|
|
181
|
+
const isAnchorTarget = targetIndex !== nonAnchorMergeTarget;
|
|
182
|
+
const mostRecentBlocker = Math.max(...anchorBlockers);
|
|
183
|
+
const newTargetIfPossible = rearrangeAroundBlockersIfPossible(connectedChains, backMapping, anchorMapping, mostRecentBlocker, targetIndex);
|
|
184
|
+
function backLinkKeys(chain, index) {
|
|
185
|
+
chain.forEach((chainKey) => setIfNotPresentAndGet(backMapping, chainKey, () => []).unshift(index));
|
|
186
|
+
}
|
|
187
|
+
if (!newTargetIfPossible || newTargetIfPossible.newTargetIndex < 0) {
|
|
188
|
+
connectedChains.push({ anchor, tail: chain, anchorBlockers });
|
|
189
|
+
const endingChainIndex = connectedChains.length - 1;
|
|
190
|
+
setIfNotPresentAndGet(anchorMapping, anchor, () => []).unshift(endingChainIndex);
|
|
191
|
+
backLinkKeys(chain, endingChainIndex);
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
const { newTargetIndex } = newTargetIfPossible;
|
|
195
|
+
const newChain = isAnchorTarget ? new Set([...chain]) : new Set();
|
|
196
|
+
const targetChain = connectedChains[newTargetIndex];
|
|
197
|
+
targetChain.tail.forEach((chainKey) => {
|
|
198
|
+
newChain.delete(chainKey);
|
|
199
|
+
newChain.add(chainKey);
|
|
200
|
+
if (!isAnchorTarget && chainKey === anchor) {
|
|
201
|
+
chain.forEach((insertedKey) => {
|
|
202
|
+
newChain.delete(insertedKey);
|
|
203
|
+
newChain.add(insertedKey);
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
});
|
|
207
|
+
connectedChains[newTargetIndex].tail = newChain;
|
|
208
|
+
backLinkKeys(newChain, newTargetIndex);
|
|
209
|
+
}
|
|
210
|
+
/**
|
|
211
|
+
* This function takes an array of new item order moves and appends them to the end of the existing chains,
|
|
212
|
+
* aggressively merging chains if possible.
|
|
213
|
+
*
|
|
214
|
+
* @param connectedChains See the description on `getConnectedChains`.
|
|
215
|
+
* @param backMapping See the description on `getConnectedChains`.
|
|
216
|
+
* @param anchorMapping See the description on `getConnectedChains`.
|
|
217
|
+
* @param newItemOrderChanged The new set of moves from the new patch that we are adding it.
|
|
218
|
+
* @returns Nothing, but modifies `connectedChains`, `backMapping` and `anchorMapping` in place to incorporate the
|
|
219
|
+
* new moves.
|
|
220
|
+
*/
|
|
221
|
+
function mergeIntoChains(connectedChains, backMapping, anchorMapping, newItemOrderChanged) {
|
|
222
|
+
var _a, _b;
|
|
223
|
+
// Pop off the last available chain, because we need to continue to growth as thought it started up again clean
|
|
224
|
+
// with the next item order. The logic changes are to account for the fact that keys might now be duplicated.
|
|
225
|
+
const lastChain = connectedChains.pop();
|
|
226
|
+
lastChain === null || lastChain === void 0 ? void 0 : lastChain.tail.forEach((keyInLastChain) => { var _a; return (_a = backMapping.get(keyInLastChain)) === null || _a === void 0 ? void 0 : _a.shift(); });
|
|
227
|
+
(lastChain === null || lastChain === void 0 ? void 0 : lastChain.anchor) !== undefined && ((_a = anchorMapping.get(lastChain.anchor)) === null || _a === void 0 ? void 0 : _a.shift());
|
|
228
|
+
let currentAnchor = lastChain === null || lastChain === void 0 ? void 0 : lastChain.anchor;
|
|
229
|
+
let currentChain = (_b = lastChain === null || lastChain === void 0 ? void 0 : lastChain.tail) !== null && _b !== void 0 ? _b : new Set();
|
|
230
|
+
let lastMoved = [...currentChain][currentChain.size - 1];
|
|
231
|
+
newItemOrderChanged === null || newItemOrderChanged === void 0 ? void 0 : newItemOrderChanged.forEach(([key, pred]) => {
|
|
232
|
+
if (lastMoved !== pred) {
|
|
233
|
+
if (currentAnchor !== undefined) {
|
|
234
|
+
insertNewChain(connectedChains, backMapping, anchorMapping, currentAnchor, currentChain);
|
|
235
|
+
}
|
|
236
|
+
currentAnchor = pred;
|
|
237
|
+
currentChain = new Set();
|
|
238
|
+
}
|
|
239
|
+
if (key !== currentAnchor) {
|
|
240
|
+
trimChains(connectedChains, backMapping, anchorMapping, key);
|
|
241
|
+
}
|
|
242
|
+
// Need to delete the key if it is already in the chain so that it gets moved to the end by the add, because
|
|
243
|
+
// order matters.
|
|
244
|
+
currentChain.delete(key);
|
|
245
|
+
currentChain.add(key);
|
|
246
|
+
lastMoved = key;
|
|
247
|
+
});
|
|
248
|
+
if (lastMoved !== undefined) {
|
|
249
|
+
// Need to delete the key if it is already in the chain so that it gets moved to the end by the add, because
|
|
250
|
+
// order matters.
|
|
251
|
+
currentChain.delete(lastMoved);
|
|
252
|
+
currentChain.add(lastMoved);
|
|
253
|
+
if (currentAnchor !== undefined) {
|
|
254
|
+
insertNewChain(connectedChains, backMapping, anchorMapping, currentAnchor, currentChain);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
/**
|
|
259
|
+
* Take an optional cumulative patch "patch up to this point" and combine it with the new patch this is being
|
|
260
|
+
* applied.
|
|
261
|
+
*
|
|
262
|
+
* Note that this function assumes that the patches are both being applied to the exact same collection. We do
|
|
263
|
+
* not validate that this is the case, and breaking that assumption could result in unexpected bad behavior. That
|
|
264
|
+
* needs to have been checked or otherwise contrained by the calling code.
|
|
265
|
+
*
|
|
266
|
+
* @param cumulativePatch The previous patches merged in to a single patch up to this point
|
|
267
|
+
* @param newPatch The new patches which are now being applied at this point
|
|
268
|
+
* @returns The combined patch
|
|
269
|
+
*/
|
|
270
|
+
function getMergedPatches(prevCumulativePatch, newPatch) {
|
|
271
|
+
if (prevCumulativePatch === undefined) {
|
|
272
|
+
return newPatch;
|
|
273
|
+
}
|
|
274
|
+
// Copy the patch so we can change it in place
|
|
275
|
+
const itemsDeleted = new Set(prevCumulativePatch.itemsDeleted);
|
|
276
|
+
const itemsAdded = new Map(Object.entries(prevCumulativePatch.itemsAdded));
|
|
277
|
+
const itemsChanged = new Map(Object.entries(prevCumulativePatch.itemsChanged));
|
|
278
|
+
const { connectedChains, backMapping, anchorMapping } = getConnectedChains(prevCumulativePatch.itemOrderChanged);
|
|
279
|
+
// Merge items changed in the new patch into the cumulative patch
|
|
280
|
+
for (const [changedKey, changes] of Object.entries(newPatch.itemsChanged)) {
|
|
281
|
+
if (itemsDeleted.has(changedKey)) {
|
|
282
|
+
continue;
|
|
283
|
+
}
|
|
284
|
+
if (itemsAdded.has(changedKey)) {
|
|
285
|
+
itemsAdded.set(changedKey, Object.assign(Object.assign({}, itemsAdded.get(changedKey)), changes));
|
|
286
|
+
}
|
|
287
|
+
else {
|
|
288
|
+
itemsChanged.set(changedKey, Object.assign(Object.assign({}, itemsChanged.get(changedKey)), changes));
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
// Add items added by the new patch to the cumulative patch
|
|
292
|
+
for (const [addedKey, addedData] of Object.entries(newPatch.itemsAdded)) {
|
|
293
|
+
itemsDeleted.delete(addedKey);
|
|
294
|
+
itemsChanged.delete(addedKey);
|
|
295
|
+
itemsAdded.set(addedKey, addedData);
|
|
296
|
+
}
|
|
297
|
+
mergeIntoChains(connectedChains, backMapping, anchorMapping, newPatch.itemOrderChanged);
|
|
298
|
+
// Clear out any items deleted in upstream patches, or actively target pre-existing items for deletion
|
|
299
|
+
const deletedItems = new Set(newPatch.itemsDeleted);
|
|
300
|
+
for (const deletedKey of deletedItems) {
|
|
301
|
+
if (itemsAdded.has(deletedKey)) {
|
|
302
|
+
itemsAdded.delete(deletedKey);
|
|
303
|
+
}
|
|
304
|
+
else {
|
|
305
|
+
itemsChanged.delete(deletedKey);
|
|
306
|
+
}
|
|
307
|
+
itemsDeleted.add(deletedKey);
|
|
308
|
+
trimChains(connectedChains, backMapping, anchorMapping, deletedKey);
|
|
309
|
+
}
|
|
310
|
+
const itemOrderChanged = [];
|
|
311
|
+
connectedChains.forEach(({ anchor, tail }) => {
|
|
312
|
+
let lastNext = anchor;
|
|
313
|
+
tail.forEach((chainEntry) => {
|
|
314
|
+
if (!deletedItems.has(chainEntry)) {
|
|
315
|
+
itemOrderChanged.push([chainEntry, lastNext]);
|
|
316
|
+
lastNext = chainEntry;
|
|
317
|
+
}
|
|
318
|
+
});
|
|
319
|
+
});
|
|
320
|
+
return new patch_1.ItemPatch(prevCumulativePatch.id, (0, object_1.fromEntries)(itemsAdded), (0, object_1.fromEntries)(itemsChanged), [...itemsDeleted], itemOrderChanged.length === 0 ? undefined : itemOrderChanged, prevCumulativePatch.syncSourceId, prevCumulativePatch.syncCollectionId);
|
|
321
|
+
}
|
|
322
|
+
exports.getMergedPatches = getMergedPatches;
|
|
@@ -59,6 +59,7 @@ export declare class ThirdPartyColumn {
|
|
|
59
59
|
toJSON(): object;
|
|
60
60
|
static deserialize(data: SerializedThirdPartyColumn): ThirdPartyColumn;
|
|
61
61
|
}
|
|
62
|
+
export declare function areThirdPartyColumnsEqual(a: ThirdPartyColumn, b: ThirdPartyColumn): boolean;
|
|
62
63
|
export declare class ThirdPartyColumnPatch {
|
|
63
64
|
readonly name?: string | undefined;
|
|
64
65
|
readonly fieldType?: FieldTypeDefinition | undefined;
|
|
@@ -67,6 +68,7 @@ export declare class ThirdPartyColumnPatch {
|
|
|
67
68
|
toJSON(): object;
|
|
68
69
|
static deserialize(data: SerializedThirdPartyColumnPatch): ThirdPartyColumnPatch;
|
|
69
70
|
}
|
|
71
|
+
export declare function areThirdPartyColumnPatchesEqual(a: ThirdPartyColumnPatch, b: ThirdPartyColumnPatch): boolean;
|
|
70
72
|
export declare class SchemaPatch extends Patch {
|
|
71
73
|
/** Mapping of item primary keys to new items introduced by Lucid */
|
|
72
74
|
columnsAdded: ThirdPartyColumn[];
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.schemaPatchParser = exports.SchemaPatch = exports.ThirdPartyColumnPatch = exports.ThirdPartyColumn = exports.itemPatchParser = exports.ItemPatch = exports.genericPatchParser = exports.Patch = void 0;
|
|
3
|
+
exports.schemaPatchParser = exports.SchemaPatch = exports.areThirdPartyColumnPatchesEqual = exports.ThirdPartyColumnPatch = exports.areThirdPartyColumnsEqual = exports.ThirdPartyColumn = exports.itemPatchParser = exports.ItemPatch = exports.genericPatchParser = exports.Patch = void 0;
|
|
4
4
|
const fieldtypedefinition_1 = require("../../core/data/fieldtypedefinition/fieldtypedefinition");
|
|
5
5
|
const patchresponsebody_1 = require("./patchresponsebody");
|
|
6
6
|
const serializedpatchtypes_1 = require("./serializedpatchtypes");
|
|
@@ -99,6 +99,10 @@ class ThirdPartyColumn {
|
|
|
99
99
|
}
|
|
100
100
|
}
|
|
101
101
|
exports.ThirdPartyColumn = ThirdPartyColumn;
|
|
102
|
+
function areThirdPartyColumnsEqual(a, b) {
|
|
103
|
+
return a.name === b.name && (0, fieldtypedefinition_1.fieldTypesEqual)(a.fieldType, b.fieldType);
|
|
104
|
+
}
|
|
105
|
+
exports.areThirdPartyColumnsEqual = areThirdPartyColumnsEqual;
|
|
102
106
|
class ThirdPartyColumnPatch {
|
|
103
107
|
constructor(name, fieldType) {
|
|
104
108
|
this.name = name;
|
|
@@ -120,6 +124,12 @@ class ThirdPartyColumnPatch {
|
|
|
120
124
|
}
|
|
121
125
|
}
|
|
122
126
|
exports.ThirdPartyColumnPatch = ThirdPartyColumnPatch;
|
|
127
|
+
function areThirdPartyColumnPatchesEqual(a, b) {
|
|
128
|
+
return (a.name === b.name &&
|
|
129
|
+
((a.fieldType === undefined && b.fieldType === undefined) ||
|
|
130
|
+
(a.fieldType !== undefined && b.fieldType !== undefined && (0, fieldtypedefinition_1.fieldTypesEqual)(a.fieldType, b.fieldType))));
|
|
131
|
+
}
|
|
132
|
+
exports.areThirdPartyColumnPatchesEqual = areThirdPartyColumnPatchesEqual;
|
|
123
133
|
class SchemaPatch extends Patch {
|
|
124
134
|
constructor(
|
|
125
135
|
/** The id of the patch */
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { SerializedSchema } from '../../core/data/serializedfield/serializedschema';
|
|
2
|
+
import { ItemPatch, Patch } from './patch';
|
|
3
|
+
export declare function uuid(): string;
|
|
4
|
+
export declare function getRandomEmptyPatch(optSyncSourceId?: string, optSyncCollectionId?: string): Patch;
|
|
5
|
+
type Range = number | [number, number];
|
|
6
|
+
export interface RandomPatchSettings {
|
|
7
|
+
previousDependency: number;
|
|
8
|
+
deleteFrequency: number;
|
|
9
|
+
addedIds: Range;
|
|
10
|
+
changedIds: Range;
|
|
11
|
+
changeDensity: number;
|
|
12
|
+
deletedIds: Range;
|
|
13
|
+
schema: SerializedSchema | Range;
|
|
14
|
+
orderedAnchors?: undefined | Range;
|
|
15
|
+
}
|
|
16
|
+
export declare function getRandomItemPatch(settings: RandomPatchSettings, previousPatches: ItemPatch[], optSyncSourceId?: string, optSyncCollectionId?: string): ItemPatch;
|
|
17
|
+
/**
|
|
18
|
+
* When we use raw JSON.stringify on objects, it can depend in subtle ways how the object was created. This
|
|
19
|
+
* function is intended to make the resulting string depend only on the logical contents so as to minimize unneeded
|
|
20
|
+
* refreshes.
|
|
21
|
+
*
|
|
22
|
+
* @param data The Sheet-type to stringify
|
|
23
|
+
* @returns A custom stringified object
|
|
24
|
+
*/
|
|
25
|
+
export declare function orderedStringify(data: unknown): string;
|
|
26
|
+
export declare function patchesEqual(patchA: Patch, patchB: Patch): boolean;
|
|
27
|
+
export {};
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.patchesEqual = exports.orderedStringify = exports.getRandomItemPatch = exports.getRandomEmptyPatch = exports.uuid = void 0;
|
|
4
|
+
const checks_1 = require("../../core/checks");
|
|
5
|
+
const datasourceutils_1 = require("../../core/data/datasource/datasourceutils");
|
|
6
|
+
const spreadsheetpossibledatatypes_1 = require("../../core/data/datasource/spreadsheetpossibledatatypes");
|
|
7
|
+
const serializedschema_1 = require("../../core/data/serializedfield/serializedschema");
|
|
8
|
+
const patch_1 = require("./patch");
|
|
9
|
+
function arraysEqualWith(areEqual) {
|
|
10
|
+
return (a, b) => {
|
|
11
|
+
if (a.length !== b.length) {
|
|
12
|
+
return false;
|
|
13
|
+
}
|
|
14
|
+
return a.every((aItem, index) => areEqual(aItem, b[index]));
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
function recordsEqualWith(areEqual) {
|
|
18
|
+
return (a, b) => {
|
|
19
|
+
for (const key in a) {
|
|
20
|
+
if (!b.hasOwnProperty(key) || !areEqual(a[key], b[key])) {
|
|
21
|
+
return false;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
for (const key in b) {
|
|
25
|
+
if (!a.hasOwnProperty(key)) {
|
|
26
|
+
return false;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return true;
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
function arraySetsEqual(deletedA, deletedB) {
|
|
33
|
+
const setA = new Set(deletedA);
|
|
34
|
+
const setB = new Set(deletedB);
|
|
35
|
+
if (setA.size !== setB.size) {
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
38
|
+
setB.forEach((b) => setA.delete(b));
|
|
39
|
+
return setA.size === 0;
|
|
40
|
+
}
|
|
41
|
+
function uuid() {
|
|
42
|
+
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => (c == 'x' ? Math.floor(Math.random() * 16) : Math.floor(Math.random() * 4) + 8).toString(16));
|
|
43
|
+
}
|
|
44
|
+
exports.uuid = uuid;
|
|
45
|
+
function randomWordChar() {
|
|
46
|
+
const upperCaseChar = (0, datasourceutils_1.alphabetize)(Math.floor(Math.random() * 26));
|
|
47
|
+
if (Math.random() > 0.5) {
|
|
48
|
+
return upperCaseChar;
|
|
49
|
+
}
|
|
50
|
+
return upperCaseChar.toLowerCase();
|
|
51
|
+
}
|
|
52
|
+
function randomWord() {
|
|
53
|
+
return Array.from({ length: 10 }, randomWordChar).join('');
|
|
54
|
+
}
|
|
55
|
+
function randomWordOrPrevious(frequency, previous) {
|
|
56
|
+
const previousArray = [...previous];
|
|
57
|
+
if (previousArray.length === 0) {
|
|
58
|
+
return randomWord;
|
|
59
|
+
}
|
|
60
|
+
return () => {
|
|
61
|
+
if (Math.random() < frequency) {
|
|
62
|
+
return previousArray[Math.floor(Math.random() * previousArray.length)];
|
|
63
|
+
}
|
|
64
|
+
return randomWord();
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
function getRandomEmptyPatch(optSyncSourceId, optSyncCollectionId) {
|
|
68
|
+
const id = uuid();
|
|
69
|
+
const syncSourceId = optSyncSourceId !== null && optSyncSourceId !== void 0 ? optSyncSourceId : uuid();
|
|
70
|
+
const syncCollectionId = optSyncCollectionId !== null && optSyncCollectionId !== void 0 ? optSyncCollectionId : uuid();
|
|
71
|
+
return new patch_1.ItemPatch(id, {}, {}, [], undefined, syncSourceId, syncCollectionId);
|
|
72
|
+
}
|
|
73
|
+
exports.getRandomEmptyPatch = getRandomEmptyPatch;
|
|
74
|
+
function randInRange(range) {
|
|
75
|
+
if ((0, checks_1.isNumber)(range)) {
|
|
76
|
+
return Math.floor(Math.random() * range);
|
|
77
|
+
}
|
|
78
|
+
return range[0] + Math.floor(Math.random() * (range[1] - range[0]));
|
|
79
|
+
}
|
|
80
|
+
function schemaFromRange(range) {
|
|
81
|
+
const width = randInRange(range);
|
|
82
|
+
const fields = Array.from({ length: width }, (_, index) => (0, datasourceutils_1.alphabetize)(index));
|
|
83
|
+
return {
|
|
84
|
+
'Fields': fields.map((field) => ({
|
|
85
|
+
'Name': field,
|
|
86
|
+
'Type': spreadsheetpossibledatatypes_1.SpreadSheetPossibleDataTypes,
|
|
87
|
+
})),
|
|
88
|
+
'PrimaryKey': [],
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
function getRandomPatchSettingsInstance(settings) {
|
|
92
|
+
const schema = (0, serializedschema_1.isSerializedSchema)(settings.schema) ? settings.schema : schemaFromRange(settings.schema);
|
|
93
|
+
return {
|
|
94
|
+
previousDependency: settings.previousDependency,
|
|
95
|
+
deleteFrequency: settings.deleteFrequency,
|
|
96
|
+
addedIds: randInRange(settings.addedIds),
|
|
97
|
+
changedIds: randInRange(settings.changedIds),
|
|
98
|
+
deletedIds: randInRange(settings.deletedIds),
|
|
99
|
+
orderedAnchors: settings.orderedAnchors !== undefined ? randInRange(settings.orderedAnchors) : undefined,
|
|
100
|
+
changeDensity: settings.changeDensity,
|
|
101
|
+
schema,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
function getRandomPatchItemFields(schema, density) {
|
|
105
|
+
const result = {};
|
|
106
|
+
const primaryKeys = new Set(schema['PrimaryKey']);
|
|
107
|
+
let isEmptyResult = true;
|
|
108
|
+
schema['Fields'].forEach((field) => {
|
|
109
|
+
if (primaryKeys.has(field['Name']) || Math.random() < density) {
|
|
110
|
+
isEmptyResult = false;
|
|
111
|
+
result[field['Name']] = randomWord();
|
|
112
|
+
}
|
|
113
|
+
});
|
|
114
|
+
return isEmptyResult ? undefined : result;
|
|
115
|
+
}
|
|
116
|
+
function getRandomPatchItem(count, schema, density, wordGetter) {
|
|
117
|
+
const result = {};
|
|
118
|
+
for (let i = 0; i < count; i++) {
|
|
119
|
+
const name = wordGetter();
|
|
120
|
+
const newFields = getRandomPatchItemFields(schema, density);
|
|
121
|
+
if (newFields) {
|
|
122
|
+
result[name] = newFields;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
return result;
|
|
126
|
+
}
|
|
127
|
+
function getPreviousPatchBuckets(previousPatches) {
|
|
128
|
+
const normal = new Set();
|
|
129
|
+
const deleted = new Set();
|
|
130
|
+
previousPatches.forEach((patch) => {
|
|
131
|
+
for (const addedKey in patch.itemsAdded) {
|
|
132
|
+
normal.add(addedKey);
|
|
133
|
+
deleted.delete(addedKey);
|
|
134
|
+
}
|
|
135
|
+
for (const changedKey in patch.itemsAdded) {
|
|
136
|
+
normal.add(changedKey);
|
|
137
|
+
deleted.delete(changedKey);
|
|
138
|
+
}
|
|
139
|
+
for (const deletedKey of patch.itemsDeleted) {
|
|
140
|
+
normal.delete(deletedKey);
|
|
141
|
+
deleted.add(deletedKey);
|
|
142
|
+
}
|
|
143
|
+
});
|
|
144
|
+
return { normal, deleted };
|
|
145
|
+
}
|
|
146
|
+
function getRandomItemPatch(settings, previousPatches, optSyncSourceId, optSyncCollectionId) {
|
|
147
|
+
var _a;
|
|
148
|
+
const id = uuid();
|
|
149
|
+
const syncSourceId = optSyncSourceId !== null && optSyncSourceId !== void 0 ? optSyncSourceId : uuid();
|
|
150
|
+
const syncCollectionId = optSyncCollectionId !== null && optSyncCollectionId !== void 0 ? optSyncCollectionId : uuid();
|
|
151
|
+
const previousPatchBuckets = getPreviousPatchBuckets(previousPatches);
|
|
152
|
+
const settingsInstance = getRandomPatchSettingsInstance(settings);
|
|
153
|
+
const deletedWordGetter = randomWordOrPrevious(settings.deleteFrequency, previousPatchBuckets.deleted);
|
|
154
|
+
const addedItems = getRandomPatchItem(settingsInstance.addedIds, settingsInstance.schema, 1.0, deletedWordGetter);
|
|
155
|
+
const existingWordGetter = randomWordOrPrevious(settings.previousDependency, previousPatchBuckets.normal);
|
|
156
|
+
const changedItems = getRandomPatchItem(settingsInstance.changedIds, settingsInstance.schema, settings.changeDensity, existingWordGetter);
|
|
157
|
+
const deletedItems = [...new Set(Array.from({ length: settingsInstance.deletedIds }, existingWordGetter))];
|
|
158
|
+
deletedItems.forEach((deletedKey) => delete changedItems[deletedKey]);
|
|
159
|
+
const itemOrderChains = new Map();
|
|
160
|
+
const orderedAnchorsSet = (settingsInstance.orderedAnchors &&
|
|
161
|
+
new Set(Array.from({ length: settingsInstance.orderedAnchors }, existingWordGetter))) ||
|
|
162
|
+
undefined;
|
|
163
|
+
const orderedAnchors = orderedAnchorsSet && [...orderedAnchorsSet];
|
|
164
|
+
if (orderedAnchors && orderedAnchors.length > 0) {
|
|
165
|
+
for (const key in addedItems) {
|
|
166
|
+
const anchorIndex = Math.floor(Math.random() * orderedAnchors.length);
|
|
167
|
+
const anchor = orderedAnchors[anchorIndex];
|
|
168
|
+
if (!itemOrderChains.has(anchor)) {
|
|
169
|
+
itemOrderChains.set(anchor, new Set());
|
|
170
|
+
}
|
|
171
|
+
(_a = itemOrderChains.get(anchor)) === null || _a === void 0 ? void 0 : _a.add(key);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
const itemOrderChanged = [];
|
|
175
|
+
itemOrderChains.forEach((chain, anchor) => {
|
|
176
|
+
let lastNext = anchor;
|
|
177
|
+
chain.forEach((chainEntry) => {
|
|
178
|
+
itemOrderChanged.push([chainEntry, lastNext]);
|
|
179
|
+
lastNext = chainEntry;
|
|
180
|
+
});
|
|
181
|
+
});
|
|
182
|
+
return new patch_1.ItemPatch(id, addedItems, changedItems, deletedItems, itemOrderChanged, syncSourceId, syncCollectionId);
|
|
183
|
+
}
|
|
184
|
+
exports.getRandomItemPatch = getRandomItemPatch;
|
|
185
|
+
/**
|
|
186
|
+
* When we use raw JSON.stringify on objects, it can depend in subtle ways how the object was created. This
|
|
187
|
+
* function is intended to make the resulting string depend only on the logical contents so as to minimize unneeded
|
|
188
|
+
* refreshes.
|
|
189
|
+
*
|
|
190
|
+
* @param data The Sheet-type to stringify
|
|
191
|
+
* @returns A custom stringified object
|
|
192
|
+
*/
|
|
193
|
+
function orderedStringify(data) {
|
|
194
|
+
if ((0, checks_1.isArray)(data)) {
|
|
195
|
+
return `[${data.map((arrayItem) => (arrayItem == null ? 'null' : orderedStringify(arrayItem))).join(',')}]`;
|
|
196
|
+
}
|
|
197
|
+
if ((0, checks_1.isObject)(data)) {
|
|
198
|
+
const entries = Object.entries(data).filter(([_key, value]) => value !== undefined);
|
|
199
|
+
entries.sort(([keyA], [keyB]) => (keyA > keyB ? 1 : keyA === keyB ? 0 : -1));
|
|
200
|
+
return `{${entries.map(([key, value]) => `${JSON.stringify(key)}:${orderedStringify(value)}`).join(',')}}`;
|
|
201
|
+
}
|
|
202
|
+
return JSON.stringify(data);
|
|
203
|
+
}
|
|
204
|
+
exports.orderedStringify = orderedStringify;
|
|
205
|
+
function patchesEqual(patchA, patchB) {
|
|
206
|
+
if (patchA instanceof patch_1.ItemPatch && patchB instanceof patch_1.ItemPatch) {
|
|
207
|
+
if (patchA.id !== patchB.id ||
|
|
208
|
+
patchA.syncSourceId !== patchB.syncSourceId ||
|
|
209
|
+
patchA.syncCollectionId !== patchB.syncCollectionId ||
|
|
210
|
+
orderedStringify(patchA.itemsAdded) !== orderedStringify(patchB.itemsAdded) ||
|
|
211
|
+
orderedStringify(patchA.itemsChanged) !== orderedStringify(patchB.itemsChanged) ||
|
|
212
|
+
!arraySetsEqual(patchA.itemsDeleted, patchB.itemsDeleted)) {
|
|
213
|
+
return false;
|
|
214
|
+
}
|
|
215
|
+
const orderMapA = new Map(patchA.itemOrderChanged);
|
|
216
|
+
const orderMapB = new Map(patchB.itemOrderChanged);
|
|
217
|
+
if (orderMapA.size !== orderMapB.size) {
|
|
218
|
+
return false;
|
|
219
|
+
}
|
|
220
|
+
return [...orderMapA].every(([from, to]) => orderMapB.get(from) === to);
|
|
221
|
+
}
|
|
222
|
+
if (patchA instanceof patch_1.SchemaPatch && patchB instanceof patch_1.SchemaPatch) {
|
|
223
|
+
if (patchA.id !== patchB.id ||
|
|
224
|
+
patchA.syncSourceId !== patchB.syncSourceId ||
|
|
225
|
+
patchA.syncCollectionId !== patchB.syncCollectionId ||
|
|
226
|
+
!arraysEqualWith(patch_1.areThirdPartyColumnsEqual)(patchA.columnsAdded, patchB.columnsAdded) ||
|
|
227
|
+
!recordsEqualWith(patch_1.areThirdPartyColumnPatchesEqual)(patchA.columnsChanged, patchB.columnsChanged) ||
|
|
228
|
+
!arraySetsEqual(patchA.columnsDeleted, patchB.columnsDeleted)) {
|
|
229
|
+
return false;
|
|
230
|
+
}
|
|
231
|
+
const orderMapA = new Map(patchA.columnOrdering);
|
|
232
|
+
const orderMapB = new Map(patchB.columnOrdering);
|
|
233
|
+
if (orderMapA.size !== orderMapB.size) {
|
|
234
|
+
return false;
|
|
235
|
+
}
|
|
236
|
+
return [...orderMapA].every(([from, to]) => orderMapB.get(from) === to);
|
|
237
|
+
}
|
|
238
|
+
return false;
|
|
239
|
+
}
|
|
240
|
+
exports.patchesEqual = patchesEqual;
|