@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,859 @@
1
+ "use strict";
2
+ /**
3
+ * @module shop.searchRanking
4
+ * @title Search ranking — operator-tunable storefront search reranker
5
+ *
6
+ * @intro
7
+ * Storefront search returns a list of candidate products from the
8
+ * underlying catalog/index. Whatever order the index hands back is
9
+ * rarely the order the operator wants the shopper to see: a
10
+ * distribution-centre operator wants in-stock items first; a
11
+ * margin-conscious operator wants high-margin items lifted; a
12
+ * merchandiser running a campaign wants three SKUs pinned to the
13
+ * top of "summer dress" for the next two weeks regardless of what
14
+ * the relevance score says. `searchRanking` is the surface that
15
+ * owns those three concerns:
16
+ *
17
+ * 1. Named **weight sets** — an operator declares a flat
18
+ * signal -> multiplier mapping (e.g. `{ relevance: 1.0,
19
+ * popularity: 0.5, in_stock: 0.3, margin: 0.2 }`) under a stable
20
+ * slug. The storefront ranker reads the *active* set on every
21
+ * query and computes a weighted score per candidate result:
22
+ *
23
+ * score = sum_signal( weight[signal] * result.signals[signal] )
24
+ *
25
+ * Results without a signal contribute zero for that signal;
26
+ * signals not in the weight set contribute zero. Operators can
27
+ * author multiple weight sets and flip the active one
28
+ * atomically via `setActiveWeights(slug)`.
29
+ *
30
+ * 2. **Manual pins** — `pinProductForQuery({ query, product_id,
31
+ * position })` records a (query, product_id) -> position
32
+ * override. `applyToResults` lifts every pinned product to its
33
+ * configured position BEFORE the weighted score sort runs;
34
+ * pinned products keep their relative order even if their
35
+ * signal-derived score would have ranked them differently. The
36
+ * query is normalised at the entry point (lowercased +
37
+ * whitespace-collapsed) so "Summer Dress" and " summer
38
+ * dress " resolve to the same pin set.
39
+ *
40
+ * 3. **Event recording + metrics** — `recordSearchEvent({
41
+ * query, product_id?, event_type, weights_slug, position?,
42
+ * session_id? })` appends a row to the event log. Three event
43
+ * types: `impression` (the rendered result list — one row per
44
+ * product), `click` (the shopper clicked through), `purchase`
45
+ * (the click closed). `metricsForWeights({ weights_slug, from,
46
+ * to })` aggregates the log inside a closed time window and
47
+ * returns:
48
+ *
49
+ * { impressions, clicks, purchases,
50
+ * ctr: clicks / impressions,
51
+ * conversion_rate: purchases / impressions,
52
+ * click_to_purchase: purchases / clicks }
53
+ *
54
+ * Ratios are `null` when the denominator is zero (no division
55
+ * by zero, no fake zero). Operators can A/B two weight sets by
56
+ * flipping the active one mid-window and reading the per-set
57
+ * numbers afterwards.
58
+ *
59
+ * PII handling — `session_id` on `recordSearchEvent` is hashed via
60
+ * `b.crypto.namespaceHash("search-ranking-session", raw)`; the raw
61
+ * value never reaches the row. `product_id` is operator-side data
62
+ * (the merchandiser already knows which SKU is which); no hashing.
63
+ *
64
+ * Storage:
65
+ * - search_weight_sets — named weight set, one active at a time
66
+ * - search_manual_pins — (query, product_id) -> position
67
+ * - search_events — append-only impression/click/purchase log
68
+ * (migration `0167_search_ranking.sql`)
69
+ *
70
+ * Composes:
71
+ * - `b.uuid.v7` — id on every event row (monotonic
72
+ * lexicographic ordering preserves
73
+ * insertion order on ties).
74
+ * - `b.crypto.namespaceHash` — session id hashing at the door.
75
+ *
76
+ * Monotonic per-process clock: two writes in the same millisecond
77
+ * on a fast loop would tie on `updated_at` / `occurred_at` and make
78
+ * a sort-by-timestamp read ambiguous. `_now()` bumps to `prior + 1`
79
+ * on collision so the timeline is strictly increasing for the life
80
+ * of the process.
81
+ *
82
+ * Surface:
83
+ * - defineWeights({ slug, name, weights })
84
+ * - applyToResults({ query?, results, weights_slug? })
85
+ * - setActiveWeights(slug)
86
+ * - activeWeights()
87
+ * - pinProductForQuery({ query, product_id, position })
88
+ * - unpinProduct({ query, product_id })
89
+ * - pinsForQuery(query)
90
+ * - recordSearchEvent({ query, product_id?, event_type,
91
+ * weights_slug, position?, session_id? })
92
+ * - metricsForWeights({ weights_slug, from, to })
93
+ * - listWeights({ include_archived? })
94
+ * - archiveWeights(slug)
95
+ *
96
+ * @primitive searchRanking
97
+ * @related b.uuid, b.crypto.namespaceHash, catalog, searchFacets
98
+ */
99
+
100
+ var SESSION_NAMESPACE = "search-ranking-session";
101
+
102
+ var MAX_SLUG_LEN = 64;
103
+ var MAX_NAME_LEN = 200;
104
+ var MAX_QUERY_LEN = 500;
105
+ var MAX_SIGNAL_NAME_LEN = 64;
106
+ var MAX_SIGNALS_PER_SET = 32;
107
+ var MAX_PRODUCT_ID_LEN = 128;
108
+ var MAX_RESULTS_PER_CALL = 1000;
109
+ var MAX_POSITION = 1000;
110
+ var MAX_SESSION_ID_LEN = 256;
111
+ var MAX_LIST_LIMIT = 500;
112
+ var DEFAULT_LIST_LIMIT = 100;
113
+
114
+ var ALLOWED_EVENT_TYPES = ["impression", "click", "purchase"];
115
+
116
+ var SLUG_RE = /^[a-z][a-z0-9_-]*$/;
117
+ var SIGNAL_RE = /^[a-z][a-z0-9_-]*$/;
118
+ // product_id is intentionally permissive — operators may key on
119
+ // upstream-catalog SKU codes (uppercased, dotted, slashed). The shape
120
+ // allow-list is alnum + hyphen + underscore + dot + slash + colon,
121
+ // which covers SKU notation across every common upstream system
122
+ // without opening the door to control bytes or whitespace.
123
+ var PRODUCT_ID_RE = /^[A-Za-z0-9._:/-]+$/;
124
+ var CONTROL_BYTE_RE = /[\x00-\x1f\x7f]/;
125
+ var ZERO_WIDTH_RE = new RegExp(
126
+ "[\\u200B-\\u200F\\u202A-\\u202E\\u2060-\\u2064\\u2066-\\u2069\\uFEFF\\u061C]"
127
+ );
128
+
129
+ var bShop;
130
+ function _b() {
131
+ if (!bShop) bShop = require("./index");
132
+ return bShop.framework;
133
+ }
134
+
135
+ // ---- monotonic clock ---------------------------------------------------
136
+ //
137
+ // Two writes in the same millisecond on a hot loop would tie on the
138
+ // (occurred_at) sort key. Bumping by 1ms on a tie keeps the per-
139
+ // process timeline strictly increasing so event ordering + the
140
+ // updated_at column reflect issue order even under contention.
141
+
142
+ var _lastTs = 0;
143
+ function _now() {
144
+ var t = Date.now();
145
+ if (t <= _lastTs) t = _lastTs + 1;
146
+ _lastTs = t;
147
+ return t;
148
+ }
149
+
150
+ // ---- validators --------------------------------------------------------
151
+
152
+ function _requireObject(input, fnLabel) {
153
+ if (!input || typeof input !== "object" || Array.isArray(input)) {
154
+ throw new TypeError(fnLabel + ": input object required");
155
+ }
156
+ }
157
+
158
+ function _slug(s, fnLabel) {
159
+ if (typeof s !== "string" || !s.length) {
160
+ throw new TypeError(fnLabel + ": slug must be a non-empty string");
161
+ }
162
+ if (s.length > MAX_SLUG_LEN) {
163
+ throw new TypeError(fnLabel + ": slug must be <= " + MAX_SLUG_LEN + " characters");
164
+ }
165
+ if (!SLUG_RE.test(s)) {
166
+ throw new TypeError(fnLabel + ": slug must match /^[a-z][a-z0-9_-]*$/");
167
+ }
168
+ return s;
169
+ }
170
+
171
+ function _name(s, fnLabel) {
172
+ if (typeof s !== "string") {
173
+ throw new TypeError(fnLabel + ": name must be a string");
174
+ }
175
+ var trimmed = s.trim();
176
+ if (!trimmed.length) {
177
+ throw new TypeError(fnLabel + ": name must be non-empty after trim");
178
+ }
179
+ if (s.length > MAX_NAME_LEN) {
180
+ throw new TypeError(fnLabel + ": name must be <= " + MAX_NAME_LEN + " characters");
181
+ }
182
+ if (CONTROL_BYTE_RE.test(s) || ZERO_WIDTH_RE.test(s)) {
183
+ throw new TypeError(fnLabel + ": name contains control / zero-width bytes");
184
+ }
185
+ return s;
186
+ }
187
+
188
+ function _weights(input, fnLabel) {
189
+ if (!input || typeof input !== "object" || Array.isArray(input)) {
190
+ throw new TypeError(fnLabel + ": weights must be a flat object of signal -> finite number");
191
+ }
192
+ var keys = Object.keys(input);
193
+ if (!keys.length) {
194
+ throw new TypeError(fnLabel + ": weights must contain at least one signal");
195
+ }
196
+ if (keys.length > MAX_SIGNALS_PER_SET) {
197
+ throw new TypeError(fnLabel + ": weights must contain <= " + MAX_SIGNALS_PER_SET + " signals");
198
+ }
199
+ var out = {};
200
+ for (var i = 0; i < keys.length; i += 1) {
201
+ var k = keys[i];
202
+ if (typeof k !== "string" || !k.length) {
203
+ throw new TypeError(fnLabel + ": signal name must be a non-empty string");
204
+ }
205
+ if (k.length > MAX_SIGNAL_NAME_LEN) {
206
+ throw new TypeError(fnLabel + ": signal name must be <= " + MAX_SIGNAL_NAME_LEN + " characters");
207
+ }
208
+ if (!SIGNAL_RE.test(k)) {
209
+ throw new TypeError(fnLabel + ": signal name '" + k + "' must match /^[a-z][a-z0-9_-]*$/");
210
+ }
211
+ var v = input[k];
212
+ if (typeof v !== "number" || !isFinite(v)) {
213
+ throw new TypeError(fnLabel + ": weights['" + k + "'] must be a finite number");
214
+ }
215
+ out[k] = v;
216
+ }
217
+ return out;
218
+ }
219
+
220
+ // Operator-supplied search query is normalised before it touches
221
+ // storage so "Summer Dress", "summer dress", and "summer dress\n"
222
+ // collapse to a single pin / metrics key. Lowercase + whitespace-
223
+ // collapse + trim; reject control + zero-width before normalising so
224
+ // a hostile query can't smuggle separator-confusion past the
225
+ // normaliser.
226
+ function _normalizeQuery(s, fnLabel) {
227
+ if (typeof s !== "string") {
228
+ throw new TypeError(fnLabel + ": query must be a string");
229
+ }
230
+ if (s.length > MAX_QUERY_LEN) {
231
+ throw new TypeError(fnLabel + ": query must be <= " + MAX_QUERY_LEN + " characters");
232
+ }
233
+ if (CONTROL_BYTE_RE.test(s) || ZERO_WIDTH_RE.test(s)) {
234
+ throw new TypeError(fnLabel + ": query contains control / zero-width bytes");
235
+ }
236
+ var normalized = s.toLowerCase().replace(/\s+/g, " ").trim();
237
+ if (!normalized.length) {
238
+ throw new TypeError(fnLabel + ": query must be non-empty after normalisation");
239
+ }
240
+ return normalized;
241
+ }
242
+
243
+ function _productId(s, fnLabel) {
244
+ if (typeof s !== "string" || !s.length) {
245
+ throw new TypeError(fnLabel + ": product_id must be a non-empty string");
246
+ }
247
+ if (s.length > MAX_PRODUCT_ID_LEN) {
248
+ throw new TypeError(fnLabel + ": product_id must be <= " + MAX_PRODUCT_ID_LEN + " characters");
249
+ }
250
+ if (!PRODUCT_ID_RE.test(s)) {
251
+ throw new TypeError(fnLabel + ": product_id must match /^[A-Za-z0-9._:/-]+$/");
252
+ }
253
+ return s;
254
+ }
255
+
256
+ function _position(n, fnLabel) {
257
+ if (!Number.isInteger(n) || n <= 0 || n > MAX_POSITION) {
258
+ throw new TypeError(fnLabel + ": position must be an integer 1..." + MAX_POSITION);
259
+ }
260
+ return n;
261
+ }
262
+
263
+ function _eventType(s, fnLabel) {
264
+ if (typeof s !== "string" || ALLOWED_EVENT_TYPES.indexOf(s) === -1) {
265
+ throw new TypeError(fnLabel + ": event_type must be one of " + ALLOWED_EVENT_TYPES.join(", "));
266
+ }
267
+ return s;
268
+ }
269
+
270
+ function _sessionIdRaw(s, fnLabel) {
271
+ if (typeof s !== "string" || !s.length) {
272
+ throw new TypeError(fnLabel + ": session_id must be a non-empty string");
273
+ }
274
+ if (s.length > MAX_SESSION_ID_LEN) {
275
+ throw new TypeError(fnLabel + ": session_id must be <= " + MAX_SESSION_ID_LEN + " characters");
276
+ }
277
+ if (CONTROL_BYTE_RE.test(s) || ZERO_WIDTH_RE.test(s)) {
278
+ throw new TypeError(fnLabel + ": session_id contains control / zero-width bytes");
279
+ }
280
+ return s;
281
+ }
282
+
283
+ function _timestampRange(from, to, fnLabel) {
284
+ if (!Number.isInteger(from) || from < 0) {
285
+ throw new TypeError(fnLabel + ": from must be a non-negative integer (ms epoch)");
286
+ }
287
+ if (!Number.isInteger(to) || to < 0) {
288
+ throw new TypeError(fnLabel + ": to must be a non-negative integer (ms epoch)");
289
+ }
290
+ if (from > to) {
291
+ throw new TypeError(fnLabel + ": from must be <= to");
292
+ }
293
+ }
294
+
295
+ function _limit(n, max, def, fnLabel) {
296
+ if (n == null) return def;
297
+ if (!Number.isInteger(n) || n <= 0 || n > max) {
298
+ throw new TypeError(fnLabel + ": limit must be an integer 1..." + max);
299
+ }
300
+ return n;
301
+ }
302
+
303
+ // ---- hydration ---------------------------------------------------------
304
+
305
+ function _hydrateWeightSet(row) {
306
+ if (!row) return null;
307
+ var weights;
308
+ try { weights = JSON.parse(row.weights_json || "{}"); }
309
+ catch (_e) { weights = {}; }
310
+ return {
311
+ slug: row.slug,
312
+ name: row.name,
313
+ weights: weights,
314
+ active: Number(row.active) === 1,
315
+ archived_at: row.archived_at == null ? null : Number(row.archived_at),
316
+ created_at: Number(row.created_at),
317
+ updated_at: Number(row.updated_at),
318
+ };
319
+ }
320
+
321
+ function _hydratePin(row) {
322
+ if (!row) return null;
323
+ return {
324
+ query: row.query,
325
+ product_id: row.product_id,
326
+ position: Number(row.position),
327
+ created_at: Number(row.created_at),
328
+ updated_at: Number(row.updated_at),
329
+ };
330
+ }
331
+
332
+ // ---- factory -----------------------------------------------------------
333
+
334
+ function create(opts) {
335
+ opts = opts || {};
336
+ var query = opts.query;
337
+ if (!query) {
338
+ query = function (sql, params) { return _b().externalDb.query(sql, params); };
339
+ }
340
+ // Catalog binding is optional — `applyToResults` ranks an in-memory
341
+ // result roster the caller already has. Operators that want the
342
+ // primitive to fetch candidates themselves can pass a catalog with
343
+ // `.list({ query })`; absent, every call must supply `results`.
344
+ var catalog = opts.catalog || null;
345
+ if (catalog && typeof catalog.list !== "function") {
346
+ throw new TypeError("searchRanking.create: catalog must expose a .list({ query }) function when provided");
347
+ }
348
+
349
+ function _hashSession(raw) {
350
+ return _b().crypto.namespaceHash(SESSION_NAMESPACE, raw);
351
+ }
352
+
353
+ async function _readWeightSet(slug) {
354
+ var r = await query(
355
+ "SELECT slug, name, weights_json, active, archived_at, created_at, updated_at " +
356
+ "FROM search_weight_sets WHERE slug = ?1",
357
+ [slug]
358
+ );
359
+ return r.rows[0] || null;
360
+ }
361
+
362
+ async function _readActiveWeightSet() {
363
+ var r = await query(
364
+ "SELECT slug, name, weights_json, active, archived_at, created_at, updated_at " +
365
+ "FROM search_weight_sets WHERE active = 1 AND archived_at IS NULL " +
366
+ "ORDER BY updated_at DESC, slug ASC LIMIT 1",
367
+ []
368
+ );
369
+ return r.rows[0] || null;
370
+ }
371
+
372
+ async function _pinsForNormalizedQuery(normalizedQuery) {
373
+ var r = await query(
374
+ "SELECT query, product_id, position, created_at, updated_at " +
375
+ "FROM search_manual_pins WHERE query = ?1 " +
376
+ "ORDER BY position ASC, created_at ASC",
377
+ [normalizedQuery]
378
+ );
379
+ var out = [];
380
+ for (var i = 0; i < r.rows.length; i += 1) out.push(_hydratePin(r.rows[i]));
381
+ return out;
382
+ }
383
+
384
+ // Compute the weighted score for a single result row against a
385
+ // weight set. Signals are looked up on `result.signals` first
386
+ // (the dedicated bucket) and fall back to top-level keys on the
387
+ // result itself for convenience. Missing signals contribute zero.
388
+ function _scoreResult(result, weights) {
389
+ var signalBag = (result && result.signals && typeof result.signals === "object")
390
+ ? result.signals
391
+ : null;
392
+ var score = 0;
393
+ var keys = Object.keys(weights);
394
+ for (var i = 0; i < keys.length; i += 1) {
395
+ var k = keys[i];
396
+ var raw;
397
+ if (signalBag && Object.prototype.hasOwnProperty.call(signalBag, k)) {
398
+ raw = signalBag[k];
399
+ } else if (result && Object.prototype.hasOwnProperty.call(result, k)) {
400
+ raw = result[k];
401
+ } else {
402
+ continue;
403
+ }
404
+ var n;
405
+ if (typeof raw === "boolean") n = raw ? 1 : 0;
406
+ else if (typeof raw === "number" && isFinite(raw)) n = raw;
407
+ else continue;
408
+ score += weights[k] * n;
409
+ }
410
+ return score;
411
+ }
412
+
413
+ return {
414
+ SESSION_NAMESPACE: SESSION_NAMESPACE,
415
+ MAX_SLUG_LEN: MAX_SLUG_LEN,
416
+ MAX_NAME_LEN: MAX_NAME_LEN,
417
+ MAX_QUERY_LEN: MAX_QUERY_LEN,
418
+ MAX_SIGNAL_NAME_LEN: MAX_SIGNAL_NAME_LEN,
419
+ MAX_SIGNALS_PER_SET: MAX_SIGNALS_PER_SET,
420
+ MAX_PRODUCT_ID_LEN: MAX_PRODUCT_ID_LEN,
421
+ MAX_RESULTS_PER_CALL: MAX_RESULTS_PER_CALL,
422
+ MAX_POSITION: MAX_POSITION,
423
+ MAX_LIST_LIMIT: MAX_LIST_LIMIT,
424
+ ALLOWED_EVENT_TYPES: ALLOWED_EVENT_TYPES.slice(),
425
+
426
+ // Define / re-define a named weight set. Idempotent on slug —
427
+ // re-defining replaces `name` + `weights` in place and bumps
428
+ // `updated_at`; `created_at` is preserved. The `active` flag
429
+ // is owned by `setActiveWeights` and is NOT touched here; a
430
+ // re-define on the currently-active set keeps it active.
431
+ defineWeights: async function (input) {
432
+ _requireObject(input, "searchRanking.defineWeights");
433
+ var slug = _slug(input.slug, "searchRanking.defineWeights");
434
+ var name = _name(input.name, "searchRanking.defineWeights");
435
+ var weights = _weights(input.weights, "searchRanking.defineWeights");
436
+ var ts = _now();
437
+
438
+ var existing = await _readWeightSet(slug);
439
+ if (existing) {
440
+ if (existing.archived_at != null) {
441
+ var aErr = new Error(
442
+ "searchRanking.defineWeights: weight set '" + slug + "' is archived; re-author under a new slug"
443
+ );
444
+ aErr.code = "SEARCH_WEIGHTS_ARCHIVED";
445
+ throw aErr;
446
+ }
447
+ await query(
448
+ "UPDATE search_weight_sets SET name = ?1, weights_json = ?2, updated_at = ?3 " +
449
+ "WHERE slug = ?4",
450
+ [name, JSON.stringify(weights), ts, slug]
451
+ );
452
+ } else {
453
+ await query(
454
+ "INSERT INTO search_weight_sets " +
455
+ "(slug, name, weights_json, active, archived_at, created_at, updated_at) " +
456
+ "VALUES (?1, ?2, ?3, 0, NULL, ?4, ?4)",
457
+ [slug, name, JSON.stringify(weights), ts]
458
+ );
459
+ }
460
+ var fresh = await _readWeightSet(slug);
461
+ return _hydrateWeightSet(fresh);
462
+ },
463
+
464
+ // Apply a weight set + manual pins to an in-memory result list.
465
+ // Pins always come first in pin-position order; remaining
466
+ // results sort by weighted score DESC with ties broken by
467
+ // product_id ASC for determinism. When `weights_slug` is
468
+ // omitted the active set is used; when no active set exists +
469
+ // no slug supplied, the original input order is preserved and a
470
+ // synthetic `_score = 0` is attached so the caller can tell the
471
+ // ranker didn't fire.
472
+ //
473
+ // Input shape: results is `[{ product_id, signals?, ... }]`. The
474
+ // returned array is the same shape with `_score` + `_pinned`
475
+ // appended to each row. A missing `product_id` on any row throws
476
+ // — the primitive can't pin / score / event-log a row it can't
477
+ // identify.
478
+ applyToResults: async function (input) {
479
+ _requireObject(input, "searchRanking.applyToResults");
480
+ if (!Array.isArray(input.results)) {
481
+ throw new TypeError("searchRanking.applyToResults: results must be an array");
482
+ }
483
+ if (input.results.length > MAX_RESULTS_PER_CALL) {
484
+ throw new TypeError(
485
+ "searchRanking.applyToResults: results must contain <= " + MAX_RESULTS_PER_CALL + " entries"
486
+ );
487
+ }
488
+ var normalizedQuery = null;
489
+ if (input.query != null) {
490
+ normalizedQuery = _normalizeQuery(input.query, "searchRanking.applyToResults");
491
+ }
492
+ // Validate every row carries a product_id BEFORE doing any
493
+ // work, so partial application can't leak past a rejected
494
+ // call.
495
+ var validated = [];
496
+ for (var i = 0; i < input.results.length; i += 1) {
497
+ var r = input.results[i];
498
+ if (!r || typeof r !== "object") {
499
+ throw new TypeError("searchRanking.applyToResults: results[" + i + "] must be an object");
500
+ }
501
+ var pid = _productId(r.product_id, "searchRanking.applyToResults: results[" + i + "]");
502
+ validated.push({ row: r, product_id: pid });
503
+ }
504
+
505
+ // Load weight set: explicit slug wins, otherwise active set,
506
+ // otherwise no-op.
507
+ var weightSet = null;
508
+ if (input.weights_slug != null) {
509
+ var slug = _slug(input.weights_slug, "searchRanking.applyToResults");
510
+ var row = await _readWeightSet(slug);
511
+ if (!row || row.archived_at != null) {
512
+ var nfErr = new Error(
513
+ "searchRanking.applyToResults: weight set '" + slug + "' not found"
514
+ );
515
+ nfErr.code = "SEARCH_WEIGHTS_NOT_FOUND";
516
+ throw nfErr;
517
+ }
518
+ weightSet = _hydrateWeightSet(row);
519
+ } else {
520
+ var activeRow = await _readActiveWeightSet();
521
+ if (activeRow) weightSet = _hydrateWeightSet(activeRow);
522
+ }
523
+ var weights = weightSet ? weightSet.weights : null;
524
+
525
+ var pins = [];
526
+ if (normalizedQuery) {
527
+ pins = await _pinsForNormalizedQuery(normalizedQuery);
528
+ }
529
+ var pinPositionByPid = {};
530
+ for (var p = 0; p < pins.length; p += 1) {
531
+ pinPositionByPid[pins[p].product_id] = pins[p].position;
532
+ }
533
+
534
+ // Score + tag every result row.
535
+ var scored = [];
536
+ for (var s = 0; s < validated.length; s += 1) {
537
+ var entry = validated[s];
538
+ var score = weights ? _scoreResult(entry.row, weights) : 0;
539
+ var pinned = Object.prototype.hasOwnProperty.call(pinPositionByPid, entry.product_id);
540
+ var out = Object.assign({}, entry.row, {
541
+ product_id: entry.product_id,
542
+ _score: score,
543
+ _pinned: pinned,
544
+ });
545
+ scored.push(out);
546
+ }
547
+
548
+ // Split into pinned + unpinned. Pinned rows sort by pin
549
+ // position ASC (ties by product_id ASC). Unpinned rows sort by
550
+ // weighted score DESC (ties by product_id ASC). Concatenate
551
+ // pinned-first so the merchandiser override always wins.
552
+ var pinnedRows = [];
553
+ var unpinnedRows = [];
554
+ for (var sr = 0; sr < scored.length; sr += 1) {
555
+ if (scored[sr]._pinned) pinnedRows.push(scored[sr]);
556
+ else unpinnedRows.push(scored[sr]);
557
+ }
558
+ pinnedRows.sort(function (a, b) {
559
+ var pa = pinPositionByPid[a.product_id];
560
+ var pb = pinPositionByPid[b.product_id];
561
+ if (pa !== pb) return pa - pb;
562
+ if (a.product_id < b.product_id) return -1;
563
+ if (a.product_id > b.product_id) return 1;
564
+ return 0;
565
+ });
566
+ unpinnedRows.sort(function (a, b) {
567
+ if (b._score !== a._score) return b._score - a._score;
568
+ if (a.product_id < b.product_id) return -1;
569
+ if (a.product_id > b.product_id) return 1;
570
+ return 0;
571
+ });
572
+ return pinnedRows.concat(unpinnedRows);
573
+ },
574
+
575
+ // Flip one weight set into the live ranker. Clears the prior
576
+ // `active = 1` flag in the same call so the invariant "exactly
577
+ // one active set" holds. A no-op when the slug is already
578
+ // active.
579
+ setActiveWeights: async function (slug) {
580
+ slug = _slug(slug, "searchRanking.setActiveWeights");
581
+ var existing = await _readWeightSet(slug);
582
+ if (!existing) {
583
+ var nfErr = new Error("searchRanking.setActiveWeights: weight set '" + slug + "' not found");
584
+ nfErr.code = "SEARCH_WEIGHTS_NOT_FOUND";
585
+ throw nfErr;
586
+ }
587
+ if (existing.archived_at != null) {
588
+ var aErr = new Error("searchRanking.setActiveWeights: weight set '" + slug + "' is archived");
589
+ aErr.code = "SEARCH_WEIGHTS_ARCHIVED";
590
+ throw aErr;
591
+ }
592
+ var ts = _now();
593
+ // Two writes — clear-then-set. The unique-active invariant
594
+ // holds across both rows because no other surface flips
595
+ // `active` and the clear runs before the set.
596
+ await query(
597
+ "UPDATE search_weight_sets SET active = 0, updated_at = ?1 WHERE active = 1 AND slug <> ?2",
598
+ [ts, slug]
599
+ );
600
+ await query(
601
+ "UPDATE search_weight_sets SET active = 1, updated_at = ?1 WHERE slug = ?2",
602
+ [ts, slug]
603
+ );
604
+ var fresh = await _readWeightSet(slug);
605
+ return _hydrateWeightSet(fresh);
606
+ },
607
+
608
+ activeWeights: async function () {
609
+ var row = await _readActiveWeightSet();
610
+ return _hydrateWeightSet(row);
611
+ },
612
+
613
+ // Pin a product to a fixed position for a query. Re-pinning
614
+ // (query, product_id) replaces the position in place; the
615
+ // (query, product_id) PK collapses repeat pins into one row.
616
+ // Two products can share a pin position; the deterministic
617
+ // tiebreak is created_at ASC (older pin wins the higher slot).
618
+ pinProductForQuery: async function (input) {
619
+ _requireObject(input, "searchRanking.pinProductForQuery");
620
+ var normalizedQuery = _normalizeQuery(input.query, "searchRanking.pinProductForQuery");
621
+ var productId = _productId(input.product_id, "searchRanking.pinProductForQuery");
622
+ var position = _position(input.position, "searchRanking.pinProductForQuery");
623
+ var ts = _now();
624
+
625
+ var r = await query(
626
+ "SELECT created_at FROM search_manual_pins WHERE query = ?1 AND product_id = ?2",
627
+ [normalizedQuery, productId]
628
+ );
629
+ if (r.rows.length) {
630
+ await query(
631
+ "UPDATE search_manual_pins SET position = ?1, updated_at = ?2 " +
632
+ "WHERE query = ?3 AND product_id = ?4",
633
+ [position, ts, normalizedQuery, productId]
634
+ );
635
+ } else {
636
+ await query(
637
+ "INSERT INTO search_manual_pins (query, product_id, position, created_at, updated_at) " +
638
+ "VALUES (?1, ?2, ?3, ?4, ?4)",
639
+ [normalizedQuery, productId, position, ts]
640
+ );
641
+ }
642
+ var fresh = await query(
643
+ "SELECT query, product_id, position, created_at, updated_at " +
644
+ "FROM search_manual_pins WHERE query = ?1 AND product_id = ?2",
645
+ [normalizedQuery, productId]
646
+ );
647
+ return _hydratePin(fresh.rows[0]);
648
+ },
649
+
650
+ // Remove a pin. Returns `{ removed: true }` on hit, `{ removed:
651
+ // false }` when the (query, product_id) wasn't pinned (an
652
+ // operator clicking "unpin" twice shouldn't see an error).
653
+ unpinProduct: async function (input) {
654
+ _requireObject(input, "searchRanking.unpinProduct");
655
+ var normalizedQuery = _normalizeQuery(input.query, "searchRanking.unpinProduct");
656
+ var productId = _productId(input.product_id, "searchRanking.unpinProduct");
657
+ var r = await query(
658
+ "DELETE FROM search_manual_pins WHERE query = ?1 AND product_id = ?2",
659
+ [normalizedQuery, productId]
660
+ );
661
+ var changes = r && (r.rowCount != null ? r.rowCount : (r.changes != null ? r.changes : 0));
662
+ return { removed: Number(changes) > 0 };
663
+ },
664
+
665
+ pinsForQuery: async function (queryInput) {
666
+ var normalizedQuery = _normalizeQuery(queryInput, "searchRanking.pinsForQuery");
667
+ return await _pinsForNormalizedQuery(normalizedQuery);
668
+ },
669
+
670
+ // Append-only impression / click / purchase event. The
671
+ // weights_slug must reference a known weight set so the rollup
672
+ // math has something to aggregate against — archived sets are
673
+ // still accepted so historical metrics for a deprecated set
674
+ // remain queryable.
675
+ recordSearchEvent: async function (input) {
676
+ _requireObject(input, "searchRanking.recordSearchEvent");
677
+ var normalizedQuery = _normalizeQuery(input.query, "searchRanking.recordSearchEvent");
678
+ var eventType = _eventType(input.event_type, "searchRanking.recordSearchEvent");
679
+ var weightsSlug = _slug(input.weights_slug, "searchRanking.recordSearchEvent");
680
+ var existing = await _readWeightSet(weightsSlug);
681
+ if (!existing) {
682
+ var nfErr = new Error(
683
+ "searchRanking.recordSearchEvent: weight set '" + weightsSlug + "' not found"
684
+ );
685
+ nfErr.code = "SEARCH_WEIGHTS_NOT_FOUND";
686
+ throw nfErr;
687
+ }
688
+ var productId = null;
689
+ if (input.product_id != null) {
690
+ productId = _productId(input.product_id, "searchRanking.recordSearchEvent");
691
+ }
692
+ var position = null;
693
+ if (input.position != null) {
694
+ position = _position(input.position, "searchRanking.recordSearchEvent");
695
+ }
696
+ var sessionHash = null;
697
+ if (input.session_id != null) {
698
+ sessionHash = _hashSession(_sessionIdRaw(input.session_id, "searchRanking.recordSearchEvent"));
699
+ }
700
+ var ts = _now();
701
+ await query(
702
+ "INSERT INTO search_events " +
703
+ "(id, query, product_id, weights_slug, event_type, position, session_id_hash, occurred_at) " +
704
+ "VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
705
+ [_b().uuid.v7(), normalizedQuery, productId, weightsSlug, eventType, position, sessionHash, ts]
706
+ );
707
+ return {
708
+ query: normalizedQuery,
709
+ product_id: productId,
710
+ weights_slug: weightsSlug,
711
+ event_type: eventType,
712
+ position: position,
713
+ occurred_at: ts,
714
+ };
715
+ },
716
+
717
+ // Aggregate the event log for a weight set inside a closed time
718
+ // window. Returns counts per event type plus the three ratios
719
+ // operators read: CTR (clicks / impressions), conversion_rate
720
+ // (purchases / impressions), click_to_purchase (purchases /
721
+ // clicks). Ratios are `null` when the denominator is zero — no
722
+ // division-by-zero, no fake zero ratio that would lie to the
723
+ // operator about "0% CTR" on a window where the result list
724
+ // never rendered.
725
+ metricsForWeights: async function (input) {
726
+ _requireObject(input, "searchRanking.metricsForWeights");
727
+ var weightsSlug = _slug(input.weights_slug, "searchRanking.metricsForWeights");
728
+ var from = input.from;
729
+ var to = input.to;
730
+ _timestampRange(from, to, "searchRanking.metricsForWeights");
731
+
732
+ var existing = await _readWeightSet(weightsSlug);
733
+ if (!existing) {
734
+ var nfErr = new Error(
735
+ "searchRanking.metricsForWeights: weight set '" + weightsSlug + "' not found"
736
+ );
737
+ nfErr.code = "SEARCH_WEIGHTS_NOT_FOUND";
738
+ throw nfErr;
739
+ }
740
+
741
+ var r = await query(
742
+ "SELECT event_type, COUNT(*) AS c FROM search_events " +
743
+ "WHERE weights_slug = ?1 AND occurred_at >= ?2 AND occurred_at <= ?3 " +
744
+ "GROUP BY event_type",
745
+ [weightsSlug, from, to]
746
+ );
747
+ var impressions = 0;
748
+ var clicks = 0;
749
+ var purchases = 0;
750
+ for (var i = 0; i < r.rows.length; i += 1) {
751
+ var row = r.rows[i];
752
+ var c = Number(row.c) || 0;
753
+ if (row.event_type === "impression") impressions = c;
754
+ else if (row.event_type === "click") clicks = c;
755
+ else if (row.event_type === "purchase") purchases = c;
756
+ }
757
+ return {
758
+ weights_slug: weightsSlug,
759
+ from: from,
760
+ to: to,
761
+ impressions: impressions,
762
+ clicks: clicks,
763
+ purchases: purchases,
764
+ ctr: impressions > 0 ? clicks / impressions : null,
765
+ conversion_rate: impressions > 0 ? purchases / impressions : null,
766
+ click_to_purchase: clicks > 0 ? purchases / clicks : null,
767
+ };
768
+ },
769
+
770
+ listWeights: async function (listOpts) {
771
+ listOpts = listOpts || {};
772
+ var limit = _limit(listOpts.limit, MAX_LIST_LIMIT, DEFAULT_LIST_LIMIT, "searchRanking.listWeights");
773
+ var includeArchived = listOpts.include_archived === true;
774
+ var sql;
775
+ var params = [];
776
+ if (includeArchived) {
777
+ sql = "SELECT slug, name, weights_json, active, archived_at, created_at, updated_at " +
778
+ "FROM search_weight_sets ORDER BY active DESC, updated_at DESC, slug ASC LIMIT ?1";
779
+ params.push(limit);
780
+ } else {
781
+ sql = "SELECT slug, name, weights_json, active, archived_at, created_at, updated_at " +
782
+ "FROM search_weight_sets WHERE archived_at IS NULL " +
783
+ "ORDER BY active DESC, updated_at DESC, slug ASC LIMIT ?1";
784
+ params.push(limit);
785
+ }
786
+ var r = await query(sql, params);
787
+ var out = [];
788
+ for (var i = 0; i < r.rows.length; i += 1) out.push(_hydrateWeightSet(r.rows[i]));
789
+ return out;
790
+ },
791
+
792
+ // Soft-delete a weight set. Sets `archived_at` + clears
793
+ // `active`; historical event rows referencing this slug remain
794
+ // intact so `metricsForWeights` keeps reporting on the
795
+ // deprecated set. Archived sets are excluded from
796
+ // `applyToResults`, `setActiveWeights`, and the default
797
+ // `listWeights` view. Operators wanting to re-author under the
798
+ // same slug pick a new one — archived sets are permanent
799
+ // tombstones to keep the historical event log unambiguous.
800
+ archiveWeights: async function (slug) {
801
+ slug = _slug(slug, "searchRanking.archiveWeights");
802
+ var existing = await _readWeightSet(slug);
803
+ if (!existing) {
804
+ var nfErr = new Error("searchRanking.archiveWeights: weight set '" + slug + "' not found");
805
+ nfErr.code = "SEARCH_WEIGHTS_NOT_FOUND";
806
+ throw nfErr;
807
+ }
808
+ if (existing.archived_at != null) {
809
+ // Idempotent — re-archiving an already-archived set is a
810
+ // no-op, returns the existing tombstone.
811
+ return _hydrateWeightSet(existing);
812
+ }
813
+ var ts = _now();
814
+ await query(
815
+ "UPDATE search_weight_sets SET archived_at = ?1, active = 0, updated_at = ?1 WHERE slug = ?2",
816
+ [ts, slug]
817
+ );
818
+ var fresh = await _readWeightSet(slug);
819
+ return _hydrateWeightSet(fresh);
820
+ },
821
+
822
+ // Catalog passthrough — when a catalog binding was provided to
823
+ // the factory, this is the convenience wrapper for "fetch
824
+ // candidates, rank them, return the ranked list" in a single
825
+ // call. When no catalog was provided the caller must use
826
+ // `applyToResults` directly with its own roster.
827
+ rankQuery: async function (input) {
828
+ _requireObject(input, "searchRanking.rankQuery");
829
+ if (!catalog) {
830
+ throw new TypeError(
831
+ "searchRanking.rankQuery: no catalog binding configured on this instance"
832
+ );
833
+ }
834
+ var normalizedQuery = _normalizeQuery(input.query, "searchRanking.rankQuery");
835
+ var listResult = await catalog.list({ query: normalizedQuery });
836
+ var rows = (listResult && listResult.rows) || [];
837
+ return await this.applyToResults({
838
+ query: normalizedQuery,
839
+ results: rows,
840
+ weights_slug: input.weights_slug,
841
+ });
842
+ },
843
+ };
844
+ }
845
+
846
+ module.exports = {
847
+ create: create,
848
+ SESSION_NAMESPACE: SESSION_NAMESPACE,
849
+ MAX_SLUG_LEN: MAX_SLUG_LEN,
850
+ MAX_NAME_LEN: MAX_NAME_LEN,
851
+ MAX_QUERY_LEN: MAX_QUERY_LEN,
852
+ MAX_SIGNAL_NAME_LEN: MAX_SIGNAL_NAME_LEN,
853
+ MAX_SIGNALS_PER_SET: MAX_SIGNALS_PER_SET,
854
+ MAX_PRODUCT_ID_LEN: MAX_PRODUCT_ID_LEN,
855
+ MAX_RESULTS_PER_CALL: MAX_RESULTS_PER_CALL,
856
+ MAX_POSITION: MAX_POSITION,
857
+ MAX_LIST_LIMIT: MAX_LIST_LIMIT,
858
+ ALLOWED_EVENT_TYPES: ALLOWED_EVENT_TYPES.slice(),
859
+ };