@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.
@@ -1,18 +1,19 @@
1
1
  # Transactional company and person merges
2
2
 
3
- Status: 7.2.0 release candidate, 2026-10-24. No consumer enables this path yet.
4
- Publishing this package does not activate its transactional merge option.
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. `transactionalPrivateData: true` opts into the transaction and native
11
- Lead/Tag/Valuation handling only; it does not activate the deferred migrations.
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 and their legacy mirrors commit together. Company merges union distinct
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 additive Leads can
24
- be initialized from chairs; Tags and Valuations must already be reconciled.
25
- Invalid owners, blocking lifecycle jobs, missing people and destination drift
26
- fail the whole transaction. A committed retry is recognized by merged history.
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 drift, valuation identity, embedded custom-field preservation/conflicts,
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
- Before adoption: review/release the package, complete Lead/Tag/Valuation writer
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
- ## 7.2.0 release review
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
- The current consumer baseline is models 17.0.0, including Account.customer and
72
- retired Interaction/activity-list models. The 42 focused merge/security tests
73
- pass against that exact dependency. The default constructor remains opt-out.
74
- Adoption also includes the reviewed default-path fixes above; it is not a claim
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
- var organizations = require('./lib/organizations');
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 = config.transactionalPrivateData === true;
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
- Merge.prototype.organizations = function(target, source, user, customerId, cb) {
34
-
35
- if (this.transactionalPrivateData) return require('./lib/atomicMerge')(this, 'Organization', target, source, user, customerId).then(result => cb(null, result), error => cb(error, null));
36
- organizations.merge(this, target, source, user, customerId, cb);
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({ $or: [companyFilter, { _id: { $in: companyIds } }] },
26
- { session, projection: { chairs: 1 } }).toArray();
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 || !equal(group.people, people)) throw Error('Lead merge requires reconciled stores');
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 };
@@ -83,6 +83,9 @@ var mergeSourceIntoTarget = function mergeEntities(target, source, cb) {
83
83
  logger.writeToInfoLog('Merge source into target...');
84
84
 
85
85
  var doNotMergeKeys = [
86
+ 'chairs',
87
+ 'filters',
88
+ 'valuations',
86
89
  'merged',
87
90
  'deleted',
88
91
  'entered',
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({ $or: [{ 'people.person': oldPersonId }, { 'chairs.first': oldPersonId }, { 'chairs.second': oldPersonId }] }).toArray().then(function(orgs) {
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 [collection, field] of [['tags', 'filters'], ['valuations', 'valuations']]) {
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dhyasama/totem-merge",
3
- "version": "7.2.0",
3
+ "version": "8.0.0",
4
4
  "description": "Org and person merging package",
5
5
  "main": "index.js",
6
6
  "scripts": {
@@ -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, 2);
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('initializes missing additive groups without losing unlimited legacy leads', async () => {
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
- const lead = await db.collection('leads').findOne({ customer: tenant, organization: target });
74
- assert.deepEqual(lead.people.map(String).sort(), people.slice(0, 4).map(String).sort());
75
- assert.deepEqual((await db.collection('leads').findOne({ customer: other })).people, [people[4]]);
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 destination drift, deletion receipts and tenant identity merges before source deletion', async () => {
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 = await state(); await assert.rejects(call('organizations'), /reconciled/); assert.deepEqual(await state(), 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, 2);
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 but still rejects unreconciled Tags', async () => {
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('blocks unreconciled Tags without modifying any record', async () => {
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 before = await state();
213
- await assert.rejects(call('organizations'), /reconciled tags/); assert.deepEqual(await state(), before);
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();