@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.
- package/CHANGELOG.md +4 -0
- package/lib/affiliates.js +1025 -0
- package/lib/collections.js +916 -0
- package/lib/customer-segments.js +817 -0
- package/lib/gift-options.js +596 -0
- package/lib/index.js +16 -0
- package/lib/mailing-audiences.js +855 -0
- package/lib/order-timeline.js +1073 -0
- package/lib/promo-banners.js +726 -0
- package/lib/quantity-discounts.js +781 -0
- package/lib/recently-viewed.js +511 -0
- package/lib/return-labels.js +477 -0
- package/lib/sales-reports.js +843 -0
- package/lib/search-synonyms.js +792 -0
- package/lib/shipping-labels.js +603 -0
- package/lib/stock-alerts.js +563 -0
- package/lib/subscription-controls.js +723 -0
- package/lib/support-tickets.js +898 -0
- package/package.json +1 -1
|
@@ -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
|
+
};
|