@classytic/crm 0.9.0 → 1.1.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/CHANGELOG.md +500 -363
- package/LICENSE +75 -21
- package/README.md +52 -3
- package/dist/events.d.ts +20 -12
- package/dist/events.js +41 -31
- package/dist/index.d.ts +506 -136
- package/dist/index.js +1143 -429
- package/package.json +79 -73
- package/scripts/lead-contact-email-key-drift.mjs +218 -0
- package/scripts/suppression-key-drift.mjs +143 -0
- /package/dist/{chunk.js → rolldown-runtime.js} +0 -0
package/package.json
CHANGED
|
@@ -1,73 +1,79 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "@classytic/crm",
|
|
3
|
-
"version": "
|
|
4
|
-
"description": "Engine-factory CRM package
|
|
5
|
-
"author": "Classytic",
|
|
6
|
-
"license": "
|
|
7
|
-
"type": "module",
|
|
8
|
-
"sideEffects": false,
|
|
9
|
-
"engines": {
|
|
10
|
-
"node": ">=22"
|
|
11
|
-
},
|
|
12
|
-
"publishConfig": {
|
|
13
|
-
"access": "public"
|
|
14
|
-
},
|
|
15
|
-
"exports": {
|
|
16
|
-
".": {
|
|
17
|
-
"types": "./dist/index.d.ts",
|
|
18
|
-
"default": "./dist/index.js"
|
|
19
|
-
},
|
|
20
|
-
"./events": {
|
|
21
|
-
"types": "./dist/events.d.ts",
|
|
22
|
-
"default": "./dist/events.js"
|
|
23
|
-
},
|
|
24
|
-
"./package.json": "./package.json"
|
|
25
|
-
},
|
|
26
|
-
"main": "./dist/index.js",
|
|
27
|
-
"types": "./dist/index.d.ts",
|
|
28
|
-
"files": [
|
|
29
|
-
"dist",
|
|
30
|
-
"
|
|
31
|
-
"
|
|
32
|
-
"
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
"
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
"
|
|
43
|
-
|
|
44
|
-
"
|
|
45
|
-
"
|
|
46
|
-
"test
|
|
47
|
-
"test:
|
|
48
|
-
"test:
|
|
49
|
-
"test:
|
|
50
|
-
"test:
|
|
51
|
-
"
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
"
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
"
|
|
58
|
-
"
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
"
|
|
62
|
-
"
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
"@
|
|
66
|
-
"
|
|
67
|
-
"
|
|
68
|
-
"
|
|
69
|
-
"
|
|
70
|
-
"
|
|
71
|
-
"
|
|
72
|
-
|
|
73
|
-
|
|
1
|
+
{
|
|
2
|
+
"name": "@classytic/crm",
|
|
3
|
+
"version": "1.1.0",
|
|
4
|
+
"description": "Engine-factory CRM package — leads, accounts, contacts, opportunities, pipelines, campaigns, subscriptions, activities, notes, email engagement. PACKAGE_RULES compliant; built on Mongoose + mongokit + arc primitives.",
|
|
5
|
+
"author": "Classytic",
|
|
6
|
+
"license": "SEE LICENSE IN LICENSE",
|
|
7
|
+
"type": "module",
|
|
8
|
+
"sideEffects": false,
|
|
9
|
+
"engines": {
|
|
10
|
+
"node": ">=22"
|
|
11
|
+
},
|
|
12
|
+
"publishConfig": {
|
|
13
|
+
"access": "public"
|
|
14
|
+
},
|
|
15
|
+
"exports": {
|
|
16
|
+
".": {
|
|
17
|
+
"types": "./dist/index.d.ts",
|
|
18
|
+
"default": "./dist/index.js"
|
|
19
|
+
},
|
|
20
|
+
"./events": {
|
|
21
|
+
"types": "./dist/events.d.ts",
|
|
22
|
+
"default": "./dist/events.js"
|
|
23
|
+
},
|
|
24
|
+
"./package.json": "./package.json"
|
|
25
|
+
},
|
|
26
|
+
"main": "./dist/index.js",
|
|
27
|
+
"types": "./dist/index.d.ts",
|
|
28
|
+
"files": [
|
|
29
|
+
"dist",
|
|
30
|
+
"scripts",
|
|
31
|
+
"README.md",
|
|
32
|
+
"CHANGELOG.md",
|
|
33
|
+
"LICENSE"
|
|
34
|
+
],
|
|
35
|
+
"repository": {
|
|
36
|
+
"type": "git",
|
|
37
|
+
"url": "git+https://github.com/classytic/crm.git"
|
|
38
|
+
},
|
|
39
|
+
"bugs": {
|
|
40
|
+
"url": "https://github.com/classytic/crm/issues"
|
|
41
|
+
},
|
|
42
|
+
"homepage": "https://github.com/classytic/crm#readme",
|
|
43
|
+
"scripts": {
|
|
44
|
+
"build": "tsdown",
|
|
45
|
+
"typecheck": "tsc --noEmit",
|
|
46
|
+
"test": "vitest run --project unit --project integration",
|
|
47
|
+
"test:unit": "vitest run --project unit",
|
|
48
|
+
"test:integration": "vitest run --project integration",
|
|
49
|
+
"test:e2e": "vitest run --project e2e",
|
|
50
|
+
"test:all": "vitest run",
|
|
51
|
+
"test:watch": "vitest --project unit --project integration",
|
|
52
|
+
"drift:suppression": "node scripts/suppression-key-drift.mjs",
|
|
53
|
+
"drift:email-keys": "node scripts/lead-contact-email-key-drift.mjs",
|
|
54
|
+
"push": "classytic-push"
|
|
55
|
+
},
|
|
56
|
+
"peerDependencies": {
|
|
57
|
+
"@classytic/contact": ">=0.1.0",
|
|
58
|
+
"@classytic/mongokit": ">=3.33.0",
|
|
59
|
+
"@classytic/primitives": ">=0.23.0",
|
|
60
|
+
"@classytic/repo-core": ">=0.24.0",
|
|
61
|
+
"mongoose": ">=9.4.1",
|
|
62
|
+
"zod": ">=4.0.0"
|
|
63
|
+
},
|
|
64
|
+
"devDependencies": {
|
|
65
|
+
"@classytic/contact": ">=0.1.0",
|
|
66
|
+
"@classytic/dev-tools": "^0.2.0",
|
|
67
|
+
"@classytic/mongokit": "^3.33.0",
|
|
68
|
+
"@classytic/primitives": ">=0.23.0",
|
|
69
|
+
"@classytic/repo-core": ">=0.24.0",
|
|
70
|
+
"@types/node": "^25.5.0",
|
|
71
|
+
"mongodb-memory-server": "^10.1.4",
|
|
72
|
+
"mongoose": "^9.4.1",
|
|
73
|
+
"publint": "^0.3.23",
|
|
74
|
+
"tsdown": "^0.22.14",
|
|
75
|
+
"typescript": "^5.8.2",
|
|
76
|
+
"vitest": "^3.2.4",
|
|
77
|
+
"zod": "^4.3.6"
|
|
78
|
+
}
|
|
79
|
+
}
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* DRY RUN — which Lead / Contact rows are forked or unreachable under canonical
|
|
4
|
+
* email keys?
|
|
5
|
+
*
|
|
6
|
+
* Lead `email` and Contact `contact.email` used to be folded by mongoose
|
|
7
|
+
* `lowercase: true` alone. That does not IDNA-fold, so `ada@bücher.de` and
|
|
8
|
+
* `ada@xn--bcher-kva.de` — one host in two encodings — were two distinct keys and
|
|
9
|
+
* the partial-unique `(organizationId, <email>)` index admitted both. One mailbox,
|
|
10
|
+
* two rows, no error. Both fields now key through `normalizeEmail` from
|
|
11
|
+
* `@classytic/contact/email` on every write and every query filter.
|
|
12
|
+
*
|
|
13
|
+
* Rows written under the old rule are NOT migrated automatically, and must not be.
|
|
14
|
+
* These are live customer records: a rekey can hit the unique index outright, and
|
|
15
|
+
* deciding which of two forked rows survives (and what happens to the
|
|
16
|
+
* opportunities, activities and subscriptions pointing at the loser) is a human
|
|
17
|
+
* call. This script only READS. It never writes, never drops, never creates an
|
|
18
|
+
* index, never merges.
|
|
19
|
+
*
|
|
20
|
+
* Four outcomes per row:
|
|
21
|
+
*
|
|
22
|
+
* unchanged the stored value already IS its canonical key. No action.
|
|
23
|
+
* rekey canonicalises to a DIFFERENT string, and nothing else in the
|
|
24
|
+
* org holds that key. A `$set` would succeed — but see the
|
|
25
|
+
* closing note: it is still not this script's call.
|
|
26
|
+
* collision rekeying it would land on a key another row in the same org
|
|
27
|
+
* already holds. The unique index means a naive `updateOne`
|
|
28
|
+
* FAILS. These are the forks: two rows, one mailbox. Merging
|
|
29
|
+
* them — and re-pointing every FK off the loser — is a human
|
|
30
|
+
* decision, not a script's.
|
|
31
|
+
* uncanonicalisable not an address the canonical grammar admits. It could never
|
|
32
|
+
* have matched a real send or a real dedup either. The write
|
|
33
|
+
* verbs now refuse these outright, so no NEW ones appear; what
|
|
34
|
+
* to do with the existing ones is a data-quality decision.
|
|
35
|
+
*
|
|
36
|
+
* Usage:
|
|
37
|
+
* node scripts/lead-contact-email-key-drift.mjs <mongodb-uri> [options]
|
|
38
|
+
*
|
|
39
|
+
* Options:
|
|
40
|
+
* --leads=NAME lead collection (default: leads)
|
|
41
|
+
* --contacts=NAME contact collection (default: contacts)
|
|
42
|
+
* --json machine-readable output
|
|
43
|
+
*
|
|
44
|
+
* Collections default to mongoose's pluralisation of the `Lead` / `Contact` model
|
|
45
|
+
* names. Pass the flags when the host set a `modelPrefix`.
|
|
46
|
+
*/
|
|
47
|
+
|
|
48
|
+
import { MongoClient } from 'mongodb';
|
|
49
|
+
import { normalizeEmail } from '@classytic/contact/email';
|
|
50
|
+
|
|
51
|
+
const args = process.argv.slice(2);
|
|
52
|
+
const uri = args.find((a) => !a.startsWith('--'));
|
|
53
|
+
const asJson = args.includes('--json');
|
|
54
|
+
const flag = (name, fallback) =>
|
|
55
|
+
(args.find((a) => a.startsWith(`--${name}=`)) ?? '').split('=')[1] || fallback;
|
|
56
|
+
|
|
57
|
+
if (!uri) {
|
|
58
|
+
console.error(
|
|
59
|
+
'usage: node scripts/lead-contact-email-key-drift.mjs <mongodb-uri> ' +
|
|
60
|
+
'[--leads=NAME] [--contacts=NAME] [--json]',
|
|
61
|
+
);
|
|
62
|
+
process.exit(2);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** The schema's key rule, without importing mongoose. */
|
|
66
|
+
function key(email) {
|
|
67
|
+
try {
|
|
68
|
+
return { ok: true, key: normalizeEmail(email) };
|
|
69
|
+
} catch (err) {
|
|
70
|
+
return { ok: false, reason: err?.code ?? 'invalid' };
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Read a dotted path off a plain document (`contact.email`). */
|
|
75
|
+
function at(doc, path) {
|
|
76
|
+
return path.split('.').reduce((o, k) => (o == null ? undefined : o[k]), doc);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Classify one collection. `emailPath` is the dotted field the unique index is
|
|
81
|
+
* built on, so the projection, the classification and the collision scope all
|
|
82
|
+
* derive from ONE declaration — a report that classified a different field from
|
|
83
|
+
* the one the index enforces would be confidently wrong.
|
|
84
|
+
*/
|
|
85
|
+
async function scan(db, { collectionName, emailPath }) {
|
|
86
|
+
const coll = db.collection(collectionName);
|
|
87
|
+
const total = await coll.countDocuments({});
|
|
88
|
+
const rows = await coll
|
|
89
|
+
.find({}, { projection: { _id: 1, organizationId: 1, [emailPath]: 1 } })
|
|
90
|
+
.toArray();
|
|
91
|
+
|
|
92
|
+
// Canonical keys ALREADY claimed per org. A rekey landing on one of these
|
|
93
|
+
// collides with the unique index whether or not both source rows move.
|
|
94
|
+
const occupied = new Map(); // org -> Map<canonicalKey, count>
|
|
95
|
+
for (const row of rows) {
|
|
96
|
+
const stored = at(row, emailPath);
|
|
97
|
+
if (typeof stored !== 'string') continue;
|
|
98
|
+
const org = String(row.organizationId ?? '');
|
|
99
|
+
if (!occupied.has(org)) occupied.set(org, new Map());
|
|
100
|
+
const k = key(stored);
|
|
101
|
+
if (!k.ok) continue;
|
|
102
|
+
const bucket = occupied.get(org);
|
|
103
|
+
bucket.set(k.key, (bucket.get(k.key) ?? 0) + 1);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const buckets = { unchanged: [], rekey: [], collision: [], uncanonicalisable: [] };
|
|
107
|
+
let withEmail = 0;
|
|
108
|
+
for (const row of rows) {
|
|
109
|
+
const stored = at(row, emailPath);
|
|
110
|
+
// The index is PARTIAL on `$type: 'string'` — a row with no email is not in
|
|
111
|
+
// it and cannot fork. Counting it as `unchanged` would inflate the clean
|
|
112
|
+
// number with rows the question does not apply to.
|
|
113
|
+
if (typeof stored !== 'string') continue;
|
|
114
|
+
withEmail += 1;
|
|
115
|
+
|
|
116
|
+
const org = String(row.organizationId ?? '');
|
|
117
|
+
const k = key(stored);
|
|
118
|
+
if (!k.ok) {
|
|
119
|
+
buckets.uncanonicalisable.push({ _id: String(row._id), organizationId: org, stored, reason: k.reason });
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
if (k.key === stored) {
|
|
123
|
+
buckets.unchanged.push({ _id: String(row._id), organizationId: org, stored });
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
const contenders = occupied.get(org)?.get(k.key) ?? 0;
|
|
127
|
+
(contenders > 1 ? buckets.collision : buckets.rekey).push({
|
|
128
|
+
_id: String(row._id),
|
|
129
|
+
organizationId: org,
|
|
130
|
+
stored,
|
|
131
|
+
canonical: k.key,
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return {
|
|
136
|
+
summary: {
|
|
137
|
+
collection: collectionName,
|
|
138
|
+
field: emailPath,
|
|
139
|
+
totalRows: total,
|
|
140
|
+
rowsWithEmail: withEmail,
|
|
141
|
+
unchanged: buckets.unchanged.length,
|
|
142
|
+
rekey: buckets.rekey.length,
|
|
143
|
+
collision: buckets.collision.length,
|
|
144
|
+
uncanonicalisable: buckets.uncanonicalisable.length,
|
|
145
|
+
unmatchableToday:
|
|
146
|
+
buckets.rekey.length + buckets.collision.length + buckets.uncanonicalisable.length,
|
|
147
|
+
},
|
|
148
|
+
buckets,
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function report(result) {
|
|
153
|
+
const s = result.summary;
|
|
154
|
+
console.log(`\ncollection ${s.collection} (${s.field})`);
|
|
155
|
+
console.log(`rows ${s.totalRows}`);
|
|
156
|
+
console.log(` with an email ${s.rowsWithEmail}`);
|
|
157
|
+
console.log(` unchanged ${s.unchanged}`);
|
|
158
|
+
console.log(` rekey ${s.rekey}`);
|
|
159
|
+
console.log(` collision ${s.collision}`);
|
|
160
|
+
console.log(` uncanonicalisable ${s.uncanonicalisable}`);
|
|
161
|
+
console.log(`UNMATCHABLE TODAY ${s.unmatchableToday}`);
|
|
162
|
+
|
|
163
|
+
for (const name of ['collision', 'rekey', 'uncanonicalisable']) {
|
|
164
|
+
const rows = result.buckets[name];
|
|
165
|
+
if (rows.length === 0) continue;
|
|
166
|
+
console.log(`\n── ${name} — ${s.collection} ──`);
|
|
167
|
+
for (const r of rows) {
|
|
168
|
+
console.log(
|
|
169
|
+
` ${r.organizationId} ${JSON.stringify(r.stored)} -> ${r.canonical ?? `(none: ${r.reason})`}`,
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// "Zero rows" and "collection absent" are DIFFERENT answers, and both print a
|
|
175
|
+
// clean-looking report. Say which one this was.
|
|
176
|
+
if (s.totalRows === 0) {
|
|
177
|
+
console.log(
|
|
178
|
+
`\nSCANNED NOTHING — collection '${s.collection}' is empty or absent in this database.\n` +
|
|
179
|
+
'That is NOT the same answer as "clean". Confirm the collection name (a host with a\n' +
|
|
180
|
+
'`modelPrefix` stores it elsewhere) and the database in the URI before concluding\n' +
|
|
181
|
+
'there is no drift.',
|
|
182
|
+
);
|
|
183
|
+
} else if (s.rowsWithEmail === 0) {
|
|
184
|
+
console.log(
|
|
185
|
+
`\nNO ROW IN '${s.collection}' CARRIES AN EMAIL — ${s.totalRows} row(s) scanned, none in the\n` +
|
|
186
|
+
'partial-unique index. Nothing here can fork; nothing here is evidence that the rest is clean.',
|
|
187
|
+
);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const client = new MongoClient(uri);
|
|
192
|
+
try {
|
|
193
|
+
await client.connect();
|
|
194
|
+
const db = client.db();
|
|
195
|
+
|
|
196
|
+
const results = [
|
|
197
|
+
await scan(db, { collectionName: flag('leads', 'leads'), emailPath: 'email' }),
|
|
198
|
+
await scan(db, { collectionName: flag('contacts', 'contacts'), emailPath: 'contact.email' }),
|
|
199
|
+
];
|
|
200
|
+
|
|
201
|
+
if (asJson) {
|
|
202
|
+
console.log(JSON.stringify(results, null, 2));
|
|
203
|
+
} else {
|
|
204
|
+
for (const r of results) report(r);
|
|
205
|
+
const collisions = results.reduce((n, r) => n + r.summary.collision, 0);
|
|
206
|
+
if (collisions > 0) {
|
|
207
|
+
console.log(
|
|
208
|
+
`\n${collisions} COLLISION(S). Each is one mailbox living as two rows. A naive \`$set\` to the\n` +
|
|
209
|
+
'canonical key hits the unique index and fails — the two rows must be MERGED, and which\n' +
|
|
210
|
+
"reason/owner/history survives, plus where the loser's opportunities, activities, notes\n" +
|
|
211
|
+
'and subscriptions re-point, is a decision this script will not make for you.',
|
|
212
|
+
);
|
|
213
|
+
}
|
|
214
|
+
console.log('\nNOTHING WAS WRITTEN. This is a report, not a migration.');
|
|
215
|
+
}
|
|
216
|
+
} finally {
|
|
217
|
+
await client.close();
|
|
218
|
+
}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* DRY RUN — which suppression rows are unreachable under canonical keys?
|
|
4
|
+
*
|
|
5
|
+
* `SuppressionRepository` used to key rows with `email.trim().toLowerCase()`. It
|
|
6
|
+
* now keys them with `normalizeEmail` from `@classytic/contact/email`, which also
|
|
7
|
+
* IDNA-folds the domain to punycode and REFUSES what it cannot canonicalise.
|
|
8
|
+
*
|
|
9
|
+
* Rows written under the old rule are not migrated automatically, and must not be:
|
|
10
|
+
* a suppression list is a do-not-contact record with legal weight, so a bad rewrite
|
|
11
|
+
* is worse than a stale row. This script only READS. It never writes, never drops,
|
|
12
|
+
* never creates an index. Run it, read the numbers, and decide.
|
|
13
|
+
*
|
|
14
|
+
* Four outcomes per row:
|
|
15
|
+
*
|
|
16
|
+
* unchanged the stored value already IS its canonical key. No action.
|
|
17
|
+
* rekey canonicalises to a DIFFERENT string — today this row can
|
|
18
|
+
* never match a lookup. It is a suppression that suppresses
|
|
19
|
+
* nothing.
|
|
20
|
+
* collision rekeying it would land on a key another row in the same org
|
|
21
|
+
* already holds. The (organizationId, email) index is unique,
|
|
22
|
+
* so a naive `updateOne` here FAILS — the two rows must be
|
|
23
|
+
* merged, and which `reason`/`suppressedAt` survives is a
|
|
24
|
+
* human decision, not a script's.
|
|
25
|
+
* uncanonicalisable not an address the canonical grammar admits. It could never
|
|
26
|
+
* have matched a real send either; deleting it is probably
|
|
27
|
+
* right, but "probably" is why this script does not.
|
|
28
|
+
*
|
|
29
|
+
* Usage:
|
|
30
|
+
* node scripts/suppression-key-drift.mjs <mongodb-uri> [--collection=suppressions] [--json]
|
|
31
|
+
*
|
|
32
|
+
* The collection defaults to `suppressions` (mongoose's pluralisation of the
|
|
33
|
+
* `Suppression` model). Pass `--collection` when the host set a `modelPrefix`.
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
import { MongoClient } from 'mongodb';
|
|
37
|
+
import { normalizeEmail } from '@classytic/contact/email';
|
|
38
|
+
|
|
39
|
+
const args = process.argv.slice(2);
|
|
40
|
+
const uri = args.find((a) => !a.startsWith('--'));
|
|
41
|
+
const collectionName = (args.find((a) => a.startsWith('--collection=')) ?? '').split('=')[1] || 'suppressions';
|
|
42
|
+
const asJson = args.includes('--json');
|
|
43
|
+
|
|
44
|
+
if (!uri) {
|
|
45
|
+
console.error('usage: node scripts/suppression-key-drift.mjs <mongodb-uri> [--collection=NAME] [--json]');
|
|
46
|
+
process.exit(2);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** The repository's key rule, without importing the repository (no mongoose here). */
|
|
50
|
+
function key(email) {
|
|
51
|
+
try {
|
|
52
|
+
return { ok: true, key: normalizeEmail(email) };
|
|
53
|
+
} catch (err) {
|
|
54
|
+
return { ok: false, reason: err?.code ?? 'invalid' };
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const client = new MongoClient(uri);
|
|
59
|
+
try {
|
|
60
|
+
await client.connect();
|
|
61
|
+
const coll = client.db().collection(collectionName);
|
|
62
|
+
|
|
63
|
+
const total = await coll.countDocuments({});
|
|
64
|
+
const rows = await coll
|
|
65
|
+
.find({}, { projection: { _id: 1, organizationId: 1, email: 1, reason: 1, suppressedAt: 1 } })
|
|
66
|
+
.toArray();
|
|
67
|
+
|
|
68
|
+
// Canonical keys ALREADY present per org — a rekey landing on one of these
|
|
69
|
+
// collides with the unique index whether or not the source rows both move.
|
|
70
|
+
const occupied = new Map(); // `${org}` -> Map<canonicalKey, count>
|
|
71
|
+
for (const row of rows) {
|
|
72
|
+
const org = String(row.organizationId ?? '');
|
|
73
|
+
if (!occupied.has(org)) occupied.set(org, new Map());
|
|
74
|
+
const k = key(String(row.email ?? ''));
|
|
75
|
+
if (!k.ok) continue;
|
|
76
|
+
const bucket = occupied.get(org);
|
|
77
|
+
bucket.set(k.key, (bucket.get(k.key) ?? 0) + 1);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const buckets = { unchanged: [], rekey: [], collision: [], uncanonicalisable: [] };
|
|
81
|
+
for (const row of rows) {
|
|
82
|
+
const stored = String(row.email ?? '');
|
|
83
|
+
const org = String(row.organizationId ?? '');
|
|
84
|
+
const k = key(stored);
|
|
85
|
+
if (!k.ok) {
|
|
86
|
+
buckets.uncanonicalisable.push({ _id: String(row._id), organizationId: org, stored, reason: k.reason });
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
if (k.key === stored) {
|
|
90
|
+
buckets.unchanged.push({ _id: String(row._id), organizationId: org, stored });
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
const contenders = occupied.get(org)?.get(k.key) ?? 0;
|
|
94
|
+
const target = contenders > 1;
|
|
95
|
+
(target ? buckets.collision : buckets.rekey).push({
|
|
96
|
+
_id: String(row._id),
|
|
97
|
+
organizationId: org,
|
|
98
|
+
stored,
|
|
99
|
+
canonical: k.key,
|
|
100
|
+
reason: row.reason,
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const summary = {
|
|
105
|
+
collection: collectionName,
|
|
106
|
+
totalRows: total,
|
|
107
|
+
unchanged: buckets.unchanged.length,
|
|
108
|
+
rekey: buckets.rekey.length,
|
|
109
|
+
collision: buckets.collision.length,
|
|
110
|
+
uncanonicalisable: buckets.uncanonicalisable.length,
|
|
111
|
+
unmatchableToday: buckets.rekey.length + buckets.collision.length + buckets.uncanonicalisable.length,
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
if (asJson) {
|
|
115
|
+
console.log(JSON.stringify({ summary, rekey: buckets.rekey, collision: buckets.collision, uncanonicalisable: buckets.uncanonicalisable }, null, 2));
|
|
116
|
+
} else {
|
|
117
|
+
console.log(`collection ${summary.collection}`);
|
|
118
|
+
console.log(`rows ${summary.totalRows}`);
|
|
119
|
+
console.log(` unchanged ${summary.unchanged}`);
|
|
120
|
+
console.log(` rekey ${summary.rekey}`);
|
|
121
|
+
console.log(` collision ${summary.collision}`);
|
|
122
|
+
console.log(` uncanonicalisable ${summary.uncanonicalisable}`);
|
|
123
|
+
console.log(`UNMATCHABLE TODAY ${summary.unmatchableToday}`);
|
|
124
|
+
for (const name of ['rekey', 'collision', 'uncanonicalisable']) {
|
|
125
|
+
if (buckets[name].length === 0) continue;
|
|
126
|
+
console.log(`\n── ${name} ──`);
|
|
127
|
+
for (const r of buckets[name]) {
|
|
128
|
+
console.log(` ${r.organizationId} ${JSON.stringify(r.stored)} -> ${r.canonical ?? `(none: ${r.reason})`}`);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
if (summary.totalRows === 0) {
|
|
132
|
+
console.log(
|
|
133
|
+
`\nSCANNED NOTHING — collection '${collectionName}' is empty or absent in this database.\n` +
|
|
134
|
+
'That is NOT the same answer as "clean". Confirm the collection name (a host with a\n' +
|
|
135
|
+
'`modelPrefix` stores it elsewhere) and the database in the URI before concluding\n' +
|
|
136
|
+
'there is no drift.',
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
console.log('\nNOTHING WAS WRITTEN. This is a report, not a migration.');
|
|
140
|
+
}
|
|
141
|
+
} finally {
|
|
142
|
+
await client.close();
|
|
143
|
+
}
|
|
File without changes
|