@dhyasama/totem-merge 7.2.0 → 7.3.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/leadMerge.js CHANGED
@@ -9,7 +9,6 @@ function id(value) {
9
9
  }
10
10
  const oid = value => new ObjectId(id(value));
11
11
  const unique = values => [...new Set(values.map(id))];
12
- const equal = (a, b) => JSON.stringify([...a].sort()) === JSON.stringify([...b].sort());
13
12
  const key = (customer, organization) => customer + '/' + organization;
14
13
 
15
14
  // Capture/validate before source deletion. The caller holds the same transaction
@@ -25,18 +24,6 @@ async function prepare(db, session, kind, target, source) {
25
24
  const companies = await db.collection('organizations').find({ $or: [companyFilter, { _id: { $in: companyIds } }] },
26
25
  { session, projection: { chairs: 1 } }).toArray();
27
26
  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
27
  // Read all groups on affected companies, including empty/new Lead records.
41
28
  const existing = await db.collection('leads').find({ organization: { $in: companies.map(company => company._id) } }, { session }).toArray();
42
29
  for (const lead of existing) {
@@ -44,7 +31,8 @@ async function prepare(db, session, kind, target, source) {
44
31
  if (typeof lead.customer === 'string' || typeof lead.organization === 'string' || !Array.isArray(lead.people)) throw Error('Malformed Lead storage');
45
32
  if (!groups.has(groupKey)) groups.set(groupKey, { customer, organization, people: [], lead: null });
46
33
  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');
34
+ if (group.lead || people.length !== lead.people.length) throw Error('Duplicate Lead record or person');
35
+ group.people = people;
48
36
  group.lead = lead; owners.add(customer);
49
37
  }
50
38
  if (leads.some(lead => !companies.some(company => id(company) === id(lead.organization)))) throw Error('Lead company is missing');
@@ -11,24 +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
14
  function valuationValue(entry) {
18
15
  // Stored values only. Do not fill absent values using schema defaults.
19
16
  const result = {};
20
17
  for (const key of ['date', 'valuation', 'pricePerShare', 'method']) if (own(entry, key)) result[key] = entry[key];
21
18
  return result;
22
19
  }
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
20
  async function prepare(db, session, kind, target, source) {
33
21
  const ids = [target._id, source._id], targetId = id(target._id), ownerIds = [];
34
22
  const plan = { targetId, kind, records: [], mirrors: {} };
@@ -48,26 +36,6 @@ async function prepare(db, session, kind, target, source) {
48
36
  if (!entities.has(entity)) entities.set(entity, []);
49
37
  entities.get(entity).push(row);
50
38
  }
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
39
  const mirror = [];
72
40
  for (const [customer, entities] of buckets) {
73
41
  const targetRows = entities.get(targetId) || [], sourceRows = entities.get(id(source._id)) || [];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dhyasama/totem-merge",
3
- "version": "7.2.0",
3
+ "version": "7.3.0",
4
4
  "description": "Org and person merging package",
5
5
  "main": "index.js",
6
6
  "scripts": {
@@ -67,12 +67,18 @@ const uri = process.env.ATOMIC_MERGE_TEST_URI;
67
67
  assert.equal(chairs.filter(chair => String(chair.customer) === String(tenant)).length, 2);
68
68
  const before = await state(); await call('organizations'); assert.deepEqual(await state(), before);
69
69
  });
70
- it('initializes missing additive groups without losing unlimited legacy leads', async () => {
70
+ it('does not resurrect legacy leads when native groups are absent', async () => {
71
71
  await db.collection('leads').deleteMany({});
72
72
  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]]);
73
+ assert.equal(await db.collection('leads').countDocuments({}), 0);
74
+ assert.deepEqual((await db.collection('organizations').findOne({ _id: target })).chairs, []);
75
+ });
76
+ it('merges native lead edits without restoring people from stale mirrors', async () => {
77
+ await db.collection('leads').updateOne({ customer: tenant, organization: source }, { $set: { people: [] } });
78
+ const expected = await db.collection('leads').findOne({ customer: tenant, organization: target });
79
+ await call('organizations');
80
+ const actual = await db.collection('leads').findOne({ customer: tenant, organization: target });
81
+ assert.deepEqual(actual.people, expected.people);
76
82
  });
77
83
  it('preserves zero-lead groups without introducing a placeholder person', async () => {
78
84
  await db.collection('organizations').updateMany({}, { $set: { chairs: [] } });
@@ -112,9 +118,9 @@ const uri = process.env.ATOMIC_MERGE_TEST_URI;
112
118
  }
113
119
  assert.equal(String((await db.collection('documents').findOne({})).createdBy), String(people[0]));
114
120
  });
115
- it('blocks destination drift, deletion receipts and tenant identity merges before source deletion', async () => {
121
+ it('blocks deletion receipts and tenant identity merges before source deletion', async () => {
116
122
  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);
123
+ let before;
118
124
  await db.collection('leads').updateOne({ customer: tenant, organization: source }, { $set: { people: [people[1], people[2], people[3]] } });
119
125
  await db.collection('deletions').insertOne({ _id: tenant, kind: 'job', operation: 'delete', status: 'completed' });
120
126
  before = await state(); await assert.rejects(call('organizations'), /lifecycle/); assert.deepEqual(await state(), before);
@@ -193,7 +199,7 @@ const uri = process.env.ATOMIC_MERGE_TEST_URI;
193
199
  assert(parent.customFields.some(entry => Array.isArray(entry.fields.sourceOnly)));
194
200
  assert(parent.customFields.some(entry => String(entry.customer) === String(other)));
195
201
  });
196
- it('ignores deferred CustomField drift but still rejects unreconciled Tags', async () => {
202
+ it('ignores deferred CustomField drift', async () => {
197
203
  await privateFixture();
198
204
  await db.collection('customfields').updateOne({ customer: tenant, 'entity.id': source }, { $set: { 'fields.shared': 999 } });
199
205
  const original = await db.collection('customfields').find({}).sort({ _id: 1 }).toArray();
@@ -206,11 +212,23 @@ const uri = process.env.ATOMIC_MERGE_TEST_URI;
206
212
  const before = await state();
207
213
  await assert.rejects(call('organizations'), /Conflicting embedded custom fields/); assert.deepEqual(await state(), before);
208
214
  });
209
- it('blocks unreconciled Tags without modifying any record', async () => {
215
+ it('does not resurrect stale Tag mirrors during a merge', async () => {
210
216
  await privateFixture();
211
217
  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);
218
+ const expected = await db.collection('tags').findOne({ customer: tenant, 'entity.id': target });
219
+ await call('organizations');
220
+ assert.deepEqual((await db.collection('tags').findOne({ customer: tenant, 'entity.id': target })).groups, expected.groups);
221
+ });
222
+ it('retains native valuation edits when mirrors contain stale amounts', async () => {
223
+ await privateFixture();
224
+ await db.collection('valuations').updateMany({ organization: source }, { $set: { valuation: 12345 } });
225
+ const expected = await db.collection('valuations').find({ organization: source }).toArray();
226
+ await call('organizations');
227
+ for (const row of expected) {
228
+ const actual = await db.collection('valuations').findOne({ _id: row._id });
229
+ assert.equal(actual.valuation, 12345);
230
+ assert.equal(String(actual.organization), String(target));
231
+ }
214
232
  });
215
233
  it('blocks colliding valuation identities rather than losing an occurrence', async () => {
216
234
  await privateFixture();