@blamejs/blamejs-shop 0.0.57 → 0.0.59

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,916 @@
1
+ "use strict";
2
+ /**
3
+ * @module shop.collections
4
+ * @title Collections primitive — operator-curated and smart product lists
5
+ *
6
+ * @intro
7
+ * A collection groups products under a slug for category pages,
8
+ * homepage callouts, and navigation menus. Two flavours, one
9
+ * surface:
10
+ *
11
+ * manual — operator handpicks members. The primitive owns an
12
+ * ordered join table (collection_members) and exposes
13
+ * addProduct / removeProduct / reorderProducts.
14
+ *
15
+ * smart — operator authors a rule set against catalog product
16
+ * fields. Membership is computed at read time by
17
+ * evaluating the rules against the catalog's products;
18
+ * no materialised join. Useful for "every sale-tagged
19
+ * product under $50" without operator bookkeeping.
20
+ *
21
+ * Rule shape:
22
+ *
23
+ * { all: [<rule>, ...]?, any: [<rule>, ...]? }
24
+ *
25
+ * A rule is `{ field, op, value }`. Field is one of
26
+ * `tags / price_minor / inventory_count / created_at / category /
27
+ * vendor`. Op is one of `eq / neq / contains / gt / gte / lt / lte
28
+ * / in / not_in / between`. The semantics:
29
+ *
30
+ * - `eq` / `neq` — strict equality / inequality.
31
+ * - `contains` — array-field membership (e.g. `tags`
32
+ * contains "sale"). Refuses on scalar fields.
33
+ * - `gt / gte / lt / lte` — numeric comparison.
34
+ * - `in / not_in` — value within / outside an array.
35
+ * - `between` — `value` is `[lo, hi]` inclusive,
36
+ * numeric only.
37
+ *
38
+ * A product matches the rule set when ALL rules in `all` are true
39
+ * AND ANY rule in `any` is true. Either group may be omitted (an
40
+ * empty group is treated as vacuously true), but at least one
41
+ * group must be non-empty so a smart collection never matches the
42
+ * entire catalog by accident.
43
+ *
44
+ * Composes:
45
+ * - `b.guardUuid` — every product_id is UUID-shape-validated.
46
+ * - `b.uuid.v7` — collection_members.id (lexicographic +
47
+ * monotonic so a pagination tiebreak is stable).
48
+ * - `b.pagination` — HMAC-tagged cursors on (position, id) for
49
+ * manual; on (id) for smart (smart sort strategies dictate the
50
+ * order key).
51
+ * - `b.safeSql` — column allow-list on `update(slug, patch)`.
52
+ *
53
+ * Surface:
54
+ * - `defineManual({ slug, title, description?, hero_image_url?,
55
+ * sort_order? })`
56
+ * - `defineSmart({ slug, title, description?, rules,
57
+ * sort_strategy })`
58
+ * - `get(slug)`
59
+ * - `list({ active_only? })`
60
+ * - `update(slug, patch)` — title / description / hero_image_url
61
+ * / sort_strategy / rules (smart only).
62
+ * - `archive(slug)` — soft delete via archived_at column.
63
+ * - `addProduct({ collection_slug, product_id, position? })`
64
+ * - `removeProduct({ collection_slug, product_id })`
65
+ * - `reorderProducts({ collection_slug, ordered_product_ids })`
66
+ * - `productsIn({ slug, limit?, cursor? })` — manual returns the
67
+ * curated rows; smart iterates the catalog and applies the
68
+ * rules.
69
+ * - `collectionsForProduct(product_id)` — reverse lookup,
70
+ * combines manual membership + smart rule evaluation.
71
+ * - `evaluateRules({ rules, product })` — pure helper, exported
72
+ * for tests.
73
+ *
74
+ * Storage: `migrations-d1/0043_collections.sql` — two tables,
75
+ * `collections` + `collection_members`. ON DELETE CASCADE drops
76
+ * member rows when a collection is hard-deleted (the primitive
77
+ * only soft-deletes via `archive`; hard delete is an operator-side
78
+ * migration concern).
79
+ *
80
+ * @primitive collections
81
+ * @related b.guardUuid, b.pagination, b.uuid, b.safeSql
82
+ */
83
+
84
+ var bShop;
85
+ function _b() {
86
+ // Lazy so unit tests can require this module without first
87
+ // initialising the vendored blamejs tree — they pass their own
88
+ // `query` + `catalog` handles and never touch the runtime
89
+ // singleton.
90
+ if (!bShop) bShop = require("./index");
91
+ return bShop.framework;
92
+ }
93
+
94
+ var SLUG_RE = /^[a-z0-9](?:[a-z0-9-]{0,198}[a-z0-9])?$/;
95
+ var MAX_TITLE_LEN = 500;
96
+ var MAX_DESC_LEN = 100000;
97
+ var MAX_HERO_URL_LEN = 2048;
98
+ var MAX_LIMIT = 200;
99
+ var DEFAULT_LIMIT = 50;
100
+
101
+ var COLLECTION_TYPES = Object.freeze(["manual", "smart"]);
102
+ var SORT_STRATEGIES = Object.freeze([
103
+ "manual", "best_selling", "newest", "price_asc", "price_desc", "alphabetical",
104
+ ]);
105
+ var RULE_FIELDS = Object.freeze([
106
+ "tags", "price_minor", "inventory_count", "created_at", "category", "vendor",
107
+ ]);
108
+ var RULE_OPS = Object.freeze([
109
+ "eq", "neq", "contains", "gt", "gte", "lt", "lte", "in", "not_in", "between",
110
+ ]);
111
+
112
+ // Fields with array semantics — only these accept `contains`. The
113
+ // other fields are scalars and `contains` against them throws.
114
+ var ARRAY_FIELDS = Object.freeze(["tags"]);
115
+
116
+ // Fields with numeric semantics — only these accept gt/gte/lt/lte
117
+ // and `between`. `eq / neq / in / not_in` still work on numerics too.
118
+ var NUMERIC_FIELDS = Object.freeze(["price_minor", "inventory_count", "created_at"]);
119
+
120
+ // Mutable columns for `update(slug, patch)`. Slug + type +
121
+ // created_at are immutable — operators archive + redefine to
122
+ // re-key. `rules_json` is patched via a dedicated path so the
123
+ // type=smart guard runs before the SQL hits.
124
+ var ALLOWED_COLUMNS = Object.freeze([
125
+ "title", "description", "hero_image_url", "sort_strategy",
126
+ ]);
127
+
128
+ var MEMBER_ORDER_KEY = ["position:asc", "id:asc"];
129
+ var SMART_ORDER_KEY = ["offset:asc"];
130
+
131
+ // ---- validators ----------------------------------------------------------
132
+
133
+ function _slug(s) {
134
+ if (typeof s !== "string" || !SLUG_RE.test(s)) {
135
+ throw new TypeError("collections: slug must be lowercase alnum + dash, no leading/trailing dash, 1..200 chars");
136
+ }
137
+ }
138
+
139
+ function _title(s) {
140
+ if (typeof s !== "string" || !s.length || s.length > MAX_TITLE_LEN) {
141
+ throw new TypeError("collections: title must be a non-empty string <= " + MAX_TITLE_LEN + " chars");
142
+ }
143
+ }
144
+
145
+ function _description(s) {
146
+ if (s == null) return "";
147
+ if (typeof s !== "string") {
148
+ throw new TypeError("collections: description must be a string or null");
149
+ }
150
+ if (s.length > MAX_DESC_LEN) {
151
+ throw new TypeError("collections: description must be <= " + MAX_DESC_LEN + " chars");
152
+ }
153
+ return s;
154
+ }
155
+
156
+ function _heroUrl(s) {
157
+ if (s == null || s === "") return null;
158
+ if (typeof s !== "string") {
159
+ throw new TypeError("collections: hero_image_url must be a string or null");
160
+ }
161
+ if (s.length > MAX_HERO_URL_LEN) {
162
+ throw new TypeError("collections: hero_image_url must be <= " + MAX_HERO_URL_LEN + " chars");
163
+ }
164
+ // Defense in depth: control bytes (CR/LF/NUL) refused so a
165
+ // malicious url can't smuggle header-injection content into a
166
+ // storefront <meta> tag that's rendered from this column.
167
+ if (/[\x00-\x1f\x7f]/.test(s)) {
168
+ throw new TypeError("collections: hero_image_url must not contain control bytes");
169
+ }
170
+ return s;
171
+ }
172
+
173
+ function _sortStrategy(s, type) {
174
+ if (typeof s !== "string" || SORT_STRATEGIES.indexOf(s) < 0) {
175
+ throw new TypeError("collections: sort_strategy must be one of " + SORT_STRATEGIES.join(", "));
176
+ }
177
+ // `manual` only makes sense for a manual collection (the operator
178
+ // controls the order via reorderProducts). Refuse on smart — a
179
+ // smart collection has no stable member order to manualise.
180
+ if (s === "manual" && type === "smart") {
181
+ throw new TypeError("collections: sort_strategy 'manual' is only valid for manual collections");
182
+ }
183
+ return s;
184
+ }
185
+
186
+ function _productId(s, label) {
187
+ try {
188
+ return _b().guardUuid.sanitize(s, { profile: "strict" });
189
+ } catch (e) {
190
+ throw new TypeError("collections: " + label + " — " + (e && e.message || "invalid UUID"));
191
+ }
192
+ }
193
+
194
+ function _validateRule(rule, idx, group) {
195
+ if (!rule || typeof rule !== "object") {
196
+ throw new TypeError("collections: rules." + group + "[" + idx + "] must be an object");
197
+ }
198
+ if (typeof rule.field !== "string" || RULE_FIELDS.indexOf(rule.field) < 0) {
199
+ throw new TypeError("collections: rules." + group + "[" + idx + "].field must be one of " + RULE_FIELDS.join(", "));
200
+ }
201
+ if (typeof rule.op !== "string" || RULE_OPS.indexOf(rule.op) < 0) {
202
+ throw new TypeError("collections: rules." + group + "[" + idx + "].op must be one of " + RULE_OPS.join(", "));
203
+ }
204
+ // contains -> array field only.
205
+ if (rule.op === "contains" && ARRAY_FIELDS.indexOf(rule.field) < 0) {
206
+ throw new TypeError("collections: rules." + group + "[" + idx + "].op 'contains' requires an array field (" +
207
+ ARRAY_FIELDS.join(", ") + ")");
208
+ }
209
+ // gt/gte/lt/lte/between -> numeric field only.
210
+ var numericOp = rule.op === "gt" || rule.op === "gte" || rule.op === "lt" || rule.op === "lte" || rule.op === "between";
211
+ if (numericOp && NUMERIC_FIELDS.indexOf(rule.field) < 0) {
212
+ throw new TypeError("collections: rules." + group + "[" + idx + "].op '" + rule.op +
213
+ "' requires a numeric field (" + NUMERIC_FIELDS.join(", ") + ")");
214
+ }
215
+ if (rule.op === "between") {
216
+ if (!Array.isArray(rule.value) || rule.value.length !== 2 ||
217
+ typeof rule.value[0] !== "number" || typeof rule.value[1] !== "number") {
218
+ throw new TypeError("collections: rules." + group + "[" + idx + "].value for 'between' must be [lo, hi] numbers");
219
+ }
220
+ } else if (rule.op === "in" || rule.op === "not_in") {
221
+ if (!Array.isArray(rule.value)) {
222
+ throw new TypeError("collections: rules." + group + "[" + idx + "].value for '" + rule.op + "' must be an array");
223
+ }
224
+ } else if (rule.value === undefined) {
225
+ throw new TypeError("collections: rules." + group + "[" + idx + "].value is required");
226
+ }
227
+ }
228
+
229
+ function _validateRules(rules) {
230
+ if (!rules || typeof rules !== "object") {
231
+ throw new TypeError("collections: rules must be an object { all?: [...], any?: [...] }");
232
+ }
233
+ var all = rules.all == null ? [] : rules.all;
234
+ var any = rules.any == null ? [] : rules.any;
235
+ if (!Array.isArray(all)) {
236
+ throw new TypeError("collections: rules.all must be an array");
237
+ }
238
+ if (!Array.isArray(any)) {
239
+ throw new TypeError("collections: rules.any must be an array");
240
+ }
241
+ if (all.length === 0 && any.length === 0) {
242
+ throw new TypeError("collections: rules must specify at least one rule in `all` or `any`");
243
+ }
244
+ for (var i = 0; i < all.length; i += 1) _validateRule(all[i], i, "all");
245
+ for (var j = 0; j < any.length; j += 1) _validateRule(any[j], j, "any");
246
+ return { all: all, any: any };
247
+ }
248
+
249
+ function _now() { return Date.now(); }
250
+
251
+ // ---- pure rule evaluator -------------------------------------------------
252
+
253
+ // Returns true if `product` satisfies the rule. Read-only against
254
+ // the product object; no I/O. The factory's `evaluateRules` wraps
255
+ // this with rule-set validation so tests can exercise every op
256
+ // in isolation against a synthetic product.
257
+ function _matchRule(rule, product) {
258
+ var fieldVal = product == null ? undefined : product[rule.field];
259
+ switch (rule.op) {
260
+ case "eq": return fieldVal === rule.value;
261
+ case "neq": return fieldVal !== rule.value;
262
+ case "contains": {
263
+ // Validator guaranteed an array field; if the catalog row
264
+ // hasn't populated it, treat as empty (not a match).
265
+ if (!Array.isArray(fieldVal)) return false;
266
+ return fieldVal.indexOf(rule.value) >= 0;
267
+ }
268
+ case "gt": return typeof fieldVal === "number" && fieldVal > rule.value;
269
+ case "gte": return typeof fieldVal === "number" && fieldVal >= rule.value;
270
+ case "lt": return typeof fieldVal === "number" && fieldVal < rule.value;
271
+ case "lte": return typeof fieldVal === "number" && fieldVal <= rule.value;
272
+ case "in": return rule.value.indexOf(fieldVal) >= 0;
273
+ case "not_in": return rule.value.indexOf(fieldVal) < 0;
274
+ case "between": {
275
+ if (typeof fieldVal !== "number") return false;
276
+ return fieldVal >= rule.value[0] && fieldVal <= rule.value[1];
277
+ }
278
+ default:
279
+ // Defensive — the validator should have refused already; if a
280
+ // future op is added in one place but not the other, fail
281
+ // closed rather than silently mismatching.
282
+ throw new TypeError("collections: unknown rule op " + JSON.stringify(rule.op));
283
+ }
284
+ }
285
+
286
+ // Internal short-circuit walker over an already-validated rule set.
287
+ function _matchRuleset(rules, product) {
288
+ for (var i = 0; i < rules.all.length; i += 1) {
289
+ if (!_matchRule(rules.all[i], product)) return false;
290
+ }
291
+ if (rules.any.length === 0) return true;
292
+ for (var j = 0; j < rules.any.length; j += 1) {
293
+ if (_matchRule(rules.any[j], product)) return true;
294
+ }
295
+ return false;
296
+ }
297
+
298
+ function _evaluateRules(input) {
299
+ if (!input || typeof input !== "object") {
300
+ throw new TypeError("collections.evaluateRules: input object required");
301
+ }
302
+ var rules = _validateRules(input.rules);
303
+ var product = input.product;
304
+ if (product == null || typeof product !== "object") {
305
+ throw new TypeError("collections.evaluateRules: product object required");
306
+ }
307
+ return _matchRuleset(rules, product);
308
+ }
309
+
310
+ // ---- sort comparators for smart collections ------------------------------
311
+
312
+ // Best-selling needs a sales-rank on the product object; the catalog
313
+ // is the source of truth. If the field's absent the comparator
314
+ // falls back to id order so the result is still deterministic.
315
+ function _smartCompare(strategy) {
316
+ switch (strategy) {
317
+ case "newest":
318
+ return function (a, b) {
319
+ var an = a.created_at || 0;
320
+ var bn = b.created_at || 0;
321
+ if (an !== bn) return bn - an;
322
+ return String(a.id || "").localeCompare(String(b.id || ""));
323
+ };
324
+ case "price_asc":
325
+ return function (a, b) {
326
+ var ap = a.price_minor == null ? Infinity : a.price_minor;
327
+ var bp = b.price_minor == null ? Infinity : b.price_minor;
328
+ if (ap !== bp) return ap - bp;
329
+ return String(a.id || "").localeCompare(String(b.id || ""));
330
+ };
331
+ case "price_desc":
332
+ return function (a, b) {
333
+ var ap = a.price_minor == null ? -Infinity : a.price_minor;
334
+ var bp = b.price_minor == null ? -Infinity : b.price_minor;
335
+ if (ap !== bp) return bp - ap;
336
+ return String(a.id || "").localeCompare(String(b.id || ""));
337
+ };
338
+ case "alphabetical":
339
+ return function (a, b) {
340
+ var at = String(a.title || "").toLowerCase();
341
+ var bt = String(b.title || "").toLowerCase();
342
+ if (at !== bt) return at < bt ? -1 : 1;
343
+ return String(a.id || "").localeCompare(String(b.id || ""));
344
+ };
345
+ case "best_selling":
346
+ return function (a, b) {
347
+ var as = a.sales_rank == null ? 0 : a.sales_rank;
348
+ var bs = b.sales_rank == null ? 0 : b.sales_rank;
349
+ if (as !== bs) return bs - as;
350
+ return String(a.id || "").localeCompare(String(b.id || ""));
351
+ };
352
+ default:
353
+ // Smart collections refuse `manual` upstream; any other
354
+ // strategy that lands here is a code-path bug, not operator
355
+ // input.
356
+ throw new TypeError("collections: smart sort_strategy '" + strategy + "' not supported");
357
+ }
358
+ }
359
+
360
+ // ---- factory -------------------------------------------------------------
361
+
362
+ function create(opts) {
363
+ opts = opts || {};
364
+ var query = opts.query;
365
+ if (!query) {
366
+ query = function (sql, params) { return _b().externalDb.query(sql, params); };
367
+ }
368
+ var catalog = opts.catalog;
369
+ if (!catalog || !catalog.products || typeof catalog.products.list !== "function") {
370
+ throw new TypeError("collections.create: opts.catalog must expose products.list({ limit, cursor? })");
371
+ }
372
+ if (typeof opts.cursorSecret !== "string" || !opts.cursorSecret.length) {
373
+ if (process.env.NODE_ENV === "production") {
374
+ throw new Error("collections.create: opts.cursorSecret is required in production");
375
+ }
376
+ opts.cursorSecret = "collections-cursor-secret-dev-only";
377
+ }
378
+ var cursorSecret = opts.cursorSecret;
379
+
380
+ // ---- internal helpers --------------------------------------------------
381
+
382
+ async function _row(slug) {
383
+ var r = await query("SELECT * FROM collections WHERE slug = ?1", [slug]);
384
+ return r.rows[0] || null;
385
+ }
386
+
387
+ function _decode(row) {
388
+ if (!row) return null;
389
+ var rules = null;
390
+ if (row.rules_json != null) {
391
+ try { rules = JSON.parse(row.rules_json); }
392
+ catch (_e) {
393
+ // Stored JSON should always parse — the primitive is the
394
+ // only writer. If it doesn't, that's a data-integrity bug
395
+ // the operator needs to see, not a silent null.
396
+ throw new Error("collections: stored rules_json for " + JSON.stringify(row.slug) + " is not valid JSON");
397
+ }
398
+ }
399
+ return {
400
+ slug: row.slug,
401
+ type: row.type,
402
+ title: row.title,
403
+ description: row.description,
404
+ hero_image_url: row.hero_image_url,
405
+ rules: rules,
406
+ sort_strategy: row.sort_strategy,
407
+ archived_at: row.archived_at,
408
+ created_at: row.created_at,
409
+ updated_at: row.updated_at,
410
+ };
411
+ }
412
+
413
+ // Walk catalog pages, yielding every non-archived product to the
414
+ // caller-supplied visitor. The catalog is expected to expose
415
+ // `products.list({ limit, cursor?, status? })`; the smart-eval
416
+ // path filters to `status = 'active'` so archived rows never leak
417
+ // into a smart collection.
418
+ async function _walkCatalogActive(visit) {
419
+ var cursor = null;
420
+ var pages = 0;
421
+ var pageLimit = 200;
422
+ // Safety cap: refuse to walk indefinitely. 200 pages * 200 rows
423
+ // = 40 000 products — the storefront's smart-collection use
424
+ // cases live well below that. A larger catalog needs the
425
+ // operator to pre-materialise membership (out of scope for v1).
426
+ var MAX_PAGES = 200;
427
+ while (pages < MAX_PAGES) {
428
+ var page = await catalog.products.list({ status: "active", limit: pageLimit, cursor: cursor });
429
+ var rows = (page && page.rows) || [];
430
+ for (var i = 0; i < rows.length; i += 1) await visit(rows[i]);
431
+ cursor = (page && page.next_cursor) || null;
432
+ pages += 1;
433
+ if (!cursor) return;
434
+ }
435
+ throw new Error("collections: catalog walk exceeded " + MAX_PAGES + " pages — pre-materialise smart membership");
436
+ }
437
+
438
+ // ---- defineManual ------------------------------------------------------
439
+
440
+ async function defineManual(input) {
441
+ if (!input || typeof input !== "object") {
442
+ throw new TypeError("collections.defineManual: input object required");
443
+ }
444
+ _slug(input.slug);
445
+ _title(input.title);
446
+ var description = _description(input.description);
447
+ var heroUrl = _heroUrl(input.hero_image_url);
448
+ var sortStrategy = input.sort_strategy == null ? "manual" : input.sort_strategy;
449
+ _sortStrategy(sortStrategy, "manual");
450
+
451
+ var existing = await _row(input.slug);
452
+ if (existing) {
453
+ throw new TypeError("collections.defineManual: slug " + JSON.stringify(input.slug) + " already exists");
454
+ }
455
+ var ts = _now();
456
+ await query(
457
+ "INSERT INTO collections (slug, type, title, description, hero_image_url, rules_json, sort_strategy, archived_at, created_at, updated_at) " +
458
+ "VALUES (?1, 'manual', ?2, ?3, ?4, NULL, ?5, NULL, ?6, ?6)",
459
+ [input.slug, input.title, description, heroUrl, sortStrategy, ts],
460
+ );
461
+ return await get(input.slug);
462
+ }
463
+
464
+ // ---- defineSmart -------------------------------------------------------
465
+
466
+ async function defineSmart(input) {
467
+ if (!input || typeof input !== "object") {
468
+ throw new TypeError("collections.defineSmart: input object required");
469
+ }
470
+ _slug(input.slug);
471
+ _title(input.title);
472
+ var description = _description(input.description);
473
+ var heroUrl = _heroUrl(input.hero_image_url);
474
+ var rules = _validateRules(input.rules);
475
+ if (typeof input.sort_strategy !== "string") {
476
+ throw new TypeError("collections.defineSmart: sort_strategy is required");
477
+ }
478
+ _sortStrategy(input.sort_strategy, "smart");
479
+
480
+ var existing = await _row(input.slug);
481
+ if (existing) {
482
+ throw new TypeError("collections.defineSmart: slug " + JSON.stringify(input.slug) + " already exists");
483
+ }
484
+ var ts = _now();
485
+ await query(
486
+ "INSERT INTO collections (slug, type, title, description, hero_image_url, rules_json, sort_strategy, archived_at, created_at, updated_at) " +
487
+ "VALUES (?1, 'smart', ?2, ?3, ?4, ?5, ?6, NULL, ?7, ?7)",
488
+ [input.slug, input.title, description, heroUrl, JSON.stringify(rules), input.sort_strategy, ts],
489
+ );
490
+ return await get(input.slug);
491
+ }
492
+
493
+ // ---- get / list --------------------------------------------------------
494
+
495
+ async function get(slug) {
496
+ _slug(slug);
497
+ return _decode(await _row(slug));
498
+ }
499
+
500
+ async function list(input) {
501
+ input = input || {};
502
+ var activeOnly = input.active_only === true;
503
+ var sql;
504
+ if (activeOnly) {
505
+ sql = "SELECT * FROM collections WHERE archived_at IS NULL ORDER BY updated_at DESC, slug DESC";
506
+ } else {
507
+ sql = "SELECT * FROM collections ORDER BY updated_at DESC, slug DESC";
508
+ }
509
+ var r = await query(sql, []);
510
+ var out = [];
511
+ for (var i = 0; i < r.rows.length; i += 1) out.push(_decode(r.rows[i]));
512
+ return out;
513
+ }
514
+
515
+ // ---- update ------------------------------------------------------------
516
+
517
+ async function update(slug, patch) {
518
+ _slug(slug);
519
+ if (!patch || typeof patch !== "object") {
520
+ throw new TypeError("collections.update: patch object required");
521
+ }
522
+ var existing = await _row(slug);
523
+ if (!existing) return null;
524
+
525
+ var sets = [];
526
+ var params = [];
527
+ var idx = 1;
528
+ function _addSet(col, val) {
529
+ _b().safeSql.assertOneOf(col, ALLOWED_COLUMNS);
530
+ sets.push(_b().safeSql.quoteIdentifier(col, "sqlite") + " = ?" + (idx++));
531
+ params.push(val);
532
+ }
533
+ if (patch.title !== undefined) {
534
+ _title(patch.title);
535
+ _addSet("title", patch.title);
536
+ }
537
+ if (patch.description !== undefined) {
538
+ _addSet("description", _description(patch.description));
539
+ }
540
+ if (patch.hero_image_url !== undefined) {
541
+ _addSet("hero_image_url", _heroUrl(patch.hero_image_url));
542
+ }
543
+ if (patch.sort_strategy !== undefined) {
544
+ _sortStrategy(patch.sort_strategy, existing.type);
545
+ _addSet("sort_strategy", patch.sort_strategy);
546
+ }
547
+ var rulesPatch = null;
548
+ if (patch.rules !== undefined) {
549
+ if (existing.type !== "smart") {
550
+ throw new TypeError("collections.update: rules patch is only valid for smart collections");
551
+ }
552
+ rulesPatch = _validateRules(patch.rules);
553
+ }
554
+ if (sets.length === 0 && rulesPatch === null) {
555
+ throw new TypeError("collections.update: patch contained no updatable fields");
556
+ }
557
+ var ts = _now();
558
+ if (sets.length > 0) {
559
+ sets.push("updated_at = ?" + (idx++));
560
+ params.push(ts);
561
+ params.push(slug);
562
+ await query(
563
+ "UPDATE collections SET " + sets.join(", ") + " WHERE slug = ?" + idx,
564
+ params,
565
+ );
566
+ }
567
+ if (rulesPatch !== null) {
568
+ await query(
569
+ "UPDATE collections SET rules_json = ?1, updated_at = ?2 WHERE slug = ?3",
570
+ [JSON.stringify(rulesPatch), ts, slug],
571
+ );
572
+ }
573
+ return await get(slug);
574
+ }
575
+
576
+ // ---- archive -----------------------------------------------------------
577
+
578
+ async function archive(slug) {
579
+ _slug(slug);
580
+ var ts = _now();
581
+ var r = await query(
582
+ "UPDATE collections SET archived_at = ?1, updated_at = ?1 WHERE slug = ?2 AND archived_at IS NULL",
583
+ [ts, slug],
584
+ );
585
+ if (r.rowCount === 0) {
586
+ // Either the row is missing or already archived. Disambiguate
587
+ // so the caller knows which.
588
+ var existing = await _row(slug);
589
+ if (!existing) return null;
590
+ return _decode(existing);
591
+ }
592
+ return await get(slug);
593
+ }
594
+
595
+ // ---- membership writes (manual only) ----------------------------------
596
+
597
+ function _assertManual(row, fn, slug) {
598
+ if (row.type !== "manual") {
599
+ throw new TypeError("collections." + fn + ": " + JSON.stringify(slug) +
600
+ " is a smart collection — membership is rule-evaluated, not curated");
601
+ }
602
+ }
603
+
604
+ async function addProduct(input) {
605
+ if (!input || typeof input !== "object") {
606
+ throw new TypeError("collections.addProduct: input object required");
607
+ }
608
+ _slug(input.collection_slug);
609
+ var productId = _productId(input.product_id, "product_id");
610
+ var row = await _row(input.collection_slug);
611
+ if (!row) throw new TypeError("collections.addProduct: collection " + JSON.stringify(input.collection_slug) + " not found");
612
+ _assertManual(row, "addProduct", input.collection_slug);
613
+ if (row.archived_at != null) {
614
+ throw new TypeError("collections.addProduct: collection " + JSON.stringify(input.collection_slug) + " is archived");
615
+ }
616
+ // Refuse duplicate add — the UNIQUE index would refuse it
617
+ // anyway, but a clean TypeError is easier on the calling
618
+ // handler than an opaque SQLITE_CONSTRAINT.
619
+ var dup = await query(
620
+ "SELECT id FROM collection_members WHERE collection_slug = ?1 AND product_id = ?2",
621
+ [input.collection_slug, productId],
622
+ );
623
+ if (dup.rows.length > 0) {
624
+ throw new TypeError("collections.addProduct: product " + JSON.stringify(productId) +
625
+ " is already a member of " + JSON.stringify(input.collection_slug));
626
+ }
627
+ var position;
628
+ if (input.position == null) {
629
+ // Append at the tail. Read max(position) and add 1 so the new
630
+ // row sorts last.
631
+ var maxRow = await query(
632
+ "SELECT MAX(position) AS max_pos FROM collection_members WHERE collection_slug = ?1",
633
+ [input.collection_slug],
634
+ );
635
+ var maxPos = maxRow.rows[0] && maxRow.rows[0].max_pos;
636
+ position = (maxPos == null ? -1 : maxPos) + 1;
637
+ } else {
638
+ if (!Number.isInteger(input.position) || input.position < 0) {
639
+ throw new TypeError("collections.addProduct: position must be a non-negative integer or null");
640
+ }
641
+ position = input.position;
642
+ }
643
+ var id = _b().uuid.v7();
644
+ var ts = _now();
645
+ await query(
646
+ "INSERT INTO collection_members (id, collection_slug, product_id, position, added_at) " +
647
+ "VALUES (?1, ?2, ?3, ?4, ?5)",
648
+ [id, input.collection_slug, productId, position, ts],
649
+ );
650
+ // Bump parent updated_at so cursor pagination on the
651
+ // collections list reflects the membership change.
652
+ await query("UPDATE collections SET updated_at = ?1 WHERE slug = ?2", [ts, input.collection_slug]);
653
+ return { id: id, collection_slug: input.collection_slug, product_id: productId, position: position, added_at: ts };
654
+ }
655
+
656
+ async function removeProduct(input) {
657
+ if (!input || typeof input !== "object") {
658
+ throw new TypeError("collections.removeProduct: input object required");
659
+ }
660
+ _slug(input.collection_slug);
661
+ var productId = _productId(input.product_id, "product_id");
662
+ var row = await _row(input.collection_slug);
663
+ if (!row) throw new TypeError("collections.removeProduct: collection " + JSON.stringify(input.collection_slug) + " not found");
664
+ _assertManual(row, "removeProduct", input.collection_slug);
665
+ var r = await query(
666
+ "DELETE FROM collection_members WHERE collection_slug = ?1 AND product_id = ?2",
667
+ [input.collection_slug, productId],
668
+ );
669
+ if (r.rowCount > 0) {
670
+ await query("UPDATE collections SET updated_at = ?1 WHERE slug = ?2", [_now(), input.collection_slug]);
671
+ }
672
+ return r.rowCount > 0;
673
+ }
674
+
675
+ async function reorderProducts(input) {
676
+ if (!input || typeof input !== "object") {
677
+ throw new TypeError("collections.reorderProducts: input object required");
678
+ }
679
+ _slug(input.collection_slug);
680
+ if (!Array.isArray(input.ordered_product_ids)) {
681
+ throw new TypeError("collections.reorderProducts: ordered_product_ids must be an array");
682
+ }
683
+ var row = await _row(input.collection_slug);
684
+ if (!row) throw new TypeError("collections.reorderProducts: collection " + JSON.stringify(input.collection_slug) + " not found");
685
+ _assertManual(row, "reorderProducts", input.collection_slug);
686
+
687
+ // Validate every id shape + dedupe before touching the DB.
688
+ var seen = Object.create(null);
689
+ var ids = [];
690
+ for (var i = 0; i < input.ordered_product_ids.length; i += 1) {
691
+ var pid = _productId(input.ordered_product_ids[i], "ordered_product_ids[" + i + "]");
692
+ if (seen[pid]) {
693
+ throw new TypeError("collections.reorderProducts: duplicate product_id " + JSON.stringify(pid));
694
+ }
695
+ seen[pid] = true;
696
+ ids.push(pid);
697
+ }
698
+
699
+ // Confirm the supplied ids exactly match the current membership
700
+ // set — partial reorders would leave positions ambiguous and
701
+ // are easier to refuse cleanly than to coerce.
702
+ var memberRows = await query(
703
+ "SELECT product_id FROM collection_members WHERE collection_slug = ?1",
704
+ [input.collection_slug],
705
+ );
706
+ var have = Object.create(null);
707
+ for (var m = 0; m < memberRows.rows.length; m += 1) have[memberRows.rows[m].product_id] = true;
708
+ if (memberRows.rows.length !== ids.length) {
709
+ throw new TypeError("collections.reorderProducts: ordered_product_ids must list every current member (" +
710
+ memberRows.rows.length + " expected, " + ids.length + " supplied)");
711
+ }
712
+ for (var k = 0; k < ids.length; k += 1) {
713
+ if (!have[ids[k]]) {
714
+ throw new TypeError("collections.reorderProducts: product_id " + JSON.stringify(ids[k]) +
715
+ " is not a member of " + JSON.stringify(input.collection_slug));
716
+ }
717
+ }
718
+
719
+ // Densely rewrite positions to 0..N-1. The position column is
720
+ // operator-controlled but the primitive normalises it so cursor
721
+ // pagination on (position, id) stays stable.
722
+ for (var p = 0; p < ids.length; p += 1) {
723
+ await query(
724
+ "UPDATE collection_members SET position = ?1 WHERE collection_slug = ?2 AND product_id = ?3",
725
+ [p, input.collection_slug, ids[p]],
726
+ );
727
+ }
728
+ await query("UPDATE collections SET updated_at = ?1 WHERE slug = ?2", [_now(), input.collection_slug]);
729
+ return true;
730
+ }
731
+
732
+ // ---- productsIn --------------------------------------------------------
733
+
734
+ async function productsIn(input) {
735
+ if (!input || typeof input !== "object") {
736
+ throw new TypeError("collections.productsIn: input object required");
737
+ }
738
+ _slug(input.slug);
739
+ var limit = input.limit == null ? DEFAULT_LIMIT : input.limit;
740
+ if (!Number.isInteger(limit) || limit <= 0 || limit > MAX_LIMIT) {
741
+ throw new TypeError("collections.productsIn: limit must be 1..." + MAX_LIMIT);
742
+ }
743
+ var row = await _row(input.slug);
744
+ if (!row) throw new TypeError("collections.productsIn: collection " + JSON.stringify(input.slug) + " not found");
745
+
746
+ if (row.type === "manual") {
747
+ var cursorVals = null;
748
+ if (input.cursor != null) {
749
+ if (typeof input.cursor !== "string") {
750
+ throw new TypeError("collections.productsIn: cursor must be an opaque string or null");
751
+ }
752
+ try {
753
+ var state = _b().pagination.decodeCursor(input.cursor, cursorSecret);
754
+ if (JSON.stringify(state.orderKey) !== JSON.stringify(MEMBER_ORDER_KEY)) {
755
+ throw new TypeError("collections.productsIn: cursor orderKey mismatch");
756
+ }
757
+ cursorVals = state.vals;
758
+ } catch (e) {
759
+ if (e instanceof TypeError) throw e;
760
+ throw new TypeError("collections.productsIn: cursor — " + (e && e.message || "malformed"));
761
+ }
762
+ }
763
+ var sql, params;
764
+ if (cursorVals) {
765
+ sql = "SELECT * FROM collection_members WHERE collection_slug = ?1 AND " +
766
+ "(position > ?2 OR (position = ?2 AND id > ?3)) " +
767
+ "ORDER BY position ASC, id ASC LIMIT ?4";
768
+ params = [input.slug, cursorVals[0], cursorVals[1], limit];
769
+ } else {
770
+ sql = "SELECT * FROM collection_members WHERE collection_slug = ?1 " +
771
+ "ORDER BY position ASC, id ASC LIMIT ?2";
772
+ params = [input.slug, limit];
773
+ }
774
+ var r = await query(sql, params);
775
+ var lastM = r.rows[r.rows.length - 1];
776
+ var nextM = null;
777
+ if (lastM && r.rows.length === limit) {
778
+ nextM = _b().pagination.encodeCursor({
779
+ orderKey: MEMBER_ORDER_KEY,
780
+ vals: [lastM.position, lastM.id],
781
+ forward: true,
782
+ }, cursorSecret);
783
+ }
784
+ return {
785
+ type: "manual",
786
+ sort_strategy: row.sort_strategy,
787
+ rows: r.rows.map(function (mr) {
788
+ return {
789
+ id: mr.id,
790
+ collection_slug: mr.collection_slug,
791
+ product_id: mr.product_id,
792
+ position: mr.position,
793
+ added_at: mr.added_at,
794
+ };
795
+ }),
796
+ next_cursor: nextM,
797
+ };
798
+ }
799
+
800
+ // Smart path: walk the catalog and evaluate rules. The cursor
801
+ // is a hash-tagged offset into the in-memory matched list; for
802
+ // a smart collection there's no SQL keyset because the order is
803
+ // dictated by `sort_strategy` and the rule evaluator runs
804
+ // application-side.
805
+ var rules = JSON.parse(row.rules_json);
806
+ var startIdx = 0;
807
+ if (input.cursor != null) {
808
+ if (typeof input.cursor !== "string") {
809
+ throw new TypeError("collections.productsIn: cursor must be an opaque string or null");
810
+ }
811
+ try {
812
+ var sstate = _b().pagination.decodeCursor(input.cursor, cursorSecret);
813
+ if (JSON.stringify(sstate.orderKey) !== JSON.stringify(SMART_ORDER_KEY)) {
814
+ throw new TypeError("collections.productsIn: cursor orderKey mismatch");
815
+ }
816
+ startIdx = sstate.vals[0];
817
+ if (!Number.isInteger(startIdx) || startIdx < 0) {
818
+ throw new TypeError("collections.productsIn: cursor offset must be a non-negative integer");
819
+ }
820
+ } catch (e2) {
821
+ if (e2 instanceof TypeError) throw e2;
822
+ throw new TypeError("collections.productsIn: cursor — " + (e2 && e2.message || "malformed"));
823
+ }
824
+ }
825
+
826
+ var matched = [];
827
+ await _walkCatalogActive(function (product) {
828
+ if (_matchRuleset(rules, product)) matched.push(product);
829
+ });
830
+ var sortStrategy = row.sort_strategy === "manual" ? "newest" : row.sort_strategy;
831
+ matched.sort(_smartCompare(sortStrategy));
832
+ var slice = matched.slice(startIdx, startIdx + limit);
833
+ var nextS = null;
834
+ if (startIdx + slice.length < matched.length) {
835
+ nextS = _b().pagination.encodeCursor({
836
+ orderKey: SMART_ORDER_KEY,
837
+ vals: [startIdx + slice.length],
838
+ forward: true,
839
+ }, cursorSecret);
840
+ }
841
+ return {
842
+ type: "smart",
843
+ sort_strategy: row.sort_strategy,
844
+ rows: slice,
845
+ next_cursor: nextS,
846
+ };
847
+ }
848
+
849
+ // ---- collectionsForProduct --------------------------------------------
850
+
851
+ async function collectionsForProduct(productId) {
852
+ var pid = _productId(productId, "product_id");
853
+ // Manual membership: a single indexed lookup.
854
+ var manualRows = await query(
855
+ "SELECT c.* FROM collections c " +
856
+ "JOIN collection_members m ON m.collection_slug = c.slug " +
857
+ "WHERE m.product_id = ?1 AND c.archived_at IS NULL " +
858
+ "ORDER BY c.updated_at DESC, c.slug DESC",
859
+ [pid],
860
+ );
861
+ var out = [];
862
+ var seen = Object.create(null);
863
+ for (var i = 0; i < manualRows.rows.length; i += 1) {
864
+ var dec = _decode(manualRows.rows[i]);
865
+ out.push(dec);
866
+ seen[dec.slug] = true;
867
+ }
868
+ // Smart membership: every active smart collection's rules
869
+ // evaluated against the supplied product. To preserve the pure
870
+ // separation between this primitive and the catalog, we need
871
+ // the product object itself — fetch via catalog.products.get
872
+ // if available, otherwise skip smart matches (a catalog without
873
+ // `get` can still drive manual collections).
874
+ if (typeof catalog.products.get === "function") {
875
+ var product = await catalog.products.get(pid);
876
+ if (product) {
877
+ var smartRows = await query(
878
+ "SELECT * FROM collections WHERE type = 'smart' AND archived_at IS NULL",
879
+ [],
880
+ );
881
+ for (var s = 0; s < smartRows.rows.length; s += 1) {
882
+ if (seen[smartRows.rows[s].slug]) continue;
883
+ var rules;
884
+ try { rules = JSON.parse(smartRows.rows[s].rules_json); }
885
+ catch (_e) { continue; }
886
+ if (_matchRuleset(rules, product)) out.push(_decode(smartRows.rows[s]));
887
+ }
888
+ }
889
+ }
890
+ return out;
891
+ }
892
+
893
+ return {
894
+ defineManual: defineManual,
895
+ defineSmart: defineSmart,
896
+ get: get,
897
+ list: list,
898
+ update: update,
899
+ archive: archive,
900
+ addProduct: addProduct,
901
+ removeProduct: removeProduct,
902
+ reorderProducts: reorderProducts,
903
+ productsIn: productsIn,
904
+ collectionsForProduct: collectionsForProduct,
905
+ evaluateRules: _evaluateRules,
906
+ };
907
+ }
908
+
909
+ module.exports = {
910
+ create: create,
911
+ COLLECTION_TYPES: COLLECTION_TYPES,
912
+ SORT_STRATEGIES: SORT_STRATEGIES,
913
+ RULE_FIELDS: RULE_FIELDS,
914
+ RULE_OPS: RULE_OPS,
915
+ ALLOWED_COLUMNS: ALLOWED_COLUMNS,
916
+ };