@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/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.source = { person: newPersonId }; }
186
- if (item.referrer && item.referrer.person && item.referrer.person.toString() === oldPersonId.toString()) { setObj.referrer = { person: newPersonId }; }
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.1.3",
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": "15.1.0",
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"