@blamejs/blamejs-shop 0.0.59 → 0.0.61
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 +4 -0
- package/lib/api-keys.js +789 -0
- package/lib/barcodes.js +671 -0
- package/lib/carrier-rates.js +683 -0
- package/lib/cart-bulk-ops.js +711 -0
- package/lib/cms-blocks.js +651 -0
- package/lib/code-minter.js +535 -0
- package/lib/coupon-stacking.js +717 -0
- package/lib/customer-import.js +590 -0
- package/lib/customer-portal.js +359 -0
- package/lib/discount-analytics.js +548 -0
- package/lib/dunning.js +700 -0
- package/lib/experiments.js +697 -0
- package/lib/gift-card-ledger.js +483 -0
- package/lib/index.js +25 -0
- package/lib/inventory-snapshots.js +691 -0
- package/lib/operator-audit-log.js +621 -0
- package/lib/print-receipts.js +675 -0
- package/lib/product-import.js +1034 -0
- package/lib/search-facets.js +825 -0
- package/lib/sms-dispatcher.js +945 -0
- package/lib/storefront-forms.js +884 -0
- package/lib/storefront-pages.js +701 -0
- package/lib/subscription-billing.js +644 -0
- package/lib/tax-rates.js +559 -0
- package/lib/tenants.js +665 -0
- package/lib/translations.js +553 -0
- package/lib/webhook-subscriptions.js +565 -0
- package/package.json +1 -1
|
@@ -0,0 +1,825 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* @module shop.searchFacets
|
|
4
|
+
* @title Search facets — filter-chip + facet-count surface for the
|
|
5
|
+
* storefront search results page
|
|
6
|
+
*
|
|
7
|
+
* @intro
|
|
8
|
+
* Storefront search results render the typical e-commerce filter
|
|
9
|
+
* chrome — "Brand: Nike (12), Adidas (8)", "Price: Under $25 (4),
|
|
10
|
+
* $25-$50 (9)", "In stock (15)". This primitive owns the two
|
|
11
|
+
* sides of that surface:
|
|
12
|
+
*
|
|
13
|
+
* 1. **Registry** — operators declare which product fields are
|
|
14
|
+
* facetable and what shape each facet takes. Three kinds are
|
|
15
|
+
* supported:
|
|
16
|
+
*
|
|
17
|
+
* * `categorical` — distinct values surfaced with their
|
|
18
|
+
* counts (e.g. tags, vendor, category). An optional
|
|
19
|
+
* `display_limit` caps how many options surface so a
|
|
20
|
+
* high-cardinality field doesn't blow the dropdown.
|
|
21
|
+
* * `numeric_range` — counts grouped into operator-defined
|
|
22
|
+
* buckets stored as `[{ label, min, max }]` (min
|
|
23
|
+
* inclusive, max exclusive; `null` on either side is
|
|
24
|
+
* unbounded). Used for prices, weights, ratings.
|
|
25
|
+
* * `boolean` — two-option facet (true / false)
|
|
26
|
+
* keyed on a truthy field value. Used for "in stock",
|
|
27
|
+
* "on sale", "subscribable".
|
|
28
|
+
*
|
|
29
|
+
* 2. **Compute** — `getFacets` runs the registered facets
|
|
30
|
+
* against the catalog's matching products for a given query +
|
|
31
|
+
* applied filters, returning per-facet option lists with
|
|
32
|
+
* counts and selection state. Counts are computed in-memory
|
|
33
|
+
* against the catalog's flat row shape; the SQL aggregation
|
|
34
|
+
* path lives in the catalog primitive (which knows how to
|
|
35
|
+
* index the underlying field), not here. This split keeps
|
|
36
|
+
* the facet registry portable across whichever search
|
|
37
|
+
* backend the operator chooses.
|
|
38
|
+
*
|
|
39
|
+
* `previewQuery({ query, filters })` returns the product count +
|
|
40
|
+
* a sample of matching products for a candidate filter
|
|
41
|
+
* combination — used by the storefront to render "Show 23 results"
|
|
42
|
+
* before the shopper commits the click.
|
|
43
|
+
*
|
|
44
|
+
* `recordFacetUse({ key, value, session_id })` appends an
|
|
45
|
+
* analytics row. The session id is namespaceHashed under
|
|
46
|
+
* `"search-facet-session"` so the log never holds a recoverable
|
|
47
|
+
* customer-side identifier. Operator dashboards read the log to
|
|
48
|
+
* spot facets nobody touches (candidates for removal) and
|
|
49
|
+
* high-traffic values (candidates for promotion to the navigation
|
|
50
|
+
* chrome).
|
|
51
|
+
*
|
|
52
|
+
* Composes:
|
|
53
|
+
* - `b.crypto.namespaceHash` — session id hashing on every
|
|
54
|
+
* analytics write.
|
|
55
|
+
* - `b.uuid.v7` — row id for usage log entries. Facet rows
|
|
56
|
+
* themselves are keyed on the operator-chosen `key` so the
|
|
57
|
+
* authoring UI can patch / archive by a stable handle.
|
|
58
|
+
* - `b.safeSql.assertOneOf` / `quoteIdentifier` — column
|
|
59
|
+
* allowlist guards on the partial-update path so a future
|
|
60
|
+
* refactor introducing dynamic column names can't widen the
|
|
61
|
+
* attack surface to identifier injection.
|
|
62
|
+
*
|
|
63
|
+
* Surface:
|
|
64
|
+
* - defineFacet({ key, field, kind, buckets?, display_limit? })
|
|
65
|
+
* - listFacets({ include_archived?, limit?, offset? })
|
|
66
|
+
* - updateFacet(key, patch)
|
|
67
|
+
* - archiveFacet(key)
|
|
68
|
+
* - getFacets({ query?, applied_filters?, scope? })
|
|
69
|
+
* - previewQuery({ query?, filters })
|
|
70
|
+
* - recordFacetUse({ key, value, session_id })
|
|
71
|
+
*
|
|
72
|
+
* Catalog contract — the operator passes a `catalog` to the
|
|
73
|
+
* factory. `catalog.list({ query, applied_filters, scope })` is
|
|
74
|
+
* the read shape; it must return
|
|
75
|
+
* `{ rows: [{ id, title?, tags?, vendor?, category?, price_minor?,
|
|
76
|
+
* in_stock?, ... }] }`. The primitive walks the rows in-memory
|
|
77
|
+
* to compute counts; a backend swap (D1 -> Postgres -> Algolia)
|
|
78
|
+
* only touches the catalog binding, not the facet registry.
|
|
79
|
+
*
|
|
80
|
+
* Storage:
|
|
81
|
+
* - `search_facets` (migration `0082_search_facets.sql`)
|
|
82
|
+
* - `search_facet_usage` (same migration)
|
|
83
|
+
*
|
|
84
|
+
* @primitive searchFacets
|
|
85
|
+
* @related b.crypto.namespaceHash, b.uuid, b.safeSql, catalog
|
|
86
|
+
*/
|
|
87
|
+
|
|
88
|
+
var SESSION_NAMESPACE = "search-facet-session";
|
|
89
|
+
var DEFAULT_LIST_LIMIT = 100;
|
|
90
|
+
var MAX_LIST_LIMIT = 500;
|
|
91
|
+
var DEFAULT_PREVIEW_SAMPLE = 10;
|
|
92
|
+
var MAX_PREVIEW_SAMPLE = 100;
|
|
93
|
+
var MAX_KEY_LEN = 64;
|
|
94
|
+
var MAX_FIELD_LEN = 64;
|
|
95
|
+
var MAX_VALUE_LEN = 256;
|
|
96
|
+
var MAX_LABEL_LEN = 128;
|
|
97
|
+
var MAX_BUCKETS = 32;
|
|
98
|
+
var MAX_DISPLAY_LIMIT = 1000;
|
|
99
|
+
var MAX_QUERY_LEN = 500;
|
|
100
|
+
var MAX_FILTERS = 32;
|
|
101
|
+
var MAX_FILTER_VALUES = 64;
|
|
102
|
+
|
|
103
|
+
var ALLOWED_KINDS = ["categorical", "numeric_range", "boolean"];
|
|
104
|
+
var ALLOWED_COLS = ["field", "kind", "buckets_json", "display_limit", "active"];
|
|
105
|
+
|
|
106
|
+
// Refuse C0 control bytes + DEL on every operator-supplied string.
|
|
107
|
+
var CONTROL_BYTE_RE = /[\x00-\x1f\x7f]/;
|
|
108
|
+
|
|
109
|
+
// Operator-chosen identifier shape — lowercase alnum + hyphen +
|
|
110
|
+
// underscore, must start with a letter. Same shape the rest of the
|
|
111
|
+
// shop uses for `slug`-like surfaces.
|
|
112
|
+
var KEY_RE = /^[a-z][a-z0-9_-]*$/;
|
|
113
|
+
// `field` may reference nested-ish catalog row keys but here we
|
|
114
|
+
// keep it to the same identifier shape so column-allowlist defenses
|
|
115
|
+
// further downstream don't have to special-case dotted paths.
|
|
116
|
+
var FIELD_RE = /^[a-z][a-z0-9_-]*$/;
|
|
117
|
+
|
|
118
|
+
// Lazy framework handle — matches the pattern the rest of the shop
|
|
119
|
+
// primitives use; avoids the require cycle that would arise from
|
|
120
|
+
// importing `./index` at module-eval time.
|
|
121
|
+
var bShop;
|
|
122
|
+
function _b() {
|
|
123
|
+
if (!bShop) bShop = require("./index");
|
|
124
|
+
return bShop.framework;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// ---- validators ---------------------------------------------------------
|
|
128
|
+
|
|
129
|
+
function _requireObject(input, fnLabel) {
|
|
130
|
+
if (!input || typeof input !== "object") {
|
|
131
|
+
throw new TypeError(fnLabel + ": input object required");
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function _key(s, fnLabel) {
|
|
136
|
+
if (typeof s !== "string" || !s.length) {
|
|
137
|
+
throw new TypeError(fnLabel + ": key must be a non-empty string");
|
|
138
|
+
}
|
|
139
|
+
if (s.length > MAX_KEY_LEN) {
|
|
140
|
+
throw new TypeError(fnLabel + ": key must be <= " + MAX_KEY_LEN + " characters");
|
|
141
|
+
}
|
|
142
|
+
if (!KEY_RE.test(s)) {
|
|
143
|
+
throw new TypeError(fnLabel + ": key must match /^[a-z][a-z0-9_-]*$/");
|
|
144
|
+
}
|
|
145
|
+
return s;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function _field(s, fnLabel) {
|
|
149
|
+
if (typeof s !== "string" || !s.length) {
|
|
150
|
+
throw new TypeError(fnLabel + ": field must be a non-empty string");
|
|
151
|
+
}
|
|
152
|
+
if (s.length > MAX_FIELD_LEN) {
|
|
153
|
+
throw new TypeError(fnLabel + ": field must be <= " + MAX_FIELD_LEN + " characters");
|
|
154
|
+
}
|
|
155
|
+
if (!FIELD_RE.test(s)) {
|
|
156
|
+
throw new TypeError(fnLabel + ": field must match /^[a-z][a-z0-9_-]*$/");
|
|
157
|
+
}
|
|
158
|
+
return s;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function _kind(s, fnLabel) {
|
|
162
|
+
if (typeof s !== "string" || ALLOWED_KINDS.indexOf(s) === -1) {
|
|
163
|
+
throw new TypeError(fnLabel + ": kind must be one of " + ALLOWED_KINDS.join(", "));
|
|
164
|
+
}
|
|
165
|
+
return s;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function _displayLimit(n, fnLabel) {
|
|
169
|
+
if (n == null) return null;
|
|
170
|
+
if (!Number.isInteger(n) || n <= 0 || n > MAX_DISPLAY_LIMIT) {
|
|
171
|
+
throw new TypeError(fnLabel + ": display_limit must be an integer 1..." + MAX_DISPLAY_LIMIT);
|
|
172
|
+
}
|
|
173
|
+
return n;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function _label(s, fnLabel) {
|
|
177
|
+
if (typeof s !== "string" || !s.length) {
|
|
178
|
+
throw new TypeError(fnLabel + ": bucket label must be a non-empty string");
|
|
179
|
+
}
|
|
180
|
+
if (s.length > MAX_LABEL_LEN) {
|
|
181
|
+
throw new TypeError(fnLabel + ": bucket label must be <= " + MAX_LABEL_LEN + " characters");
|
|
182
|
+
}
|
|
183
|
+
if (CONTROL_BYTE_RE.test(s)) {
|
|
184
|
+
throw new TypeError(fnLabel + ": bucket label must not contain control bytes");
|
|
185
|
+
}
|
|
186
|
+
return s;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function _bucketBound(n, label, fnLabel) {
|
|
190
|
+
if (n == null) return null;
|
|
191
|
+
if (typeof n !== "number" || !isFinite(n)) {
|
|
192
|
+
throw new TypeError(fnLabel + ": bucket " + label + " must be a finite number or null");
|
|
193
|
+
}
|
|
194
|
+
return n;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function _buckets(input, fnLabel) {
|
|
198
|
+
if (!Array.isArray(input)) {
|
|
199
|
+
throw new TypeError(fnLabel + ": buckets must be an array of { label, min, max }");
|
|
200
|
+
}
|
|
201
|
+
if (input.length < 1) {
|
|
202
|
+
throw new TypeError(fnLabel + ": buckets must contain at least 1 entry");
|
|
203
|
+
}
|
|
204
|
+
if (input.length > MAX_BUCKETS) {
|
|
205
|
+
throw new TypeError(fnLabel + ": buckets must contain <= " + MAX_BUCKETS + " entries");
|
|
206
|
+
}
|
|
207
|
+
var out = [];
|
|
208
|
+
var seen = {};
|
|
209
|
+
for (var i = 0; i < input.length; i += 1) {
|
|
210
|
+
var b = input[i];
|
|
211
|
+
if (!b || typeof b !== "object") {
|
|
212
|
+
throw new TypeError(fnLabel + ": buckets[" + i + "] must be an object");
|
|
213
|
+
}
|
|
214
|
+
var lab = _label(b.label, fnLabel);
|
|
215
|
+
if (seen[lab]) {
|
|
216
|
+
throw new TypeError(fnLabel + ": bucket labels must be unique (got '" + lab + "' twice)");
|
|
217
|
+
}
|
|
218
|
+
seen[lab] = true;
|
|
219
|
+
var min = _bucketBound(b.min, "min", fnLabel);
|
|
220
|
+
var max = _bucketBound(b.max, "max", fnLabel);
|
|
221
|
+
if (min != null && max != null && min >= max) {
|
|
222
|
+
throw new TypeError(fnLabel + ": bucket '" + lab + "' min must be < max");
|
|
223
|
+
}
|
|
224
|
+
out.push({ label: lab, min: min, max: max });
|
|
225
|
+
}
|
|
226
|
+
return out;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function _limit(n, fnLabel) {
|
|
230
|
+
if (n == null) return DEFAULT_LIST_LIMIT;
|
|
231
|
+
if (!Number.isInteger(n) || n <= 0 || n > MAX_LIST_LIMIT) {
|
|
232
|
+
throw new TypeError(fnLabel + ": limit must be an integer 1..." + MAX_LIST_LIMIT);
|
|
233
|
+
}
|
|
234
|
+
return n;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function _offset(n, fnLabel) {
|
|
238
|
+
if (n == null) return 0;
|
|
239
|
+
if (!Number.isInteger(n) || n < 0) {
|
|
240
|
+
throw new TypeError(fnLabel + ": offset must be a non-negative integer");
|
|
241
|
+
}
|
|
242
|
+
return n;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function _sampleSize(n, fnLabel) {
|
|
246
|
+
if (n == null) return DEFAULT_PREVIEW_SAMPLE;
|
|
247
|
+
if (!Number.isInteger(n) || n < 0 || n > MAX_PREVIEW_SAMPLE) {
|
|
248
|
+
throw new TypeError(fnLabel + ": sample must be an integer 0..." + MAX_PREVIEW_SAMPLE);
|
|
249
|
+
}
|
|
250
|
+
return n;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function _optQuery(s, fnLabel) {
|
|
254
|
+
if (s == null) return null;
|
|
255
|
+
if (typeof s !== "string") {
|
|
256
|
+
throw new TypeError(fnLabel + ": query must be a string or null");
|
|
257
|
+
}
|
|
258
|
+
if (s.length > MAX_QUERY_LEN) {
|
|
259
|
+
throw new TypeError(fnLabel + ": query must be <= " + MAX_QUERY_LEN + " characters");
|
|
260
|
+
}
|
|
261
|
+
return s;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function _appliedFilters(input, fnLabel) {
|
|
265
|
+
if (input == null) return {};
|
|
266
|
+
if (typeof input !== "object" || Array.isArray(input)) {
|
|
267
|
+
throw new TypeError(fnLabel + ": applied_filters must be an object");
|
|
268
|
+
}
|
|
269
|
+
var keys = Object.keys(input);
|
|
270
|
+
if (keys.length > MAX_FILTERS) {
|
|
271
|
+
throw new TypeError(fnLabel + ": applied_filters must contain <= " + MAX_FILTERS + " keys");
|
|
272
|
+
}
|
|
273
|
+
var out = {};
|
|
274
|
+
for (var i = 0; i < keys.length; i += 1) {
|
|
275
|
+
var k = _key(keys[i], fnLabel);
|
|
276
|
+
var v = input[keys[i]];
|
|
277
|
+
if (!Array.isArray(v)) {
|
|
278
|
+
throw new TypeError(fnLabel + ": applied_filters['" + k + "'] must be an array of values");
|
|
279
|
+
}
|
|
280
|
+
if (v.length > MAX_FILTER_VALUES) {
|
|
281
|
+
throw new TypeError(fnLabel + ": applied_filters['" + k + "'] must contain <= " + MAX_FILTER_VALUES + " values");
|
|
282
|
+
}
|
|
283
|
+
var values = [];
|
|
284
|
+
for (var j = 0; j < v.length; j += 1) {
|
|
285
|
+
var raw = v[j];
|
|
286
|
+
var s;
|
|
287
|
+
if (typeof raw === "boolean") {
|
|
288
|
+
s = raw ? "true" : "false";
|
|
289
|
+
} else if (typeof raw === "number" && isFinite(raw)) {
|
|
290
|
+
s = String(raw);
|
|
291
|
+
} else if (typeof raw === "string") {
|
|
292
|
+
s = raw;
|
|
293
|
+
} else {
|
|
294
|
+
throw new TypeError(fnLabel + ": applied_filters['" + k + "'][" + j + "] must be a string, number, or boolean");
|
|
295
|
+
}
|
|
296
|
+
if (s.length > MAX_VALUE_LEN) {
|
|
297
|
+
throw new TypeError(fnLabel + ": applied_filters['" + k + "'][" + j + "] must be <= " + MAX_VALUE_LEN + " characters");
|
|
298
|
+
}
|
|
299
|
+
if (CONTROL_BYTE_RE.test(s)) {
|
|
300
|
+
throw new TypeError(fnLabel + ": applied_filters['" + k + "'][" + j + "] must not contain control bytes");
|
|
301
|
+
}
|
|
302
|
+
values.push(s);
|
|
303
|
+
}
|
|
304
|
+
out[k] = values;
|
|
305
|
+
}
|
|
306
|
+
return out;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function _value(raw, fnLabel) {
|
|
310
|
+
var s;
|
|
311
|
+
if (typeof raw === "boolean") {
|
|
312
|
+
s = raw ? "true" : "false";
|
|
313
|
+
} else if (typeof raw === "number" && isFinite(raw)) {
|
|
314
|
+
s = String(raw);
|
|
315
|
+
} else if (typeof raw === "string") {
|
|
316
|
+
s = raw;
|
|
317
|
+
} else {
|
|
318
|
+
throw new TypeError(fnLabel + ": value must be a string, number, or boolean");
|
|
319
|
+
}
|
|
320
|
+
if (!s.length) {
|
|
321
|
+
throw new TypeError(fnLabel + ": value must be non-empty");
|
|
322
|
+
}
|
|
323
|
+
if (s.length > MAX_VALUE_LEN) {
|
|
324
|
+
throw new TypeError(fnLabel + ": value must be <= " + MAX_VALUE_LEN + " characters");
|
|
325
|
+
}
|
|
326
|
+
if (CONTROL_BYTE_RE.test(s)) {
|
|
327
|
+
throw new TypeError(fnLabel + ": value must not contain control bytes");
|
|
328
|
+
}
|
|
329
|
+
return s;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
function _sessionId(s, fnLabel) {
|
|
333
|
+
if (typeof s !== "string" || !s.length) {
|
|
334
|
+
throw new TypeError(fnLabel + ": session_id must be a non-empty string");
|
|
335
|
+
}
|
|
336
|
+
if (s.length > 256) {
|
|
337
|
+
throw new TypeError(fnLabel + ": session_id must be <= 256 characters");
|
|
338
|
+
}
|
|
339
|
+
if (CONTROL_BYTE_RE.test(s)) {
|
|
340
|
+
throw new TypeError(fnLabel + ": session_id must not contain control bytes");
|
|
341
|
+
}
|
|
342
|
+
return s;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// ---- in-memory facet computation ----------------------------------------
|
|
346
|
+
|
|
347
|
+
// Coerce an arbitrary field value into the array shape every facet
|
|
348
|
+
// counter walks. Tags-style array fields stay as arrays; scalar fields
|
|
349
|
+
// wrap into a single-element array. Missing / null / undefined fields
|
|
350
|
+
// produce an empty array (the row contributes no count to that facet).
|
|
351
|
+
function _coerceFieldValues(raw) {
|
|
352
|
+
if (raw == null) return [];
|
|
353
|
+
if (Array.isArray(raw)) return raw;
|
|
354
|
+
return [raw];
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
function _stringifyValue(v) {
|
|
358
|
+
if (v == null) return null;
|
|
359
|
+
if (typeof v === "boolean") return v ? "true" : "false";
|
|
360
|
+
if (typeof v === "number") return String(v);
|
|
361
|
+
if (typeof v === "string") return v;
|
|
362
|
+
return null;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
// `numeric_range` bucketing — a row's value lands in the first bucket
|
|
366
|
+
// whose [min, max) window contains it. Unbounded ends are honoured
|
|
367
|
+
// (null min = -Inf, null max = +Inf). Non-numeric values are dropped.
|
|
368
|
+
function _bucketOf(buckets, raw) {
|
|
369
|
+
if (typeof raw !== "number" || !isFinite(raw)) return null;
|
|
370
|
+
for (var i = 0; i < buckets.length; i += 1) {
|
|
371
|
+
var b = buckets[i];
|
|
372
|
+
var minOk = b.min == null || raw >= b.min;
|
|
373
|
+
var maxOk = b.max == null || raw < b.max;
|
|
374
|
+
if (minOk && maxOk) return b.label;
|
|
375
|
+
}
|
|
376
|
+
return null;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
// Apply an `applied_filters` set to one row. A row passes if every
|
|
380
|
+
// applied facet either selected one of the row's values (categorical
|
|
381
|
+
// / boolean) or selected a bucket containing the row's numeric value
|
|
382
|
+
// (numeric_range). Facets the operator didn't register are ignored
|
|
383
|
+
// rather than refused so a stale URL with a removed-facet param
|
|
384
|
+
// degrades gracefully.
|
|
385
|
+
function _rowPassesFilters(facetsByKey, row, filters) {
|
|
386
|
+
var keys = Object.keys(filters);
|
|
387
|
+
for (var i = 0; i < keys.length; i += 1) {
|
|
388
|
+
var fk = keys[i];
|
|
389
|
+
var facet = facetsByKey[fk];
|
|
390
|
+
if (!facet) continue;
|
|
391
|
+
var wanted = filters[fk];
|
|
392
|
+
if (!wanted.length) continue;
|
|
393
|
+
var fieldValues = _coerceFieldValues(row[facet.field]);
|
|
394
|
+
if (facet.kind === "numeric_range") {
|
|
395
|
+
var labels = [];
|
|
396
|
+
for (var v = 0; v < fieldValues.length; v += 1) {
|
|
397
|
+
var lab = _bucketOf(facet.buckets || [], fieldValues[v]);
|
|
398
|
+
if (lab != null) labels.push(lab);
|
|
399
|
+
}
|
|
400
|
+
var hit = false;
|
|
401
|
+
for (var w = 0; w < wanted.length; w += 1) {
|
|
402
|
+
if (labels.indexOf(wanted[w]) !== -1) { hit = true; break; }
|
|
403
|
+
}
|
|
404
|
+
if (!hit) return false;
|
|
405
|
+
} else if (facet.kind === "boolean") {
|
|
406
|
+
// Boolean facets test truthiness of the field; the wanted
|
|
407
|
+
// value is the stringified boolean ("true" / "false").
|
|
408
|
+
var truthy = false;
|
|
409
|
+
for (var t = 0; t < fieldValues.length; t += 1) {
|
|
410
|
+
if (fieldValues[t]) { truthy = true; break; }
|
|
411
|
+
}
|
|
412
|
+
var asStr = truthy ? "true" : "false";
|
|
413
|
+
if (wanted.indexOf(asStr) === -1) return false;
|
|
414
|
+
} else {
|
|
415
|
+
var stringified = [];
|
|
416
|
+
for (var s = 0; s < fieldValues.length; s += 1) {
|
|
417
|
+
var sv = _stringifyValue(fieldValues[s]);
|
|
418
|
+
if (sv != null) stringified.push(sv);
|
|
419
|
+
}
|
|
420
|
+
var anyHit = false;
|
|
421
|
+
for (var sw = 0; sw < wanted.length; sw += 1) {
|
|
422
|
+
if (stringified.indexOf(wanted[sw]) !== -1) { anyHit = true; break; }
|
|
423
|
+
}
|
|
424
|
+
if (!anyHit) return false;
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
return true;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
function _countFacet(facet, rows, applied) {
|
|
431
|
+
// For the facet currently being counted, drop ITS own constraint
|
|
432
|
+
// from the applied set — otherwise a shopper who selects "Brand:
|
|
433
|
+
// Nike" would only see "Brand: Nike (N)" with every other brand
|
|
434
|
+
// dropped to 0. The standard e-commerce facet UX is "show every
|
|
435
|
+
// other option's count as if this facet were unselected." Apply
|
|
436
|
+
// every OTHER filter as a normal AND.
|
|
437
|
+
var filtersWithoutSelf = {};
|
|
438
|
+
var keys = Object.keys(applied);
|
|
439
|
+
for (var i = 0; i < keys.length; i += 1) {
|
|
440
|
+
if (keys[i] !== facet.key) filtersWithoutSelf[keys[i]] = applied[keys[i]];
|
|
441
|
+
}
|
|
442
|
+
// The walker uses the facet definition to interpret each
|
|
443
|
+
// remaining filter; the caller supplies the registered facets
|
|
444
|
+
// via the `facetsByKey` argument.
|
|
445
|
+
return function (facetsByKey) {
|
|
446
|
+
var matching = [];
|
|
447
|
+
for (var r = 0; r < rows.length; r += 1) {
|
|
448
|
+
if (_rowPassesFilters(facetsByKey, rows[r], filtersWithoutSelf)) {
|
|
449
|
+
matching.push(rows[r]);
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
// Now tabulate this facet's values.
|
|
453
|
+
var counts = {};
|
|
454
|
+
if (facet.kind === "numeric_range") {
|
|
455
|
+
for (var n = 0; n < matching.length; n += 1) {
|
|
456
|
+
var vals = _coerceFieldValues(matching[n][facet.field]);
|
|
457
|
+
var seen = {};
|
|
458
|
+
for (var nv = 0; nv < vals.length; nv += 1) {
|
|
459
|
+
var lab = _bucketOf(facet.buckets || [], vals[nv]);
|
|
460
|
+
if (lab == null || seen[lab]) continue;
|
|
461
|
+
seen[lab] = true;
|
|
462
|
+
counts[lab] = (counts[lab] || 0) + 1;
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
var out = [];
|
|
466
|
+
var bucketDefs = facet.buckets || [];
|
|
467
|
+
for (var bi = 0; bi < bucketDefs.length; bi += 1) {
|
|
468
|
+
out.push({ value: bucketDefs[bi].label, label: bucketDefs[bi].label, count: counts[bucketDefs[bi].label] || 0 });
|
|
469
|
+
}
|
|
470
|
+
return out;
|
|
471
|
+
}
|
|
472
|
+
if (facet.kind === "boolean") {
|
|
473
|
+
var trueCount = 0;
|
|
474
|
+
var falseCount = 0;
|
|
475
|
+
for (var m = 0; m < matching.length; m += 1) {
|
|
476
|
+
var fv = _coerceFieldValues(matching[m][facet.field]);
|
|
477
|
+
var anyTruthy = false;
|
|
478
|
+
for (var fvi = 0; fvi < fv.length; fvi += 1) {
|
|
479
|
+
if (fv[fvi]) { anyTruthy = true; break; }
|
|
480
|
+
}
|
|
481
|
+
if (anyTruthy) trueCount += 1;
|
|
482
|
+
else falseCount += 1;
|
|
483
|
+
}
|
|
484
|
+
return [
|
|
485
|
+
{ value: "true", label: "Yes", count: trueCount },
|
|
486
|
+
{ value: "false", label: "No", count: falseCount },
|
|
487
|
+
];
|
|
488
|
+
}
|
|
489
|
+
// categorical
|
|
490
|
+
for (var c = 0; c < matching.length; c += 1) {
|
|
491
|
+
var cv = _coerceFieldValues(matching[c][facet.field]);
|
|
492
|
+
var ckSeen = {};
|
|
493
|
+
for (var ci = 0; ci < cv.length; ci += 1) {
|
|
494
|
+
var sv = _stringifyValue(cv[ci]);
|
|
495
|
+
if (sv == null || ckSeen[sv]) continue;
|
|
496
|
+
ckSeen[sv] = true;
|
|
497
|
+
counts[sv] = (counts[sv] || 0) + 1;
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
var coKeys = Object.keys(counts);
|
|
501
|
+
coKeys.sort(function (a, b) {
|
|
502
|
+
if (counts[b] !== counts[a]) return counts[b] - counts[a];
|
|
503
|
+
if (a < b) return -1;
|
|
504
|
+
if (a > b) return 1;
|
|
505
|
+
return 0;
|
|
506
|
+
});
|
|
507
|
+
var co = [];
|
|
508
|
+
for (var k = 0; k < coKeys.length; k += 1) {
|
|
509
|
+
co.push({ value: coKeys[k], label: coKeys[k], count: counts[coKeys[k]] });
|
|
510
|
+
}
|
|
511
|
+
if (facet.display_limit != null && co.length > facet.display_limit) {
|
|
512
|
+
co = co.slice(0, facet.display_limit);
|
|
513
|
+
}
|
|
514
|
+
return co;
|
|
515
|
+
};
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
function _hydrateFacet(row) {
|
|
519
|
+
if (!row) return null;
|
|
520
|
+
var buckets = null;
|
|
521
|
+
if (row.buckets_json != null) {
|
|
522
|
+
try { buckets = JSON.parse(row.buckets_json); }
|
|
523
|
+
catch (_e) { buckets = null; } // allow:empty-catch-swallow — malformed buckets fall back to null; the row is still surfaced so the operator can re-author
|
|
524
|
+
if (!Array.isArray(buckets)) buckets = null;
|
|
525
|
+
}
|
|
526
|
+
return {
|
|
527
|
+
key: row.key,
|
|
528
|
+
field: row.field,
|
|
529
|
+
kind: row.kind,
|
|
530
|
+
buckets: buckets,
|
|
531
|
+
display_limit: row.display_limit == null ? null : Number(row.display_limit),
|
|
532
|
+
active: Number(row.active) === 1,
|
|
533
|
+
archived_at: row.archived_at == null ? null : Number(row.archived_at),
|
|
534
|
+
created_at: Number(row.created_at),
|
|
535
|
+
updated_at: Number(row.updated_at),
|
|
536
|
+
};
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
// ---- factory ------------------------------------------------------------
|
|
540
|
+
|
|
541
|
+
function create(opts) {
|
|
542
|
+
opts = opts || {};
|
|
543
|
+
var query = opts.query;
|
|
544
|
+
if (!query) {
|
|
545
|
+
query = function (sql, params) { return _b().externalDb.query(sql, params); };
|
|
546
|
+
}
|
|
547
|
+
var catalog = opts.catalog || null;
|
|
548
|
+
if (!catalog || typeof catalog.list !== "function") {
|
|
549
|
+
throw new TypeError("searchFacets.create: catalog with a .list({ query, applied_filters, scope }) function required");
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
// In-memory facet registry cache — invalidated on every write.
|
|
553
|
+
var _facetsCache = null;
|
|
554
|
+
|
|
555
|
+
function _invalidate() { _facetsCache = null; }
|
|
556
|
+
|
|
557
|
+
async function _loadFacets() {
|
|
558
|
+
if (_facetsCache) return _facetsCache;
|
|
559
|
+
var rows = (await query(
|
|
560
|
+
"SELECT key, field, kind, buckets_json, display_limit, active, archived_at, created_at, updated_at " +
|
|
561
|
+
"FROM search_facets WHERE archived_at IS NULL AND active = 1 " +
|
|
562
|
+
"ORDER BY created_at ASC, key ASC",
|
|
563
|
+
[]
|
|
564
|
+
)).rows;
|
|
565
|
+
var out = [];
|
|
566
|
+
for (var i = 0; i < rows.length; i += 1) {
|
|
567
|
+
var h = _hydrateFacet(rows[i]);
|
|
568
|
+
if (h) out.push(h);
|
|
569
|
+
}
|
|
570
|
+
_facetsCache = out;
|
|
571
|
+
return out;
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
return {
|
|
575
|
+
SESSION_NAMESPACE: SESSION_NAMESPACE,
|
|
576
|
+
ALLOWED_KINDS: ALLOWED_KINDS.slice(),
|
|
577
|
+
|
|
578
|
+
defineFacet: async function (input) {
|
|
579
|
+
_requireObject(input, "searchFacets.defineFacet");
|
|
580
|
+
var key = _key(input.key, "searchFacets.defineFacet");
|
|
581
|
+
var field = _field(input.field, "searchFacets.defineFacet");
|
|
582
|
+
var kind = _kind(input.kind, "searchFacets.defineFacet");
|
|
583
|
+
var bucketsJson = null;
|
|
584
|
+
var displayLimit = null;
|
|
585
|
+
if (kind === "numeric_range") {
|
|
586
|
+
if (input.buckets == null) {
|
|
587
|
+
throw new TypeError("searchFacets.defineFacet: numeric_range requires buckets");
|
|
588
|
+
}
|
|
589
|
+
bucketsJson = JSON.stringify(_buckets(input.buckets, "searchFacets.defineFacet"));
|
|
590
|
+
} else if (input.buckets != null) {
|
|
591
|
+
throw new TypeError("searchFacets.defineFacet: buckets only valid for kind=numeric_range");
|
|
592
|
+
}
|
|
593
|
+
if (kind === "categorical") {
|
|
594
|
+
displayLimit = _displayLimit(input.display_limit, "searchFacets.defineFacet");
|
|
595
|
+
} else if (input.display_limit != null) {
|
|
596
|
+
throw new TypeError("searchFacets.defineFacet: display_limit only valid for kind=categorical");
|
|
597
|
+
}
|
|
598
|
+
var now = Date.now();
|
|
599
|
+
try {
|
|
600
|
+
await query(
|
|
601
|
+
"INSERT INTO search_facets " +
|
|
602
|
+
"(key, field, kind, buckets_json, display_limit, active, archived_at, created_at, updated_at) " +
|
|
603
|
+
"VALUES (?1, ?2, ?3, ?4, ?5, 1, NULL, ?6, ?6)",
|
|
604
|
+
[key, field, kind, bucketsJson, displayLimit, now]
|
|
605
|
+
);
|
|
606
|
+
} catch (e) {
|
|
607
|
+
if (e && /UNIQUE|PRIMARY KEY|constraint/i.test(String(e.message || e))) {
|
|
608
|
+
throw new TypeError("searchFacets.defineFacet: key already exists: " + key);
|
|
609
|
+
}
|
|
610
|
+
throw e;
|
|
611
|
+
}
|
|
612
|
+
_invalidate();
|
|
613
|
+
var row = (await query(
|
|
614
|
+
"SELECT key, field, kind, buckets_json, display_limit, active, archived_at, created_at, updated_at " +
|
|
615
|
+
"FROM search_facets WHERE key = ?1 LIMIT 1",
|
|
616
|
+
[key]
|
|
617
|
+
)).rows[0];
|
|
618
|
+
return _hydrateFacet(row);
|
|
619
|
+
},
|
|
620
|
+
|
|
621
|
+
listFacets: async function (listOpts) {
|
|
622
|
+
listOpts = listOpts || {};
|
|
623
|
+
var includeArchived = listOpts.include_archived === true;
|
|
624
|
+
var limit = _limit(listOpts.limit, "searchFacets.listFacets");
|
|
625
|
+
var offset = _offset(listOpts.offset, "searchFacets.listFacets");
|
|
626
|
+
var sql = "SELECT key, field, kind, buckets_json, display_limit, active, archived_at, created_at, updated_at " +
|
|
627
|
+
"FROM search_facets ";
|
|
628
|
+
if (!includeArchived) sql += "WHERE archived_at IS NULL ";
|
|
629
|
+
sql += "ORDER BY created_at ASC, key ASC LIMIT ?1 OFFSET ?2";
|
|
630
|
+
var rows = (await query(sql, [limit, offset])).rows;
|
|
631
|
+
var out = [];
|
|
632
|
+
for (var i = 0; i < rows.length; i += 1) {
|
|
633
|
+
var h = _hydrateFacet(rows[i]);
|
|
634
|
+
if (h) out.push(h);
|
|
635
|
+
}
|
|
636
|
+
return out;
|
|
637
|
+
},
|
|
638
|
+
|
|
639
|
+
updateFacet: async function (key, patch) {
|
|
640
|
+
var k = _key(key, "searchFacets.updateFacet");
|
|
641
|
+
if (!patch || typeof patch !== "object") {
|
|
642
|
+
throw new TypeError("searchFacets.updateFacet: patch object required");
|
|
643
|
+
}
|
|
644
|
+
// Need the current kind so we can validate buckets / display_limit
|
|
645
|
+
// against the right facet shape.
|
|
646
|
+
var existing = (await query(
|
|
647
|
+
"SELECT key, field, kind, buckets_json, display_limit, active, archived_at, created_at, updated_at " +
|
|
648
|
+
"FROM search_facets WHERE key = ?1 LIMIT 1",
|
|
649
|
+
[k]
|
|
650
|
+
)).rows[0];
|
|
651
|
+
if (!existing) return null;
|
|
652
|
+
var effectiveKind = patch.kind === undefined ? existing.kind : _kind(patch.kind, "searchFacets.updateFacet");
|
|
653
|
+
var sets = [];
|
|
654
|
+
var params = [];
|
|
655
|
+
var i = 1;
|
|
656
|
+
function _addSet(col, val) {
|
|
657
|
+
_b().safeSql.assertOneOf(col, ALLOWED_COLS);
|
|
658
|
+
sets.push(_b().safeSql.quoteIdentifier(col, "sqlite") + " = ?" + (i++));
|
|
659
|
+
params.push(val);
|
|
660
|
+
}
|
|
661
|
+
if (patch.field !== undefined) {
|
|
662
|
+
_addSet("field", _field(patch.field, "searchFacets.updateFacet"));
|
|
663
|
+
}
|
|
664
|
+
if (patch.kind !== undefined) {
|
|
665
|
+
_addSet("kind", effectiveKind);
|
|
666
|
+
}
|
|
667
|
+
if (patch.buckets !== undefined) {
|
|
668
|
+
if (effectiveKind !== "numeric_range") {
|
|
669
|
+
throw new TypeError("searchFacets.updateFacet: buckets only valid for kind=numeric_range");
|
|
670
|
+
}
|
|
671
|
+
_addSet("buckets_json", patch.buckets == null
|
|
672
|
+
? null
|
|
673
|
+
: JSON.stringify(_buckets(patch.buckets, "searchFacets.updateFacet")));
|
|
674
|
+
} else if (patch.kind !== undefined && effectiveKind !== "numeric_range") {
|
|
675
|
+
// Switching away from numeric_range — null the buckets.
|
|
676
|
+
_addSet("buckets_json", null);
|
|
677
|
+
}
|
|
678
|
+
if (patch.display_limit !== undefined) {
|
|
679
|
+
if (effectiveKind !== "categorical") {
|
|
680
|
+
throw new TypeError("searchFacets.updateFacet: display_limit only valid for kind=categorical");
|
|
681
|
+
}
|
|
682
|
+
_addSet("display_limit", _displayLimit(patch.display_limit, "searchFacets.updateFacet"));
|
|
683
|
+
} else if (patch.kind !== undefined && effectiveKind !== "categorical") {
|
|
684
|
+
_addSet("display_limit", null);
|
|
685
|
+
}
|
|
686
|
+
if (patch.active !== undefined) {
|
|
687
|
+
if (typeof patch.active !== "boolean") {
|
|
688
|
+
throw new TypeError("searchFacets.updateFacet: active must be a boolean");
|
|
689
|
+
}
|
|
690
|
+
_addSet("active", patch.active ? 1 : 0);
|
|
691
|
+
}
|
|
692
|
+
if (sets.length === 0) {
|
|
693
|
+
throw new TypeError("searchFacets.updateFacet: patch contained no updatable fields");
|
|
694
|
+
}
|
|
695
|
+
sets.push("updated_at = ?" + (i++));
|
|
696
|
+
params.push(Date.now());
|
|
697
|
+
params.push(k);
|
|
698
|
+
var r = await query(
|
|
699
|
+
"UPDATE search_facets SET " + sets.join(", ") + " WHERE key = ?" + i,
|
|
700
|
+
params
|
|
701
|
+
);
|
|
702
|
+
if (r.rowCount === 0) return null;
|
|
703
|
+
_invalidate();
|
|
704
|
+
var row = (await query(
|
|
705
|
+
"SELECT key, field, kind, buckets_json, display_limit, active, archived_at, created_at, updated_at " +
|
|
706
|
+
"FROM search_facets WHERE key = ?1 LIMIT 1",
|
|
707
|
+
[k]
|
|
708
|
+
)).rows[0];
|
|
709
|
+
return row ? _hydrateFacet(row) : null;
|
|
710
|
+
},
|
|
711
|
+
|
|
712
|
+
archiveFacet: async function (key) {
|
|
713
|
+
var k = _key(key, "searchFacets.archiveFacet");
|
|
714
|
+
var now = Date.now();
|
|
715
|
+
var r = await query(
|
|
716
|
+
"UPDATE search_facets SET archived_at = ?1, active = 0, updated_at = ?1 " +
|
|
717
|
+
"WHERE key = ?2 AND archived_at IS NULL",
|
|
718
|
+
[now, k]
|
|
719
|
+
);
|
|
720
|
+
_invalidate();
|
|
721
|
+
return { archived: Number(r.rowCount || 0) > 0 };
|
|
722
|
+
},
|
|
723
|
+
|
|
724
|
+
getFacets: async function (input) {
|
|
725
|
+
input = input || {};
|
|
726
|
+
var qStr = _optQuery(input.query, "searchFacets.getFacets");
|
|
727
|
+
var applied = _appliedFilters(input.applied_filters, "searchFacets.getFacets");
|
|
728
|
+
var scope = input.scope == null ? null : input.scope;
|
|
729
|
+
if (scope != null && typeof scope !== "object") {
|
|
730
|
+
throw new TypeError("searchFacets.getFacets: scope must be an object or null");
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
var facets = await _loadFacets();
|
|
734
|
+
// Lookup map every counter consults when filtering rows for
|
|
735
|
+
// the "leave-this-facet-unconstrained" view.
|
|
736
|
+
var facetsByKey = {};
|
|
737
|
+
for (var fi = 0; fi < facets.length; fi += 1) {
|
|
738
|
+
facetsByKey[facets[fi].key] = facets[fi];
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
// Pull the matching rows ONCE — every facet counter walks the
|
|
742
|
+
// same in-memory snapshot. The catalog binding owns the actual
|
|
743
|
+
// search semantics (LIKE / FTS / external service); the facet
|
|
744
|
+
// primitive only consumes the rows it returns.
|
|
745
|
+
var matched = await catalog.list({
|
|
746
|
+
query: qStr,
|
|
747
|
+
applied_filters: {}, // counts are computed by leaving the focal facet open; the per-facet walker drops the focal constraint before counting
|
|
748
|
+
scope: scope,
|
|
749
|
+
});
|
|
750
|
+
var rows = (matched && Array.isArray(matched.rows)) ? matched.rows : [];
|
|
751
|
+
|
|
752
|
+
var out = [];
|
|
753
|
+
for (var f = 0; f < facets.length; f += 1) {
|
|
754
|
+
var facet = facets[f];
|
|
755
|
+
var counter = _countFacet(facet, rows, applied);
|
|
756
|
+
var options = counter(facetsByKey);
|
|
757
|
+
var selectedSet = {};
|
|
758
|
+
var selected = applied[facet.key] || [];
|
|
759
|
+
for (var s = 0; s < selected.length; s += 1) selectedSet[selected[s]] = true;
|
|
760
|
+
for (var o = 0; o < options.length; o += 1) {
|
|
761
|
+
options[o].selected = selectedSet[options[o].value] === true;
|
|
762
|
+
}
|
|
763
|
+
out.push({
|
|
764
|
+
key: facet.key,
|
|
765
|
+
label: facet.key,
|
|
766
|
+
kind: facet.kind,
|
|
767
|
+
options: options,
|
|
768
|
+
});
|
|
769
|
+
}
|
|
770
|
+
return out;
|
|
771
|
+
},
|
|
772
|
+
|
|
773
|
+
previewQuery: async function (input) {
|
|
774
|
+
_requireObject(input, "searchFacets.previewQuery");
|
|
775
|
+
var qStr = _optQuery(input.query, "searchFacets.previewQuery");
|
|
776
|
+
var filters = _appliedFilters(input.filters, "searchFacets.previewQuery");
|
|
777
|
+
var sample = _sampleSize(input.sample, "searchFacets.previewQuery");
|
|
778
|
+
|
|
779
|
+
var facets = await _loadFacets();
|
|
780
|
+
var facetsByKey = {};
|
|
781
|
+
for (var fi = 0; fi < facets.length; fi += 1) {
|
|
782
|
+
facetsByKey[facets[fi].key] = facets[fi];
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
var matched = await catalog.list({
|
|
786
|
+
query: qStr,
|
|
787
|
+
applied_filters: {},
|
|
788
|
+
scope: null,
|
|
789
|
+
});
|
|
790
|
+
var rows = (matched && Array.isArray(matched.rows)) ? matched.rows : [];
|
|
791
|
+
|
|
792
|
+
var passing = [];
|
|
793
|
+
for (var r = 0; r < rows.length; r += 1) {
|
|
794
|
+
if (_rowPassesFilters(facetsByKey, rows[r], filters)) {
|
|
795
|
+
passing.push(rows[r]);
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
return {
|
|
799
|
+
total: passing.length,
|
|
800
|
+
sample: passing.slice(0, sample),
|
|
801
|
+
};
|
|
802
|
+
},
|
|
803
|
+
|
|
804
|
+
recordFacetUse: async function (input) {
|
|
805
|
+
_requireObject(input, "searchFacets.recordFacetUse");
|
|
806
|
+
var key = _key(input.key, "searchFacets.recordFacetUse");
|
|
807
|
+
var value = _value(input.value, "searchFacets.recordFacetUse");
|
|
808
|
+
var sessionId = _sessionId(input.session_id, "searchFacets.recordFacetUse");
|
|
809
|
+
var hashed = _b().crypto.namespaceHash(SESSION_NAMESPACE, sessionId);
|
|
810
|
+
var id = _b().uuid.v7();
|
|
811
|
+
var now = Date.now();
|
|
812
|
+
await query(
|
|
813
|
+
"INSERT INTO search_facet_usage " +
|
|
814
|
+
"(id, facet_key, value, session_id_hash, occurred_at) " +
|
|
815
|
+
"VALUES (?1, ?2, ?3, ?4, ?5)",
|
|
816
|
+
[id, key, value, hashed, now]
|
|
817
|
+
);
|
|
818
|
+
return { id: id };
|
|
819
|
+
},
|
|
820
|
+
};
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
module.exports = {
|
|
824
|
+
create: create,
|
|
825
|
+
};
|