@blamejs/blamejs-shop 0.0.53 → 0.0.56

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,528 @@
1
+ /**
2
+ * Search suggestions — autocomplete dropdown data for the storefront
3
+ * search input. Visitors typing in the header search box expect three
4
+ * categories side-by-side:
5
+ *
6
+ * 1. Top product matches by title / description prefix (sourced from
7
+ * the catalog primitive's existing `products.search` shape).
8
+ * 2. Top recent popular queries other shoppers typed (aggregated
9
+ * from `search_query_log` over a 30-day window).
10
+ * 3. Operator-curated featured suggestions (e.g. typing "free"
11
+ * surfaces "Free shipping over $50") from
12
+ * `featured_search_suggestions`.
13
+ *
14
+ * The route handler that exposes `GET /search/suggestions?q=…` is
15
+ * wired by the storefront dispatcher separately — this primitive is
16
+ * pure data: input shape validation, persistence, aggregation.
17
+ *
18
+ * Composes:
19
+ * - `b.crypto.namespaceHash` — every `session_id` value is hashed
20
+ * under namespace `"search-suggestions-session"` before any DB
21
+ * write. The log table never holds a recoverable customer-side
22
+ * identifier. Query text is NOT hashed (it's user-typed search
23
+ * text, not PII — operators rely on plaintext to spot zero-
24
+ * result terms and stock-gap signals).
25
+ * - `b.uuid.v7` — row id for both featured suggestions and query
26
+ * log entries.
27
+ * - `b.safeSql.assertOneOf` / `quoteIdentifier` — column
28
+ * allowlisting on the partial-update path so a future refactor
29
+ * introducing dynamic column names can't widen the attack
30
+ * surface to identifier injection.
31
+ *
32
+ * Surface:
33
+ * - `recordQuery({ q, session_id, result_count })` — append a row
34
+ * to `search_query_log`. Normalises `q` (lowercase + trim), hashes
35
+ * `session_id`, refuses empty / oversized queries and bad
36
+ * `result_count`. Returns `{ id }`.
37
+ * - `suggest({ q, limit? })` — returns `{ products, queries,
38
+ * featured }`. `products` runs through the catalog primitive's
39
+ * `products.search` (title + description prefix), restricted to
40
+ * `status: "active"`. `queries` aggregates the popular-query
41
+ * window (last 30 days). `featured` returns matching curated
42
+ * rows with `status='active'` and within their `(starts_at,
43
+ * expires_at)` window. `limit` (default 5) bounds each category
44
+ * independently.
45
+ * - `addFeatured({ prefix, display_text, link_url, priority?,
46
+ * starts_at?, expires_at? })` — operator-only. Inserts a row.
47
+ * Returns the new row.
48
+ * - `updateFeatured(id, patch)` — partial update. Allows
49
+ * `display_text`, `link_url`, `priority`, `starts_at`,
50
+ * `expires_at`, `status`. Returns the updated row, or `null` if
51
+ * the id didn't exist.
52
+ * - `deleteFeatured(id)` — operator-only. Returns
53
+ * `{ removed: boolean }`.
54
+ * - `popularQueries({ from?, to?, limit? })` — operator dashboard
55
+ * aggregate. Returns `[ { query_normalized, count, last_seen,
56
+ * zero_result_share } ]` sorted by count desc.
57
+ * - `cleanupOldQueries(ts)` — retention sweep; deletes rows with
58
+ * `occurred_at < ts`. Returns `{ removed: number }`.
59
+ *
60
+ * Storage:
61
+ * - `featured_search_suggestions` (migration
62
+ * `0030_search_suggestions.sql`).
63
+ * - `search_query_log` (same migration).
64
+ *
65
+ * @primitive searchSuggestions
66
+ * @related b.crypto.namespaceHash, b.uuid, catalog.products.search
67
+ */
68
+
69
+ "use strict";
70
+
71
+ var SESSION_NAMESPACE = "search-suggestions-session";
72
+ var DEFAULT_LIMIT = 5;
73
+ var MAX_LIMIT = 50;
74
+ var MAX_QUERY_LEN = 200;
75
+ var MAX_PREFIX_LEN = 100;
76
+ var MAX_DISPLAY_TEXT_LEN = 200;
77
+ var MAX_LINK_URL_LEN = 2048;
78
+ var MAX_RESULT_COUNT = 1000000;
79
+ var POPULAR_WINDOW_MS = 30 * 24 * 60 * 60 * 1000;
80
+ var FEATURED_STATUSES = ["active", "expired", "draft"];
81
+ var ALLOWED_FEATURED_COLS = [
82
+ "prefix", "display_text", "link_url", "priority",
83
+ "starts_at", "expires_at", "status",
84
+ ];
85
+
86
+ // Lazy framework handle — same pattern as the rest of the shop
87
+ // primitives; avoids the require cycle that would arise from
88
+ // importing `./index` at module-eval time.
89
+ var bShop;
90
+ function _b() {
91
+ if (!bShop) bShop = require("./index");
92
+ return bShop.framework;
93
+ }
94
+
95
+ function _requireObject(input, fnLabel) {
96
+ if (!input || typeof input !== "object") {
97
+ throw new TypeError(fnLabel + ": input object required");
98
+ }
99
+ }
100
+
101
+ function _normalizeQuery(s, label) {
102
+ if (typeof s !== "string") {
103
+ throw new TypeError(label + ": q must be a string");
104
+ }
105
+ // Refuse control bytes so a malicious search term can't smuggle
106
+ // header-injection-class content into operator dashboards that
107
+ // render the term inline.
108
+ if (/[\x00-\x1f\x7f]/.test(s)) {
109
+ throw new TypeError(label + ": q must not contain control bytes");
110
+ }
111
+ var trimmed = s.trim().toLowerCase();
112
+ if (!trimmed.length) {
113
+ throw new TypeError(label + ": q must be a non-empty string");
114
+ }
115
+ if (trimmed.length > MAX_QUERY_LEN) {
116
+ throw new TypeError(label + ": q must be <= " + MAX_QUERY_LEN + " chars");
117
+ }
118
+ return trimmed;
119
+ }
120
+
121
+ function _normalizePrefix(s, label) {
122
+ if (typeof s !== "string") {
123
+ throw new TypeError(label + ": prefix must be a string");
124
+ }
125
+ if (/[\x00-\x1f\x7f]/.test(s)) {
126
+ throw new TypeError(label + ": prefix must not contain control bytes");
127
+ }
128
+ var trimmed = s.trim().toLowerCase();
129
+ if (!trimmed.length) {
130
+ throw new TypeError(label + ": prefix must be a non-empty string");
131
+ }
132
+ if (trimmed.length > MAX_PREFIX_LEN) {
133
+ throw new TypeError(label + ": prefix must be <= " + MAX_PREFIX_LEN + " chars");
134
+ }
135
+ return trimmed;
136
+ }
137
+
138
+ function _displayText(s, label) {
139
+ if (typeof s !== "string" || !s.length) {
140
+ throw new TypeError(label + ": display_text must be a non-empty string");
141
+ }
142
+ if (/[\x00-\x1f\x7f]/.test(s)) {
143
+ throw new TypeError(label + ": display_text must not contain control bytes");
144
+ }
145
+ if (s.length > MAX_DISPLAY_TEXT_LEN) {
146
+ throw new TypeError(label + ": display_text must be <= " + MAX_DISPLAY_TEXT_LEN + " chars");
147
+ }
148
+ return s;
149
+ }
150
+
151
+ function _linkUrl(s, label) {
152
+ if (typeof s !== "string" || !s.length) {
153
+ throw new TypeError(label + ": link_url must be a non-empty string");
154
+ }
155
+ if (/[\x00-\x1f\x7f]/.test(s)) {
156
+ throw new TypeError(label + ": link_url must not contain control bytes");
157
+ }
158
+ if (s.length > MAX_LINK_URL_LEN) {
159
+ throw new TypeError(label + ": link_url must be <= " + MAX_LINK_URL_LEN + " chars");
160
+ }
161
+ // Refuse `javascript:` / `data:` / `vbscript:` schemes — these
162
+ // would let a curated suggestion stage a script-in-href XSS the
163
+ // moment a visitor clicks the dropdown row. The operator-facing
164
+ // UI never legitimately needs these; the refusal is a hard wall,
165
+ // not a configurable preference.
166
+ if (/^\s*(javascript|data|vbscript):/i.test(s)) {
167
+ throw new TypeError(label + ": link_url scheme refused");
168
+ }
169
+ return s;
170
+ }
171
+
172
+ function _priority(n, label) {
173
+ if (n == null) return 0;
174
+ if (!Number.isInteger(n) || n < 0 || n > 1000000) {
175
+ throw new TypeError(label + ": priority must be a non-negative integer <= 1000000");
176
+ }
177
+ return n;
178
+ }
179
+
180
+ function _epochMs(n, label, fnLabel) {
181
+ if (!Number.isInteger(n) || n < 0) {
182
+ throw new TypeError(fnLabel + ": " + label + " must be a non-negative integer epoch-ms");
183
+ }
184
+ return n;
185
+ }
186
+
187
+ function _optEpochMs(n, label, fnLabel) {
188
+ if (n == null) return null;
189
+ return _epochMs(n, label, fnLabel);
190
+ }
191
+
192
+ function _limit(n, fnLabel) {
193
+ if (n == null) return DEFAULT_LIMIT;
194
+ if (!Number.isInteger(n) || n <= 0 || n > MAX_LIMIT) {
195
+ throw new TypeError(fnLabel + ": limit must be 1..." + MAX_LIMIT);
196
+ }
197
+ return n;
198
+ }
199
+
200
+ function _sessionId(s, fnLabel) {
201
+ if (typeof s !== "string" || !s.length) {
202
+ throw new TypeError(fnLabel + ": session_id must be a non-empty string");
203
+ }
204
+ if (s.length > 256) {
205
+ throw new TypeError(fnLabel + ": session_id must be <= 256 chars");
206
+ }
207
+ if (/[\x00-\x1f\x7f]/.test(s)) {
208
+ throw new TypeError(fnLabel + ": session_id must not contain control bytes");
209
+ }
210
+ return s;
211
+ }
212
+
213
+ function _resultCount(n, fnLabel) {
214
+ if (n == null) return 0;
215
+ if (!Number.isInteger(n) || n < 0 || n > MAX_RESULT_COUNT) {
216
+ throw new TypeError(fnLabel + ": result_count must be a non-negative integer <= " + MAX_RESULT_COUNT);
217
+ }
218
+ return n;
219
+ }
220
+
221
+ function _featuredStatus(s, fnLabel) {
222
+ if (FEATURED_STATUSES.indexOf(s) === -1) {
223
+ throw new TypeError(fnLabel + ": status must be one of " + FEATURED_STATUSES.join(", "));
224
+ }
225
+ return s;
226
+ }
227
+
228
+ function _id(id, fnLabel) {
229
+ if (typeof id !== "string" || !id.length) {
230
+ throw new TypeError(fnLabel + ": id must be a non-empty string");
231
+ }
232
+ return id;
233
+ }
234
+
235
+ function create(opts) {
236
+ opts = opts || {};
237
+ var query = opts.query;
238
+ if (!query) {
239
+ query = function (sql, params) { return _b().externalDb.query(sql, params); };
240
+ }
241
+ var catalog = opts.catalog || null;
242
+
243
+ function _featuredByPrefix(qLower, limit, now) {
244
+ // Featured rows are pinned to a specific lowercased prefix. The
245
+ // typical match is "is this prefix exactly what the customer
246
+ // typed so far?" — i.e. the row's prefix equals the leading
247
+ // chars of `q`. To honour both directions (customer typed
248
+ // `free` and the row prefix is `free`; customer typed `f` and
249
+ // the row prefix is `free` so the operator's promo surfaces
250
+ // early) we match either way:
251
+ // * `prefix = q` — exact prefix hit
252
+ // * `prefix LIKE q || '%'` — customer typed less than the
253
+ // full prefix; surface the row as soon as it's an
254
+ // unambiguous extension of what they've typed
255
+ // Active + non-expired only. Ordered by priority DESC then
256
+ // created_at DESC so an operator-pinned row wins ties.
257
+ var pattern = qLower.replace(/\\/g, "\\\\").replace(/%/g, "\\%").replace(/_/g, "\\_") + "%";
258
+ return query(
259
+ "SELECT id, prefix, display_text, link_url, priority, " +
260
+ "starts_at, expires_at, status, created_at, updated_at " +
261
+ "FROM featured_search_suggestions " +
262
+ "WHERE status = 'active' " +
263
+ "AND starts_at <= ?1 " +
264
+ "AND (expires_at IS NULL OR expires_at > ?1) " +
265
+ "AND (prefix = ?2 OR prefix LIKE ?3 ESCAPE '\\') " +
266
+ "ORDER BY priority DESC, created_at DESC, id DESC " +
267
+ "LIMIT ?4",
268
+ [now, qLower, pattern, limit],
269
+ );
270
+ }
271
+
272
+ function _popularByPrefix(qLower, limit, sinceTs) {
273
+ // Popular queries aggregate over the recent window. The
274
+ // `query_normalized` column is already lowercased + trimmed at
275
+ // the write site, so the LIKE pattern matches the column
276
+ // directly. Escape clause neutralises LIKE metachars so a
277
+ // search containing `%` or `_` matches the literal character.
278
+ var pattern = qLower.replace(/\\/g, "\\\\").replace(/%/g, "\\%").replace(/_/g, "\\_") + "%";
279
+ return query(
280
+ "SELECT query_normalized, COUNT(*) AS count, MAX(occurred_at) AS last_seen " +
281
+ "FROM search_query_log " +
282
+ "WHERE occurred_at >= ?1 " +
283
+ "AND query_normalized LIKE ?2 ESCAPE '\\' " +
284
+ "GROUP BY query_normalized " +
285
+ "ORDER BY count DESC, last_seen DESC " +
286
+ "LIMIT ?3",
287
+ [sinceTs, pattern, limit],
288
+ );
289
+ }
290
+
291
+ return {
292
+ SESSION_NAMESPACE: SESSION_NAMESPACE,
293
+ POPULAR_WINDOW_MS: POPULAR_WINDOW_MS,
294
+
295
+ recordQuery: async function (input) {
296
+ _requireObject(input, "searchSuggestions.recordQuery");
297
+ var qNorm = _normalizeQuery(input.q, "searchSuggestions.recordQuery");
298
+ var sessionId = _sessionId(input.session_id, "searchSuggestions.recordQuery");
299
+ var resultCount = _resultCount(input.result_count, "searchSuggestions.recordQuery");
300
+ var sessionHash = _b().crypto.namespaceHash(SESSION_NAMESPACE, sessionId);
301
+ var id = _b().uuid.v7();
302
+ var now = Date.now();
303
+ await query(
304
+ "INSERT INTO search_query_log " +
305
+ "(id, query_normalized, session_id_hash, result_count, occurred_at) " +
306
+ "VALUES (?1, ?2, ?3, ?4, ?5)",
307
+ [id, qNorm, sessionHash, resultCount, now],
308
+ );
309
+ return { id: id };
310
+ },
311
+
312
+ suggest: async function (input) {
313
+ input = input || {};
314
+ var qNorm = _normalizeQuery(input.q, "searchSuggestions.suggest");
315
+ var limit = _limit(input.limit, "searchSuggestions.suggest");
316
+ var now = Date.now();
317
+ var sinceTs = now - POPULAR_WINDOW_MS;
318
+
319
+ // Featured + popular run as independent queries against this
320
+ // primitive's own tables. Product matches delegate to the
321
+ // catalog primitive's existing `products.search` shape so the
322
+ // dropdown stays consistent with what `/search?q=…` would
323
+ // return (case-insensitive title + description LIKE, status
324
+ // active only).
325
+ var featuredP = _featuredByPrefix(qNorm, limit, now);
326
+ var popularP = _popularByPrefix(qNorm, limit, sinceTs);
327
+ var productsP;
328
+ if (catalog && catalog.products && typeof catalog.products.search === "function") {
329
+ productsP = catalog.products.search({ q: qNorm, status: "active", limit: limit });
330
+ } else {
331
+ productsP = Promise.resolve({ rows: [], next_cursor: null });
332
+ }
333
+
334
+ var resolved = await Promise.all([featuredP, popularP, productsP]);
335
+ var featuredR = resolved[0];
336
+ var popularR = resolved[1];
337
+ var productsR = resolved[2];
338
+
339
+ var queries = [];
340
+ for (var i = 0; i < popularR.rows.length; i += 1) {
341
+ var row = popularR.rows[i];
342
+ queries.push({
343
+ query_normalized: row.query_normalized,
344
+ count: Number(row.count),
345
+ last_seen: Number(row.last_seen),
346
+ });
347
+ }
348
+ var featured = [];
349
+ for (var j = 0; j < featuredR.rows.length; j += 1) {
350
+ var f = featuredR.rows[j];
351
+ featured.push({
352
+ id: f.id,
353
+ prefix: f.prefix,
354
+ display_text: f.display_text,
355
+ link_url: f.link_url,
356
+ priority: Number(f.priority),
357
+ starts_at: Number(f.starts_at),
358
+ expires_at: f.expires_at == null ? null : Number(f.expires_at),
359
+ status: f.status,
360
+ });
361
+ }
362
+ // The catalog primitive returns `{ rows, next_cursor }`; the
363
+ // dropdown only consumes the rows array. Pass through as-is
364
+ // so the route handler can render the same shape it would
365
+ // get from a `/search` request.
366
+ var products = Array.isArray(productsR && productsR.rows) ? productsR.rows : [];
367
+ return {
368
+ products: products,
369
+ queries: queries,
370
+ featured: featured,
371
+ };
372
+ },
373
+
374
+ addFeatured: async function (input) {
375
+ _requireObject(input, "searchSuggestions.addFeatured");
376
+ var prefix = _normalizePrefix(input.prefix, "searchSuggestions.addFeatured");
377
+ var displayText = _displayText(input.display_text, "searchSuggestions.addFeatured");
378
+ var linkUrl = _linkUrl(input.link_url, "searchSuggestions.addFeatured");
379
+ var priority = _priority(input.priority, "searchSuggestions.addFeatured");
380
+ var now = Date.now();
381
+ var startsAt = input.starts_at == null
382
+ ? now
383
+ : _epochMs(input.starts_at, "starts_at", "searchSuggestions.addFeatured");
384
+ var expiresAt = _optEpochMs(input.expires_at, "expires_at", "searchSuggestions.addFeatured");
385
+ if (expiresAt != null && expiresAt <= startsAt) {
386
+ throw new TypeError("searchSuggestions.addFeatured: expires_at must be > starts_at");
387
+ }
388
+ var status = input.status == null ? "active" : _featuredStatus(input.status, "searchSuggestions.addFeatured");
389
+ var id = _b().uuid.v7();
390
+ await query(
391
+ "INSERT INTO featured_search_suggestions " +
392
+ "(id, prefix, display_text, link_url, priority, starts_at, expires_at, status, created_at, updated_at) " +
393
+ "VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?9)",
394
+ [id, prefix, displayText, linkUrl, priority, startsAt, expiresAt, status, now],
395
+ );
396
+ var row = (await query(
397
+ "SELECT * FROM featured_search_suggestions WHERE id = ?1 LIMIT 1",
398
+ [id],
399
+ )).rows[0];
400
+ return row || null;
401
+ },
402
+
403
+ updateFeatured: async function (id, patch) {
404
+ _id(id, "searchSuggestions.updateFeatured");
405
+ if (!patch || typeof patch !== "object") {
406
+ throw new TypeError("searchSuggestions.updateFeatured: patch object required");
407
+ }
408
+ var sets = [];
409
+ var params = [];
410
+ var i = 1;
411
+ function _addSet(col, val) {
412
+ // Defense in depth — the column literal here is hand-
413
+ // written, but route every dynamic identifier through the
414
+ // safeSql allowlist so a future refactor that widens the
415
+ // column set can't smuggle identifier injection past the
416
+ // gate.
417
+ _b().safeSql.assertOneOf(col, ALLOWED_FEATURED_COLS);
418
+ sets.push(_b().safeSql.quoteIdentifier(col, "sqlite") + " = ?" + (i++));
419
+ params.push(val);
420
+ }
421
+ if (patch.prefix !== undefined) {
422
+ _addSet("prefix", _normalizePrefix(patch.prefix, "searchSuggestions.updateFeatured"));
423
+ }
424
+ if (patch.display_text !== undefined) {
425
+ _addSet("display_text", _displayText(patch.display_text, "searchSuggestions.updateFeatured"));
426
+ }
427
+ if (patch.link_url !== undefined) {
428
+ _addSet("link_url", _linkUrl(patch.link_url, "searchSuggestions.updateFeatured"));
429
+ }
430
+ if (patch.priority !== undefined) {
431
+ _addSet("priority", _priority(patch.priority, "searchSuggestions.updateFeatured"));
432
+ }
433
+ if (patch.starts_at !== undefined) {
434
+ _addSet("starts_at", _epochMs(patch.starts_at, "starts_at", "searchSuggestions.updateFeatured"));
435
+ }
436
+ if (patch.expires_at !== undefined) {
437
+ _addSet("expires_at", patch.expires_at == null
438
+ ? null
439
+ : _epochMs(patch.expires_at, "expires_at", "searchSuggestions.updateFeatured"));
440
+ }
441
+ if (patch.status !== undefined) {
442
+ _addSet("status", _featuredStatus(patch.status, "searchSuggestions.updateFeatured"));
443
+ }
444
+ if (sets.length === 0) {
445
+ // No-op update — callers shouldn't rely on a heartbeat
446
+ // updated_at flip from an empty patch; throw so the caller
447
+ // is explicit about intent.
448
+ throw new TypeError("searchSuggestions.updateFeatured: patch contained no updatable fields");
449
+ }
450
+ sets.push("updated_at = ?" + (i++));
451
+ params.push(Date.now());
452
+ params.push(id);
453
+ var r = await query(
454
+ "UPDATE featured_search_suggestions SET " + sets.join(", ") + " WHERE id = ?" + i,
455
+ params,
456
+ );
457
+ if (r.rowCount === 0) return null;
458
+ var row = (await query(
459
+ "SELECT * FROM featured_search_suggestions WHERE id = ?1 LIMIT 1",
460
+ [id],
461
+ )).rows[0];
462
+ return row || null;
463
+ },
464
+
465
+ deleteFeatured: async function (id) {
466
+ _id(id, "searchSuggestions.deleteFeatured");
467
+ var r = await query(
468
+ "DELETE FROM featured_search_suggestions WHERE id = ?1",
469
+ [id],
470
+ );
471
+ return { removed: Number(r.rowCount || 0) > 0 };
472
+ },
473
+
474
+ popularQueries: async function (popOpts) {
475
+ popOpts = popOpts || {};
476
+ var now = Date.now();
477
+ var from = popOpts.from == null
478
+ ? now - POPULAR_WINDOW_MS
479
+ : _epochMs(popOpts.from, "from", "searchSuggestions.popularQueries");
480
+ var to = popOpts.to == null
481
+ ? now
482
+ : _epochMs(popOpts.to, "to", "searchSuggestions.popularQueries");
483
+ if (to < from) {
484
+ throw new TypeError("searchSuggestions.popularQueries: to must be >= from");
485
+ }
486
+ var limit = _limit(popOpts.limit, "searchSuggestions.popularQueries");
487
+ var rows = (await query(
488
+ "SELECT query_normalized, " +
489
+ "COUNT(*) AS count, " +
490
+ "MAX(occurred_at) AS last_seen, " +
491
+ "SUM(CASE WHEN result_count = 0 THEN 1 ELSE 0 END) AS zero_count " +
492
+ "FROM search_query_log " +
493
+ "WHERE occurred_at >= ?1 AND occurred_at <= ?2 " +
494
+ "GROUP BY query_normalized " +
495
+ "ORDER BY count DESC, last_seen DESC " +
496
+ "LIMIT ?3",
497
+ [from, to, limit],
498
+ )).rows;
499
+ var out = [];
500
+ for (var i = 0; i < rows.length; i += 1) {
501
+ var count = Number(rows[i].count);
502
+ var zero = Number(rows[i].zero_count || 0);
503
+ out.push({
504
+ query_normalized: rows[i].query_normalized,
505
+ count: count,
506
+ last_seen: Number(rows[i].last_seen),
507
+ zero_result_share: count === 0 ? 0 : zero / count,
508
+ });
509
+ }
510
+ return out;
511
+ },
512
+
513
+ cleanupOldQueries: async function (ts) {
514
+ if (!Number.isInteger(ts) || ts < 0) {
515
+ throw new TypeError("searchSuggestions.cleanupOldQueries: ts must be a non-negative integer epoch-ms");
516
+ }
517
+ var r = await query(
518
+ "DELETE FROM search_query_log WHERE occurred_at < ?1",
519
+ [ts],
520
+ );
521
+ return { removed: Number(r.rowCount || 0) };
522
+ },
523
+ };
524
+ }
525
+
526
+ module.exports = {
527
+ create: create,
528
+ };