@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.
- package/CHANGELOG.md +6 -0
- package/lib/announcement-bar.js +753 -0
- package/lib/banner-ab-tests.js +806 -0
- package/lib/bin-locations.js +791 -0
- package/lib/blog-articles.js +1173 -0
- package/lib/carrier-accounts.js +805 -0
- package/lib/cart-recovery.js +1133 -0
- package/lib/category-navigation.js +934 -0
- package/lib/consent-ledger.js +539 -0
- package/lib/customer-impersonation.js +743 -0
- package/lib/customer-merge.js +879 -0
- package/lib/demand-forecast.js +1121 -0
- package/lib/dispute-resolution.js +886 -0
- package/lib/email-ab-tests.js +918 -0
- package/lib/email-engagement-score.js +649 -0
- package/lib/event-log.js +713 -0
- package/lib/fulfillment-sla.js +791 -0
- package/lib/index.js +41 -0
- package/lib/inventory-audits.js +852 -0
- package/lib/line-gift-wrap.js +430 -0
- package/lib/marketing-budget.js +792 -0
- package/lib/operator-activity-feed.js +977 -0
- package/lib/operator-approvals.js +942 -0
- package/lib/operator-help-center.js +1020 -0
- package/lib/operator-inbox.js +889 -0
- package/lib/operator-sessions.js +701 -0
- package/lib/order-exchanges.js +602 -0
- package/lib/product-compare.js +804 -0
- package/lib/pwa-manifest.js +1005 -0
- package/lib/referral-leaderboard.js +612 -0
- package/lib/sales-tax-filings.js +807 -0
- package/lib/search-ranking.js +859 -0
- package/lib/shipping-insurance.js +757 -0
- package/lib/shrinkage-report.js +1182 -0
- package/lib/sidebar-widgets.js +952 -0
- package/lib/smart-restocking.js +1048 -0
- package/lib/stock-receipts.js +834 -0
- package/lib/subscription-analytics.js +1032 -0
- package/lib/suggestion-box.js +921 -0
- package/lib/tax-remittance.js +625 -0
- package/lib/vendor-invoices.js +1021 -0
- package/lib/winback-campaigns.js +1350 -0
- package/lib/wishlist-digest.js +1133 -0
- 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
|
+
};
|