@blamejs/blamejs-shop 0.0.72 → 0.0.76

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 (44) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/lib/announcement-bar.js +753 -0
  3. package/lib/banner-ab-tests.js +806 -0
  4. package/lib/bin-locations.js +791 -0
  5. package/lib/blog-articles.js +1173 -0
  6. package/lib/carrier-accounts.js +805 -0
  7. package/lib/cart-recovery.js +1133 -0
  8. package/lib/category-navigation.js +934 -0
  9. package/lib/consent-ledger.js +539 -0
  10. package/lib/customer-impersonation.js +743 -0
  11. package/lib/customer-merge.js +879 -0
  12. package/lib/demand-forecast.js +1121 -0
  13. package/lib/dispute-resolution.js +886 -0
  14. package/lib/email-ab-tests.js +918 -0
  15. package/lib/email-engagement-score.js +649 -0
  16. package/lib/event-log.js +713 -0
  17. package/lib/fulfillment-sla.js +791 -0
  18. package/lib/index.js +41 -0
  19. package/lib/inventory-audits.js +852 -0
  20. package/lib/line-gift-wrap.js +430 -0
  21. package/lib/marketing-budget.js +792 -0
  22. package/lib/operator-activity-feed.js +977 -0
  23. package/lib/operator-approvals.js +942 -0
  24. package/lib/operator-help-center.js +1020 -0
  25. package/lib/operator-inbox.js +889 -0
  26. package/lib/operator-sessions.js +701 -0
  27. package/lib/order-exchanges.js +602 -0
  28. package/lib/product-compare.js +804 -0
  29. package/lib/pwa-manifest.js +1005 -0
  30. package/lib/referral-leaderboard.js +612 -0
  31. package/lib/sales-tax-filings.js +807 -0
  32. package/lib/search-ranking.js +859 -0
  33. package/lib/shipping-insurance.js +757 -0
  34. package/lib/shrinkage-report.js +1182 -0
  35. package/lib/sidebar-widgets.js +952 -0
  36. package/lib/smart-restocking.js +1048 -0
  37. package/lib/stock-receipts.js +834 -0
  38. package/lib/subscription-analytics.js +1032 -0
  39. package/lib/suggestion-box.js +921 -0
  40. package/lib/tax-remittance.js +625 -0
  41. package/lib/vendor-invoices.js +1021 -0
  42. package/lib/winback-campaigns.js +1350 -0
  43. package/lib/wishlist-digest.js +1133 -0
  44. package/package.json +1 -1
