@blamejs/blamejs-shop 0.0.65 → 0.0.70

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/lib/assembly-instructions.js +777 -0
  3. package/lib/auto-replenish.js +933 -0
  4. package/lib/business-hours.js +980 -0
  5. package/lib/click-and-collect.js +711 -0
  6. package/lib/clickstream.js +713 -0
  7. package/lib/cost-layers.js +774 -0
  8. package/lib/credit-limits.js +752 -0
  9. package/lib/currency-rounding.js +525 -0
  10. package/lib/customer-activity.js +862 -0
  11. package/lib/customer-notes.js +712 -0
  12. package/lib/customer-risk-profile.js +593 -0
  13. package/lib/customer-surveys.js +1012 -0
  14. package/lib/damage-photos.js +473 -0
  15. package/lib/discount-allocation.js +557 -0
  16. package/lib/dropship-forwarding.js +645 -0
  17. package/lib/email-templates.js +817 -0
  18. package/lib/index.js +45 -0
  19. package/lib/inventory-allocations.js +559 -0
  20. package/lib/inventory-writeoffs.js +636 -0
  21. package/lib/knowledge-base.js +1104 -0
  22. package/lib/locale-router.js +1077 -0
  23. package/lib/operator-roles.js +768 -0
  24. package/lib/order-escalation.js +951 -0
  25. package/lib/order-ratings.js +495 -0
  26. package/lib/order-tags.js +944 -0
  27. package/lib/packing-slips.js +810 -0
  28. package/lib/payment-retries.js +816 -0
  29. package/lib/pick-lists.js +639 -0
  30. package/lib/pixel-events.js +995 -0
  31. package/lib/preorder.js +595 -0
  32. package/lib/print-queue.js +681 -0
  33. package/lib/product-qa.js +749 -0
  34. package/lib/promo-bundles.js +835 -0
  35. package/lib/push-notifications.js +937 -0
  36. package/lib/refund-automation.js +853 -0
  37. package/lib/reorder-reminders.js +798 -0
  38. package/lib/robots-config.js +753 -0
  39. package/lib/seller-signup.js +1052 -0
  40. package/lib/site-redirects.js +690 -0
  41. package/lib/sitemap-generator.js +717 -0
  42. package/lib/subscription-gifts.js +710 -0
  43. package/lib/tax-cert-renewals.js +632 -0
  44. package/lib/theme-assets.js +711 -0
  45. package/lib/tier-benefits.js +776 -0
  46. package/lib/vendor/MANIFEST.json +2 -2
  47. package/lib/vendor/blamejs/CHANGELOG.md +2 -0
  48. package/lib/vendor/blamejs/api-snapshot.json +2 -2
  49. package/lib/vendor/blamejs/lib/metrics.js +68 -4
  50. package/lib/vendor/blamejs/package.json +1 -1
  51. package/lib/vendor/blamejs/release-notes/v0.12.5.json +40 -0
  52. package/lib/wishlist-alerts.js +842 -0
  53. package/lib/wishlist-sharing.js +718 -0
  54. package/package.json +1 -1
