@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.
@@ -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
+ };