@dhyasama/totem-merge 7.2.0 → 8.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/docs/transactional-lead-merges.md +21 -23
- package/index.js +8 -78
- package/lib/leadMerge.js +4 -33
- package/lib/organizations.js +3 -0
- package/lib/people.js +1 -7
- package/lib/privateRecordMerge.js +2 -44
- package/package.json +1 -1
- package/test/atomic-lead-merge.mongo.test.js +38 -12
|
@@ -1,18 +1,19 @@
|
|
|
1
1
|
# Transactional company and person merges
|
|
2
2
|
|
|
3
|
-
Status:
|
|
4
|
-
|
|
3
|
+
Status: 8.0.0 native-only release candidate, 2026-10-25.
|
|
4
|
+
The 7.3.0 compatibility release is deployed before mirror retirement.
|
|
5
5
|
|
|
6
6
|
## First-rollout scope
|
|
7
7
|
|
|
8
8
|
The owner's first rollout covers Customer settings (already completed), Lead,
|
|
9
9
|
Tag and Valuation. Notes, Documents and custom fields remain on their existing
|
|
10
|
-
records.
|
|
11
|
-
Lead/Tag/Valuation
|
|
10
|
+
records. All public merge entry points require the transaction and native
|
|
11
|
+
Lead/Tag/Valuation collections. The legacy merge mode is removed; no setting
|
|
12
|
+
can opt out. This is a major release and does not activate deferred migrations.
|
|
12
13
|
|
|
13
14
|
Both document and ID entry points reload the source and target on every retry.
|
|
14
15
|
Source deletion, target save, existing reference updates, Lead/Tag/Valuation
|
|
15
|
-
updates
|
|
16
|
+
updates commit together. Organization mirror arrays are not updated. Company merges union distinct
|
|
16
17
|
leads per customer with no count limit. Person merges replace the source Person
|
|
17
18
|
and deduplicate assignments. Tags combine groups/items per owner. Every valuation
|
|
18
19
|
occurrence and database ID survives; conflicting legacy IDs block the merge.
|
|
@@ -20,10 +21,10 @@ occurrence and database ID survives; conflicting legacy IDs block the merge.
|
|
|
20
21
|
The path requires a replica set, models on one connection, uniqueness indexes on
|
|
21
22
|
Lead/Tag/Valuation, and the existing open customer-settings control record. Its
|
|
22
23
|
transaction fence coordinates with lifecycle admission and private-data writers.
|
|
23
|
-
No write pause or queue/schedule hold is introduced. Missing
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
24
|
+
No write pause or queue/schedule hold is introduced. Missing native records
|
|
25
|
+
mean empty data; old arrays never restore private records. Invalid owners,
|
|
26
|
+
blocking lifecycle jobs, missing people and colliding native identities fail
|
|
27
|
+
the whole transaction. A committed retry is recognized by merged history.
|
|
27
28
|
|
|
28
29
|
## Deferred fields stay in legacy storage
|
|
29
30
|
|
|
@@ -55,23 +56,20 @@ release review; they are separate from enabling new-store authority.
|
|
|
55
56
|
42 targeted tests pass, including synthetic replica-set merges against the web
|
|
56
57
|
consumer's published models 17.0.0. Coverage includes unlimited Leads, tenant
|
|
57
58
|
isolation, transaction rollback/retries, concurrent fenced edits, committed replay,
|
|
58
|
-
Tag
|
|
59
|
+
stale Tag mirrors, valuation identity, embedded custom-field preservation/conflicts,
|
|
59
60
|
untouched native CustomFields/entities, legacy attachment links and lifecycle
|
|
60
61
|
checks. Merges work without a CustomField uniqueness index and never initialize
|
|
61
62
|
attachment entities. Tests use a uniquely named disposable local database.
|
|
62
63
|
|
|
63
|
-
|
|
64
|
-
coverage and reconciliation, test production-sized transaction cost, and verify
|
|
65
|
-
all consumers, caches and Pentest behavior. The narrowed preparation completed a clean 423-Customer production comparison
|
|
66
|
-
on 2026-10-24; repeat live parity checks before activation. Native attachment and CustomField consumers
|
|
67
|
-
must remain disabled. This draft is not readiness to cut over or remove fields.
|
|
64
|
+
## Retirement release verification
|
|
68
65
|
|
|
69
|
-
|
|
66
|
+
The 7.3.0 compatibility release is verified on both web hosts and sheet-sync.
|
|
67
|
+
Version 8.0.0 removes mirror writes and the legacy merge entry paths. All 47
|
|
68
|
+
focused merge/security checks pass on Node 24 with consumer models 19.0.0.
|
|
69
|
+
Notes, documents and embedded custom fields stay in scope only for preserving
|
|
70
|
+
existing associations and values; their deferred migrations remain disabled.
|
|
70
71
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
that every legacy merge behavior is identical. Before transactional activation,
|
|
76
|
-
finish writer deployment and production-scale cost checks. Once enabled, rollback
|
|
77
|
-
must retain native/legacy mirroring rather than reverting to an unfenced merger.
|
|
72
|
+
Once mirror writes stop, rollbacks must use 7.3.0 or a later native-authoritative
|
|
73
|
+
release. An earlier legacy reader would observe stale data. Stored-array and
|
|
74
|
+
schema removal is a subsequent deployment after all native-only writers are
|
|
75
|
+
verified; no write pause or schedule hold is required.
|
package/index.js
CHANGED
|
@@ -1,13 +1,12 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
var people = require('./lib/people');
|
|
3
|
+
const atomicMerge = require('./lib/atomicMerge');
|
|
5
4
|
var Logger = require('@dhyasama/totem-logger');
|
|
6
5
|
|
|
7
6
|
// Constructor
|
|
8
7
|
var Merge = function(mongoose, config) {
|
|
9
8
|
|
|
10
|
-
this.transactionalPrivateData =
|
|
9
|
+
this.transactionalPrivateData = true;
|
|
11
10
|
this.env = process.env.NODE_ENV || 'development';
|
|
12
11
|
this.logger = new Logger(config.logentries);
|
|
13
12
|
this.CapTable = mongoose.model('CapTable');
|
|
@@ -30,80 +29,11 @@ var Merge = function(mongoose, config) {
|
|
|
30
29
|
|
|
31
30
|
//////////////////////////////////////////////
|
|
32
31
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
};
|
|
38
|
-
|
|
39
|
-
Merge.prototype.organizationsById = function(targetId, sourceId, user, customerId, cb) {
|
|
40
|
-
|
|
41
|
-
if (this.transactionalPrivateData) return require('./lib/atomicMerge')(this, 'Organization', targetId, sourceId, user, customerId).then(result => cb(null, result), error => cb(error, null));
|
|
42
|
-
|
|
43
|
-
var self = this;
|
|
44
|
-
|
|
45
|
-
self.logger.writeToInfoLog('Merge org ' + sourceId + ' into ' + targetId);
|
|
46
|
-
|
|
47
|
-
self.Organization.getById(targetId, { CUSTOMER_ID: customerId, isWorkerProcess: true }, function(err, target) {
|
|
48
|
-
|
|
49
|
-
if (err) { return cb(err, null); }
|
|
50
|
-
else if (!target) { return cb(new Error('target not found'), null); }
|
|
51
|
-
|
|
52
|
-
self.logger.writeToInfoLog(' Found target ' + target.name);
|
|
53
|
-
|
|
54
|
-
self.Organization.getById(sourceId, { CUSTOMER_ID: customerId, isWorkerProcess: true }, function(err, source) {
|
|
55
|
-
|
|
56
|
-
if (err) { return cb(err, null); }
|
|
57
|
-
else if (!source) { return cb(new Error('source not found'), null); }
|
|
58
|
-
|
|
59
|
-
self.logger.writeToInfoLog(' Found source ' + source.name);
|
|
60
|
-
self.logger.writeToInfoLog(' Merging...');
|
|
61
|
-
|
|
62
|
-
organizations.merge(self, target, source, user, customerId, cb);
|
|
63
|
-
|
|
64
|
-
});
|
|
65
|
-
|
|
66
|
-
});
|
|
67
|
-
|
|
68
|
-
};
|
|
69
|
-
|
|
70
|
-
Merge.prototype.people = function(target, source, user, customerId, cb) {
|
|
71
|
-
|
|
72
|
-
if (this.transactionalPrivateData) return require('./lib/atomicMerge')(this, 'Person', target, source, user, customerId).then(result => cb(null, result), error => cb(error, null));
|
|
73
|
-
people.merge(this, target, source, user, customerId, cb);
|
|
74
|
-
};
|
|
75
|
-
|
|
76
|
-
Merge.prototype.peopleById = function(targetId, sourceId, user, customerId, cb) {
|
|
77
|
-
|
|
78
|
-
if (this.transactionalPrivateData) return require('./lib/atomicMerge')(this, 'Person', targetId, sourceId, user, customerId).then(result => cb(null, result), error => cb(error, null));
|
|
79
|
-
|
|
80
|
-
var self = this;
|
|
81
|
-
|
|
82
|
-
self.logger.writeToInfoLog('Merge person ' + sourceId + ' into ' + targetId);
|
|
83
|
-
|
|
84
|
-
self.Person.getById(targetId, { CUSTOMER_ID: customerId, isWorkerProcess: true }, function(err, target) {
|
|
85
|
-
|
|
86
|
-
if (err) { return cb(err, null); }
|
|
87
|
-
else if (!target) { return cb(new Error('target not found'), null); }
|
|
88
|
-
|
|
89
|
-
console.log(' Found target', target.name.full);
|
|
90
|
-
console.log(' Target sources:', target.sources.length);
|
|
91
|
-
|
|
92
|
-
self.Person.getById(sourceId, { CUSTOMER_ID: customerId, isWorkerProcess: true }, function(err, source) {
|
|
93
|
-
|
|
94
|
-
if (err) { return cb(err, null); }
|
|
95
|
-
else if (!source) { return cb(new Error('source not found'), null); }
|
|
96
|
-
|
|
97
|
-
self.logger.writeToInfoLog(' Found source ' + source.name.full);
|
|
98
|
-
self.logger.writeToInfoLog(' Source sources ' + source.sources.length);
|
|
99
|
-
self.logger.writeToInfoLog(' Merging...');
|
|
100
|
-
|
|
101
|
-
people.merge(self, target, source, user, customerId, cb);
|
|
102
|
-
|
|
103
|
-
});
|
|
104
|
-
|
|
105
|
-
});
|
|
106
|
-
|
|
107
|
-
};
|
|
32
|
+
// Native collections and the lifecycle transaction are mandatory after retirement.
|
|
33
|
+
for (const [method, kind] of [['organizations', 'Organization'], ['organizationsById', 'Organization'], ['people', 'Person'], ['peopleById', 'Person']]) {
|
|
34
|
+
Merge.prototype[method] = function(target, source, user, customerId, cb) {
|
|
35
|
+
return atomicMerge(this, kind, target, source, user, customerId).then(result => cb(null, result), error => cb(error, null));
|
|
36
|
+
};
|
|
37
|
+
}
|
|
108
38
|
|
|
109
39
|
module.exports = Merge;
|
package/lib/leadMerge.js
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
'use strict';
|
|
2
|
-
const crypto = require('node:crypto');
|
|
3
2
|
const { Types: { ObjectId } } = require('mongoose');
|
|
4
3
|
const text = value => String(value?._id || value || '').toLowerCase();
|
|
5
4
|
function id(value) {
|
|
@@ -9,34 +8,19 @@ function id(value) {
|
|
|
9
8
|
}
|
|
10
9
|
const oid = value => new ObjectId(id(value));
|
|
11
10
|
const unique = values => [...new Set(values.map(id))];
|
|
12
|
-
const equal = (a, b) => JSON.stringify([...a].sort()) === JSON.stringify([...b].sort());
|
|
13
11
|
const key = (customer, organization) => customer + '/' + organization;
|
|
14
12
|
|
|
15
13
|
// Capture/validate before source deletion. The caller holds the same transaction
|
|
16
14
|
// fence as Lead writers and customer deletion/downgrade admission throughout.
|
|
17
15
|
async function prepare(db, session, kind, target, source) {
|
|
18
16
|
const targetId = id(target), sourceId = id(source);
|
|
19
|
-
const companyFilter = kind === 'Organization' ? { _id: { $in: [oid(targetId), oid(sourceId)] } } :
|
|
20
|
-
{ $or: ['first', 'second'].map(slot => ({ ['chairs.' + slot]: { $in: [oid(targetId), oid(sourceId)] } })) };
|
|
21
17
|
const leadFilter = kind === 'Organization' ? { organization: { $in: [oid(targetId), oid(sourceId)] } } :
|
|
22
18
|
{ people: { $in: [oid(targetId), oid(sourceId)] } };
|
|
23
19
|
const leads = await db.collection('leads').find(leadFilter, { session }).toArray();
|
|
24
20
|
const companyIds = leads.map(lead => oid(lead.organization));
|
|
25
|
-
const companies = await db.collection('organizations').find({ $
|
|
26
|
-
{ session, projection: {
|
|
21
|
+
const companies = await db.collection('organizations').find({ _id: { $in: kind === 'Organization' ? [oid(targetId), oid(sourceId)] : companyIds } },
|
|
22
|
+
{ session, projection: { _id: 1 } }).toArray();
|
|
27
23
|
const groups = new Map(), owners = new Set();
|
|
28
|
-
for (const company of companies) {
|
|
29
|
-
if (company.chairs != null && !Array.isArray(company.chairs)) throw Error('Malformed legacy lead array');
|
|
30
|
-
for (const chair of company.chairs || []) {
|
|
31
|
-
const customer = id(chair.customer), organization = id(company);
|
|
32
|
-
if (Object.keys(chair).some(field => !['_id', 'customer', 'first', 'second'].includes(field))) throw Error('Unknown legacy lead fields');
|
|
33
|
-
const groupKey = key(customer, organization);
|
|
34
|
-
if (!groups.has(groupKey)) groups.set(groupKey, { customer, organization, people: [], lead: null });
|
|
35
|
-
const group = groups.get(groupKey);
|
|
36
|
-
group.people = unique([...group.people, ...[chair.first, chair.second].filter(value => value != null)]);
|
|
37
|
-
owners.add(customer);
|
|
38
|
-
}
|
|
39
|
-
}
|
|
40
24
|
// Read all groups on affected companies, including empty/new Lead records.
|
|
41
25
|
const existing = await db.collection('leads').find({ organization: { $in: companies.map(company => company._id) } }, { session }).toArray();
|
|
42
26
|
for (const lead of existing) {
|
|
@@ -44,7 +28,8 @@ async function prepare(db, session, kind, target, source) {
|
|
|
44
28
|
if (typeof lead.customer === 'string' || typeof lead.organization === 'string' || !Array.isArray(lead.people)) throw Error('Malformed Lead storage');
|
|
45
29
|
if (!groups.has(groupKey)) groups.set(groupKey, { customer, organization, people: [], lead: null });
|
|
46
30
|
const group = groups.get(groupKey), people = unique(lead.people);
|
|
47
|
-
if (group.lead || people.length !== lead.people.length
|
|
31
|
+
if (group.lead || people.length !== lead.people.length) throw Error('Duplicate Lead record or person');
|
|
32
|
+
group.people = people;
|
|
48
33
|
group.lead = lead; owners.add(customer);
|
|
49
34
|
}
|
|
50
35
|
if (leads.some(lead => !companies.some(company => id(company) === id(lead.organization)))) throw Error('Lead company is missing');
|
|
@@ -80,21 +65,7 @@ async function apply(db, session, plan) {
|
|
|
80
65
|
organization: oid(group.organization), people: group.people.map(oid)
|
|
81
66
|
}, $inc: { __v: 1 } }, { session });
|
|
82
67
|
if (result.matchedCount !== 1) throw Error('Lead changed during merge');
|
|
83
|
-
} else {
|
|
84
|
-
const stable = crypto.createHash('sha256').update(`customer-data-v1:${group.customer}/organizations/${group.organization}/chairs`).digest('hex').slice(0, 24);
|
|
85
|
-
await db.collection('leads').insertOne({ _id: new ObjectId(stable), customer: oid(group.customer), organization: oid(group.organization),
|
|
86
|
-
people: group.people.map(oid), __v: 0 }, { session });
|
|
87
|
-
}
|
|
88
|
-
}
|
|
89
|
-
const organizations = plan.kind === 'Organization' ? [plan.targetId] : plan.companies.map(id);
|
|
90
|
-
for (const organization of organizations) {
|
|
91
|
-
const chairs = [];
|
|
92
|
-
for (const group of output.values()) if (group.organization === organization) {
|
|
93
|
-
for (let i = 0; i < group.people.length; i += 2) chairs.push({ _id: new ObjectId(), customer: oid(group.customer), first: oid(group.people[i]),
|
|
94
|
-
...(group.people[i + 1] ? { second: oid(group.people[i + 1]) } : {}) });
|
|
95
68
|
}
|
|
96
|
-
const result = await db.collection('organizations').updateOne({ _id: oid(organization) }, { $set: { chairs } }, { session });
|
|
97
|
-
if (result.matchedCount !== 1) throw Error('Lead company changed during merge');
|
|
98
69
|
}
|
|
99
70
|
}
|
|
100
71
|
module.exports = { prepare, apply };
|
package/lib/organizations.js
CHANGED
package/lib/people.js
CHANGED
|
@@ -483,7 +483,7 @@ var updateOrganizations = function updateOrganizations(newPersonId, oldPersonId,
|
|
|
483
483
|
cb = _.once(cb);
|
|
484
484
|
|
|
485
485
|
// Get all orgs for this person
|
|
486
|
-
self.Organization.collection.find({
|
|
486
|
+
self.Organization.collection.find({ 'people.person': oldPersonId }).toArray().then(function(orgs) {
|
|
487
487
|
|
|
488
488
|
if (!orgs || orgs.length === 0) return cb(null, []);
|
|
489
489
|
|
|
@@ -542,12 +542,6 @@ var updateOrganizations = function updateOrganizations(newPersonId, oldPersonId,
|
|
|
542
542
|
return person;
|
|
543
543
|
});
|
|
544
544
|
|
|
545
|
-
setObj.chairs = _.map(org.chairs || [], function(chair) {
|
|
546
|
-
if (chair && chair.first && chair.first.toString() === oldPersonId.toString()) { chair.first = newPersonId; }
|
|
547
|
-
if (chair && chair.second && chair.second.toString() === oldPersonId.toString()) { chair.second = newPersonId; }
|
|
548
|
-
return chair;
|
|
549
|
-
});
|
|
550
|
-
|
|
551
545
|
tasks.push({
|
|
552
546
|
id: org._id,
|
|
553
547
|
update: { $set: setObj }
|
|
@@ -11,30 +11,12 @@ function groups(entries) {
|
|
|
11
11
|
}
|
|
12
12
|
return result;
|
|
13
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
14
|
async function prepare(db, session, kind, target, source) {
|
|
33
15
|
const ids = [target._id, source._id], targetId = id(target._id), ownerIds = [];
|
|
34
16
|
const plan = { targetId, kind, records: [], mirrors: {} };
|
|
35
17
|
// Raw parent reads prevent Mongoose defaults from becoming stored values.
|
|
36
18
|
const parents = await db.collection(kind === 'Organization' ? 'organizations' : 'people').find({ _id: { $in: ids } }, { session }).toArray();
|
|
37
|
-
for (const
|
|
19
|
+
for (const collection of ['tags', 'valuations']) {
|
|
38
20
|
if (kind === 'Person' && collection !== 'tags') continue;
|
|
39
21
|
const filter = collection === 'valuations' ? { organization: { $in: ids } } :
|
|
40
22
|
{ 'entity.model': kind, 'entity.id': { $in: ids } };
|
|
@@ -48,27 +30,6 @@ async function prepare(db, session, kind, target, source) {
|
|
|
48
30
|
if (!entities.has(entity)) entities.set(entity, []);
|
|
49
31
|
entities.get(entity).push(row);
|
|
50
32
|
}
|
|
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
33
|
for (const [customer, entities] of buckets) {
|
|
73
34
|
const targetRows = entities.get(targetId) || [], sourceRows = entities.get(id(source._id)) || [];
|
|
74
35
|
if (collection !== 'valuations' && (targetRows.length > 1 || sourceRows.length > 1)) throw Error('Duplicate private records');
|
|
@@ -82,7 +43,6 @@ async function prepare(db, session, kind, target, source) {
|
|
|
82
43
|
seen.add(legacyId);
|
|
83
44
|
}
|
|
84
45
|
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
46
|
}
|
|
87
47
|
} else if (collection === 'tags') {
|
|
88
48
|
const combined = new Map();
|
|
@@ -93,10 +53,8 @@ async function prepare(db, session, kind, target, source) {
|
|
|
93
53
|
}
|
|
94
54
|
const next = [...combined.values()];
|
|
95
55
|
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
56
|
}
|
|
98
57
|
}
|
|
99
|
-
if (kind === 'Organization') plan.mirrors[field] = mirror;
|
|
100
58
|
}
|
|
101
59
|
// Custom fields stay embedded. Preserve both parents' values without consulting
|
|
102
60
|
// or updating the deferred CustomField collection. The old generic array
|
|
@@ -125,7 +83,7 @@ async function apply(db, session, plan) {
|
|
|
125
83
|
{ $set: item.set, $inc: { __v: 1 } }, { session });
|
|
126
84
|
if (result.matchedCount !== 1) throw Error('Private record changed during merge');
|
|
127
85
|
}
|
|
128
|
-
if (plan.kind === 'Organization') {
|
|
86
|
+
if (plan.kind === 'Organization' && Object.keys(plan.mirrors).length) {
|
|
129
87
|
const result = await db.collection('organizations').updateOne({ _id: oid(plan.targetId) }, { $set: plan.mirrors }, { session });
|
|
130
88
|
if (result.matchedCount !== 1) throw Error('Private merge target changed');
|
|
131
89
|
}
|
package/package.json
CHANGED
|
@@ -49,6 +49,14 @@ const uri = process.env.ATOMIC_MERGE_TEST_URI;
|
|
|
49
49
|
});
|
|
50
50
|
after(async () => { if (db) await db.dropDatabase(); await m.disconnect(); });
|
|
51
51
|
|
|
52
|
+
it('cannot opt out of native transactions through the old configuration flag', async () => {
|
|
53
|
+
context.transactionalPrivateData = false;
|
|
54
|
+
try {
|
|
55
|
+
await call('organizations');
|
|
56
|
+
assert.equal(await db.collection('leads').countDocuments({ organization: source }), 0);
|
|
57
|
+
assert.equal((await db.collection('leads').findOne({ customer: tenant, organization: target })).people.length, 4);
|
|
58
|
+
} finally { context.transactionalPrivateData = true; }
|
|
59
|
+
});
|
|
52
60
|
it('unions unlimited company leads per tenant and atomically relinks every owner’s deals', async () => {
|
|
53
61
|
await db.collection('deals').insertMany([tenant, other].map(customer => ({ customer, organization: source, stage: 'Lead' })));
|
|
54
62
|
const targetLead = await db.collection('leads').findOne({ customer: tenant, organization: target });
|
|
@@ -64,15 +72,21 @@ const uri = process.env.ATOMIC_MERGE_TEST_URI;
|
|
|
64
72
|
assert.deepEqual((await db.collection('leads').findOne({ customer: other })).people, [people[4]]);
|
|
65
73
|
assert.equal(await db.collection('deals').countDocuments({ organization: target }), 2);
|
|
66
74
|
const chairs = (await db.collection('organizations').findOne({ _id: target })).chairs;
|
|
67
|
-
assert.equal(chairs.filter(chair => String(chair.customer) === String(tenant)).length,
|
|
75
|
+
assert.equal(chairs.filter(chair => String(chair.customer) === String(tenant)).length, 1);
|
|
68
76
|
const before = await state(); await call('organizations'); assert.deepEqual(await state(), before);
|
|
69
77
|
});
|
|
70
|
-
it('
|
|
78
|
+
it('does not resurrect legacy leads when native groups are absent', async () => {
|
|
71
79
|
await db.collection('leads').deleteMany({});
|
|
72
80
|
await call('organizations');
|
|
73
|
-
|
|
74
|
-
assert.
|
|
75
|
-
|
|
81
|
+
assert.equal(await db.collection('leads').countDocuments({}), 0);
|
|
82
|
+
assert.equal((await db.collection('organizations').findOne({ _id: target })).chairs.length, 2);
|
|
83
|
+
});
|
|
84
|
+
it('merges native lead edits without restoring people from stale mirrors', async () => {
|
|
85
|
+
await db.collection('leads').updateOne({ customer: tenant, organization: source }, { $set: { people: [] } });
|
|
86
|
+
const expected = await db.collection('leads').findOne({ customer: tenant, organization: target });
|
|
87
|
+
await call('organizations');
|
|
88
|
+
const actual = await db.collection('leads').findOne({ customer: tenant, organization: target });
|
|
89
|
+
assert.deepEqual(actual.people, expected.people);
|
|
76
90
|
});
|
|
77
91
|
it('preserves zero-lead groups without introducing a placeholder person', async () => {
|
|
78
92
|
await db.collection('organizations').updateMany({}, { $set: { chairs: [] } });
|
|
@@ -112,9 +126,9 @@ const uri = process.env.ATOMIC_MERGE_TEST_URI;
|
|
|
112
126
|
}
|
|
113
127
|
assert.equal(String((await db.collection('documents').findOne({})).createdBy), String(people[0]));
|
|
114
128
|
});
|
|
115
|
-
it('blocks
|
|
129
|
+
it('blocks deletion receipts and tenant identity merges before source deletion', async () => {
|
|
116
130
|
await db.collection('leads').updateOne({ customer: tenant, organization: source }, { $set: { people: [] } });
|
|
117
|
-
let before
|
|
131
|
+
let before;
|
|
118
132
|
await db.collection('leads').updateOne({ customer: tenant, organization: source }, { $set: { people: [people[1], people[2], people[3]] } });
|
|
119
133
|
await db.collection('deletions').insertOne({ _id: tenant, kind: 'job', operation: 'delete', status: 'completed' });
|
|
120
134
|
before = await state(); await assert.rejects(call('organizations'), /lifecycle/); assert.deepEqual(await state(), before);
|
|
@@ -187,13 +201,13 @@ const uri = process.env.ATOMIC_MERGE_TEST_URI;
|
|
|
187
201
|
assert.deepEqual(values.map(row => String(row._id)).sort(), originalValuations.map(row => String(row._id)).sort());
|
|
188
202
|
assert(values.every(row => row.valuation === 0 && !Object.hasOwn(row, 'pricePerShare')));
|
|
189
203
|
const parent = await db.collection('organizations').findOne({ _id: target });
|
|
190
|
-
assert.equal(parent.valuations.length,
|
|
204
|
+
assert.equal(parent.valuations.length, 1);
|
|
191
205
|
assert(parent.valuations.every(row => !Object.hasOwn(row, 'pricePerShare')));
|
|
192
206
|
assert(parent.customFields.some(entry => entry.fields.targetOnly === false));
|
|
193
207
|
assert(parent.customFields.some(entry => Array.isArray(entry.fields.sourceOnly)));
|
|
194
208
|
assert(parent.customFields.some(entry => String(entry.customer) === String(other)));
|
|
195
209
|
});
|
|
196
|
-
it('ignores deferred CustomField drift
|
|
210
|
+
it('ignores deferred CustomField drift', async () => {
|
|
197
211
|
await privateFixture();
|
|
198
212
|
await db.collection('customfields').updateOne({ customer: tenant, 'entity.id': source }, { $set: { 'fields.shared': 999 } });
|
|
199
213
|
const original = await db.collection('customfields').find({}).sort({ _id: 1 }).toArray();
|
|
@@ -206,11 +220,23 @@ const uri = process.env.ATOMIC_MERGE_TEST_URI;
|
|
|
206
220
|
const before = await state();
|
|
207
221
|
await assert.rejects(call('organizations'), /Conflicting embedded custom fields/); assert.deepEqual(await state(), before);
|
|
208
222
|
});
|
|
209
|
-
it('
|
|
223
|
+
it('does not resurrect stale Tag mirrors during a merge', async () => {
|
|
210
224
|
await privateFixture();
|
|
211
225
|
await db.collection('tags').updateOne({ customer: tenant, 'entity.id': source }, { $set: { groups: [] } });
|
|
212
|
-
const
|
|
213
|
-
await
|
|
226
|
+
const expected = await db.collection('tags').findOne({ customer: tenant, 'entity.id': target });
|
|
227
|
+
await call('organizations');
|
|
228
|
+
assert.deepEqual((await db.collection('tags').findOne({ customer: tenant, 'entity.id': target })).groups, expected.groups);
|
|
229
|
+
});
|
|
230
|
+
it('retains native valuation edits when mirrors contain stale amounts', async () => {
|
|
231
|
+
await privateFixture();
|
|
232
|
+
await db.collection('valuations').updateMany({ organization: source }, { $set: { valuation: 12345 } });
|
|
233
|
+
const expected = await db.collection('valuations').find({ organization: source }).toArray();
|
|
234
|
+
await call('organizations');
|
|
235
|
+
for (const row of expected) {
|
|
236
|
+
const actual = await db.collection('valuations').findOne({ _id: row._id });
|
|
237
|
+
assert.equal(actual.valuation, 12345);
|
|
238
|
+
assert.equal(String(actual.organization), String(target));
|
|
239
|
+
}
|
|
214
240
|
});
|
|
215
241
|
it('blocks colliding valuation identities rather than losing an occurrence', async () => {
|
|
216
242
|
await privateFixture();
|