@blamejs/blamejs-shop 0.0.72 → 0.0.75

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.
Files changed (44) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/lib/announcement-bar.js +753 -0
  3. package/lib/banner-ab-tests.js +806 -0
  4. package/lib/bin-locations.js +791 -0
  5. package/lib/blog-articles.js +1173 -0
  6. package/lib/carrier-accounts.js +805 -0
  7. package/lib/cart-recovery.js +1133 -0
  8. package/lib/category-navigation.js +934 -0
  9. package/lib/consent-ledger.js +539 -0
  10. package/lib/customer-impersonation.js +743 -0
  11. package/lib/customer-merge.js +879 -0
  12. package/lib/demand-forecast.js +1121 -0
  13. package/lib/dispute-resolution.js +886 -0
  14. package/lib/email-ab-tests.js +918 -0
  15. package/lib/email-engagement-score.js +649 -0
  16. package/lib/event-log.js +713 -0
  17. package/lib/fulfillment-sla.js +791 -0
  18. package/lib/index.js +41 -0
  19. package/lib/inventory-audits.js +852 -0
  20. package/lib/line-gift-wrap.js +430 -0
  21. package/lib/marketing-budget.js +792 -0
  22. package/lib/operator-activity-feed.js +977 -0
  23. package/lib/operator-approvals.js +942 -0
  24. package/lib/operator-help-center.js +1020 -0
  25. package/lib/operator-inbox.js +889 -0
  26. package/lib/operator-sessions.js +701 -0
  27. package/lib/order-exchanges.js +602 -0
  28. package/lib/product-compare.js +804 -0
  29. package/lib/pwa-manifest.js +1005 -0
  30. package/lib/referral-leaderboard.js +612 -0
  31. package/lib/sales-tax-filings.js +807 -0
  32. package/lib/search-ranking.js +859 -0
  33. package/lib/shipping-insurance.js +757 -0
  34. package/lib/shrinkage-report.js +1182 -0
  35. package/lib/sidebar-widgets.js +952 -0
  36. package/lib/smart-restocking.js +1048 -0
  37. package/lib/stock-receipts.js +834 -0
  38. package/lib/subscription-analytics.js +1032 -0
  39. package/lib/suggestion-box.js +921 -0
  40. package/lib/tax-remittance.js +625 -0
  41. package/lib/vendor-invoices.js +1021 -0
  42. package/lib/winback-campaigns.js +1350 -0
  43. package/lib/wishlist-digest.js +1133 -0
  44. package/package.json +1 -1