@@ -0,0 +1,934 @@
1
+ "use strict";
2
+ /**
3
+ * @module shop.categoryNavigation
4
+ * @title Category navigation — storefront category tree + mega-menu
5
+ *
6
+ * @intro
7
+ * Hierarchical (parent / child) categories that drive two surfaces:
8
+ *
9
+ * 1. Breadcrumbs. Product + collection pages render the ancestor
10
+ * chain from the root down to the current category. The chain
11
+ * is the operator-facing identity of where the customer is in
12
+ * the catalog ("Home > Outdoors > Tents > Family Tents").
13
+ * 2. Mega-menu rendering data. The storefront header consumes
14
+ * `tree({ depth: 2 })` to render the dropdown panels — every
15
+ * top-level category becomes a column, its children become
16
+ * the column's link list, and `hero_image_url` lands as the
17
+ * column's featured artwork.
18
+ *
19
+ * Cycle defense:
20
+ *
21
+ * The schema's `CHECK(slug != parent_slug)` catches the trivial
22
+ * self-parent case, but A -> B -> A and deeper cycles require an
23
+ * ancestor walk because SQLite has no recursive-CTE-as-CHECK
24
+ * shape. `defineCategory` and `move` both walk the proposed
25
+ * parent's ancestor chain before persisting; if the moving slug
26
+ * appears anywhere in that chain the call is refused with
27
+ * `CATEGORY_CYCLE`. The walk is bounded by `MAX_TREE_DEPTH` so
28
+ * a malformed graph (e.g. an operator-edited database) can't
29
+ * infinite-loop the primitive.
30
+ *
31
+ * Archive semantics:
32
+ *
33
+ * `archive({ slug })` soft-deletes the row (`archived_at` set,
34
+ * `active` cleared). `cascade: true` recursively archives every
35
+ * descendant — the storefront shouldn't render a category whose
36
+ * parent the operator just removed. Without `cascade`, the call
37
+ * refuses when descendants exist so the operator doesn't orphan
38
+ * a sub-tree by accident. Re-defining an archived slug (via
39
+ * `defineCategory` with the same slug) revives it: `archived_at`
40
+ * clears and `active` flips back to true.
41
+ *
42
+ * Position ordering:
43
+ *
44
+ * Siblings under the same parent sort by `(position ASC, slug
45
+ * ASC)`. `reorderSiblings` takes a complete list of the parent's
46
+ * direct children in the desired order; partial lists are
47
+ * refused so a missing slug can't silently drift to the end.
48
+ * New rows land at the tail (max existing position + 1) unless
49
+ * the operator supplies an explicit `position`.
50
+ *
51
+ * Hero image gate:
52
+ *
53
+ * `hero_image_url` passes through `b.safeUrl.parse` (https-only)
54
+ * OR accepts a /-rooted absolute path. javascript: / data: /
55
+ * protocol-relative `//host/...` URLs are refused before the
56
+ * row lands on disk — the same gate the announcementBar /
57
+ * knowledgeBase primitives apply.
58
+ *
59
+ * Optional catalog handle:
60
+ *
61
+ * `opts.catalog` is an optional read-only marker today — the
62
+ * primitive doesn't reach into it. A future verb that wants to
63
+ * refuse archive-with-products or surface a richer
64
+ * `productCount` can consume the handle without changing the
65
+ * factory shape. `productCount({ slug, include_descendants? })`
66
+ * today returns 0 when the catalog handle is absent and delegates
67
+ * to `catalog.products.countByCategory(slug)` when it exposes
68
+ * that method.
69
+ *
70
+ * Composes:
71
+ * - `b.safeUrl.parse` — hero_image_url protocol gate
72
+ *
73
+ * Monotonic per-process clock: two writes in the same millisecond
74
+ * would tie on `updated_at` and make a sort-by-timestamp read
75
+ * ambiguous. `_now` bumps to `prior + 1` on collision so the
76
+ * ordering carries a strict per-process timeline (matches the
77
+ * knowledgeBase / announcementBar discipline).
78
+ *
79
+ * Surface:
80
+ * - defineCategory({ slug, parent_slug?, title, description?,
81
+ * hero_image_url?, position?, active? })
82
+ * - getCategory(slug)
83
+ * - categoriesByParent({ parent_slug? })
84
+ * - tree({ root_slug?, depth? })
85
+ * - breadcrumbsFor({ slug })
86
+ * - descendantsOf({ slug, depth? })
87
+ * - move({ slug, new_parent_slug, new_position? })
88
+ * - archive({ slug, cascade? })
89
+ * - update({ slug, patch })
90
+ * - reorderSiblings({ parent_slug, ordered_slugs })
91
+ * - productCount({ slug, include_descendants? })
92
+ * - popularCategories({ from, to, limit })
93
+ *
94
+ * Storage:
95
+ * - categories (migration `0201_category_navigation.sql`).
96
+ *
97
+ * @primitive categoryNavigation
98
+ * @related b.safeUrl, shop.catalog, shop.storefront
99
+ */
100
+
101
+ var MAX_SLUG_LEN = 120;
102
+ var MAX_TITLE_LEN = 200;
103
+ var MAX_DESCRIPTION_LEN = 2000;
104
+ var MAX_HERO_URL_LEN = 2048;
105
+ var MAX_POSITION = 1000000;
106
+ var MAX_TREE_DEPTH = 16;
107
+ var DEFAULT_TREE_DEPTH = 16;
108
+ var MAX_LIST_LIMIT = 200;
109
+ var DEFAULT_POPULAR_LIMIT = 10;
110
+ var MAX_POPULAR_LIMIT = 100;
111
+ var MAX_REORDER_BATCH = 500;
112
+
113
+ var SLUG_RE = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/;
114
+ var CONTROL_BYTE_STRICT_RE = /[\x00-\x1f\x7f]/;
115
+ var CONTROL_BYTE_BODY_RE = /[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/;
116
+ var ZERO_WIDTH_RE = new RegExp(
117
+ "[\\u200B-\\u200F\\u202A-\\u202E\\u2060-\\u2064\\u2066-\\u2069\\uFEFF\\u061C]"
118
+ );
119
+
120
+ var bShop;
121
+ function _b() {
122
+ if (!bShop) bShop = require("./index");
123
+ return bShop.framework;
124
+ }
125
+
126
+ // ---- monotonic clock ---------------------------------------------------
127
+ //
128
+ // Operator-driven writes can land in the same millisecond on fast
129
+ // machines. Bumping by 1ms on a tie keeps the timeline strictly
130
+ // increasing so a sort-by-timestamp read returns events in the order
131
+ // they were issued.
132
+
133
+ var _lastTs = 0;
134
+ function _now() {
135
+ var t = Date.now();
136
+ if (t <= _lastTs) t = _lastTs + 1;
137
+ _lastTs = t;
138
+ return t;
139
+ }
140
+
141
+ // ---- validators --------------------------------------------------------
142
+
143
+ function _slug(s, label) {
144
+ label = label || "slug";
145
+ if (typeof s !== "string" || !s.length) {
146
+ throw new TypeError("categoryNavigation: " + label + " must be a non-empty string");
147
+ }
148
+ if (s.length > MAX_SLUG_LEN) {
149
+ throw new TypeError("categoryNavigation: " + label + " must be <= " + MAX_SLUG_LEN + " characters");
150
+ }
151
+ if (!SLUG_RE.test(s)) {
152
+ throw new TypeError("categoryNavigation: " + label + " must match /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/");
153
+ }
154
+ return s;
155
+ }
156
+
157
+ function _title(s) {
158
+ if (typeof s !== "string") {
159
+ throw new TypeError("categoryNavigation: title must be a string");
160
+ }
161
+ var trimmed = s.trim();
162
+ if (!trimmed.length) {
163
+ throw new TypeError("categoryNavigation: title must be non-empty after trim");
164
+ }
165
+ if (s.length > MAX_TITLE_LEN) {
166
+ throw new TypeError("categoryNavigation: title must be <= " + MAX_TITLE_LEN + " characters");
167
+ }
168
+ if (CONTROL_BYTE_STRICT_RE.test(s)) {
169
+ throw new TypeError("categoryNavigation: title contains control bytes");
170
+ }
171
+ if (ZERO_WIDTH_RE.test(s)) {
172
+ throw new TypeError("categoryNavigation: title contains zero-width / direction-override characters");
173
+ }
174
+ return s;
175
+ }
176
+
177
+ function _description(s) {
178
+ if (s == null) return null;
179
+ if (typeof s !== "string") {
180
+ throw new TypeError("categoryNavigation: description must be a string or null");
181
+ }
182
+ if (s.length > MAX_DESCRIPTION_LEN) {
183
+ throw new TypeError("categoryNavigation: description must be <= " + MAX_DESCRIPTION_LEN + " characters");
184
+ }
185
+ if (CONTROL_BYTE_BODY_RE.test(s)) {
186
+ throw new TypeError("categoryNavigation: description contains control bytes");
187
+ }
188
+ if (ZERO_WIDTH_RE.test(s)) {
189
+ throw new TypeError("categoryNavigation: description contains zero-width / direction-override characters");
190
+ }
191
+ return s;
192
+ }
193
+
194
+ // Validate a hero-image URL through `b.safeUrl.parse` (https-only) OR
195
+ // accept a /-rooted absolute path. The same boundary defense the
196
+ // announcementBar / knowledgeBase primitives apply — javascript: /
197
+ // data: / protocol-relative `//host/...` are refused before
198
+ // persistence.
199
+ function _heroImageUrl(s) {
200
+ if (s == null) return null;
201
+ if (typeof s !== "string" || !s.length) {
202
+ throw new TypeError("categoryNavigation: hero_image_url must be a non-empty string or null");
203
+ }
204
+ if (s.length > MAX_HERO_URL_LEN) {
205
+ throw new TypeError("categoryNavigation: hero_image_url must be <= " + MAX_HERO_URL_LEN + " characters");
206
+ }
207
+ if (CONTROL_BYTE_BODY_RE.test(s) || ZERO_WIDTH_RE.test(s)) {
208
+ throw new TypeError("categoryNavigation: hero_image_url contains control / zero-width bytes");
209
+ }
210
+ if (s.charCodeAt(0) === 47 /* "/" */) {
211
+ if (s.length > 1 && s.charCodeAt(1) === 47) {
212
+ throw new TypeError("categoryNavigation: hero_image_url protocol-relative `//host/...` refused — use absolute https://");
213
+ }
214
+ if (s.indexOf("..") !== -1) {
215
+ throw new TypeError("categoryNavigation: hero_image_url path must not contain '..'");
216
+ }
217
+ return s;
218
+ }
219
+ try {
220
+ _b().safeUrl.parse(s, { allowedProtocols: ["https:"] });
221
+ } catch (e) {
222
+ throw new TypeError("categoryNavigation: hero_image_url — " + (e && e.message || "must be https:// or a /-rooted absolute path"));
223
+ }
224
+ return s;
225
+ }
226
+
227
+ function _position(n, label) {
228
+ label = label || "position";
229
+ if (n == null) return null;
230
+ if (!Number.isInteger(n) || n < 0 || n > MAX_POSITION) {
231
+ throw new TypeError("categoryNavigation: " + label + " must be an integer 0..." + MAX_POSITION);
232
+ }
233
+ return n;
234
+ }
235
+
236
+ function _depth(n, label) {
237
+ label = label || "depth";
238
+ if (n == null) return DEFAULT_TREE_DEPTH;
239
+ if (!Number.isInteger(n) || n < 1 || n > MAX_TREE_DEPTH) {
240
+ throw new TypeError("categoryNavigation: " + label + " must be an integer 1..." + MAX_TREE_DEPTH);
241
+ }
242
+ return n;
243
+ }
244
+
245
+ function _bool(v, label) {
246
+ if (typeof v !== "boolean") {
247
+ throw new TypeError("categoryNavigation: " + label + " must be a boolean");
248
+ }
249
+ return v;
250
+ }
251
+
252
+ function _limit(n, max, def, label) {
253
+ if (n == null) return def;
254
+ if (!Number.isInteger(n) || n <= 0 || n > max) {
255
+ throw new TypeError("categoryNavigation: " + label + " must be an integer 1..." + max);
256
+ }
257
+ return n;
258
+ }
259
+
260
+ function _timestampRange(from, to, label) {
261
+ if (!Number.isInteger(from) || from < 0) {
262
+ throw new TypeError("categoryNavigation." + label + ": from must be a non-negative integer (ms epoch)");
263
+ }
264
+ if (!Number.isInteger(to) || to < 0) {
265
+ throw new TypeError("categoryNavigation." + label + ": to must be a non-negative integer (ms epoch)");
266
+ }
267
+ if (from > to) {
268
+ throw new TypeError("categoryNavigation." + label + ": from must be <= to");
269
+ }
270
+ }
271
+
272
+ // ---- hydration ---------------------------------------------------------
273
+
274
+ function _hydrateRow(row) {
275
+ if (!row) return null;
276
+ return {
277
+ slug: row.slug,
278
+ parent_slug: row.parent_slug == null ? null : row.parent_slug,
279
+ title: row.title,
280
+ description: row.description == null ? null : row.description,
281
+ hero_image_url: row.hero_image_url == null ? null : row.hero_image_url,
282
+ position: Number(row.position) || 0,
283
+ active: row.active === 1 || row.active === true,
284
+ archived_at: row.archived_at == null ? null : Number(row.archived_at),
285
+ created_at: Number(row.created_at),
286
+ updated_at: Number(row.updated_at),
287
+ };
288
+ }
289
+
290
+ // ---- factory -----------------------------------------------------------
291
+
292
+ function create(opts) {
293
+ opts = opts || {};
294
+ var query = opts.query;
295
+ if (!query) {
296
+ query = function (sql, params) { return _b().externalDb.query(sql, params); };
297
+ }
298
+
299
+ // `catalog` is held as an optional read-only marker today — the
300
+ // primitive doesn't read from it on the core verbs. `productCount`
301
+ // delegates to `catalog.products.countByCategory(slug)` when the
302
+ // handle is wired; without it, the count is 0. A future verb that
303
+ // refuses archive-with-products can consume the handle without
304
+ // changing the factory shape.
305
+ var catalog = opts.catalog || null;
306
+ if (catalog !== null && typeof catalog !== "object") {
307
+ throw new TypeError("categoryNavigation.create: opts.catalog must be an object or null");
308
+ }
309
+
310
+ async function _readBySlug(slug) {
311
+ var r = await query("SELECT * FROM categories WHERE slug = ?1", [slug]);
312
+ return r.rows[0] || null;
313
+ }
314
+
315
+ async function _readChildren(parentSlug) {
316
+ if (parentSlug == null) {
317
+ var r1 = await query(
318
+ "SELECT * FROM categories WHERE parent_slug IS NULL AND archived_at IS NULL " +
319
+ "ORDER BY position ASC, slug ASC",
320
+ []
321
+ );
322
+ return r1.rows;
323
+ }
324
+ var r2 = await query(
325
+ "SELECT * FROM categories WHERE parent_slug = ?1 AND archived_at IS NULL " +
326
+ "ORDER BY position ASC, slug ASC",
327
+ [parentSlug]
328
+ );
329
+ return r2.rows;
330
+ }
331
+
332
+ // Walk ancestors from a starting slug upward. Returns an array of
333
+ // raw rows ordered child -> root. Bounded by MAX_TREE_DEPTH so a
334
+ // malformed graph (operator-edited DB) can't infinite-loop.
335
+ async function _walkAncestors(slug) {
336
+ var chain = [];
337
+ var cur = slug;
338
+ var seen = {};
339
+ for (var i = 0; i < MAX_TREE_DEPTH + 1; i += 1) {
340
+ if (cur == null) break;
341
+ if (seen[cur]) {
342
+ var loopErr = new Error("categoryNavigation: malformed graph — cycle detected at '" + cur + "'");
343
+ loopErr.code = "CATEGORY_GRAPH_MALFORMED";
344
+ throw loopErr;
345
+ }
346
+ seen[cur] = 1;
347
+ var row = await _readBySlug(cur);
348
+ if (!row) break;
349
+ chain.push(row);
350
+ cur = row.parent_slug;
351
+ }
352
+ if (i > MAX_TREE_DEPTH) {
353
+ var depthErr = new Error("categoryNavigation: ancestor chain exceeds MAX_TREE_DEPTH (" + MAX_TREE_DEPTH + ")");
354
+ depthErr.code = "CATEGORY_GRAPH_MALFORMED";
355
+ throw depthErr;
356
+ }
357
+ return chain;
358
+ }
359
+
360
+ // Returns true when `candidateAncestor` appears anywhere in the
361
+ // ancestor chain of `descendantSlug` (inclusive). Used by `move`
362
+ // and `defineCategory` to refuse cycle-introducing edges before
363
+ // the row lands on disk.
364
+ async function _isAncestorOrSelf(candidateAncestor, descendantSlug) {
365
+ var cur = descendantSlug;
366
+ var seen = {};
367
+ for (var i = 0; i < MAX_TREE_DEPTH + 1; i += 1) {
368
+ if (cur == null) return false;
369
+ if (cur === candidateAncestor) return true;
370
+ if (seen[cur]) return false;
371
+ seen[cur] = 1;
372
+ var row = await _readBySlug(cur);
373
+ if (!row) return false;
374
+ cur = row.parent_slug;
375
+ }
376
+ return false;
377
+ }
378
+
379
+ async function _nextPositionUnder(parentSlug) {
380
+ var sql, params;
381
+ if (parentSlug == null) {
382
+ sql = "SELECT MAX(position) AS max_pos FROM categories WHERE parent_slug IS NULL";
383
+ params = [];
384
+ } else {
385
+ sql = "SELECT MAX(position) AS max_pos FROM categories WHERE parent_slug = ?1";
386
+ params = [parentSlug];
387
+ }
388
+ var r = await query(sql, params);
389
+ var max = r.rows[0] && r.rows[0].max_pos;
390
+ if (max == null) return 0;
391
+ return Number(max) + 1;
392
+ }
393
+
394
+ // Recursive descendant fetch. Returns rows ordered breadth-first
395
+ // (depth-1 children first, then depth-2, etc.). Each row carries
396
+ // an injected `_depth` field so callers can render the tree
397
+ // without re-walking. Bounded by MAX_TREE_DEPTH; archived rows
398
+ // excluded.
399
+ async function _readDescendants(rootSlug, maxDepth) {
400
+ var out = [];
401
+ var queue = [{ slug: rootSlug, depth: 0 }];
402
+ while (queue.length) {
403
+ var head = queue.shift();
404
+ if (head.depth >= maxDepth) continue;
405
+ var kids = await _readChildren(head.slug);
406
+ for (var i = 0; i < kids.length; i += 1) {
407
+ var kid = kids[i];
408
+ kid._depth = head.depth + 1;
409
+ out.push(kid);
410
+ queue.push({ slug: kid.slug, depth: head.depth + 1 });
411
+ }
412
+ }
413
+ return out;
414
+ }
415
+
416
+ return {
417
+ MAX_SLUG_LEN: MAX_SLUG_LEN,
418
+ MAX_TITLE_LEN: MAX_TITLE_LEN,
419
+ MAX_DESCRIPTION_LEN: MAX_DESCRIPTION_LEN,
420
+ MAX_HERO_URL_LEN: MAX_HERO_URL_LEN,
421
+ MAX_POSITION: MAX_POSITION,
422
+ MAX_TREE_DEPTH: MAX_TREE_DEPTH,
423
+ MAX_LIST_LIMIT: MAX_LIST_LIMIT,
424
+ MAX_POPULAR_LIMIT: MAX_POPULAR_LIMIT,
425
+ MAX_REORDER_BATCH: MAX_REORDER_BATCH,
426
+
427
+ // Idempotent insert / update of a single category row. The first
428
+ // call for a slug inserts; subsequent calls patch the title /
429
+ // description / hero / position / active fields in place. A
430
+ // re-define on an archived slug revives the row (`archived_at`
431
+ // clears, `active` flips back to true). `parent_slug` is
432
+ // validated to exist + the proposed edge is checked against
433
+ // cycles before persistence.
434
+ defineCategory: async function (input) {
435
+ if (!input || typeof input !== "object") {
436
+ throw new TypeError("categoryNavigation.defineCategory: input object required");
437
+ }
438
+ var slug = _slug(input.slug, "slug");
439
+ var title = _title(input.title);
440
+ var description = _description(input.description);
441
+ var heroImage = _heroImageUrl(input.hero_image_url);
442
+ var explicitPosition = _position(input.position, "position");
443
+ var active = input.active == null ? true : _bool(input.active, "active");
444
+
445
+ var parentSlug = null;
446
+ if (input.parent_slug != null) {
447
+ parentSlug = _slug(input.parent_slug, "parent_slug");
448
+ if (parentSlug === slug) {
449
+ throw new TypeError("categoryNavigation.defineCategory: parent_slug must differ from slug");
450
+ }
451
+ var parentRow = await _readBySlug(parentSlug);
452
+ if (!parentRow) {
453
+ var pErr = new Error("categoryNavigation.defineCategory: parent_slug '" + parentSlug + "' not found");
454
+ pErr.code = "CATEGORY_PARENT_NOT_FOUND";
455
+ throw pErr;
456
+ }
457
+ if (parentRow.archived_at != null) {
458
+ var aErr = new Error("categoryNavigation.defineCategory: parent_slug '" + parentSlug + "' is archived");
459
+ aErr.code = "CATEGORY_PARENT_ARCHIVED";
460
+ throw aErr;
461
+ }
462
+ // Cycle check: would the proposed parent end up downstream
463
+ // of `slug`? Only relevant on an update (the row already
464
+ // exists). For a fresh insert no descendants exist yet, so
465
+ // the candidate parent's chain can be trusted.
466
+ var existing = await _readBySlug(slug);
467
+ if (existing) {
468
+ var wouldCycle = await _isAncestorOrSelf(slug, parentSlug);
469
+ if (wouldCycle) {
470
+ var cErr = new Error("categoryNavigation.defineCategory: setting parent_slug '" + parentSlug + "' would create a cycle through '" + slug + "'");
471
+ cErr.code = "CATEGORY_CYCLE";
472
+ throw cErr;
473
+ }
474
+ }
475
+ }
476
+
477
+ var ts = _now();
478
+ var existingRow = await _readBySlug(slug);
479
+ var position;
480
+ if (existingRow) {
481
+ position = explicitPosition == null ? Number(existingRow.position) : explicitPosition;
482
+ await query(
483
+ "UPDATE categories " +
484
+ "SET parent_slug = ?1, title = ?2, description = ?3, hero_image_url = ?4, " +
485
+ " position = ?5, active = ?6, archived_at = NULL, updated_at = ?7 " +
486
+ "WHERE slug = ?8",
487
+ [parentSlug, title, description, heroImage, position, active ? 1 : 0, ts, slug]
488
+ );
489
+ } else {
490
+ position = explicitPosition == null
491
+ ? await _nextPositionUnder(parentSlug)
492
+ : explicitPosition;
493
+ await query(
494
+ "INSERT INTO categories " +
495
+ "(slug, parent_slug, title, description, hero_image_url, position, active, " +
496
+ " archived_at, created_at, updated_at) " +
497
+ "VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, NULL, ?8, ?8)",
498
+ [slug, parentSlug, title, description, heroImage, position, active ? 1 : 0, ts]
499
+ );
500
+ }
501
+ var fresh = await _readBySlug(slug);
502
+ return _hydrateRow(fresh);
503
+ },
504
+
505
+ getCategory: async function (slug) {
506
+ slug = _slug(slug, "slug");
507
+ var row = await _readBySlug(slug);
508
+ if (!row || row.archived_at != null) return null;
509
+ return _hydrateRow(row);
510
+ },
511
+
512
+ // Direct children of a parent. `parent_slug` omitted / null
513
+ // returns the top-level (root) categories. Archived rows are
514
+ // excluded from every read.
515
+ categoriesByParent: async function (input) {
516
+ input = input || {};
517
+ var parentSlug = null;
518
+ if (input.parent_slug != null) {
519
+ parentSlug = _slug(input.parent_slug, "parent_slug");
520
+ var parentRow = await _readBySlug(parentSlug);
521
+ if (!parentRow || parentRow.archived_at != null) {
522
+ var err = new Error("categoryNavigation.categoriesByParent: parent_slug '" + parentSlug + "' not found");
523
+ err.code = "CATEGORY_NOT_FOUND";
524
+ throw err;
525
+ }
526
+ }
527
+ var rows = await _readChildren(parentSlug);
528
+ return rows.map(_hydrateRow);
529
+ },
530
+
531
+ // Nested tree. `root_slug` omitted / null roots at the top-level
532
+ // (returns every top-level category with its descendants nested).
533
+ // `depth` caps the recursion; default 16, max 16. Each returned
534
+ // node has a `children` array (empty when no children at that
535
+ // depth, or when `depth` cuts off further recursion).
536
+ tree: async function (input) {
537
+ input = input || {};
538
+ var depth = _depth(input.depth, "depth");
539
+ var rootSlug = null;
540
+ var rootRow = null;
541
+ if (input.root_slug != null) {
542
+ rootSlug = _slug(input.root_slug, "root_slug");
543
+ rootRow = await _readBySlug(rootSlug);
544
+ if (!rootRow || rootRow.archived_at != null) {
545
+ var err = new Error("categoryNavigation.tree: root_slug '" + rootSlug + "' not found");
546
+ err.code = "CATEGORY_NOT_FOUND";
547
+ throw err;
548
+ }
549
+ }
550
+
551
+ async function _build(parentSlug, remaining) {
552
+ if (remaining <= 0) return [];
553
+ var kids = await _readChildren(parentSlug);
554
+ var out = [];
555
+ for (var i = 0; i < kids.length; i += 1) {
556
+ var node = _hydrateRow(kids[i]);
557
+ node.children = await _build(kids[i].slug, remaining - 1);
558
+ out.push(node);
559
+ }
560
+ return out;
561
+ }
562
+
563
+ if (rootRow) {
564
+ var rootNode = _hydrateRow(rootRow);
565
+ rootNode.children = await _build(rootSlug, depth);
566
+ return rootNode;
567
+ }
568
+ return await _build(null, depth);
569
+ },
570
+
571
+ // Returns the ancestor chain from root -> slug (inclusive of
572
+ // `slug` itself), one hydrated row per level. Useful for
573
+ // breadcrumb rendering. The current page (slug) is the last
574
+ // entry; the storefront typically renders it as plain text
575
+ // rather than a link.
576
+ breadcrumbsFor: async function (input) {
577
+ if (!input || typeof input !== "object") {
578
+ throw new TypeError("categoryNavigation.breadcrumbsFor: input object required");
579
+ }
580
+ var slug = _slug(input.slug, "slug");
581
+ var row = await _readBySlug(slug);
582
+ if (!row || row.archived_at != null) {
583
+ var err = new Error("categoryNavigation.breadcrumbsFor: slug '" + slug + "' not found");
584
+ err.code = "CATEGORY_NOT_FOUND";
585
+ throw err;
586
+ }
587
+ var chain = await _walkAncestors(slug);
588
+ // _walkAncestors returns child -> root; flip for breadcrumb
589
+ // rendering (root -> child).
590
+ var ordered = chain.slice().reverse();
591
+ return ordered.map(_hydrateRow);
592
+ },
593
+
594
+ // Flat descendant list under a slug. `depth` caps the recursion
595
+ // (default 16). Returned rows carry a `depth` field (1 = direct
596
+ // child of `slug`, 2 = grandchild, etc.) — the caller can
597
+ // reconstruct a nested shape if needed, or just iterate the
598
+ // flat list (e.g. to count descendants).
599
+ descendantsOf: async function (input) {
600
+ if (!input || typeof input !== "object") {
601
+ throw new TypeError("categoryNavigation.descendantsOf: input object required");
602
+ }
603
+ var slug = _slug(input.slug, "slug");
604
+ var depth = _depth(input.depth, "depth");
605
+ var row = await _readBySlug(slug);
606
+ if (!row || row.archived_at != null) {
607
+ var err = new Error("categoryNavigation.descendantsOf: slug '" + slug + "' not found");
608
+ err.code = "CATEGORY_NOT_FOUND";
609
+ throw err;
610
+ }
611
+ var rows = await _readDescendants(slug, depth);
612
+ var out = [];
613
+ for (var i = 0; i < rows.length; i += 1) {
614
+ var hydrated = _hydrateRow(rows[i]);
615
+ hydrated.depth = rows[i]._depth;
616
+ out.push(hydrated);
617
+ }
618
+ return out;
619
+ },
620
+
621
+ // Re-parent + optionally re-position a category. The cycle
622
+ // guard is the load-bearing check: moving `outdoors` under its
623
+ // own descendant `tents` would create a cycle, and a JSON
624
+ // round-trip can't validate that without an ancestor walk.
625
+ // `new_parent_slug: null` promotes the category to top-level.
626
+ move: async function (input) {
627
+ if (!input || typeof input !== "object") {
628
+ throw new TypeError("categoryNavigation.move: input object required");
629
+ }
630
+ var slug = _slug(input.slug, "slug");
631
+ var newParentSlug = null;
632
+ if (input.new_parent_slug != null) {
633
+ newParentSlug = _slug(input.new_parent_slug, "new_parent_slug");
634
+ }
635
+ var newPosition = _position(input.new_position, "new_position");
636
+
637
+ var row = await _readBySlug(slug);
638
+ if (!row || row.archived_at != null) {
639
+ var err = new Error("categoryNavigation.move: slug '" + slug + "' not found");
640
+ err.code = "CATEGORY_NOT_FOUND";
641
+ throw err;
642
+ }
643
+
644
+ if (newParentSlug !== null) {
645
+ if (newParentSlug === slug) {
646
+ throw new TypeError("categoryNavigation.move: new_parent_slug must differ from slug");
647
+ }
648
+ var parentRow = await _readBySlug(newParentSlug);
649
+ if (!parentRow || parentRow.archived_at != null) {
650
+ var pErr = new Error("categoryNavigation.move: new_parent_slug '" + newParentSlug + "' not found");
651
+ pErr.code = "CATEGORY_PARENT_NOT_FOUND";
652
+ throw pErr;
653
+ }
654
+ // Cycle check — would the proposed parent be downstream of
655
+ // the moving slug?
656
+ var wouldCycle = await _isAncestorOrSelf(slug, newParentSlug);
657
+ if (wouldCycle) {
658
+ var cErr = new Error("categoryNavigation.move: moving '" + slug + "' under '" + newParentSlug + "' would create a cycle");
659
+ cErr.code = "CATEGORY_CYCLE";
660
+ throw cErr;
661
+ }
662
+ }
663
+
664
+ var ts = _now();
665
+ var position = newPosition == null
666
+ ? await _nextPositionUnder(newParentSlug)
667
+ : newPosition;
668
+ await query(
669
+ "UPDATE categories SET parent_slug = ?1, position = ?2, updated_at = ?3 WHERE slug = ?4",
670
+ [newParentSlug, position, ts, slug]
671
+ );
672
+ var fresh = await _readBySlug(slug);
673
+ return _hydrateRow(fresh);
674
+ },
675
+
676
+ // Soft-delete. `cascade: true` recursively archives every
677
+ // descendant; without it, the call refuses when descendants
678
+ // exist so an operator doesn't orphan a sub-tree. Archived
679
+ // rows are hidden from every read surface.
680
+ archive: async function (input) {
681
+ if (!input || typeof input !== "object") {
682
+ throw new TypeError("categoryNavigation.archive: input object required");
683
+ }
684
+ var slug = _slug(input.slug, "slug");
685
+ var cascade = input.cascade === true;
686
+ var row = await _readBySlug(slug);
687
+ if (!row) {
688
+ var err = new Error("categoryNavigation.archive: slug '" + slug + "' not found");
689
+ err.code = "CATEGORY_NOT_FOUND";
690
+ throw err;
691
+ }
692
+ if (row.archived_at != null) {
693
+ return _hydrateRow(row);
694
+ }
695
+
696
+ var descendants = await _readDescendants(slug, MAX_TREE_DEPTH);
697
+ if (descendants.length && !cascade) {
698
+ var dErr = new Error("categoryNavigation.archive: slug '" + slug + "' has " + descendants.length + " descendant(s); pass cascade=true to archive the sub-tree");
699
+ dErr.code = "CATEGORY_HAS_DESCENDANTS";
700
+ throw dErr;
701
+ }
702
+
703
+ var ts = _now();
704
+ await query(
705
+ "UPDATE categories SET archived_at = ?1, active = 0, updated_at = ?1 WHERE slug = ?2",
706
+ [ts, slug]
707
+ );
708
+ if (cascade) {
709
+ for (var i = 0; i < descendants.length; i += 1) {
710
+ await query(
711
+ "UPDATE categories SET archived_at = ?1, active = 0, updated_at = ?1 WHERE slug = ?2",
712
+ [ts, descendants[i].slug]
713
+ );
714
+ }
715
+ }
716
+ var fresh = await _readBySlug(slug);
717
+ return _hydrateRow(fresh);
718
+ },
719
+
720
+ // Patch an existing category. The patch envelope can contain any
721
+ // subset of { title, description, hero_image_url, active,
722
+ // position }. Parent re-parenting goes through `move` so the
723
+ // cycle guard fires; trying to patch `parent_slug` here is
724
+ // refused.
725
+ update: async function (input) {
726
+ if (!input || typeof input !== "object") {
727
+ throw new TypeError("categoryNavigation.update: input object required");
728
+ }
729
+ var slug = _slug(input.slug, "slug");
730
+ var patch = input.patch;
731
+ if (!patch || typeof patch !== "object") {
732
+ throw new TypeError("categoryNavigation.update: patch object required");
733
+ }
734
+ if (Object.prototype.hasOwnProperty.call(patch, "parent_slug")) {
735
+ throw new TypeError("categoryNavigation.update: re-parenting goes through move(), not update()");
736
+ }
737
+ if (Object.prototype.hasOwnProperty.call(patch, "slug")) {
738
+ throw new TypeError("categoryNavigation.update: slug is immutable");
739
+ }
740
+ var row = await _readBySlug(slug);
741
+ if (!row) {
742
+ var err = new Error("categoryNavigation.update: slug '" + slug + "' not found");
743
+ err.code = "CATEGORY_NOT_FOUND";
744
+ throw err;
745
+ }
746
+ if (row.archived_at != null) {
747
+ var aErr = new Error("categoryNavigation.update: slug '" + slug + "' is archived");
748
+ aErr.code = "CATEGORY_ARCHIVED";
749
+ throw aErr;
750
+ }
751
+
752
+ var title = Object.prototype.hasOwnProperty.call(patch, "title")
753
+ ? _title(patch.title)
754
+ : row.title;
755
+ var description = Object.prototype.hasOwnProperty.call(patch, "description")
756
+ ? _description(patch.description)
757
+ : row.description;
758
+ var heroImage = Object.prototype.hasOwnProperty.call(patch, "hero_image_url")
759
+ ? _heroImageUrl(patch.hero_image_url)
760
+ : row.hero_image_url;
761
+ var active = Object.prototype.hasOwnProperty.call(patch, "active")
762
+ ? _bool(patch.active, "active")
763
+ : (row.active === 1 || row.active === true);
764
+ var position = Object.prototype.hasOwnProperty.call(patch, "position")
765
+ ? _position(patch.position, "position")
766
+ : Number(row.position);
767
+ if (position == null) position = Number(row.position);
768
+
769
+ var ts = _now();
770
+ await query(
771
+ "UPDATE categories " +
772
+ "SET title = ?1, description = ?2, hero_image_url = ?3, active = ?4, " +
773
+ " position = ?5, updated_at = ?6 " +
774
+ "WHERE slug = ?7",
775
+ [title, description, heroImage, active ? 1 : 0, position, ts, slug]
776
+ );
777
+ var fresh = await _readBySlug(slug);
778
+ return _hydrateRow(fresh);
779
+ },
780
+
781
+ // Re-assign sibling positions in bulk. `ordered_slugs` must be a
782
+ // COMPLETE list of the parent's direct children — a missing slug
783
+ // is refused so it can't silently drift to the end of the order.
784
+ // Positions are re-stamped 0..N-1 in the supplied order.
785
+ reorderSiblings: async function (input) {
786
+ if (!input || typeof input !== "object") {
787
+ throw new TypeError("categoryNavigation.reorderSiblings: input object required");
788
+ }
789
+ var parentSlug = null;
790
+ if (input.parent_slug != null) {
791
+ parentSlug = _slug(input.parent_slug, "parent_slug");
792
+ var parentRow = await _readBySlug(parentSlug);
793
+ if (!parentRow || parentRow.archived_at != null) {
794
+ var pErr = new Error("categoryNavigation.reorderSiblings: parent_slug '" + parentSlug + "' not found");
795
+ pErr.code = "CATEGORY_NOT_FOUND";
796
+ throw pErr;
797
+ }
798
+ }
799
+ var orderedSlugs = input.ordered_slugs;
800
+ if (!Array.isArray(orderedSlugs)) {
801
+ throw new TypeError("categoryNavigation.reorderSiblings: ordered_slugs must be an array");
802
+ }
803
+ if (orderedSlugs.length > MAX_REORDER_BATCH) {
804
+ throw new TypeError("categoryNavigation.reorderSiblings: ordered_slugs must contain <= " + MAX_REORDER_BATCH + " entries");
805
+ }
806
+ var seen = {};
807
+ for (var i = 0; i < orderedSlugs.length; i += 1) {
808
+ var s = _slug(orderedSlugs[i], "ordered_slugs[" + i + "]");
809
+ if (seen[s]) {
810
+ throw new TypeError("categoryNavigation.reorderSiblings: ordered_slugs contains duplicate '" + s + "'");
811
+ }
812
+ seen[s] = 1;
813
+ }
814
+
815
+ var children = await _readChildren(parentSlug);
816
+ if (children.length !== orderedSlugs.length) {
817
+ throw new TypeError(
818
+ "categoryNavigation.reorderSiblings: ordered_slugs has " + orderedSlugs.length +
819
+ " entries but parent has " + children.length + " direct children — supply a complete list"
820
+ );
821
+ }
822
+ var childSet = {};
823
+ for (var c = 0; c < children.length; c += 1) childSet[children[c].slug] = 1;
824
+ for (var k = 0; k < orderedSlugs.length; k += 1) {
825
+ if (!childSet[orderedSlugs[k]]) {
826
+ throw new TypeError(
827
+ "categoryNavigation.reorderSiblings: '" + orderedSlugs[k] +
828
+ "' is not a direct child of parent_slug " + JSON.stringify(parentSlug)
829
+ );
830
+ }
831
+ }
832
+
833
+ var ts = _now();
834
+ for (var p = 0; p < orderedSlugs.length; p += 1) {
835
+ await query(
836
+ "UPDATE categories SET position = ?1, updated_at = ?2 WHERE slug = ?3",
837
+ [p, ts, orderedSlugs[p]]
838
+ );
839
+ }
840
+ var fresh = await _readChildren(parentSlug);
841
+ return fresh.map(_hydrateRow);
842
+ },
843
+
844
+ // Product-count adjacency. When `opts.catalog` exposes a
845
+ // `products.countByCategory(slug)` method the primitive delegates
846
+ // to it; without the handle the count is 0. `include_descendants`
847
+ // sums every descendant's count under the slug (one DB walk +
848
+ // one count call per category).
849
+ productCount: async function (input) {
850
+ if (!input || typeof input !== "object") {
851
+ throw new TypeError("categoryNavigation.productCount: input object required");
852
+ }
853
+ var slug = _slug(input.slug, "slug");
854
+ var includeDescendants = input.include_descendants === true;
855
+ var row = await _readBySlug(slug);
856
+ if (!row || row.archived_at != null) {
857
+ var err = new Error("categoryNavigation.productCount: slug '" + slug + "' not found");
858
+ err.code = "CATEGORY_NOT_FOUND";
859
+ throw err;
860
+ }
861
+
862
+ function _countFor(s) {
863
+ if (!catalog || !catalog.products || typeof catalog.products.countByCategory !== "function") {
864
+ return Promise.resolve(0);
865
+ }
866
+ return Promise.resolve(catalog.products.countByCategory(s));
867
+ }
868
+
869
+ var ownCount = Number(await _countFor(slug)) || 0;
870
+ if (!includeDescendants) {
871
+ return { slug: slug, count: ownCount, includes_descendants: false };
872
+ }
873
+ var descendants = await _readDescendants(slug, MAX_TREE_DEPTH);
874
+ var total = ownCount;
875
+ for (var i = 0; i < descendants.length; i += 1) {
876
+ total += Number(await _countFor(descendants[i].slug)) || 0;
877
+ }
878
+ return { slug: slug, count: total, includes_descendants: true };
879
+ },
880
+
881
+ // Top-N categories by combined product count over the active +
882
+ // non-archived set. The "popular" signal is the product-count
883
+ // delegation above (no separate view log — categories are
884
+ // operator-curated, not customer-visited like KB articles), so
885
+ // the window is informational: the returned snapshot reflects
886
+ // the catalog state at call time, restricted to categories
887
+ // created within [from, to]. Operators who want a true traffic-
888
+ // weighted popularity ranking compose with the clickstream
889
+ // primitive.
890
+ popularCategories: async function (input) {
891
+ if (!input || typeof input !== "object") {
892
+ throw new TypeError("categoryNavigation.popularCategories: input object required");
893
+ }
894
+ _timestampRange(input.from, input.to, "popularCategories");
895
+ var limit = _limit(input.limit, MAX_POPULAR_LIMIT, DEFAULT_POPULAR_LIMIT, "limit");
896
+
897
+ var r = await query(
898
+ "SELECT slug FROM categories " +
899
+ "WHERE archived_at IS NULL AND active = 1 " +
900
+ " AND created_at >= ?1 AND created_at <= ?2",
901
+ [input.from, input.to]
902
+ );
903
+ var scored = [];
904
+ for (var i = 0; i < r.rows.length; i += 1) {
905
+ var s = r.rows[i].slug;
906
+ var count = 0;
907
+ if (catalog && catalog.products && typeof catalog.products.countByCategory === "function") {
908
+ count = Number(await catalog.products.countByCategory(s)) || 0;
909
+ }
910
+ scored.push({ slug: s, count: count });
911
+ }
912
+ scored.sort(function (a, b) {
913
+ if (b.count !== a.count) return b.count - a.count;
914
+ if (a.slug < b.slug) return -1;
915
+ if (a.slug > b.slug) return 1;
916
+ return 0;
917
+ });
918
+ return scored.slice(0, limit);
919
+ },
920
+ };
921
+ }
922
+
923
+ module.exports = {
924
+ create: create,
925
+ MAX_SLUG_LEN: MAX_SLUG_LEN,
926
+ MAX_TITLE_LEN: MAX_TITLE_LEN,
927
+ MAX_DESCRIPTION_LEN: MAX_DESCRIPTION_LEN,
928
+ MAX_HERO_URL_LEN: MAX_HERO_URL_LEN,
929
+ MAX_POSITION: MAX_POSITION,
930
+ MAX_TREE_DEPTH: MAX_TREE_DEPTH,
931
+ MAX_LIST_LIMIT: MAX_LIST_LIMIT,
932
+ MAX_POPULAR_LIMIT: MAX_POPULAR_LIMIT,
933
+ MAX_REORDER_BATCH: MAX_REORDER_BATCH,
934
+ };