@dhyasama/totem-merge 7.1.3 → 7.2.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/docs/transactional-lead-merges.md +77 -0
- package/index.js +9 -0
- package/lib/atomicMerge.js +53 -0
- package/lib/attachmentMerge.js +65 -0
- package/lib/controls/customer-settings-gate.js +79 -0
- package/lib/customerMergeGuard.js +1 -1
- package/lib/leadMerge.js +100 -0
- package/lib/organizations.js +30 -9
- package/lib/people.js +36 -14
- package/lib/privateMergeChecks.js +27 -0
- package/lib/privateRecordMerge.js +133 -0
- package/lib/transactionContext.js +29 -0
- package/package.json +2 -2
- package/test/atomic-lead-merge.mongo.test.js +311 -0
- package/test/customer-merge-guard.test.js +5 -0
package/lib/people.js
CHANGED
|
@@ -142,7 +142,7 @@ var updateReferences = function updateReferences(context, target, source, merged
|
|
|
142
142
|
// Do not update cap tables or rounds as these are complicated
|
|
143
143
|
// and get cleaned and updated on each pull anyway
|
|
144
144
|
|
|
145
|
-
async.parallel([
|
|
145
|
+
(context.mergeSession ? async.series : async.parallel)([
|
|
146
146
|
|
|
147
147
|
updateDeals.bind(context, newPersonId, oldPersonId, mergedBy),
|
|
148
148
|
|
|
@@ -169,6 +169,7 @@ var updateDeals = function updateDeals(newPersonId, oldPersonId, user, cb) {
|
|
|
169
169
|
console.log(' Updating deals...');
|
|
170
170
|
|
|
171
171
|
var self = this;
|
|
172
|
+
cb = _.once(cb);
|
|
172
173
|
|
|
173
174
|
// Get all deals for this person
|
|
174
175
|
self.Deal.collection.find({ $or: [{ 'source.person': oldPersonId }, { 'referrer.person': oldPersonId }]}).toArray().then(function(items) {
|
|
@@ -182,11 +183,12 @@ var updateDeals = function updateDeals(newPersonId, oldPersonId, user, cb) {
|
|
|
182
183
|
|
|
183
184
|
var setObj = {};
|
|
184
185
|
|
|
185
|
-
if (item.source && item.source.person && item.source.person.toString() === oldPersonId.toString()) { setObj.
|
|
186
|
-
if (item.referrer && item.referrer.person && item.referrer.person.toString() === oldPersonId.toString()) { setObj.
|
|
186
|
+
if (item.source && item.source.person && item.source.person.toString() === oldPersonId.toString()) { setObj['source.person'] = newPersonId; }
|
|
187
|
+
if (item.referrer && item.referrer.person && item.referrer.person.toString() === oldPersonId.toString()) { setObj['referrer.person'] = newPersonId; }
|
|
187
188
|
|
|
188
189
|
tasks.push({
|
|
189
190
|
id: item._id,
|
|
191
|
+
customer: item.customer,
|
|
190
192
|
update: { $set: setObj }
|
|
191
193
|
});
|
|
192
194
|
|
|
@@ -195,13 +197,14 @@ var updateDeals = function updateDeals(newPersonId, oldPersonId, user, cb) {
|
|
|
195
197
|
console.log(' Found ' + tasks.length + ' deal references to update');
|
|
196
198
|
|
|
197
199
|
var queue = async.queue(function(task, callback) {
|
|
198
|
-
self.Deal.modifyById(task.id, task.update, {}, function(err, result) {
|
|
200
|
+
self.Deal.modifyById(task.id, task.update, { CUSTOMER_ID: task.customer }, function(err, result) {
|
|
199
201
|
if (err) { return callback(err, null); }
|
|
200
202
|
else if (result) { updated.push(result); }
|
|
201
203
|
return callback(null, result);
|
|
202
204
|
});
|
|
203
|
-
}, 8);
|
|
205
|
+
}, self.mergeSession ? 1 : 8);
|
|
204
206
|
|
|
207
|
+
queue.error = function(err) { queue.kill(); cb(err, null); };
|
|
205
208
|
queue.drain = function() { return cb(null, updated); };
|
|
206
209
|
|
|
207
210
|
queue.push(tasks);
|
|
@@ -215,6 +218,7 @@ var updateDocuments = function updateDocuments(newPersonId, oldPersonId, user, c
|
|
|
215
218
|
console.log(' Updating docs...');
|
|
216
219
|
|
|
217
220
|
var self = this;
|
|
221
|
+
cb = _.once(cb);
|
|
218
222
|
|
|
219
223
|
if (!self.Document || !self.Document.collection) { return cb(null, []); }
|
|
220
224
|
|
|
@@ -235,6 +239,7 @@ var updateDocuments = function updateDocuments(newPersonId, oldPersonId, user, c
|
|
|
235
239
|
if (Object.keys(setObj).length > 0) {
|
|
236
240
|
tasks.push({
|
|
237
241
|
id: item._id,
|
|
242
|
+
customer: item.customer,
|
|
238
243
|
update: { $set: setObj }
|
|
239
244
|
});
|
|
240
245
|
}
|
|
@@ -244,13 +249,14 @@ var updateDocuments = function updateDocuments(newPersonId, oldPersonId, user, c
|
|
|
244
249
|
console.log(' Found ' + tasks.length + ' doc references to update');
|
|
245
250
|
|
|
246
251
|
var queue = async.queue(function(task, callback) {
|
|
247
|
-
self.Document.modifyById(task.id, task.update, {}, function(err, result) {
|
|
252
|
+
self.Document.modifyById(task.id, task.update, { CUSTOMER_ID: task.customer }, function(err, result) {
|
|
248
253
|
if (err) { return callback(err, null); }
|
|
249
254
|
else if (result) { updated.push(result); }
|
|
250
255
|
return callback(null, result);
|
|
251
256
|
});
|
|
252
|
-
}, 8);
|
|
257
|
+
}, self.mergeSession ? 1 : 8);
|
|
253
258
|
|
|
259
|
+
queue.error = function(err) { queue.kill(); cb(err, null); };
|
|
254
260
|
queue.drain = function() { return cb(null, updated); };
|
|
255
261
|
|
|
256
262
|
queue.push(tasks);
|
|
@@ -264,6 +270,7 @@ var updateMeetings = function updateMeetings(newPersonId, oldPersonId, user, cb)
|
|
|
264
270
|
console.log(' Updating meetings...');
|
|
265
271
|
|
|
266
272
|
var self = this;
|
|
273
|
+
cb = _.once(cb);
|
|
267
274
|
|
|
268
275
|
if (!self.Meeting || !self.Meeting.collection) {
|
|
269
276
|
return cb(null, []);
|
|
@@ -286,6 +293,7 @@ var updateMeetings = function updateMeetings(newPersonId, oldPersonId, user, cb)
|
|
|
286
293
|
|
|
287
294
|
tasks.push({
|
|
288
295
|
id: item._id,
|
|
296
|
+
customer: item.customer,
|
|
289
297
|
update: { $set: { 'people': item.people } }
|
|
290
298
|
});
|
|
291
299
|
|
|
@@ -298,8 +306,9 @@ var updateMeetings = function updateMeetings(newPersonId, oldPersonId, user, cb)
|
|
|
298
306
|
if (result) { updated.push(result); }
|
|
299
307
|
return callback(null, result);
|
|
300
308
|
}, function(err) { return callback(err, null); });
|
|
301
|
-
}, 8);
|
|
309
|
+
}, self.mergeSession ? 1 : 8);
|
|
302
310
|
|
|
311
|
+
queue.error = function(err) { queue.kill(); cb(err, null); };
|
|
303
312
|
queue.drain = function() { return cb(null, updated); };
|
|
304
313
|
|
|
305
314
|
queue.push(tasks);
|
|
@@ -313,6 +322,7 @@ var updateLimitedPartners = function updateLimitedPartners(newPersonId, oldPerso
|
|
|
313
322
|
console.log(' Updating lps...');
|
|
314
323
|
|
|
315
324
|
var self = this;
|
|
325
|
+
cb = _.once(cb);
|
|
316
326
|
|
|
317
327
|
// Get all lps for this person
|
|
318
328
|
self.LimitedPartner.collection.find({ 'people': oldPersonId }).toArray().then(function(items) {
|
|
@@ -331,6 +341,7 @@ var updateLimitedPartners = function updateLimitedPartners(newPersonId, oldPerso
|
|
|
331
341
|
|
|
332
342
|
tasks.push({
|
|
333
343
|
id: item._id,
|
|
344
|
+
customer: item.customer,
|
|
334
345
|
update: { $set: {
|
|
335
346
|
'people': item.people
|
|
336
347
|
}
|
|
@@ -347,8 +358,9 @@ var updateLimitedPartners = function updateLimitedPartners(newPersonId, oldPerso
|
|
|
347
358
|
else if (result) { updated.push(result); }
|
|
348
359
|
return callback(null, result);
|
|
349
360
|
});
|
|
350
|
-
}, 8);
|
|
361
|
+
}, self.mergeSession ? 1 : 8);
|
|
351
362
|
|
|
363
|
+
queue.error = function(err) { queue.kill(); cb(err, null); };
|
|
352
364
|
queue.drain = function() { return cb(null, updated); };
|
|
353
365
|
|
|
354
366
|
queue.push(tasks);
|
|
@@ -362,6 +374,7 @@ var updateMessages = function updateMessages(newPersonId, oldPersonId, user, cb)
|
|
|
362
374
|
console.log(' Updating messages...');
|
|
363
375
|
|
|
364
376
|
var self = this;
|
|
377
|
+
cb = _.once(cb);
|
|
365
378
|
|
|
366
379
|
// Get all messages for this person
|
|
367
380
|
self.Message.collection.find({ $or: [{ 'recipients.external': oldPersonId }, { 'recipients.internal': oldPersonId }] }).toArray().then(function(items) {
|
|
@@ -385,6 +398,7 @@ var updateMessages = function updateMessages(newPersonId, oldPersonId, user, cb)
|
|
|
385
398
|
|
|
386
399
|
tasks.push({
|
|
387
400
|
id: item._id,
|
|
401
|
+
customer: item.customer,
|
|
388
402
|
update: { $set: {
|
|
389
403
|
'recipients.internal': item.recipients.internal,
|
|
390
404
|
'recipients.external': item.recipients.external
|
|
@@ -402,8 +416,9 @@ var updateMessages = function updateMessages(newPersonId, oldPersonId, user, cb)
|
|
|
402
416
|
else if (result) { updated.push(result); }
|
|
403
417
|
return callback(null, result);
|
|
404
418
|
});
|
|
405
|
-
}, 8);
|
|
419
|
+
}, self.mergeSession ? 1 : 8);
|
|
406
420
|
|
|
421
|
+
queue.error = function(err) { queue.kill(); cb(err, null); };
|
|
407
422
|
queue.drain = function() { return cb(null, updated); };
|
|
408
423
|
|
|
409
424
|
queue.push(tasks);
|
|
@@ -417,6 +432,7 @@ var updateNotes = function updateNotes(newPersonId, oldPersonId, user, cb) {
|
|
|
417
432
|
console.log(' Updating notes...');
|
|
418
433
|
|
|
419
434
|
var self = this;
|
|
435
|
+
cb = _.once(cb);
|
|
420
436
|
|
|
421
437
|
// Get all notes for this person
|
|
422
438
|
self.Note.collection.find({ 'createdBy': oldPersonId }).toArray().then(function(items) {
|
|
@@ -434,6 +450,7 @@ var updateNotes = function updateNotes(newPersonId, oldPersonId, user, cb) {
|
|
|
434
450
|
|
|
435
451
|
tasks.push({
|
|
436
452
|
id: item._id,
|
|
453
|
+
customer: item.customer,
|
|
437
454
|
update: { $set: setObj }
|
|
438
455
|
});
|
|
439
456
|
|
|
@@ -442,13 +459,14 @@ var updateNotes = function updateNotes(newPersonId, oldPersonId, user, cb) {
|
|
|
442
459
|
console.log(' Found ' + tasks.length + ' note references to update');
|
|
443
460
|
|
|
444
461
|
var queue = async.queue(function(task, callback) {
|
|
445
|
-
self.Note.modifyById(task.id, task.update, {}, function(err, result) {
|
|
462
|
+
self.Note.modifyById(task.id, task.update, { CUSTOMER_ID: task.customer }, function(err, result) {
|
|
446
463
|
if (err) { return callback(err, null); }
|
|
447
464
|
else if (result) { updated.push(result); }
|
|
448
465
|
return callback(null, result);
|
|
449
466
|
});
|
|
450
|
-
}, 8);
|
|
467
|
+
}, self.mergeSession ? 1 : 8);
|
|
451
468
|
|
|
469
|
+
queue.error = function(err) { queue.kill(); cb(err, null); };
|
|
452
470
|
queue.drain = function() { return cb(null, updated); };
|
|
453
471
|
|
|
454
472
|
queue.push(tasks);
|
|
@@ -462,6 +480,7 @@ var updateOrganizations = function updateOrganizations(newPersonId, oldPersonId,
|
|
|
462
480
|
console.log(' Updating orgs...');
|
|
463
481
|
|
|
464
482
|
var self = this;
|
|
483
|
+
cb = _.once(cb);
|
|
465
484
|
|
|
466
485
|
// Get all orgs for this person
|
|
467
486
|
self.Organization.collection.find({ $or: [{ 'people.person': oldPersonId }, { 'chairs.first': oldPersonId }, { 'chairs.second': oldPersonId }] }).toArray().then(function(orgs) {
|
|
@@ -544,8 +563,9 @@ var updateOrganizations = function updateOrganizations(newPersonId, oldPersonId,
|
|
|
544
563
|
else if (result) { updated.push(result); }
|
|
545
564
|
return callback(null, result);
|
|
546
565
|
});
|
|
547
|
-
}, 8);
|
|
566
|
+
}, self.mergeSession ? 1 : 8);
|
|
548
567
|
|
|
568
|
+
queue.error = function(err) { queue.kill(); cb(err, null); };
|
|
549
569
|
queue.drain = function() { return cb(null, updated); };
|
|
550
570
|
|
|
551
571
|
queue.push(tasks);
|
|
@@ -559,6 +579,7 @@ var updatePeople = function updatePeople(newPersonId, oldPersonId, user, cb) {
|
|
|
559
579
|
console.log(' Updating people...');
|
|
560
580
|
|
|
561
581
|
var self = this;
|
|
582
|
+
cb = _.once(cb);
|
|
562
583
|
|
|
563
584
|
// Update Person.related where oldPersonId appears
|
|
564
585
|
self.Person.collection.find({ 'related': oldPersonId }).toArray().then(function(people) {
|
|
@@ -592,8 +613,9 @@ var updatePeople = function updatePeople(newPersonId, oldPersonId, user, cb) {
|
|
|
592
613
|
else if (result) { updated.push(result); }
|
|
593
614
|
return callback(null, result);
|
|
594
615
|
});
|
|
595
|
-
}, 8);
|
|
616
|
+
}, self.mergeSession ? 1 : 8);
|
|
596
617
|
|
|
618
|
+
queue.error = function(err) { queue.kill(); cb(err, null); };
|
|
597
619
|
queue.drain = function() { return cb(null, updated); };
|
|
598
620
|
|
|
599
621
|
queue.push(tasks);
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
const { Types: { ObjectId } } = require('mongoose');
|
|
3
|
+
const { isDeepStrictEqual } = require('node:util');
|
|
4
|
+
function id(value) {
|
|
5
|
+
if (value?._bsontype !== 'ObjectId' || !/^[a-f0-9]{24}$/i.test(String(value))) throw Error('Malformed private merge reference');
|
|
6
|
+
return String(value);
|
|
7
|
+
}
|
|
8
|
+
const oid = value => new ObjectId(value);
|
|
9
|
+
function array(value) {
|
|
10
|
+
if (value == null) return [];
|
|
11
|
+
if (!Array.isArray(value)) throw Error('Malformed private merge array');
|
|
12
|
+
return value;
|
|
13
|
+
}
|
|
14
|
+
function object(value) {
|
|
15
|
+
if (!value || ![Object.prototype, null].includes(Object.getPrototypeOf(value))) throw Error('Malformed custom fields');
|
|
16
|
+
return value;
|
|
17
|
+
}
|
|
18
|
+
async function owners(db, session, values) {
|
|
19
|
+
for (const customer of new Set(values)) {
|
|
20
|
+
const tenant = await db.collection('customers').findOne({ _id: oid(customer) }, { session, projection: { organization: 1 } });
|
|
21
|
+
if (!tenant || id(tenant.organization) !== customer) throw Error('Customer identity mismatch');
|
|
22
|
+
const variants = [customer, oid(customer)];
|
|
23
|
+
const jobs = await db.collection('deletions').find({ kind: 'job', $or: [{ _id: { $in: variants } }, { customerId: { $in: variants } }] }, { session }).toArray();
|
|
24
|
+
if (jobs.some(job => job.operation === 'delete' || !['completed', 'scheduled'].includes(job.status))) throw Error('Customer lifecycle blocks private merge');
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
module.exports = { id, oid, array, object, owners, equal: isDeepStrictEqual };
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
const { id, oid, array, object, owners, equal } = require('./privateMergeChecks');
|
|
3
|
+
const own = (object, key) => Object.prototype.hasOwnProperty.call(object, key);
|
|
4
|
+
function groups(entries) {
|
|
5
|
+
const result = new Map();
|
|
6
|
+
for (const entry of array(entries)) {
|
|
7
|
+
if (typeof entry.group !== 'string' || !entry.group.trim() || entry.group !== entry.group.trim() || result.has(entry.group)) throw Error('Malformed tag groups');
|
|
8
|
+
const items = array(entry.items);
|
|
9
|
+
if (items.some(item => typeof item !== 'string' || !item.trim() || item !== item.trim()) || new Set(items).size !== items.length) throw Error('Malformed tag items');
|
|
10
|
+
result.set(entry.group, { ...entry, items: [...items] });
|
|
11
|
+
}
|
|
12
|
+
return result;
|
|
13
|
+
}
|
|
14
|
+
function tagValue(entries) {
|
|
15
|
+
return [...groups(entries)].map(([name, entry]) => [name, [...entry.items].sort()]).sort((a, b) => a[0].localeCompare(b[0]));
|
|
16
|
+
}
|
|
17
|
+
function valuationValue(entry) {
|
|
18
|
+
// Stored values only. Do not fill absent values using schema defaults.
|
|
19
|
+
const result = {};
|
|
20
|
+
for (const key of ['date', 'valuation', 'pricePerShare', 'method']) if (own(entry, key)) result[key] = entry[key];
|
|
21
|
+
return result;
|
|
22
|
+
}
|
|
23
|
+
function sameEntries(actual, expected) {
|
|
24
|
+
const unmatched = [...expected];
|
|
25
|
+
for (const entry of actual) {
|
|
26
|
+
const position = unmatched.findIndex(candidate => equal(candidate, entry));
|
|
27
|
+
if (position < 0) return false;
|
|
28
|
+
unmatched.splice(position, 1);
|
|
29
|
+
}
|
|
30
|
+
return unmatched.length === 0;
|
|
31
|
+
}
|
|
32
|
+
async function prepare(db, session, kind, target, source) {
|
|
33
|
+
const ids = [target._id, source._id], targetId = id(target._id), ownerIds = [];
|
|
34
|
+
const plan = { targetId, kind, records: [], mirrors: {} };
|
|
35
|
+
// Raw parent reads prevent Mongoose defaults from becoming stored values.
|
|
36
|
+
const parents = await db.collection(kind === 'Organization' ? 'organizations' : 'people').find({ _id: { $in: ids } }, { session }).toArray();
|
|
37
|
+
for (const [collection, field] of [['tags', 'filters'], ['valuations', 'valuations']]) {
|
|
38
|
+
if (kind === 'Person' && collection !== 'tags') continue;
|
|
39
|
+
const filter = collection === 'valuations' ? { organization: { $in: ids } } :
|
|
40
|
+
{ 'entity.model': kind, 'entity.id': { $in: ids } };
|
|
41
|
+
const rows = await db.collection(collection).find(filter, { session }).toArray();
|
|
42
|
+
const buckets = new Map();
|
|
43
|
+
for (const row of rows) {
|
|
44
|
+
const customer = id(row.customer), entity = id(collection === 'valuations' ? row.organization : row.entity.id);
|
|
45
|
+
ownerIds.push(customer);
|
|
46
|
+
if (!buckets.has(customer)) buckets.set(customer, new Map());
|
|
47
|
+
const entities = buckets.get(customer);
|
|
48
|
+
if (!entities.has(entity)) entities.set(entity, []);
|
|
49
|
+
entities.get(entity).push(row);
|
|
50
|
+
}
|
|
51
|
+
if (kind === 'Organization') {
|
|
52
|
+
for (const parent of parents) {
|
|
53
|
+
const legacy = array(parent[field]), entity = id(parent._id);
|
|
54
|
+
const customers = new Set(legacy.map(entry => id(entry.customer)));
|
|
55
|
+
for (const [customer, entities] of buckets) if (entities.has(entity)) customers.add(customer);
|
|
56
|
+
for (const customer of customers) {
|
|
57
|
+
const old = legacy.filter(entry => id(entry.customer) === customer);
|
|
58
|
+
const current = buckets.get(customer)?.get(entity) || [];
|
|
59
|
+
let matches;
|
|
60
|
+
if (collection === 'tags') matches = current.length === 1 && equal(tagValue(current[0].groups), tagValue(old));
|
|
61
|
+
else matches = sameEntries(current.map(valuationValue), old.map(valuationValue));
|
|
62
|
+
if (!matches) throw Error('Private merge requires reconciled ' + collection);
|
|
63
|
+
// A legacy valuation ID is part of its identity, not just its value.
|
|
64
|
+
if (collection === 'valuations' && current.some(row => row.legacyId &&
|
|
65
|
+
!old.some(entry => entry._id && id(entry._id) === id(row.legacyId) && equal(valuationValue(entry), valuationValue(row))))) {
|
|
66
|
+
throw Error('Valuation legacy identity mismatch');
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
const mirror = [];
|
|
72
|
+
for (const [customer, entities] of buckets) {
|
|
73
|
+
const targetRows = entities.get(targetId) || [], sourceRows = entities.get(id(source._id)) || [];
|
|
74
|
+
if (collection !== 'valuations' && (targetRows.length > 1 || sourceRows.length > 1)) throw Error('Duplicate private records');
|
|
75
|
+
const all = [...targetRows, ...sourceRows];
|
|
76
|
+
if (collection === 'valuations') {
|
|
77
|
+
const seen = new Set();
|
|
78
|
+
for (const row of all) {
|
|
79
|
+
if (row.legacyId) {
|
|
80
|
+
const legacyId = id(row.legacyId);
|
|
81
|
+
if (seen.has(legacyId)) throw Error('Valuation legacy ID collision requires resolution');
|
|
82
|
+
seen.add(legacyId);
|
|
83
|
+
}
|
|
84
|
+
plan.records.push({ collection, keep: row, set: { organization: oid(targetId) }, remove: [] });
|
|
85
|
+
mirror.push({ _id: row.legacyId || row._id, customer: oid(customer), ...valuationValue(row) });
|
|
86
|
+
}
|
|
87
|
+
} else if (collection === 'tags') {
|
|
88
|
+
const combined = new Map();
|
|
89
|
+
for (const row of all) for (const [name, entry] of groups(row.groups)) {
|
|
90
|
+
if (!combined.has(name)) combined.set(name, { ...entry, items: [] });
|
|
91
|
+
const group = combined.get(name);
|
|
92
|
+
group.items = [...new Set([...group.items, ...entry.items])];
|
|
93
|
+
}
|
|
94
|
+
const next = [...combined.values()];
|
|
95
|
+
plan.records.push({ collection, keep: all[0], remove: all.slice(1), set: { entity: { model: kind, id: oid(targetId) }, groups: next } });
|
|
96
|
+
mirror.push(...next.map(group => ({ ...group, customer: oid(customer) })));
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
if (kind === 'Organization') plan.mirrors[field] = mirror;
|
|
100
|
+
}
|
|
101
|
+
// Custom fields stay embedded. Preserve both parents' values without consulting
|
|
102
|
+
// or updating the deferred CustomField collection. The old generic array
|
|
103
|
+
// merger can discard entries that have no subdocument ID.
|
|
104
|
+
if (kind === 'Organization' && parents.some(parent => own(parent, 'customFields'))) {
|
|
105
|
+
const embedded = new Map();
|
|
106
|
+
const ordered = [targetId, id(source._id)].map(value => parents.find(parent => id(parent._id) === value));
|
|
107
|
+
for (const parent of ordered) for (const entry of array(parent.customFields)) {
|
|
108
|
+
const customer = id(entry.customer); ownerIds.push(customer);
|
|
109
|
+
if (!embedded.has(customer)) embedded.set(customer, { ...entry, customer: oid(customer), fields: Object.create(null) });
|
|
110
|
+
const fields = embedded.get(customer).fields;
|
|
111
|
+
for (const [key, value] of Object.entries(object(entry.fields))) {
|
|
112
|
+
if (own(fields, key) && !equal(fields[key], value)) throw Error('Conflicting embedded custom fields require resolution');
|
|
113
|
+
fields[key] = value;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
plan.mirrors.customFields = [...embedded.values()];
|
|
117
|
+
}
|
|
118
|
+
await owners(db, session, ownerIds);
|
|
119
|
+
return plan;
|
|
120
|
+
}
|
|
121
|
+
async function apply(db, session, plan) {
|
|
122
|
+
for (const item of plan.records) {
|
|
123
|
+
for (const row of item.remove) await db.collection(item.collection).deleteOne({ _id: row._id, customer: row.customer }, { session });
|
|
124
|
+
const result = await db.collection(item.collection).updateOne({ _id: item.keep._id, customer: item.keep.customer },
|
|
125
|
+
{ $set: item.set, $inc: { __v: 1 } }, { session });
|
|
126
|
+
if (result.matchedCount !== 1) throw Error('Private record changed during merge');
|
|
127
|
+
}
|
|
128
|
+
if (plan.kind === 'Organization') {
|
|
129
|
+
const result = await db.collection('organizations').updateOne({ _id: oid(plan.targetId) }, { $set: plan.mirrors }, { session });
|
|
130
|
+
if (result.matchedCount !== 1) throw Error('Private merge target changed');
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
module.exports = { prepare, apply };
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// Per-merge facades, never mutations of global models. Only operations used by
|
|
3
|
+
// the merge package are exposed, with the same session on every query/write.
|
|
4
|
+
function collection(raw, session) {
|
|
5
|
+
return {
|
|
6
|
+
find: (filter, options) => raw.find(filter, { ...options, session }),
|
|
7
|
+
findOne: (filter, options) => raw.findOne(filter, { ...options, session }),
|
|
8
|
+
updateOne: (filter, update, options) => raw.updateOne(filter, update, { ...options, session })
|
|
9
|
+
};
|
|
10
|
+
}
|
|
11
|
+
module.exports = function transactionContext(context, session) {
|
|
12
|
+
const result = { ...context, mergeSession: session };
|
|
13
|
+
for (const [name, Model] of Object.entries(context)) {
|
|
14
|
+
if (!Model?.modelName) continue;
|
|
15
|
+
const scoped = {
|
|
16
|
+
modelName: Model.modelName,
|
|
17
|
+
collection: collection(Model.collection, session),
|
|
18
|
+
db: { collection: name => collection(Model.db.collection(name), session) },
|
|
19
|
+
findByIdAndUpdate: (...args) => Model.findByIdAndUpdate(...args).session(session),
|
|
20
|
+
findOneAndUpdate: (...args) => Model.findOneAndUpdate(...args).session(session),
|
|
21
|
+
deleteOne: (...args) => Model.deleteOne(...args).session(session)
|
|
22
|
+
};
|
|
23
|
+
for (const method of ['upsert', 'modifyById']) {
|
|
24
|
+
if (Model[method]) scoped[method] = (...args) => Model[method].apply(scoped, args);
|
|
25
|
+
}
|
|
26
|
+
result[name] = scoped;
|
|
27
|
+
}
|
|
28
|
+
return result;
|
|
29
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dhyasama/totem-merge",
|
|
3
|
-
"version": "7.
|
|
3
|
+
"version": "7.2.0",
|
|
4
4
|
"description": "Org and person merging package",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"scripts": {
|
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
"mongoose": "^8.24.3"
|
|
28
28
|
},
|
|
29
29
|
"devDependencies": {
|
|
30
|
-
"@dhyasama/totem-models": "
|
|
30
|
+
"@dhyasama/totem-models": "17.0.0",
|
|
31
31
|
"mocha": "^12.0.2",
|
|
32
32
|
"mocha-mongoose": "^1.1.1",
|
|
33
33
|
"should": "^8.2.2"
|