@@ -0,0 +1,879 @@
1
+ "use strict";
2
+ /**
3
+ * @module shop.customerMerge
4
+ * @title Customer merge — deduplicate accounts onto a canonical id
5
+ *
6
+ * @intro
7
+ * Operator-driven consolidation of duplicate customer accounts.
8
+ * The same person can land in the customer table twice — two
9
+ * email addresses (work + personal), two passkey enrollments
10
+ * that never got linked, a guest checkout that later registered
11
+ * under a different address. The merge primitive picks a
12
+ * canonical (target) customer and a duplicate (source), proposes
13
+ * a dry-run plan listing every reparent the merge would
14
+ * commit, then executes the plan atomically: every order /
15
+ * subscription / loyalty-ledger row / review / address /
16
+ * payment-method that points at the source customer is
17
+ * rewritten to point at the target. The source customer row is
18
+ * soft-archived and a redirect marker is recorded so any later
19
+ * read of the source id transparently resolves to the target.
20
+ *
21
+ * Four-stage FSM:
22
+ *
23
+ * proposeMerge - records a {source, target, plan_json}
24
+ * row in `customer_merges` with status
25
+ * `proposed`. No rows reparented; `plan_json`
26
+ * is the frozen snapshot of per-primitive
27
+ * affected-row counts captured against the
28
+ * database AT proposal time.
29
+ *
30
+ * executeMerge - reparents every affected row atomically.
31
+ * Pre-flight all child primitives FIRST (count
32
+ * what we're about to touch) - any pre-flight
33
+ * error surfaces before any UPDATE runs.
34
+ * Then commit all reparents; stamp
35
+ * executed_at / executed_by; insert the
36
+ * redirect marker; the merge row lands in
37
+ * `executed`.
38
+ *
39
+ * rollbackMerge - within `ROLLBACK_WINDOW_MS` (7 days) of
40
+ * execution, reverses every reparent that
41
+ * `executeMerge` wrote, deletes the redirect
42
+ * marker, and lands the merge in
43
+ * `rolled_back`. Past the window, refuses.
44
+ * The rollback uses the SAME per-primitive
45
+ * reparent verbs in reverse — it does NOT
46
+ * restore arbitrary historical state, only
47
+ * the inverse of what THIS merge wrote.
48
+ *
49
+ * cancelMerge - drops a `proposed` plan without ever
50
+ * executing it. Lands the merge in
51
+ * `cancelled`. Idempotent on the cancelled
52
+ * terminal state.
53
+ *
54
+ * Composes (every child primitive is OPTIONAL — wire only what
55
+ * the operator's deployment uses):
56
+ *
57
+ * - opts.customers — `getCustomerById(id)` /
58
+ * `archiveCustomer(id)` /
59
+ * `restoreCustomer(id)`. REQUIRED;
60
+ * the source row's soft-archive +
61
+ * the merge's own customer-existence
62
+ * gate live here.
63
+ * - opts.order — `reparentForCustomer(fromId, toId)`
64
+ * / `countForCustomer(id)`. Optional;
65
+ * absent, the merge plan reports
66
+ * `orders: 0` and skips the reparent.
67
+ * - opts.subscriptions — same shape.
68
+ * - opts.loyalty — same shape.
69
+ * - opts.reviews — same shape.
70
+ * - opts.addresses — same shape.
71
+ * - opts.paymentMethods — same shape.
72
+ *
73
+ * Every child verb's contract:
74
+ * - `countForCustomer(id)` returns an integer (rows that
75
+ * reference this customer id).
76
+ * - `reparentForCustomer(fromId, toId)` rewrites every row
77
+ * pointing at `fromId` to point at `toId`. Returns
78
+ * `{ rowCount }` so the merge can sanity-check the actual
79
+ * writes against the plan.
80
+ *
81
+ * The merge primitive itself owns NO domain rows — it ONLY
82
+ * reparents through these injected verbs. That's the
83
+ * composition story: operators bring their own primitives;
84
+ * this primitive orchestrates the multi-table rewrite + the
85
+ * audit trail + the rollback window.
86
+ *
87
+ * findDuplicateCandidates:
88
+ *
89
+ * Heuristic candidate generation, NOT a primary-key index.
90
+ * The operator's customers primitive exposes a list-by-name +
91
+ * list-by-email-hash surface; this primitive scans for
92
+ * fuzzy-name collisions above a configurable similarity floor
93
+ * (Jaro-Winkler over the lowercased display_name) and surfaces
94
+ * them as candidates. The caller still has to make the merge
95
+ * decision — the candidate list is operator-review material,
96
+ * not auto-confirm material.
97
+ *
98
+ * redirectFor:
99
+ *
100
+ * A canonical-id lookup helper for callers that hold a
101
+ * possibly-stale source_customer_id. Returns the canonical
102
+ * target_customer_id when a redirect exists, else null. Use
103
+ * at the edge of a long-lived URL / cached link to transparently
104
+ * follow a merge.
105
+ *
106
+ * historyForCustomer:
107
+ *
108
+ * Every merge event that mentions a given customer id (as
109
+ * either source OR target). Ordered by created_at DESC. Used
110
+ * by the customer-detail operator console to surface "this
111
+ * account was merged from X on Y" or "Z was merged into this
112
+ * account on Y".
113
+ *
114
+ * listMerges:
115
+ *
116
+ * Audit + dashboard surface. Filters on status + date range.
117
+ * Returns ordered (created_at DESC, id DESC) rows; no cursor —
118
+ * the audit-trail volume is operator-scale (dozens to
119
+ * hundreds per year), so a hard limit cap suffices.
120
+ *
121
+ * Monotonic per-process clock: two operator actions (propose +
122
+ * execute, execute + rollback) can land in the same millisecond
123
+ * on fast machines. `_now` bumps to `prior + 1` on collision so
124
+ * the (created_at DESC, id DESC) listing + the
125
+ * historyForCustomer timeline read carry a strict per-process
126
+ * ordering.
127
+ *
128
+ * Composes:
129
+ * - `b.uuid.v7` - merge row ids
130
+ * - `b.guardUuid.sanitize` - strict UUID gate on every
131
+ * customer id reaching this
132
+ * primitive
133
+ *
134
+ * Surface:
135
+ * - findDuplicateCandidates({ limit, similarity_min })
136
+ * - proposeMerge({ source_customer_id, target_customer_id,
137
+ * requested_by })
138
+ * - executeMerge({ merge_id, executed_by })
139
+ * - rollbackMerge({ merge_id, reason })
140
+ * - cancelMerge({ merge_id, reason })
141
+ * - getMerge(merge_id)
142
+ * - historyForCustomer(customer_id)
143
+ * - listMerges({ status?, from?, to?, limit? })
144
+ * - redirectFor(customer_id)
145
+ *
146
+ * Storage:
147
+ * - customer_merges, customer_merge_redirects
148
+ * (migration `0194_customer_merge.sql`).
149
+ *
150
+ * @primitive customerMerge
151
+ * @related b.uuid.v7, b.guardUuid, shop.customers, shop.order,
152
+ * shop.subscriptions, shop.loyalty, shop.reviews,
153
+ * shop.addresses, shop.paymentMethods
154
+ */
155
+
156
+ var MAX_LIST_LIMIT = 200;
157
+ var DEFAULT_LIST_LIMIT = 50;
158
+ var MAX_CANDIDATE_LIMIT = 200;
159
+ var DEFAULT_CAND_LIMIT = 25;
160
+ var MAX_REASON_LEN = 280;
161
+ var ROLLBACK_WINDOW_MS = 7 * 24 * 60 * 60 * 1000;
162
+ var DEFAULT_SIMILARITY = 0.85;
163
+ var MIN_SIMILARITY = 0.50;
164
+ var MAX_SIMILARITY = 1.00;
165
+
166
+ var MERGE_STATUSES = Object.freeze([
167
+ "proposed", "executed", "rolled_back", "cancelled",
168
+ ]);
169
+
170
+ // Child-primitive registry — the merge orchestrates these by
171
+ // rewriting every row that points at a source_customer_id to
172
+ // point at the target_customer_id. Each entry is { key: opts-
173
+ // key, plan: plan-field-name }. The plan field is what surfaces
174
+ // on the dry-run + the listMerges payload, so it's stable
175
+ // operator-facing naming.
176
+ var CHILD_PRIMITIVES = Object.freeze([
177
+ { key: "order", plan: "orders" },
178
+ { key: "subscriptions", plan: "subscriptions" },
179
+ { key: "loyalty", plan: "loyalty_entries" },
180
+ { key: "reviews", plan: "reviews" },
181
+ { key: "addresses", plan: "addresses" },
182
+ { key: "paymentMethods", plan: "payment_methods" },
183
+ ]);
184
+
185
+ var bShop;
186
+ function _b() {
187
+ if (!bShop) bShop = require("./index");
188
+ return bShop.framework;
189
+ }
190
+
191
+ // ---- monotonic clock ---------------------------------------------------
192
+ //
193
+ // Operator-driven writes (propose immediately followed by execute,
194
+ // execute immediately followed by rollback in an operator console)
195
+ // can land in the same millisecond on fast machines. Bumping by
196
+ // 1ms on a tie keeps the timeline strictly increasing so a sort-
197
+ // by-timestamp read returns events in the order they were issued.
198
+
199
+ var _lastTs = 0;
200
+ function _now() {
201
+ var t = Date.now();
202
+ if (t <= _lastTs) t = _lastTs + 1;
203
+ _lastTs = t;
204
+ return t;
205
+ }
206
+
207
+ // ---- validators --------------------------------------------------------
208
+
209
+ function _customerId(s, label) {
210
+ try {
211
+ return _b().guardUuid.sanitize(s, { profile: "strict" });
212
+ } catch (e) {
213
+ throw new TypeError("customerMerge: " + label + " - " + (e && e.message || "invalid UUID"));
214
+ }
215
+ }
216
+
217
+ function _mergeId(s) {
218
+ try {
219
+ return _b().guardUuid.sanitize(s, { profile: "strict" });
220
+ } catch (e) {
221
+ throw new TypeError("customerMerge: merge_id - " + (e && e.message || "invalid UUID"));
222
+ }
223
+ }
224
+
225
+ function _operatorId(s, label) {
226
+ if (typeof s !== "string" || !s.length || s.length > 200) {
227
+ throw new TypeError("customerMerge: " + label + " must be a non-empty string <= 200 characters");
228
+ }
229
+ if (/[\x00-\x1f\x7f]/.test(s)) {
230
+ throw new TypeError("customerMerge: " + label + " contains control bytes");
231
+ }
232
+ return s;
233
+ }
234
+
235
+ function _reason(s, label) {
236
+ if (typeof s !== "string" || !s.length) {
237
+ throw new TypeError("customerMerge: " + label + " must be a non-empty string");
238
+ }
239
+ if (s.length > MAX_REASON_LEN) {
240
+ throw new TypeError("customerMerge: " + label + " must be <= " + MAX_REASON_LEN + " characters");
241
+ }
242
+ if (/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/.test(s)) {
243
+ throw new TypeError("customerMerge: " + label + " contains control bytes");
244
+ }
245
+ return s;
246
+ }
247
+
248
+ function _limit(n, max, def, label) {
249
+ if (n == null) return def;
250
+ if (!Number.isInteger(n) || n <= 0 || n > max) {
251
+ throw new TypeError("customerMerge: " + label + " must be an integer 1..." + max);
252
+ }
253
+ return n;
254
+ }
255
+
256
+ function _similarity(n) {
257
+ if (n == null) return DEFAULT_SIMILARITY;
258
+ if (typeof n !== "number" || !isFinite(n) || n < MIN_SIMILARITY || n > MAX_SIMILARITY) {
259
+ throw new TypeError("customerMerge: similarity_min must be a number in [" +
260
+ MIN_SIMILARITY + ", " + MAX_SIMILARITY + "]");
261
+ }
262
+ return n;
263
+ }
264
+
265
+ function _timestampRange(from, to, label) {
266
+ if (from != null && (!Number.isInteger(from) || from < 0)) {
267
+ throw new TypeError("customerMerge." + label + ": from must be a non-negative integer (ms epoch)");
268
+ }
269
+ if (to != null && (!Number.isInteger(to) || to < 0)) {
270
+ throw new TypeError("customerMerge." + label + ": to must be a non-negative integer (ms epoch)");
271
+ }
272
+ if (from != null && to != null && from > to) {
273
+ throw new TypeError("customerMerge." + label + ": from must be <= to");
274
+ }
275
+ }
276
+
277
+ function _status(s) {
278
+ if (typeof s !== "string" || MERGE_STATUSES.indexOf(s) === -1) {
279
+ throw new TypeError("customerMerge: status must be one of " + MERGE_STATUSES.join(", "));
280
+ }
281
+ return s;
282
+ }
283
+
284
+ // ---- Jaro-Winkler similarity -------------------------------------------
285
+ //
286
+ // In-process string-similarity scorer for the candidate-finder.
287
+ // Operates on lowercased ASCII. The Jaro distance counts matching
288
+ // characters within a window of half-the-max-length minus one;
289
+ // Jaro-Winkler boosts the score for shared leading prefixes.
290
+ //
291
+ // Returns 0.0 .. 1.0. Two identical strings score 1.0; two
292
+ // strings with no common characters score 0.0.
293
+
294
+ function _jaroWinkler(a, b) {
295
+ if (a === b) return 1;
296
+ if (!a.length || !b.length) return 0;
297
+
298
+ var aLen = a.length;
299
+ var bLen = b.length;
300
+ var matchWindow = Math.max(0, Math.floor(Math.max(aLen, bLen) / 2) - 1);
301
+
302
+ var aMatches = new Array(aLen);
303
+ var bMatches = new Array(bLen);
304
+ var matches = 0;
305
+
306
+ for (var i = 0; i < aLen; i += 1) {
307
+ var lo = Math.max(0, i - matchWindow);
308
+ var hi = Math.min(bLen - 1, i + matchWindow);
309
+ for (var j = lo; j <= hi; j += 1) {
310
+ if (bMatches[j]) continue;
311
+ if (a.charAt(i) !== b.charAt(j)) continue;
312
+ aMatches[i] = true;
313
+ bMatches[j] = true;
314
+ matches += 1;
315
+ break;
316
+ }
317
+ }
318
+ if (matches === 0) return 0;
319
+
320
+ // Count transpositions: matched characters in a, scanned in
321
+ // order against matched characters in b. Mismatched pairs are
322
+ // transpositions / 2.
323
+ var transpositions = 0;
324
+ var k = 0;
325
+ for (var ii = 0; ii < aLen; ii += 1) {
326
+ if (!aMatches[ii]) continue;
327
+ while (!bMatches[k]) k += 1;
328
+ if (a.charAt(ii) !== b.charAt(k)) transpositions += 1;
329
+ k += 1;
330
+ }
331
+ transpositions = transpositions / 2;
332
+
333
+ var jaro = (matches / aLen + matches / bLen + (matches - transpositions) / matches) / 3;
334
+
335
+ // Winkler boost: up to 4 leading characters of shared prefix.
336
+ var prefix = 0;
337
+ var prefixCap = Math.min(4, Math.min(aLen, bLen));
338
+ for (var p = 0; p < prefixCap; p += 1) {
339
+ if (a.charAt(p) !== b.charAt(p)) break;
340
+ prefix += 1;
341
+ }
342
+ return jaro + prefix * 0.1 * (1 - jaro);
343
+ }
344
+
345
+ function _normalizeName(s) {
346
+ if (typeof s !== "string") return "";
347
+ return s.toLowerCase().replace(/\s+/g, " ").trim();
348
+ }
349
+
350
+ // ---- hydration ---------------------------------------------------------
351
+
352
+ function _hydrateMerge(row) {
353
+ if (!row) return null;
354
+ var plan;
355
+ try { plan = JSON.parse(row.plan_json || "{}"); }
356
+ catch (_e) { plan = {}; }
357
+ return {
358
+ id: row.id,
359
+ source_customer_id: row.source_customer_id,
360
+ target_customer_id: row.target_customer_id,
361
+ status: row.status,
362
+ plan: plan,
363
+ requested_by: row.requested_by,
364
+ executed_at: row.executed_at == null ? null : Number(row.executed_at),
365
+ executed_by: row.executed_by == null ? null : row.executed_by,
366
+ rolled_back_at: row.rolled_back_at == null ? null : Number(row.rolled_back_at),
367
+ rollback_reason: row.rollback_reason == null ? null : row.rollback_reason,
368
+ cancelled_at: row.cancelled_at == null ? null : Number(row.cancelled_at),
369
+ cancel_reason: row.cancel_reason == null ? null : row.cancel_reason,
370
+ created_at: Number(row.created_at),
371
+ };
372
+ }
373
+
374
+ function _hydrateRedirect(row) {
375
+ if (!row) return null;
376
+ return {
377
+ source_customer_id: row.source_customer_id,
378
+ target_customer_id: row.target_customer_id,
379
+ merge_id: row.merge_id,
380
+ executed_at: Number(row.executed_at),
381
+ };
382
+ }
383
+
384
+ // ---- child-primitive contract validation ------------------------------
385
+ //
386
+ // Every wired child primitive must expose two verbs:
387
+ // countForCustomer(customer_id) -> Promise<integer>
388
+ // reparentForCustomer(fromId, toId) -> Promise<{ rowCount: integer }>
389
+ // Absent either when the handle IS supplied is a config-time bug —
390
+ // throw at create() so the operator catches the typo at boot.
391
+
392
+ function _validateChild(handle, key) {
393
+ if (!handle) return;
394
+ if (typeof handle.countForCustomer !== "function") {
395
+ throw new TypeError("customerMerge.create: opts." + key +
396
+ " must expose a countForCustomer(customer_id) method");
397
+ }
398
+ if (typeof handle.reparentForCustomer !== "function") {
399
+ throw new TypeError("customerMerge.create: opts." + key +
400
+ " must expose a reparentForCustomer(fromId, toId) method");
401
+ }
402
+ }
403
+
404
+ function _validateCustomers(handle) {
405
+ if (typeof handle.getCustomerById !== "function") {
406
+ throw new TypeError("customerMerge.create: opts.customers must expose a getCustomerById(id) method");
407
+ }
408
+ if (typeof handle.archiveCustomer !== "function") {
409
+ throw new TypeError("customerMerge.create: opts.customers must expose an archiveCustomer(id) method");
410
+ }
411
+ if (typeof handle.restoreCustomer !== "function") {
412
+ throw new TypeError("customerMerge.create: opts.customers must expose a restoreCustomer(id) method");
413
+ }
414
+ if (typeof handle.listForCandidates !== "function") {
415
+ throw new TypeError("customerMerge.create: opts.customers must expose a listForCandidates({ limit }) method");
416
+ }
417
+ }
418
+
419
+ // ---- factory -----------------------------------------------------------
420
+
421
+ function create(opts) {
422
+ opts = opts || {};
423
+ var query = opts.query;
424
+ if (!query) {
425
+ query = function (sql, params) { return _b().externalDb.query(sql, params); };
426
+ }
427
+
428
+ // customers is REQUIRED — the merge gates both customer-exists
429
+ // checks AND the source-archive call through this handle.
430
+ if (!opts.customers || typeof opts.customers !== "object") {
431
+ throw new TypeError("customerMerge.create: opts.customers is required");
432
+ }
433
+ var customers = opts.customers;
434
+ _validateCustomers(customers);
435
+
436
+ // Every other child is optional.
437
+ var children = {};
438
+ for (var i = 0; i < CHILD_PRIMITIVES.length; i += 1) {
439
+ var spec = CHILD_PRIMITIVES[i];
440
+ var handle = opts[spec.key];
441
+ _validateChild(handle, spec.key);
442
+ children[spec.key] = handle || null;
443
+ }
444
+
445
+ async function _getMergeRow(id) {
446
+ var r = await query("SELECT * FROM customer_merges WHERE id = ?1", [id]);
447
+ return r.rows[0] || null;
448
+ }
449
+
450
+ async function _getRedirectRow(sourceId) {
451
+ var r = await query(
452
+ "SELECT * FROM customer_merge_redirects WHERE source_customer_id = ?1",
453
+ [sourceId],
454
+ );
455
+ return r.rows[0] || null;
456
+ }
457
+
458
+ // Walk every wired child primitive and tally the affected-row
459
+ // counts. The returned plan is the dry-run snapshot for
460
+ // proposeMerge and the verification footprint for executeMerge /
461
+ // rollbackMerge.
462
+ async function _countPlanForSource(sourceId) {
463
+ var plan = {};
464
+ var total = 0;
465
+ for (var c = 0; c < CHILD_PRIMITIVES.length; c += 1) {
466
+ var spec = CHILD_PRIMITIVES[c];
467
+ var handle = children[spec.key];
468
+ if (!handle) {
469
+ plan[spec.plan] = 0;
470
+ continue;
471
+ }
472
+ var n = await handle.countForCustomer(sourceId);
473
+ if (!Number.isInteger(n) || n < 0) {
474
+ throw new TypeError("customerMerge: opts." + spec.key +
475
+ ".countForCustomer returned non-integer (" + JSON.stringify(n) + ")");
476
+ }
477
+ plan[spec.plan] = n;
478
+ total += n;
479
+ }
480
+ plan.total_rows = total;
481
+ return plan;
482
+ }
483
+
484
+ // Reparent in dependency-naive order: every wired child gets the
485
+ // reparent call. Returns the actual row-count snapshot so the
486
+ // caller can sanity-check against the proposeMerge plan.
487
+ async function _reparentAll(sourceId, targetId) {
488
+ var actual = {};
489
+ var total = 0;
490
+ for (var c = 0; c < CHILD_PRIMITIVES.length; c += 1) {
491
+ var spec = CHILD_PRIMITIVES[c];
492
+ var handle = children[spec.key];
493
+ if (!handle) {
494
+ actual[spec.plan] = 0;
495
+ continue;
496
+ }
497
+ var res = await handle.reparentForCustomer(sourceId, targetId);
498
+ var rowCount = res && Number.isInteger(res.rowCount) ? res.rowCount : 0;
499
+ actual[spec.plan] = rowCount;
500
+ total += rowCount;
501
+ }
502
+ actual.total_rows = total;
503
+ return actual;
504
+ }
505
+
506
+ return {
507
+
508
+ MERGE_STATUSES: MERGE_STATUSES.slice(),
509
+ ROLLBACK_WINDOW_MS: ROLLBACK_WINDOW_MS,
510
+ MAX_LIST_LIMIT: MAX_LIST_LIMIT,
511
+ MAX_CANDIDATE_LIMIT: MAX_CANDIDATE_LIMIT,
512
+ MAX_REASON_LEN: MAX_REASON_LEN,
513
+ DEFAULT_SIMILARITY: DEFAULT_SIMILARITY,
514
+
515
+ // Heuristic duplicate finder. Pulls a batch of recent customer
516
+ // rows through the customers primitive's listForCandidates({
517
+ // limit }) verb and pairs them by lowercased-name Jaro-Winkler
518
+ // similarity. Returns the pairs above `similarity_min`,
519
+ // ordered by similarity DESC. Already-merged source ids
520
+ // (i.e. rows with an existing redirect marker) are excluded
521
+ // from the candidate set so a confirmed merge doesn't re-
522
+ // surface as a "possible duplicate."
523
+ findDuplicateCandidates: async function (input) {
524
+ input = input || {};
525
+ var limit = _limit(input.limit, MAX_CANDIDATE_LIMIT, DEFAULT_CAND_LIMIT, "limit");
526
+ var similarityMin = _similarity(input.similarity_min);
527
+
528
+ var scanLimit = Math.min(MAX_CANDIDATE_LIMIT, Math.max(limit * 4, 100));
529
+ var listed = await customers.listForCandidates({ limit: scanLimit });
530
+ var rows = (listed && Array.isArray(listed.rows)) ? listed.rows : [];
531
+
532
+ // Filter out already-redirected source ids (each one is by
533
+ // definition no longer canonical and surfacing it as a
534
+ // "potential duplicate" is noise).
535
+ var redirectRows = (await query(
536
+ "SELECT source_customer_id FROM customer_merge_redirects", [],
537
+ )).rows;
538
+ var redirected = Object.create(null);
539
+ for (var r = 0; r < redirectRows.length; r += 1) {
540
+ redirected[redirectRows[r].source_customer_id] = true;
541
+ }
542
+ var pool = [];
543
+ for (var k = 0; k < rows.length; k += 1) {
544
+ var row = rows[k];
545
+ if (!row || !row.id) continue;
546
+ if (redirected[row.id]) continue;
547
+ var norm = _normalizeName(row.display_name || "");
548
+ if (!norm.length) continue;
549
+ pool.push({ id: row.id, name: norm, display_name: row.display_name });
550
+ }
551
+
552
+ var candidates = [];
553
+ for (var a = 0; a < pool.length; a += 1) {
554
+ for (var b = a + 1; b < pool.length; b += 1) {
555
+ var score = _jaroWinkler(pool[a].name, pool[b].name);
556
+ if (score >= similarityMin) {
557
+ // Stable order — smaller id first so the same pair
558
+ // surfaces identically across runs.
559
+ var pair;
560
+ if (pool[a].id < pool[b].id) {
561
+ pair = { a_id: pool[a].id, a_display_name: pool[a].display_name,
562
+ b_id: pool[b].id, b_display_name: pool[b].display_name,
563
+ similarity: score };
564
+ } else {
565
+ pair = { a_id: pool[b].id, a_display_name: pool[b].display_name,
566
+ b_id: pool[a].id, b_display_name: pool[a].display_name,
567
+ similarity: score };
568
+ }
569
+ candidates.push(pair);
570
+ }
571
+ }
572
+ }
573
+ candidates.sort(function (x, y) {
574
+ if (y.similarity !== x.similarity) return y.similarity - x.similarity;
575
+ if (x.a_id < y.a_id) return -1;
576
+ if (x.a_id > y.a_id) return 1;
577
+ if (x.b_id < y.b_id) return -1;
578
+ if (x.b_id > y.b_id) return 1;
579
+ return 0;
580
+ });
581
+ return candidates.slice(0, limit);
582
+ },
583
+
584
+ // Record a dry-run merge plan. Both customer ids must exist
585
+ // and be distinct. The source must not already have a
586
+ // redirect (an already-merged source can't be re-merged), and
587
+ // the target must not be an already-merged source itself
588
+ // (chained redirects refused — operators always merge ONTO a
589
+ // canonical id). An existing `proposed` plan for the same
590
+ // (source, target) pair is refused; cancel it first or
591
+ // re-use it.
592
+ proposeMerge: async function (input) {
593
+ if (!input || typeof input !== "object") {
594
+ throw new TypeError("customerMerge.proposeMerge: input object required");
595
+ }
596
+ var sourceId = _customerId(input.source_customer_id, "source_customer_id");
597
+ var targetId = _customerId(input.target_customer_id, "target_customer_id");
598
+ var requestedBy = _operatorId(input.requested_by, "requested_by");
599
+
600
+ if (sourceId === targetId) {
601
+ throw new TypeError("customerMerge.proposeMerge: source_customer_id and target_customer_id must differ");
602
+ }
603
+
604
+ var sourceCustomer = await customers.getCustomerById(sourceId);
605
+ if (!sourceCustomer) {
606
+ var srcErr = new Error("customerMerge.proposeMerge: source_customer_id " + sourceId + " not found");
607
+ srcErr.code = "CUSTOMER_MERGE_SOURCE_NOT_FOUND";
608
+ throw srcErr;
609
+ }
610
+ var targetCustomer = await customers.getCustomerById(targetId);
611
+ if (!targetCustomer) {
612
+ var tgtErr = new Error("customerMerge.proposeMerge: target_customer_id " + targetId + " not found");
613
+ tgtErr.code = "CUSTOMER_MERGE_TARGET_NOT_FOUND";
614
+ throw tgtErr;
615
+ }
616
+
617
+ var existingRedirect = await _getRedirectRow(sourceId);
618
+ if (existingRedirect) {
619
+ var redErr = new Error("customerMerge.proposeMerge: source_customer_id " + sourceId +
620
+ " is already merged into " + existingRedirect.target_customer_id);
621
+ redErr.code = "CUSTOMER_MERGE_SOURCE_ALREADY_MERGED";
622
+ throw redErr;
623
+ }
624
+ var targetAsSource = await _getRedirectRow(targetId);
625
+ if (targetAsSource) {
626
+ var chainErr = new Error("customerMerge.proposeMerge: target_customer_id " + targetId +
627
+ " is itself merged into " + targetAsSource.target_customer_id +
628
+ " - chained redirects refused");
629
+ chainErr.code = "CUSTOMER_MERGE_TARGET_IS_REDIRECT";
630
+ throw chainErr;
631
+ }
632
+
633
+ // Refuse a duplicate proposed-state plan for the same pair.
634
+ var existingProposed = (await query(
635
+ "SELECT id FROM customer_merges " +
636
+ "WHERE source_customer_id = ?1 AND target_customer_id = ?2 AND status = 'proposed'",
637
+ [sourceId, targetId],
638
+ )).rows[0];
639
+ if (existingProposed) {
640
+ var dupErr = new Error("customerMerge.proposeMerge: a proposed plan already exists for " +
641
+ "(source=" + sourceId + ", target=" + targetId + "): merge_id=" + existingProposed.id);
642
+ dupErr.code = "CUSTOMER_MERGE_DUPLICATE_PROPOSAL";
643
+ throw dupErr;
644
+ }
645
+
646
+ var plan = await _countPlanForSource(sourceId);
647
+ var id = _b().uuid.v7();
648
+ var ts = _now();
649
+ await query(
650
+ "INSERT INTO customer_merges " +
651
+ "(id, source_customer_id, target_customer_id, status, plan_json, requested_by, created_at) " +
652
+ "VALUES (?1, ?2, ?3, 'proposed', ?4, ?5, ?6)",
653
+ [id, sourceId, targetId, JSON.stringify(plan), requestedBy, ts],
654
+ );
655
+ return _hydrateMerge(await _getMergeRow(id));
656
+ },
657
+
658
+ // Reparent every child row. Pre-flight all primitives FIRST
659
+ // (the count walk surfaces any handle-level error before any
660
+ // UPDATE runs); then commit every reparent; then archive the
661
+ // source customer; then insert the redirect marker; then stamp
662
+ // executed_at + executed_by + lands the merge in executed.
663
+ executeMerge: async function (input) {
664
+ if (!input || typeof input !== "object") {
665
+ throw new TypeError("customerMerge.executeMerge: input object required");
666
+ }
667
+ var mergeId = _mergeId(input.merge_id);
668
+ var executedBy = _operatorId(input.executed_by, "executed_by");
669
+
670
+ var row = await _getMergeRow(mergeId);
671
+ if (!row) {
672
+ var nfErr = new Error("customerMerge.executeMerge: merge_id " + mergeId + " not found");
673
+ nfErr.code = "CUSTOMER_MERGE_NOT_FOUND";
674
+ throw nfErr;
675
+ }
676
+ if (row.status !== "proposed") {
677
+ var stErr = new Error("customerMerge.executeMerge: merge_id " + mergeId +
678
+ " is " + row.status + ", only proposed merges can be executed");
679
+ stErr.code = "CUSTOMER_MERGE_NOT_PROPOSED";
680
+ throw stErr;
681
+ }
682
+
683
+ // Pre-flight: recount the plan. If the actual current state
684
+ // doesn't match the captured plan, we refuse — the operator
685
+ // re-proposes (state drifted between proposal and execute).
686
+ var freshPlan = await _countPlanForSource(row.source_customer_id);
687
+ var capturedPlan;
688
+ try { capturedPlan = JSON.parse(row.plan_json || "{}"); }
689
+ catch (_e) { capturedPlan = {}; }
690
+ if (freshPlan.total_rows !== capturedPlan.total_rows) {
691
+ var drErr = new Error("customerMerge.executeMerge: plan drifted since proposal " +
692
+ "(proposed total=" + capturedPlan.total_rows + ", actual=" + freshPlan.total_rows +
693
+ ") - re-propose");
694
+ drErr.code = "CUSTOMER_MERGE_PLAN_DRIFTED";
695
+ throw drErr;
696
+ }
697
+
698
+ // Commit every reparent.
699
+ var actual = await _reparentAll(row.source_customer_id, row.target_customer_id);
700
+
701
+ // Archive the source customer + insert the redirect marker.
702
+ await customers.archiveCustomer(row.source_customer_id);
703
+ var ts = _now();
704
+ await query(
705
+ "INSERT INTO customer_merge_redirects " +
706
+ "(source_customer_id, target_customer_id, merge_id, executed_at) " +
707
+ "VALUES (?1, ?2, ?3, ?4)",
708
+ [row.source_customer_id, row.target_customer_id, mergeId, ts],
709
+ );
710
+
711
+ // Land the merge in executed; freeze the actual reparent
712
+ // counts on plan_json so rollback has the exact footprint.
713
+ var sealed = Object.assign({}, capturedPlan, { actual: actual });
714
+ await query(
715
+ "UPDATE customer_merges SET status = 'executed', plan_json = ?1, " +
716
+ "executed_at = ?2, executed_by = ?3 WHERE id = ?4",
717
+ [JSON.stringify(sealed), ts, executedBy, mergeId],
718
+ );
719
+ return _hydrateMerge(await _getMergeRow(mergeId));
720
+ },
721
+
722
+ // Reverse every reparent. Within ROLLBACK_WINDOW_MS of the
723
+ // execute timestamp. Past the window, refuses outright — the
724
+ // assumption is that downstream systems (analytics, reporting,
725
+ // operator notifications) have observed the merge and any
726
+ // mass-reverse would create a "rollback storm" of stale-data
727
+ // side effects.
728
+ rollbackMerge: async function (input) {
729
+ if (!input || typeof input !== "object") {
730
+ throw new TypeError("customerMerge.rollbackMerge: input object required");
731
+ }
732
+ var mergeId = _mergeId(input.merge_id);
733
+ var reason = _reason(input.reason, "reason");
734
+
735
+ var row = await _getMergeRow(mergeId);
736
+ if (!row) {
737
+ var nfErr = new Error("customerMerge.rollbackMerge: merge_id " + mergeId + " not found");
738
+ nfErr.code = "CUSTOMER_MERGE_NOT_FOUND";
739
+ throw nfErr;
740
+ }
741
+ if (row.status !== "executed") {
742
+ var stErr = new Error("customerMerge.rollbackMerge: merge_id " + mergeId +
743
+ " is " + row.status + ", only executed merges can be rolled back");
744
+ stErr.code = "CUSTOMER_MERGE_NOT_EXECUTED";
745
+ throw stErr;
746
+ }
747
+ var now = _now();
748
+ if (now - Number(row.executed_at) > ROLLBACK_WINDOW_MS) {
749
+ var winErr = new Error("customerMerge.rollbackMerge: merge_id " + mergeId +
750
+ " executed " + Math.floor((now - Number(row.executed_at)) / (24 * 60 * 60 * 1000)) +
751
+ " days ago, past the " + (ROLLBACK_WINDOW_MS / (24 * 60 * 60 * 1000)) +
752
+ "-day rollback window");
753
+ winErr.code = "CUSTOMER_MERGE_ROLLBACK_WINDOW_EXPIRED";
754
+ throw winErr;
755
+ }
756
+
757
+ // Reverse every reparent (target -> source).
758
+ await _reparentAll(row.target_customer_id, row.source_customer_id);
759
+
760
+ // Restore the source customer + drop the redirect marker.
761
+ await customers.restoreCustomer(row.source_customer_id);
762
+ await query(
763
+ "DELETE FROM customer_merge_redirects WHERE source_customer_id = ?1",
764
+ [row.source_customer_id],
765
+ );
766
+
767
+ await query(
768
+ "UPDATE customer_merges SET status = 'rolled_back', " +
769
+ "rolled_back_at = ?1, rollback_reason = ?2 WHERE id = ?3",
770
+ [now, reason, mergeId],
771
+ );
772
+ return _hydrateMerge(await _getMergeRow(mergeId));
773
+ },
774
+
775
+ // Drop a proposed plan without ever executing it. Idempotent
776
+ // on the terminal cancelled state (re-cancelling returns the
777
+ // already-cancelled row).
778
+ cancelMerge: async function (input) {
779
+ if (!input || typeof input !== "object") {
780
+ throw new TypeError("customerMerge.cancelMerge: input object required");
781
+ }
782
+ var mergeId = _mergeId(input.merge_id);
783
+ var reason = _reason(input.reason, "reason");
784
+ var row = await _getMergeRow(mergeId);
785
+ if (!row) {
786
+ var nfErr = new Error("customerMerge.cancelMerge: merge_id " + mergeId + " not found");
787
+ nfErr.code = "CUSTOMER_MERGE_NOT_FOUND";
788
+ throw nfErr;
789
+ }
790
+ if (row.status === "cancelled") {
791
+ return _hydrateMerge(row);
792
+ }
793
+ if (row.status !== "proposed") {
794
+ var stErr = new Error("customerMerge.cancelMerge: merge_id " + mergeId +
795
+ " is " + row.status + ", only proposed merges can be cancelled");
796
+ stErr.code = "CUSTOMER_MERGE_NOT_PROPOSED";
797
+ throw stErr;
798
+ }
799
+ var ts = _now();
800
+ await query(
801
+ "UPDATE customer_merges SET status = 'cancelled', " +
802
+ "cancelled_at = ?1, cancel_reason = ?2 WHERE id = ?3",
803
+ [ts, reason, mergeId],
804
+ );
805
+ return _hydrateMerge(await _getMergeRow(mergeId));
806
+ },
807
+
808
+ // Single-merge read. Returns null on miss.
809
+ getMerge: async function (mergeId) {
810
+ mergeId = _mergeId(mergeId);
811
+ return _hydrateMerge(await _getMergeRow(mergeId));
812
+ },
813
+
814
+ // Every merge that mentions a customer id (as source OR
815
+ // target). Sorted (created_at DESC, id DESC).
816
+ historyForCustomer: async function (customerId) {
817
+ customerId = _customerId(customerId, "customer_id");
818
+ var r = await query(
819
+ "SELECT * FROM customer_merges " +
820
+ "WHERE source_customer_id = ?1 OR target_customer_id = ?1 " +
821
+ "ORDER BY created_at DESC, id DESC",
822
+ [customerId],
823
+ );
824
+ return r.rows.map(_hydrateMerge);
825
+ },
826
+
827
+ // Audit listing. Filters: status + created_at range. Sorted
828
+ // (created_at DESC, id DESC). No cursor — operator-scale
829
+ // audit volume is bounded (dozens to hundreds per year).
830
+ listMerges: async function (listOpts) {
831
+ listOpts = listOpts || {};
832
+ var limit = _limit(listOpts.limit, MAX_LIST_LIMIT, DEFAULT_LIST_LIMIT, "limit");
833
+ _timestampRange(listOpts.from, listOpts.to, "listMerges");
834
+
835
+ var where = [];
836
+ var params = [];
837
+ var idx = 1;
838
+ if (listOpts.status != null) {
839
+ var status = _status(listOpts.status);
840
+ where.push("status = ?" + idx);
841
+ params.push(status);
842
+ idx += 1;
843
+ }
844
+ if (listOpts.from != null) {
845
+ where.push("created_at >= ?" + idx);
846
+ params.push(listOpts.from);
847
+ idx += 1;
848
+ }
849
+ if (listOpts.to != null) {
850
+ where.push("created_at <= ?" + idx);
851
+ params.push(listOpts.to);
852
+ idx += 1;
853
+ }
854
+ var sql = "SELECT * FROM customer_merges";
855
+ if (where.length) sql += " WHERE " + where.join(" AND ");
856
+ sql += " ORDER BY created_at DESC, id DESC LIMIT ?" + idx;
857
+ params.push(limit);
858
+ var r = await query(sql, params);
859
+ return r.rows.map(_hydrateMerge);
860
+ },
861
+
862
+ // Resolve a possibly-stale source_customer_id to the canonical
863
+ // target. Returns the redirect row when one exists, else null.
864
+ redirectFor: async function (customerId) {
865
+ customerId = _customerId(customerId, "customer_id");
866
+ return _hydrateRedirect(await _getRedirectRow(customerId));
867
+ },
868
+ };
869
+ }
870
+
871
+ module.exports = {
872
+ create: create,
873
+ MERGE_STATUSES: MERGE_STATUSES.slice(),
874
+ ROLLBACK_WINDOW_MS: ROLLBACK_WINDOW_MS,
875
+ MAX_LIST_LIMIT: MAX_LIST_LIMIT,
876
+ MAX_CANDIDATE_LIMIT: MAX_CANDIDATE_LIMIT,
877
+ MAX_REASON_LEN: MAX_REASON_LEN,
878
+ DEFAULT_SIMILARITY: DEFAULT_SIMILARITY,
879
+ };