@@ -0,0 +1,690 @@
1
+ "use strict";
2
+ /**
3
+ * @module shop.siteRedirects
4
+ * @title Site-redirects primitive — operator-defined 301/302/307/308
5
+ * URL redirects with hit counters
6
+ *
7
+ * @intro
8
+ * When a marketing slug migrates, a product is retired, or a region-
9
+ * specific landing page rotates, the storefront needs a single
10
+ * answer: "if a request lands at `/old-path`, where does it go?"
11
+ * `siteRedirects` is the operator-author table that backs that
12
+ * answer.
13
+ *
14
+ * Each redirect carries five operator-author fields plus an
15
+ * optional expiry:
16
+ *
17
+ * - `slug` — stable id for dashboards + audit trails;
18
+ * the operator picks the slug, the framework
19
+ * guarantees uniqueness.
20
+ * - `source_path` — the inbound URL the storefront sees. Always
21
+ * /-rooted; trailing slash is significant
22
+ * (matches the URL the browser actually sends).
23
+ * - `target_url` — either a /-rooted internal path or a full
24
+ * https:// URL. javascript: / data: /
25
+ * protocol-relative `//host/...` refused via
26
+ * `b.safeUrl.parse`.
27
+ * - `code` — 301 (permanent), 302 (temporary), 307
28
+ * (temporary, preserves method+body) or 308
29
+ * (permanent, preserves method+body). The
30
+ * primitive enforces the four-verb set at the
31
+ * edge so a typo never reaches a customer
32
+ * browser.
33
+ * - `match_kind` — `exact` (full-path equality), `prefix`
34
+ * (request path starts with the source), or
35
+ * `regex` (anchored regex against the request
36
+ * path). Regex patterns with backreferences or
37
+ * lookahead are refused at define time so a
38
+ * hostile author can't land a catastrophic-
39
+ * backtracking pattern in the live table.
40
+ * - `expires_at` — optional epoch-ms cutoff. After expiry the
41
+ * redirect stops resolving (treated like
42
+ * archived) but persists until cleanupExpired
43
+ * sweeps it. Operators wanting a permanent
44
+ * redirect leave this null.
45
+ *
46
+ * `resolveForPath(path)` walks the three match_kinds in precedence
47
+ * order — exact first, then prefix (longest-source-path-first), then
48
+ * regex (slug ASC) — returning the most-specific live row whose
49
+ * pattern covers `path`. Archived rows + expired rows + inactive
50
+ * rows drop out. Returning the most-specific match keeps a
51
+ * `/sale/holiday/black-friday` row in charge of its own URL while a
52
+ * broader `/sale/` prefix row catches everything else.
53
+ *
54
+ * `recordHit({slug, occurred_at?})` increments the row's running
55
+ * `hit_count` AND appends a row to `site_redirect_hits` for the
56
+ * per-event log. Dashboards walk the per-event table via
57
+ * `topHits({from, to, limit})` to answer "which redirects fired the
58
+ * most last week"; the running counter is the cheap O(1) lifetime
59
+ * total when the dashboard doesn't need the window.
60
+ *
61
+ * Composes:
62
+ * - `b.uuid.v7` — id mint for `site_redirect_hits` rows.
63
+ * - `b.safeUrl.parse` — `target_url` https:// gate. /-rooted
64
+ * internal paths are admitted via the same control-byte +
65
+ * protocol-relative refusal as `promoBanners.link_url`.
66
+ *
67
+ * Surface:
68
+ * defineRedirect({ slug, source_path, target_url, code,
69
+ * match_kind, active, expires_at? })
70
+ * resolveForPath(path)
71
+ * recordHit({ slug, occurred_at? })
72
+ * listRedirects({ active_only? })
73
+ * getRedirect(slug)
74
+ * updateRedirect({ slug, ... })
75
+ * archiveRedirect(slug)
76
+ * unarchiveRedirect(slug)
77
+ * topHits({ from, to, limit })
78
+ * cleanupExpired({ now? })
79
+ *
80
+ * Storage:
81
+ * - `site_redirects` (migration `0119_site_redirects.sql`)
82
+ * - `site_redirect_hits` (migration `0119_site_redirects.sql`)
83
+ *
84
+ * @primitive siteRedirects
85
+ * @related b.uuid, b.safeUrl
86
+ */
87
+
88
+ // ---- constants ----------------------------------------------------------
89
+
90
+ var MAX_SLUG_LEN = 80;
91
+ var SLUG_RE = /^[a-z0-9][a-z0-9_-]{0,79}$/;
92
+
93
+ var MAX_SOURCE_PATH_LEN = 2048;
94
+ var MAX_TARGET_URL_LEN = 2048;
95
+
96
+ var CODES = Object.freeze([301, 302, 307, 308]);
97
+ var MATCH_KINDS = Object.freeze(["exact", "prefix", "regex"]);
98
+
99
+ var MAX_REGEX_SOURCE_LEN = 512;
100
+
101
+ var MAX_LIMIT = 1000;
102
+ var DEFAULT_LIMIT = 100;
103
+
104
+ var CONTROL_BYTE_RE = /[\x00-\x1f\x7f]/;
105
+ // Zero-width + invisible bytes: U+200B (zero-width space),
106
+ // U+200C (zero-width non-joiner), U+200D (zero-width joiner),
107
+ // U+2060 (word joiner), U+FEFF (zero-width no-break space / BOM).
108
+ // Source code uses escape sequences so the file itself stays
109
+ // linter-clean (raw zero-width bytes in source trip both
110
+ // no-irregular-whitespace and no-misleading-character-class).
111
+ var ZERO_WIDTH_RE = new RegExp(
112
+ "[\\u200B-\\u200F\\u202A-\\u202E\\u2060-\\u2064\\u2066-\\u2069\\uFEFF\\u061C]"
113
+ );
114
+
115
+ // Lazy framework handle — matches the convention every other shop
116
+ // primitive uses; avoids the require cycle that would arise from
117
+ // importing `./index` at module-eval time.
118
+ var bShop;
119
+ function _b() {
120
+ if (!bShop) bShop = require("./index");
121
+ return bShop.framework;
122
+ }
123
+
124
+ // Monotonic clock — ensures consecutive _now() calls from the same
125
+ // process never collide in the same millisecond, so updated_at /
126
+ // created_at ordering stays stable when two writes land back-to-back
127
+ // inside a single tick. The framework's own observability sinks use
128
+ // the same shape.
129
+ var _lastTs = 0;
130
+ function _now() {
131
+ var t = Date.now();
132
+ if (t <= _lastTs) { t = _lastTs + 1; }
133
+ _lastTs = t;
134
+ return t;
135
+ }
136
+
137
+ // ---- validators ---------------------------------------------------------
138
+
139
+ function _slug(s) {
140
+ if (typeof s !== "string" || !s.length) {
141
+ throw new TypeError("siteRedirects: slug must be a non-empty string");
142
+ }
143
+ if (s.length > MAX_SLUG_LEN) {
144
+ throw new TypeError("siteRedirects: slug must be <= " + MAX_SLUG_LEN + " characters");
145
+ }
146
+ if (!SLUG_RE.test(s)) {
147
+ throw new TypeError(
148
+ "siteRedirects: slug must match /^[a-z0-9][a-z0-9_-]{0,79}$/"
149
+ );
150
+ }
151
+ return s;
152
+ }
153
+
154
+ function _sourcePath(s) {
155
+ if (typeof s !== "string" || !s.length) {
156
+ throw new TypeError("siteRedirects: source_path must be a non-empty string");
157
+ }
158
+ if (s.length > MAX_SOURCE_PATH_LEN) {
159
+ throw new TypeError(
160
+ "siteRedirects: source_path must be <= " + MAX_SOURCE_PATH_LEN + " characters"
161
+ );
162
+ }
163
+ if (CONTROL_BYTE_RE.test(s) || ZERO_WIDTH_RE.test(s)) {
164
+ throw new TypeError("siteRedirects: source_path contains control / zero-width bytes");
165
+ }
166
+ if (s.charCodeAt(0) !== 47 /* "/" */) {
167
+ throw new TypeError("siteRedirects: source_path must be /-rooted (start with '/')");
168
+ }
169
+ if (s.length > 1 && s.charCodeAt(1) === 47) {
170
+ throw new TypeError(
171
+ "siteRedirects: source_path protocol-relative `//host/...` refused — must be /-rooted"
172
+ );
173
+ }
174
+ return s;
175
+ }
176
+
177
+ // /-rooted internal path OR https://. Same envelope as
178
+ // promoBanners.link_url / trustBadges.link_url so an operator
179
+ // rotating between primitives doesn't relearn the gate.
180
+ function _targetUrl(s) {
181
+ if (typeof s !== "string" || !s.length) {
182
+ throw new TypeError("siteRedirects: target_url must be a non-empty string");
183
+ }
184
+ if (s.length > MAX_TARGET_URL_LEN) {
185
+ throw new TypeError(
186
+ "siteRedirects: target_url must be <= " + MAX_TARGET_URL_LEN + " characters"
187
+ );
188
+ }
189
+ if (CONTROL_BYTE_RE.test(s) || ZERO_WIDTH_RE.test(s)) {
190
+ throw new TypeError("siteRedirects: target_url contains control / zero-width bytes");
191
+ }
192
+ if (s.charCodeAt(0) === 47 /* "/" */) {
193
+ if (s.length > 1 && s.charCodeAt(1) === 47) {
194
+ throw new TypeError(
195
+ "siteRedirects: target_url protocol-relative `//host/...` refused — use absolute https://"
196
+ );
197
+ }
198
+ if (s.indexOf("..") !== -1) {
199
+ throw new TypeError("siteRedirects: target_url path must not contain '..'");
200
+ }
201
+ return s;
202
+ }
203
+ try {
204
+ _b().safeUrl.parse(s, { allowedProtocols: ["https:"] });
205
+ } catch (e) {
206
+ throw new TypeError(
207
+ "siteRedirects: target_url — " +
208
+ (e && e.message ? e.message : "must be https:// or a /-rooted absolute path")
209
+ );
210
+ }
211
+ return s;
212
+ }
213
+
214
+ function _code(n) {
215
+ if (!Number.isInteger(n) || CODES.indexOf(n) === -1) {
216
+ throw new TypeError("siteRedirects: code must be one of " + CODES.join(", "));
217
+ }
218
+ return n;
219
+ }
220
+
221
+ function _matchKind(s) {
222
+ if (typeof s !== "string" || MATCH_KINDS.indexOf(s) === -1) {
223
+ throw new TypeError("siteRedirects: match_kind must be one of " + MATCH_KINDS.join(", "));
224
+ }
225
+ return s;
226
+ }
227
+
228
+ function _active(b) {
229
+ if (typeof b !== "boolean") {
230
+ throw new TypeError("siteRedirects: active must be a boolean");
231
+ }
232
+ return b ? 1 : 0;
233
+ }
234
+
235
+ function _expiresAt(n, label) {
236
+ if (n == null) return null;
237
+ if (!Number.isInteger(n) || n <= 0) {
238
+ throw new TypeError("siteRedirects: " + label + " must be a positive integer epoch-ms");
239
+ }
240
+ return n;
241
+ }
242
+
243
+ function _epochMs(n, label) {
244
+ if (!Number.isInteger(n) || n <= 0) {
245
+ throw new TypeError("siteRedirects: " + label + " must be a positive integer epoch-ms");
246
+ }
247
+ return n;
248
+ }
249
+
250
+ // Refuse regex patterns that carry backreferences (`\1`..`\9`) or
251
+ // lookahead/lookbehind (`(?=...)`, `(?!...)`, `(?<=...)`, `(?<!...)`).
252
+ // These are the two regex-shape categories that drive catastrophic
253
+ // backtracking under adversarial input. Operators wanting these
254
+ // shapes register multiple `prefix` / `exact` rules instead — the
255
+ // regex match_kind is for cheap path-translation, not arbitrary
256
+ // pattern matching.
257
+ //
258
+ // `_compileRegex` walks the source for the forbidden tokens BEFORE
259
+ // handing it to RegExp(), then anchors the compiled pattern with `^`
260
+ // and `$` so `resolveForPath` answers an exact-path match (not a
261
+ // substring match a la `/foo` finding `/foo/bar`).
262
+ function _compileRegex(source) {
263
+ if (typeof source !== "string" || !source.length) {
264
+ throw new TypeError("siteRedirects: source_path (regex) must be a non-empty string");
265
+ }
266
+ if (source.length > MAX_REGEX_SOURCE_LEN) {
267
+ throw new TypeError(
268
+ "siteRedirects: regex source must be <= " + MAX_REGEX_SOURCE_LEN + " characters"
269
+ );
270
+ }
271
+ if (CONTROL_BYTE_RE.test(source) || ZERO_WIDTH_RE.test(source)) {
272
+ throw new TypeError("siteRedirects: regex source contains control / zero-width bytes");
273
+ }
274
+ // Backreferences: \1 .. \9 outside a character class. The walker
275
+ // tracks character-class depth so `[\1]` (literal byte) doesn't
276
+ // trip the gate, but `(.)\1` (a real backreference) does.
277
+ var inClass = 0;
278
+ for (var i = 0; i < source.length; i += 1) {
279
+ var ch = source.charAt(i);
280
+ if (ch === "\\" && i + 1 < source.length) {
281
+ var nxt = source.charAt(i + 1);
282
+ if (inClass === 0 && nxt >= "1" && nxt <= "9") {
283
+ throw new TypeError(
284
+ "siteRedirects: regex backreferences (\\1..\\9) refused — operators register " +
285
+ "separate redirect rows for each variant instead"
286
+ );
287
+ }
288
+ i += 1; // skip escaped char
289
+ continue;
290
+ }
291
+ if (ch === "[") { inClass += 1; continue; }
292
+ if (ch === "]" && inClass > 0) { inClass -= 1; continue; }
293
+ if (inClass > 0) continue;
294
+ if (ch === "(" && i + 2 < source.length && source.charAt(i + 1) === "?") {
295
+ var third = source.charAt(i + 2);
296
+ if (third === "=" || third === "!" || third === "<") {
297
+ throw new TypeError(
298
+ "siteRedirects: regex lookahead / lookbehind (`(?=`, `(?!`, `(?<=`, `(?<!`) refused " +
299
+ "— catastrophic-backtracking surface; rewrite as `prefix` or split into multiple rows"
300
+ );
301
+ }
302
+ }
303
+ }
304
+ // Build the anchored form. RegExp() throws SyntaxError on a
305
+ // malformed source — re-shape that into the primitive's TypeError
306
+ // envelope so callers can match on the consistent error shape.
307
+ var anchored = "^(?:" + source + ")$";
308
+ try {
309
+ return new RegExp(anchored);
310
+ } catch (e) {
311
+ throw new TypeError(
312
+ "siteRedirects: regex source did not compile — " +
313
+ (e && e.message ? e.message : "invalid pattern")
314
+ );
315
+ }
316
+ }
317
+
318
+ // ---- row hydration ------------------------------------------------------
319
+
320
+ function _hydrateRedirect(row) {
321
+ if (!row) return null;
322
+ return {
323
+ slug: row.slug,
324
+ source_path: row.source_path,
325
+ target_url: row.target_url,
326
+ code: Number(row.code),
327
+ match_kind: row.match_kind,
328
+ active: Number(row.active) === 1,
329
+ expires_at: row.expires_at == null ? null : Number(row.expires_at),
330
+ archived_at: row.archived_at == null ? null : Number(row.archived_at),
331
+ hit_count: Number(row.hit_count),
332
+ created_at: Number(row.created_at),
333
+ updated_at: Number(row.updated_at),
334
+ };
335
+ }
336
+
337
+ // ---- factory ------------------------------------------------------------
338
+
339
+ function create(opts) {
340
+ opts = opts || {};
341
+ var query = opts.query;
342
+ if (!query) {
343
+ query = function (sql, params) { return _b().externalDb.query(sql, params); };
344
+ }
345
+
346
+ async function _getRow(slug) {
347
+ var r = await query(
348
+ "SELECT * FROM site_redirects WHERE slug = ?1",
349
+ [slug],
350
+ );
351
+ return r.rows[0] || null;
352
+ }
353
+
354
+ async function getRedirect(slug) {
355
+ _slug(slug);
356
+ return _hydrateRedirect(await _getRow(slug));
357
+ }
358
+
359
+ async function defineRedirect(input) {
360
+ if (!input || typeof input !== "object") {
361
+ throw new TypeError("siteRedirects.defineRedirect: input object required");
362
+ }
363
+ var slug = _slug(input.slug);
364
+ var matchKind = _matchKind(input.match_kind);
365
+ var sourcePath;
366
+ if (matchKind === "regex") {
367
+ // The regex compiler refuses backreferences / lookahead /
368
+ // lookbehind + control bytes. We DON'T enforce /-rooted on
369
+ // regex source — operators sometimes want patterns like
370
+ // `/blog/\\d{4}/.+` that ARE /-rooted in practice but where the
371
+ // operator writes the leading `/` themselves. The bytes-level
372
+ // gates above still apply.
373
+ _compileRegex(input.source_path);
374
+ sourcePath = input.source_path;
375
+ if (sourcePath.length > MAX_SOURCE_PATH_LEN) {
376
+ throw new TypeError(
377
+ "siteRedirects: source_path must be <= " + MAX_SOURCE_PATH_LEN + " characters"
378
+ );
379
+ }
380
+ } else {
381
+ sourcePath = _sourcePath(input.source_path);
382
+ }
383
+ var targetUrl = _targetUrl(input.target_url);
384
+ var code = _code(input.code);
385
+ var activeInt = _active(input.active);
386
+ var expiresAt = _expiresAt(input.expires_at, "expires_at");
387
+
388
+ var ts = _now();
389
+ var existing = await _getRow(slug);
390
+ var createdAt = existing ? Number(existing.created_at) : ts;
391
+ var hitCount = existing ? Number(existing.hit_count) : 0;
392
+ // Re-defining a previously-archived slug clears archived_at — the
393
+ // shape mirrors carrier_transits / shipping_holidays in
394
+ // delivery-estimate: an upsert reopens the row.
395
+ await query(
396
+ "INSERT INTO site_redirects " +
397
+ "(slug, source_path, target_url, code, match_kind, active, expires_at, " +
398
+ " archived_at, hit_count, created_at, updated_at) " +
399
+ "VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, NULL, ?8, ?9, ?10) " +
400
+ "ON CONFLICT(slug) DO UPDATE SET " +
401
+ " source_path = excluded.source_path, " +
402
+ " target_url = excluded.target_url, " +
403
+ " code = excluded.code, " +
404
+ " match_kind = excluded.match_kind, " +
405
+ " active = excluded.active, " +
406
+ " expires_at = excluded.expires_at, " +
407
+ " archived_at = NULL, " +
408
+ " updated_at = excluded.updated_at",
409
+ [slug, sourcePath, targetUrl, code, matchKind, activeInt,
410
+ expiresAt, hitCount, createdAt, ts],
411
+ );
412
+ return _hydrateRedirect(await _getRow(slug));
413
+ }
414
+
415
+ async function updateRedirect(input) {
416
+ if (!input || typeof input !== "object") {
417
+ throw new TypeError("siteRedirects.updateRedirect: input object required");
418
+ }
419
+ var slug = _slug(input.slug);
420
+ var existing = await _getRow(slug);
421
+ if (!existing) {
422
+ throw new TypeError(
423
+ "siteRedirects.updateRedirect: slug " + JSON.stringify(slug) + " not found"
424
+ );
425
+ }
426
+ // Caller may rotate any combination of fields. `slug` is the key
427
+ // and not rotatable in place — operators wanting a new slug
428
+ // archive the old row and defineRedirect a new one.
429
+ var merged = {
430
+ slug: slug,
431
+ source_path: input.source_path != null ? input.source_path : existing.source_path,
432
+ target_url: input.target_url != null ? input.target_url : existing.target_url,
433
+ code: input.code != null ? input.code : Number(existing.code),
434
+ match_kind: input.match_kind != null ? input.match_kind : existing.match_kind,
435
+ active: input.active != null ? input.active : (Number(existing.active) === 1),
436
+ // expires_at allows explicit `null` rotation (operator wants the
437
+ // redirect to become permanent) — Object.prototype.hasOwnProperty
438
+ // distinguishes "omitted" from "set to null".
439
+ expires_at: Object.prototype.hasOwnProperty.call(input, "expires_at")
440
+ ? input.expires_at
441
+ : (existing.expires_at == null ? null : Number(existing.expires_at)),
442
+ };
443
+ return defineRedirect(merged);
444
+ }
445
+
446
+ async function archiveRedirect(slug) {
447
+ _slug(slug);
448
+ var ts = _now();
449
+ var r = await query(
450
+ "UPDATE site_redirects SET archived_at = ?1, updated_at = ?1 " +
451
+ "WHERE slug = ?2 AND archived_at IS NULL",
452
+ [ts, slug],
453
+ );
454
+ if (Number(r.rowCount || 0) === 0) {
455
+ var existing = await getRedirect(slug);
456
+ if (!existing) {
457
+ throw new TypeError(
458
+ "siteRedirects.archiveRedirect: slug " + JSON.stringify(slug) + " not found"
459
+ );
460
+ }
461
+ // Idempotent re-archive — return the already-archived row.
462
+ return existing;
463
+ }
464
+ return await getRedirect(slug);
465
+ }
466
+
467
+ async function unarchiveRedirect(slug) {
468
+ _slug(slug);
469
+ var ts = _now();
470
+ var r = await query(
471
+ "UPDATE site_redirects SET archived_at = NULL, updated_at = ?1 " +
472
+ "WHERE slug = ?2 AND archived_at IS NOT NULL",
473
+ [ts, slug],
474
+ );
475
+ if (Number(r.rowCount || 0) === 0) {
476
+ var existing = await getRedirect(slug);
477
+ if (!existing) {
478
+ throw new TypeError(
479
+ "siteRedirects.unarchiveRedirect: slug " + JSON.stringify(slug) + " not found"
480
+ );
481
+ }
482
+ return existing;
483
+ }
484
+ return await getRedirect(slug);
485
+ }
486
+
487
+ async function listRedirects(listOpts) {
488
+ listOpts = listOpts || {};
489
+ var sql = "SELECT * FROM site_redirects WHERE archived_at IS NULL";
490
+ var params = [];
491
+ if (listOpts.active_only === true) {
492
+ sql += " AND active = 1";
493
+ }
494
+ sql += " ORDER BY match_kind ASC, length(source_path) DESC, slug ASC";
495
+ var r = await query(sql, params);
496
+ var out = [];
497
+ for (var i = 0; i < r.rows.length; i += 1) out.push(_hydrateRedirect(r.rows[i]));
498
+ return out;
499
+ }
500
+
501
+ // Resolve `path` against the live redirect table. Precedence:
502
+ // 1. exact match on source_path
503
+ // 2. prefix match — longest source_path wins
504
+ // 3. regex match — first slug in ASCII order whose anchored
505
+ // pattern accepts the path
506
+ // Archived / inactive / expired rows drop out of every tier.
507
+ async function resolveForPath(path) {
508
+ if (typeof path !== "string" || !path.length) {
509
+ throw new TypeError("siteRedirects.resolveForPath: path must be a non-empty string");
510
+ }
511
+ if (CONTROL_BYTE_RE.test(path)) {
512
+ throw new TypeError("siteRedirects.resolveForPath: path contains control bytes");
513
+ }
514
+ var now = _now();
515
+
516
+ // -- exact ----------------------------------------------------------
517
+ var exact = await query(
518
+ "SELECT * FROM site_redirects " +
519
+ "WHERE match_kind = 'exact' AND active = 1 AND archived_at IS NULL " +
520
+ "AND source_path = ?1 " +
521
+ "AND (expires_at IS NULL OR expires_at > ?2) " +
522
+ "LIMIT 1",
523
+ [path, now],
524
+ );
525
+ if (exact.rows.length) return _hydrateRedirect(exact.rows[0]);
526
+
527
+ // -- prefix — longest source_path wins -----------------------------
528
+ var prefix = await query(
529
+ "SELECT * FROM site_redirects " +
530
+ "WHERE match_kind = 'prefix' AND active = 1 AND archived_at IS NULL " +
531
+ "AND (expires_at IS NULL OR expires_at > ?1) " +
532
+ "ORDER BY length(source_path) DESC, slug ASC",
533
+ [now],
534
+ );
535
+ for (var i = 0; i < prefix.rows.length; i += 1) {
536
+ var row = prefix.rows[i];
537
+ if (path.indexOf(row.source_path) === 0) return _hydrateRedirect(row);
538
+ }
539
+
540
+ // -- regex ---------------------------------------------------------
541
+ var rgx = await query(
542
+ "SELECT * FROM site_redirects " +
543
+ "WHERE match_kind = 'regex' AND active = 1 AND archived_at IS NULL " +
544
+ "AND (expires_at IS NULL OR expires_at > ?1) " +
545
+ "ORDER BY slug ASC",
546
+ [now],
547
+ );
548
+ for (var j = 0; j < rgx.rows.length; j += 1) {
549
+ var rrow = rgx.rows[j];
550
+ // _compileRegex applied the gates + anchored the pattern at
551
+ // define time; we re-compile here on each call because the
552
+ // anchored RegExp object isn't persisted (only the source is).
553
+ // SQLite caches the row scan; the JS-side compile is the hot
554
+ // step but the table is operator-author-small (dozens of rows,
555
+ // not thousands), so the per-call compile is fine.
556
+ var compiled;
557
+ try {
558
+ compiled = _compileRegex(rrow.source_path);
559
+ } catch (_e) {
560
+ // drop-silent — by design: a row that fails re-compile (e.g.
561
+ // because the gate tightened in a later release and an older
562
+ // row predates it) is treated as if it doesn't match. The
563
+ // operator surfaces the row at audit time via listRedirects
564
+ // + a refusal at the next updateRedirect call.
565
+ continue;
566
+ }
567
+ if (compiled.test(path)) return _hydrateRedirect(rrow);
568
+ }
569
+ return null;
570
+ }
571
+
572
+ // Append a hit row + bump the running counter. `occurred_at`
573
+ // defaults to the monotonic _now(); operators backfilling historic
574
+ // hits pass it explicitly.
575
+ async function recordHit(input) {
576
+ if (!input || typeof input !== "object") {
577
+ throw new TypeError("siteRedirects.recordHit: input object required");
578
+ }
579
+ var slug = _slug(input.slug);
580
+ var occurredAt = input.occurred_at == null ? _now() : _epochMs(input.occurred_at, "occurred_at");
581
+ var existing = await _getRow(slug);
582
+ if (!existing) {
583
+ throw new TypeError(
584
+ "siteRedirects.recordHit: slug " + JSON.stringify(slug) + " not found"
585
+ );
586
+ }
587
+ var hitId = _b().uuid.v7();
588
+ await query(
589
+ "INSERT INTO site_redirect_hits (id, slug, occurred_at) VALUES (?1, ?2, ?3)",
590
+ [hitId, slug, occurredAt],
591
+ );
592
+ await query(
593
+ "UPDATE site_redirects SET hit_count = hit_count + 1, updated_at = ?1 WHERE slug = ?2",
594
+ [_now(), slug],
595
+ );
596
+ return {
597
+ id: hitId,
598
+ slug: slug,
599
+ occurred_at: occurredAt,
600
+ };
601
+ }
602
+
603
+ // Operator dashboard — busiest redirects in a [from, to] window.
604
+ // Walks the hits table (NOT the running counter) so the window is
605
+ // accurate after an archive / cleanupExpired.
606
+ async function topHits(input) {
607
+ if (!input || typeof input !== "object") {
608
+ throw new TypeError("siteRedirects.topHits: input object required");
609
+ }
610
+ var from = _epochMs(input.from, "from");
611
+ var to = _epochMs(input.to, "to");
612
+ if (to < from) {
613
+ throw new TypeError("siteRedirects.topHits: to must be >= from");
614
+ }
615
+ var limit = input.limit == null ? DEFAULT_LIMIT : input.limit;
616
+ if (!Number.isInteger(limit) || limit <= 0 || limit > MAX_LIMIT) {
617
+ throw new TypeError(
618
+ "siteRedirects.topHits: limit must be a positive integer <= " + MAX_LIMIT
619
+ );
620
+ }
621
+ var r = await query(
622
+ "SELECT slug, COUNT(*) AS hits FROM site_redirect_hits " +
623
+ "WHERE occurred_at >= ?1 AND occurred_at <= ?2 " +
624
+ "GROUP BY slug ORDER BY hits DESC, slug ASC LIMIT ?3",
625
+ [from, to, limit],
626
+ );
627
+ var out = [];
628
+ for (var i = 0; i < r.rows.length; i += 1) {
629
+ out.push({
630
+ slug: r.rows[i].slug,
631
+ hits: Number(r.rows[i].hits),
632
+ });
633
+ }
634
+ return {
635
+ from: from,
636
+ to: to,
637
+ limit: limit,
638
+ results: out,
639
+ };
640
+ }
641
+
642
+ // Delete redirect rows whose expires_at has elapsed. Returns the
643
+ // sweep count + the `now` cutoff for round-trip clarity in tests
644
+ // and dashboards. cleanupExpired does NOT touch site_redirect_hits
645
+ // — the per-event audit log persists beyond the redirect's
646
+ // lifetime so operators can answer "how many requests hit the
647
+ // retired URL before we cleaned it up."
648
+ async function cleanupExpired(cleanupOpts) {
649
+ cleanupOpts = cleanupOpts || {};
650
+ var now = cleanupOpts.now == null ? _now() : _epochMs(cleanupOpts.now, "now");
651
+ var r = await query(
652
+ "DELETE FROM site_redirects WHERE expires_at IS NOT NULL AND expires_at <= ?1",
653
+ [now],
654
+ );
655
+ return {
656
+ now: now,
657
+ swept: Number(r.rowCount || 0),
658
+ };
659
+ }
660
+
661
+ return {
662
+ CODES: CODES,
663
+ MATCH_KINDS: MATCH_KINDS,
664
+ MAX_SLUG_LEN: MAX_SLUG_LEN,
665
+ MAX_SOURCE_PATH_LEN: MAX_SOURCE_PATH_LEN,
666
+ MAX_TARGET_URL_LEN: MAX_TARGET_URL_LEN,
667
+ SLUG_RE: SLUG_RE,
668
+
669
+ defineRedirect: defineRedirect,
670
+ updateRedirect: updateRedirect,
671
+ getRedirect: getRedirect,
672
+ listRedirects: listRedirects,
673
+ resolveForPath: resolveForPath,
674
+ recordHit: recordHit,
675
+ topHits: topHits,
676
+ archiveRedirect: archiveRedirect,
677
+ unarchiveRedirect: unarchiveRedirect,
678
+ cleanupExpired: cleanupExpired,
679
+ };
680
+ }
681
+
682
+ module.exports = {
683
+ create: create,
684
+ CODES: CODES,
685
+ MATCH_KINDS: MATCH_KINDS,
686
+ MAX_SLUG_LEN: MAX_SLUG_LEN,
687
+ MAX_SOURCE_PATH_LEN: MAX_SOURCE_PATH_LEN,
688
+ MAX_TARGET_URL_LEN: MAX_TARGET_URL_LEN,
689
+ SLUG_RE: SLUG_RE,
690
+ };