@blamejs/blamejs-shop 0.0.56 → 0.0.57

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,726 @@
1
+ "use strict";
2
+ /**
3
+ * Product variants — normalized option-axis layer on top of
4
+ * `catalog.products`. A T-shirt product has axes (color, size) with
5
+ * options (red/blue/green × S/M/L/XL) producing the cartesian
6
+ * product of 12 variant SKUs. The primitive lets operators register
7
+ * the axes + option values, generate the cartesian product for
8
+ * inspection, materialize it into per-SKU rows, and patch each
9
+ * variant's overrides (price, weight, image, inventory).
10
+ *
11
+ * Composes:
12
+ * - `b.guardUuid` — every product / variant / axis / option id
13
+ * passes through the strict UUID profile at the entry point. No
14
+ * raw caller string ever lands in SQL.
15
+ * - `b.uuid.v7` — millisecond-prefixed row id. Lexicographically
16
+ * sortable → B-tree-locality wins on PKs whose insert pattern is
17
+ * "newest at the end."
18
+ * - `b.safeSql` — UPDATE column allowlist routes through
19
+ * `safeSql.assertOneOf` + `safeSql.quoteIdentifier` so a future
20
+ * refactor that introduces a dynamic patch key can't widen the
21
+ * surface to identifier injection.
22
+ *
23
+ * Surface:
24
+ * - `defineAxis({ product_id, axis_name, options: [...] })` —
25
+ * register an axis (e.g. `color`). Position-stable. Refuses if
26
+ * `axis_name` already exists for the product, refuses empty
27
+ * options, refuses duplicate option labels (case-insensitive).
28
+ * Returns `{ id, product_id, axis_name, position, options: [...] }`.
29
+ * - `generateMatrix(product_id)` — cartesian product of all
30
+ * defined axes. Returns the variant rows that WOULD be created
31
+ * without writing them, so the operator can inspect SKU shapes
32
+ * and decide whether to commit.
33
+ * - `materializeMatrix(product_id, { sku_prefix, base_price_minor })`
34
+ * — writes the cartesian product. SKU shape:
35
+ * `<sku_prefix>-<axis1-opt>-<axis2-opt>-...` with each option
36
+ * value lowercase-ASCII-slugged. Existing live variants are
37
+ * re-checked so a re-run after a new axis-option is added only
38
+ * adds the missing rows.
39
+ * - `getVariant(variant_id)` — by id; archived rows still resolve
40
+ * so historic order lines render correctly.
41
+ * - `variantsForProduct(product_id, { include_archived? })` —
42
+ * default lists live only; opt-in includes archived rows.
43
+ * - `findVariant({ product_id, axis_values: { color: 'red', size: 'L' } })`
44
+ * — exact-match lookup against the axis_values map. Returns
45
+ * `null` if no live variant matches. Archived rows are excluded
46
+ * (operators inspect archived state via `getVariant` /
47
+ * `variantsForProduct({ include_archived: true })`).
48
+ * - `updateVariant(variant_id, patch)` — partial patch. Allowlist:
49
+ * `sku`, `price_minor`, `weight_grams`, `image_url`,
50
+ * `inventory_count`, `archived`. Unknown columns throw — closes
51
+ * the column-name-injection hole and surfaces typos at write
52
+ * time.
53
+ * - `archiveVariant(variant_id)` / `unarchiveVariant(variant_id)` —
54
+ * toggle `archived_at`.
55
+ * - `archiveAxisOption({ product_id, axis_name, option_value })` —
56
+ * soft-archive an option value. Cascades: every live variant
57
+ * carrying that (axis, option) pair is archived in the same
58
+ * write. Historic order lines that referenced an archived
59
+ * variant still resolve via `getVariant`.
60
+ *
61
+ * Storage:
62
+ * - `product_variant_axes` (migration `0033_product_variants.sql`)
63
+ * - `product_variant_axis_options` (same migration)
64
+ * - `product_variants` (same migration)
65
+ *
66
+ * @primitive variants
67
+ * @related b.guardUuid, b.safeSql, b.uuid, catalog
68
+ */
69
+
70
+ var MAX_AXIS_NAME_LEN = 64;
71
+ var MAX_OPTION_VALUE_LEN = 64;
72
+ var MAX_OPTIONS_PER_AXIS = 64;
73
+ var MAX_AXES_PER_PRODUCT = 8;
74
+ var MAX_SKU_PREFIX_LEN = 64;
75
+ var MAX_SKU_LEN = 128;
76
+ var MAX_IMAGE_URL_LEN = 2048;
77
+ var AXIS_NAME_RE = /^[a-z][a-z0-9_]{0,63}$/;
78
+ var SKU_PREFIX_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
79
+ var SKU_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
80
+
81
+ // Columns each updateVariant() call may touch. Locked down via
82
+ // `b.safeSql.assertOneOf` so a future change introducing a dynamic
83
+ // patch key path can't widen the surface to an attacker-controlled
84
+ // column name. `archived` is the caller-facing key that maps to the
85
+ // `archived_at` timestamp column — handled out-of-band so the
86
+ // allowlist matches the storage column name exactly.
87
+ var ALLOWED_VARIANT_COLUMNS = Object.freeze([
88
+ "sku", "price_minor", "weight_grams", "image_url", "inventory_count", "archived_at",
89
+ ]);
90
+
91
+ var bShop;
92
+ function _b() {
93
+ // Lazy-loaded so unit tests can require the module without first
94
+ // initializing the vendored blamejs tree. Matches the pattern used
95
+ // by every other shop primitive.
96
+ if (!bShop) bShop = require("./index");
97
+ return bShop.framework;
98
+ }
99
+
100
+ // ---- validators ----------------------------------------------------------
101
+
102
+ function _id(s, label) {
103
+ try {
104
+ return _b().guardUuid.sanitize(s, { profile: "strict" });
105
+ } catch (e) {
106
+ throw new TypeError("variants: " + label + " — " + (e && e.message || "invalid UUID"));
107
+ }
108
+ }
109
+
110
+ function _axisName(s) {
111
+ if (typeof s !== "string" || !AXIS_NAME_RE.test(s)) {
112
+ throw new TypeError("variants: axis_name must match /^[a-z][a-z0-9_]*$/ (lowercase, " + MAX_AXIS_NAME_LEN + " chars max)");
113
+ }
114
+ return s;
115
+ }
116
+
117
+ function _optionValue(s, label) {
118
+ if (typeof s !== "string" || !s.length || s.length > MAX_OPTION_VALUE_LEN) {
119
+ throw new TypeError("variants: " + label + " must be a non-empty string ≤ " + MAX_OPTION_VALUE_LEN + " chars");
120
+ }
121
+ // Refuse control bytes — the option value renders into HTML on
122
+ // the PDP variant selector AND becomes part of the SKU slug. CR /
123
+ // LF / NUL in either context is a smuggling surface.
124
+ if (/[\x00-\x1f\x7f]/.test(s)) {
125
+ throw new TypeError("variants: " + label + " must not contain control bytes");
126
+ }
127
+ return s;
128
+ }
129
+
130
+ function _skuPrefix(s) {
131
+ if (typeof s !== "string" || !SKU_PREFIX_RE.test(s)) {
132
+ throw new TypeError("variants: sku_prefix must match /^[A-Za-z0-9][A-Za-z0-9._-]*$/ (≤ " + MAX_SKU_PREFIX_LEN + " chars)");
133
+ }
134
+ return s;
135
+ }
136
+
137
+ function _sku(s) {
138
+ if (typeof s !== "string" || !SKU_RE.test(s)) {
139
+ throw new TypeError("variants: sku must match /^[A-Za-z0-9][A-Za-z0-9._-]*$/ (≤ " + MAX_SKU_LEN + " chars)");
140
+ }
141
+ return s;
142
+ }
143
+
144
+ function _nonNegInt(n, label) {
145
+ if (!Number.isInteger(n) || n < 0) {
146
+ throw new TypeError("variants: " + label + " must be a non-negative integer");
147
+ }
148
+ return n;
149
+ }
150
+
151
+ function _imageUrl(s) {
152
+ if (s == null || s === "") return "";
153
+ if (typeof s !== "string" || s.length > MAX_IMAGE_URL_LEN) {
154
+ throw new TypeError("variants: image_url must be a string ≤ " + MAX_IMAGE_URL_LEN + " chars");
155
+ }
156
+ // Allow https URLs and protocol-relative storefront paths (e.g.
157
+ // `/assets/...`). Anything else is refused — the image_url lands
158
+ // verbatim into an `<img src="...">` on the PDP, so javascript:,
159
+ // data:, and other exotic schemes are a smuggling surface.
160
+ if (s.charCodeAt(0) === 47 /* "/" */) {
161
+ if (/[\x00-\x1f\x7f]/.test(s) || s.indexOf("..") !== -1) {
162
+ throw new TypeError("variants: image_url path must not contain control bytes or '..'");
163
+ }
164
+ return s;
165
+ }
166
+ try {
167
+ _b().safeUrl.parse(s, { allowedProtocols: ["https:"] });
168
+ } catch (e) {
169
+ throw new TypeError("variants: image_url — " + (e && e.message || "must be https:// or a /-rooted path"));
170
+ }
171
+ return s;
172
+ }
173
+
174
+ function _bool(b, label) {
175
+ if (typeof b !== "boolean") {
176
+ throw new TypeError("variants: " + label + " must be a boolean");
177
+ }
178
+ return b;
179
+ }
180
+
181
+ // Slug a single option label into the SKU-safe shape used in the
182
+ // compounded `<sku_prefix>-<axis1-opt>-<axis2-opt>-...` string. Lower-
183
+ // case ASCII alnum; everything else (whitespace, punctuation, multi-
184
+ // byte) collapses to a single `-`. Leading / trailing hyphens are
185
+ // trimmed. The full-axis-value SKU stays human-readable (`tshirt-
186
+ // navy-large`) without bringing identifier-injection surface to the
187
+ // SQL — the SKU column is a parameter, never an identifier.
188
+ function _slugOption(s) {
189
+ var out = "";
190
+ var lastWasHyphen = false;
191
+ for (var i = 0; i < s.length; i += 1) {
192
+ var c = s.charCodeAt(i);
193
+ var ch;
194
+ if (c >= 65 && c <= 90) ch = String.fromCharCode(c + 32); // A-Z -> a-z
195
+ else if (c >= 97 && c <= 122) ch = String.fromCharCode(c); // a-z
196
+ else if (c >= 48 && c <= 57) ch = String.fromCharCode(c); // 0-9
197
+ else ch = "-";
198
+ if (ch === "-") {
199
+ if (!lastWasHyphen && out.length > 0) {
200
+ out += "-";
201
+ lastWasHyphen = true;
202
+ }
203
+ } else {
204
+ out += ch;
205
+ lastWasHyphen = false;
206
+ }
207
+ }
208
+ // Trim trailing hyphen.
209
+ if (out.length && out.charAt(out.length - 1) === "-") out = out.slice(0, -1);
210
+ if (!out.length) {
211
+ throw new TypeError("variants: option value slugged to empty string (no ASCII alnum characters)");
212
+ }
213
+ return out;
214
+ }
215
+
216
+ // Canonical JSON form for the axis-values map — keys sorted alpha-
217
+ // betically so a re-materialize after a column reorder still hits
218
+ // the same string. Used both for storage and for `findVariant`
219
+ // equality so the comparison is a single string-compare instead of
220
+ // a deep-object walk.
221
+ function _canonicalAxisValuesJson(map) {
222
+ var keys = Object.keys(map).sort();
223
+ var out = {};
224
+ for (var i = 0; i < keys.length; i += 1) out[keys[i]] = map[keys[i]];
225
+ return JSON.stringify(out);
226
+ }
227
+
228
+ // ---- factory -------------------------------------------------------------
229
+
230
+ function create(opts) {
231
+ opts = opts || {};
232
+ var query = opts.query;
233
+ if (!query) {
234
+ query = function (sql, params) { return _b().externalDb.query(sql, params); };
235
+ }
236
+ // `catalog` is the catalog primitive instance — used to validate
237
+ // that the `product_id` resolves to a real product before any
238
+ // axis / variant write. The caller passes it through so a future
239
+ // multi-tenant deployment (one catalog instance per shop) can wire
240
+ // the variants instance against the matching catalog without
241
+ // resolving via the global require graph.
242
+ var catalog = opts.catalog || null;
243
+
244
+ async function _assertProductExists(productId) {
245
+ if (!catalog) return; // catalog-free test mode — caller takes responsibility
246
+ var p = await catalog.products.get(productId);
247
+ if (!p) throw new TypeError("variants: product_id — no product matches " + productId);
248
+ }
249
+
250
+ async function _axesForProduct(productId) {
251
+ var r = await query(
252
+ "SELECT * FROM product_variant_axes WHERE product_id = ?1 ORDER BY position ASC, created_at ASC",
253
+ [productId],
254
+ );
255
+ return r.rows;
256
+ }
257
+
258
+ async function _optionsForAxis(axisId, includeArchived) {
259
+ var sql = includeArchived
260
+ ? "SELECT * FROM product_variant_axis_options WHERE axis_id = ?1 ORDER BY position ASC"
261
+ : "SELECT * FROM product_variant_axis_options WHERE axis_id = ?1 AND archived_at IS NULL ORDER BY position ASC";
262
+ var r = await query(sql, [axisId]);
263
+ return r.rows;
264
+ }
265
+
266
+ async function _existingVariantSkus(productId) {
267
+ var r = await query(
268
+ "SELECT sku FROM product_variants WHERE product_id = ?1",
269
+ [productId],
270
+ );
271
+ var set = Object.create(null);
272
+ for (var i = 0; i < r.rows.length; i += 1) set[r.rows[i].sku] = true;
273
+ return set;
274
+ }
275
+
276
+ // Internal: build the cartesian product as an array of `{ sku,
277
+ // axis_values }` rows given the already-fetched axes + options.
278
+ // The slug is derived deterministically so the same input always
279
+ // produces the same SKU — re-running materialize is idempotent.
280
+ function _buildMatrix(skuPrefix, axesWithOptions) {
281
+ var rows = [{ axis_values: {}, slugParts: [] }];
282
+ for (var a = 0; a < axesWithOptions.length; a += 1) {
283
+ var axis = axesWithOptions[a].axis;
284
+ var options = axesWithOptions[a].options;
285
+ var next = [];
286
+ for (var r = 0; r < rows.length; r += 1) {
287
+ for (var o = 0; o < options.length; o += 1) {
288
+ var optValue = options[o].option_value;
289
+ var copy = {};
290
+ var k;
291
+ for (k in rows[r].axis_values) {
292
+ if (Object.prototype.hasOwnProperty.call(rows[r].axis_values, k)) {
293
+ copy[k] = rows[r].axis_values[k];
294
+ }
295
+ }
296
+ copy[axis.axis_name] = optValue;
297
+ next.push({
298
+ axis_values: copy,
299
+ slugParts: rows[r].slugParts.concat(_slugOption(optValue)),
300
+ });
301
+ }
302
+ }
303
+ rows = next;
304
+ }
305
+ var out = [];
306
+ for (var i = 0; i < rows.length; i += 1) {
307
+ var sku = skuPrefix + (rows[i].slugParts.length ? "-" + rows[i].slugParts.join("-") : "");
308
+ _sku(sku); // surfaces an over-long sku_prefix + option combo at generate time
309
+ out.push({
310
+ sku: sku.toLowerCase(),
311
+ axis_values: rows[i].axis_values,
312
+ });
313
+ }
314
+ return out;
315
+ }
316
+
317
+ return {
318
+ // ---- defineAxis -----------------------------------------------------
319
+ //
320
+ // Registers an axis + its option values atomically. The unique
321
+ // index on (product_id, axis_name) refuses the second call for
322
+ // the same name; we pre-flight the check so the operator gets a
323
+ // descriptive TypeError instead of a raw constraint-violation
324
+ // string.
325
+ defineAxis: async function (input) {
326
+ if (!input || typeof input !== "object") {
327
+ throw new TypeError("variants.defineAxis: input object required");
328
+ }
329
+ var productId = _id(input.product_id, "product_id");
330
+ var axisName = _axisName(input.axis_name);
331
+ if (!Array.isArray(input.options) || input.options.length === 0) {
332
+ throw new TypeError("variants.defineAxis: options must be a non-empty array");
333
+ }
334
+ if (input.options.length > MAX_OPTIONS_PER_AXIS) {
335
+ throw new TypeError("variants.defineAxis: options must be ≤ " + MAX_OPTIONS_PER_AXIS + " entries");
336
+ }
337
+ // Validate every option value + refuse case-insensitive
338
+ // duplicates so the operator can't accidentally register `Red`
339
+ // alongside `red` (they'd slug to the same SKU, so we treat
340
+ // them as the same option value).
341
+ var seen = Object.create(null);
342
+ var optionValues = [];
343
+ for (var i = 0; i < input.options.length; i += 1) {
344
+ var v = _optionValue(input.options[i], "options[" + i + "]");
345
+ var key = v.toLowerCase();
346
+ if (seen[key]) {
347
+ throw new TypeError("variants.defineAxis: duplicate option label " + JSON.stringify(input.options[i]) + " (case-insensitive)");
348
+ }
349
+ seen[key] = true;
350
+ optionValues.push(v);
351
+ }
352
+ await _assertProductExists(productId);
353
+
354
+ var existingAxes = await _axesForProduct(productId);
355
+ if (existingAxes.length >= MAX_AXES_PER_PRODUCT) {
356
+ throw new TypeError("variants.defineAxis: product already has " + MAX_AXES_PER_PRODUCT + " axes (max)");
357
+ }
358
+ for (var ea = 0; ea < existingAxes.length; ea += 1) {
359
+ if (existingAxes[ea].axis_name === axisName) {
360
+ throw new TypeError("variants.defineAxis: axis_name " + JSON.stringify(axisName) + " already registered for this product");
361
+ }
362
+ }
363
+
364
+ var axisId = _b().uuid.v7();
365
+ var position = existingAxes.length;
366
+ var now = Date.now();
367
+ await query(
368
+ "INSERT INTO product_variant_axes (id, product_id, axis_name, position, created_at) " +
369
+ "VALUES (?1, ?2, ?3, ?4, ?5)",
370
+ [axisId, productId, axisName, position, now],
371
+ );
372
+ var insertedOptions = [];
373
+ for (var oi = 0; oi < optionValues.length; oi += 1) {
374
+ var optId = _b().uuid.v7();
375
+ await query(
376
+ "INSERT INTO product_variant_axis_options (id, axis_id, option_value, position, archived_at) " +
377
+ "VALUES (?1, ?2, ?3, ?4, NULL)",
378
+ [optId, axisId, optionValues[oi], oi],
379
+ );
380
+ insertedOptions.push({
381
+ id: optId,
382
+ axis_id: axisId,
383
+ option_value: optionValues[oi],
384
+ position: oi,
385
+ archived_at: null,
386
+ });
387
+ }
388
+ return {
389
+ id: axisId,
390
+ product_id: productId,
391
+ axis_name: axisName,
392
+ position: position,
393
+ created_at: now,
394
+ options: insertedOptions,
395
+ };
396
+ },
397
+
398
+ // ---- generateMatrix -------------------------------------------------
399
+ //
400
+ // Pure-read: returns the cartesian product as `[ { sku,
401
+ // axis_values } ]` rows without writing. Operators call this to
402
+ // preview SKUs before committing to `materializeMatrix`.
403
+ generateMatrix: async function (productId) {
404
+ var pid = _id(productId, "product_id");
405
+ var axes = await _axesForProduct(pid);
406
+ if (axes.length === 0) {
407
+ throw new TypeError("variants.generateMatrix: no axes registered for product " + pid);
408
+ }
409
+ var axesWithOptions = [];
410
+ for (var i = 0; i < axes.length; i += 1) {
411
+ var opts = await _optionsForAxis(axes[i].id, false); // live options only
412
+ if (opts.length === 0) {
413
+ throw new TypeError("variants.generateMatrix: axis " + JSON.stringify(axes[i].axis_name) + " has no live options");
414
+ }
415
+ axesWithOptions.push({ axis: axes[i], options: opts });
416
+ }
417
+ // Use a placeholder prefix so the matrix rows can be inspected
418
+ // shape-only — the operator overrides at materialize time.
419
+ return _buildMatrix("preview", axesWithOptions).map(function (row) {
420
+ return {
421
+ sku: row.sku.replace(/^preview-?/, ""), // strip placeholder; caller wires their own prefix
422
+ axis_values: row.axis_values,
423
+ };
424
+ });
425
+ },
426
+
427
+ // ---- materializeMatrix ----------------------------------------------
428
+ //
429
+ // Writes the cartesian-product rows. Idempotent — a re-run with
430
+ // a freshly-added axis option only inserts the missing rows;
431
+ // existing SKUs are left untouched.
432
+ materializeMatrix: async function (productId, input) {
433
+ var pid = _id(productId, "product_id");
434
+ if (!input || typeof input !== "object") {
435
+ throw new TypeError("variants.materializeMatrix: input object required");
436
+ }
437
+ var skuPrefix = _skuPrefix(input.sku_prefix);
438
+ var basePrice = _nonNegInt(input.base_price_minor, "base_price_minor");
439
+
440
+ var axes = await _axesForProduct(pid);
441
+ if (axes.length === 0) {
442
+ throw new TypeError("variants.materializeMatrix: no axes registered for product " + pid);
443
+ }
444
+ var axesWithOptions = [];
445
+ for (var i = 0; i < axes.length; i += 1) {
446
+ var opts = await _optionsForAxis(axes[i].id, false);
447
+ if (opts.length === 0) {
448
+ throw new TypeError("variants.materializeMatrix: axis " + JSON.stringify(axes[i].axis_name) + " has no live options");
449
+ }
450
+ axesWithOptions.push({ axis: axes[i], options: opts });
451
+ }
452
+ var matrix = _buildMatrix(skuPrefix, axesWithOptions);
453
+ var existing = await _existingVariantSkus(pid);
454
+
455
+ var inserted = [];
456
+ var skipped = 0;
457
+ for (var m = 0; m < matrix.length; m += 1) {
458
+ var row = matrix[m];
459
+ if (existing[row.sku]) { skipped += 1; continue; }
460
+ var id = _b().uuid.v7();
461
+ var now = Date.now();
462
+ var json = _canonicalAxisValuesJson(row.axis_values);
463
+ await query(
464
+ "INSERT INTO product_variants (id, product_id, sku, axis_values_json, price_minor, " +
465
+ "weight_grams, image_url, inventory_count, archived_at, created_at, updated_at) " +
466
+ "VALUES (?1, ?2, ?3, ?4, ?5, 0, '', 0, NULL, ?6, ?6)",
467
+ [id, pid, row.sku, json, basePrice, now],
468
+ );
469
+ inserted.push({
470
+ id: id,
471
+ product_id: pid,
472
+ sku: row.sku,
473
+ axis_values: row.axis_values,
474
+ axis_values_json: json,
475
+ price_minor: basePrice,
476
+ weight_grams: 0,
477
+ image_url: "",
478
+ inventory_count: 0,
479
+ archived_at: null,
480
+ created_at: now,
481
+ updated_at: now,
482
+ });
483
+ }
484
+ return { inserted: inserted, skipped: skipped, total: matrix.length };
485
+ },
486
+
487
+ // ---- getVariant -----------------------------------------------------
488
+ //
489
+ // By id. Archived rows still resolve so an order line created
490
+ // before the archive can still render its variant.
491
+ getVariant: async function (variantId) {
492
+ var vid = _id(variantId, "variant_id");
493
+ var r = await query("SELECT * FROM product_variants WHERE id = ?1", [vid]);
494
+ var row = r.rows[0] || null;
495
+ if (row) row.axis_values = JSON.parse(row.axis_values_json);
496
+ return row;
497
+ },
498
+
499
+ // ---- variantsForProduct ---------------------------------------------
500
+ //
501
+ // Default lists live only. `include_archived: true` returns the
502
+ // full set (admin / inventory views).
503
+ variantsForProduct: async function (productId, listOpts) {
504
+ var pid = _id(productId, "product_id");
505
+ listOpts = listOpts || {};
506
+ var includeArchived = false;
507
+ if (listOpts.include_archived !== undefined) {
508
+ includeArchived = _bool(listOpts.include_archived, "include_archived");
509
+ }
510
+ var sql = includeArchived
511
+ ? "SELECT * FROM product_variants WHERE product_id = ?1 ORDER BY created_at ASC, id ASC"
512
+ : "SELECT * FROM product_variants WHERE product_id = ?1 AND archived_at IS NULL ORDER BY created_at ASC, id ASC";
513
+ var r = await query(sql, [pid]);
514
+ return r.rows.map(function (row) {
515
+ row.axis_values = JSON.parse(row.axis_values_json);
516
+ return row;
517
+ });
518
+ },
519
+
520
+ // ---- findVariant ----------------------------------------------------
521
+ //
522
+ // Exact-match by (product_id, axis_values). The axis_values_json
523
+ // column is stored canonical (keys sorted), so the lookup
524
+ // canonicalises the input the same way and matches as a single
525
+ // string compare. Archived variants are excluded — operators
526
+ // looking for an archived row use `getVariant` or
527
+ // `variantsForProduct({ include_archived: true })`.
528
+ findVariant: async function (input) {
529
+ if (!input || typeof input !== "object") {
530
+ throw new TypeError("variants.findVariant: input object required");
531
+ }
532
+ var pid = _id(input.product_id, "product_id");
533
+ if (!input.axis_values || typeof input.axis_values !== "object" || Array.isArray(input.axis_values)) {
534
+ throw new TypeError("variants.findVariant: axis_values must be a plain object (axis_name -> option_value)");
535
+ }
536
+ // Validate every key + value before hashing them into the
537
+ // canonical JSON — refuses control bytes, oversized values,
538
+ // and non-axis-name-shaped keys.
539
+ var keys = Object.keys(input.axis_values);
540
+ if (keys.length === 0) {
541
+ throw new TypeError("variants.findVariant: axis_values must have at least one entry");
542
+ }
543
+ for (var i = 0; i < keys.length; i += 1) {
544
+ _axisName(keys[i]);
545
+ _optionValue(input.axis_values[keys[i]], "axis_values[" + JSON.stringify(keys[i]) + "]");
546
+ }
547
+ var canonical = _canonicalAxisValuesJson(input.axis_values);
548
+ var r = await query(
549
+ "SELECT * FROM product_variants WHERE product_id = ?1 AND axis_values_json = ?2 " +
550
+ "AND archived_at IS NULL LIMIT 1",
551
+ [pid, canonical],
552
+ );
553
+ var row = r.rows[0] || null;
554
+ if (row) row.axis_values = JSON.parse(row.axis_values_json);
555
+ return row;
556
+ },
557
+
558
+ // ---- updateVariant --------------------------------------------------
559
+ //
560
+ // Partial patch. The allowlist routes through `b.safeSql` so an
561
+ // unknown column key throws — the column is never composed into
562
+ // SQL from a caller-supplied identifier.
563
+ updateVariant: async function (variantId, patch) {
564
+ var vid = _id(variantId, "variant_id");
565
+ if (!patch || typeof patch !== "object") {
566
+ throw new TypeError("variants.updateVariant: patch object required");
567
+ }
568
+ // Refuse any key that isn't on the caller-facing allowlist
569
+ // BEFORE composing SQL. The caller-facing key set is
570
+ // {sku, price_minor, weight_grams, image_url, inventory_count,
571
+ // archived} — `archived` maps to `archived_at` (timestamp)
572
+ // out-of-band so the storage column stays the timestamp.
573
+ var ALLOWED_PATCH_KEYS = ["sku", "price_minor", "weight_grams", "image_url", "inventory_count", "archived"];
574
+ var patchKeys = Object.keys(patch);
575
+ for (var pk = 0; pk < patchKeys.length; pk += 1) {
576
+ if (ALLOWED_PATCH_KEYS.indexOf(patchKeys[pk]) === -1) {
577
+ throw new TypeError("variants.updateVariant: unknown patch key " + JSON.stringify(patchKeys[pk]) +
578
+ " (allowed: " + ALLOWED_PATCH_KEYS.join(", ") + ")");
579
+ }
580
+ }
581
+ var sets = [];
582
+ var params = [];
583
+ var i = 1;
584
+ function _addSet(col, val) {
585
+ _b().safeSql.assertOneOf(col, ALLOWED_VARIANT_COLUMNS);
586
+ sets.push(_b().safeSql.quoteIdentifier(col, "sqlite") + " = ?" + (i++));
587
+ params.push(val);
588
+ }
589
+ if (patch.sku !== undefined) {
590
+ _sku(patch.sku);
591
+ _addSet("sku", patch.sku);
592
+ }
593
+ if (patch.price_minor !== undefined) {
594
+ _nonNegInt(patch.price_minor, "price_minor");
595
+ _addSet("price_minor", patch.price_minor);
596
+ }
597
+ if (patch.weight_grams !== undefined) {
598
+ _nonNegInt(patch.weight_grams, "weight_grams");
599
+ _addSet("weight_grams", patch.weight_grams);
600
+ }
601
+ if (patch.image_url !== undefined) {
602
+ _addSet("image_url", _imageUrl(patch.image_url));
603
+ }
604
+ if (patch.inventory_count !== undefined) {
605
+ _nonNegInt(patch.inventory_count, "inventory_count");
606
+ _addSet("inventory_count", patch.inventory_count);
607
+ }
608
+ if (patch.archived !== undefined) {
609
+ var archived = _bool(patch.archived, "archived");
610
+ _addSet("archived_at", archived ? Date.now() : null);
611
+ }
612
+ if (sets.length === 0) {
613
+ throw new TypeError("variants.updateVariant: patch contained no updatable fields");
614
+ }
615
+ var ts = Date.now();
616
+ sets.push("updated_at = ?" + (i++));
617
+ params.push(ts);
618
+ params.push(vid);
619
+ var r = await query(
620
+ "UPDATE product_variants SET " + sets.join(", ") + " WHERE id = ?" + i,
621
+ params,
622
+ );
623
+ if (r.rowCount === 0) return null;
624
+ return await this.getVariant(vid);
625
+ },
626
+
627
+ // ---- archiveVariant / unarchiveVariant ------------------------------
628
+ archiveVariant: async function (variantId) {
629
+ var vid = _id(variantId, "variant_id");
630
+ var ts = Date.now();
631
+ var r = await query(
632
+ "UPDATE product_variants SET archived_at = ?1, updated_at = ?1 WHERE id = ?2 AND archived_at IS NULL",
633
+ [ts, vid],
634
+ );
635
+ if (r.rowCount === 0) {
636
+ // Could be missing or already archived — disambiguate so the
637
+ // caller gets a meaningful answer.
638
+ var existing = await query("SELECT id FROM product_variants WHERE id = ?1", [vid]);
639
+ if (existing.rows.length === 0) return null;
640
+ }
641
+ return await this.getVariant(vid);
642
+ },
643
+
644
+ unarchiveVariant: async function (variantId) {
645
+ var vid = _id(variantId, "variant_id");
646
+ var ts = Date.now();
647
+ await query(
648
+ "UPDATE product_variants SET archived_at = NULL, updated_at = ?1 WHERE id = ?2",
649
+ [ts, vid],
650
+ );
651
+ return await this.getVariant(vid);
652
+ },
653
+
654
+ // ---- archiveAxisOption ----------------------------------------------
655
+ //
656
+ // Soft-archives the (axis_name, option_value) pair so the
657
+ // storefront PDP can't render it as a selectable option, then
658
+ // cascades the archive to every live variant that carries that
659
+ // pair in its axis_values map.
660
+ archiveAxisOption: async function (input) {
661
+ if (!input || typeof input !== "object") {
662
+ throw new TypeError("variants.archiveAxisOption: input object required");
663
+ }
664
+ var pid = _id(input.product_id, "product_id");
665
+ var axisName = _axisName(input.axis_name);
666
+ var optValue = _optionValue(input.option_value, "option_value");
667
+
668
+ // Resolve the axis + option rows.
669
+ var axisRow = (await query(
670
+ "SELECT id FROM product_variant_axes WHERE product_id = ?1 AND axis_name = ?2",
671
+ [pid, axisName],
672
+ )).rows[0];
673
+ if (!axisRow) {
674
+ throw new TypeError("variants.archiveAxisOption: no axis " + JSON.stringify(axisName) + " for product " + pid);
675
+ }
676
+ var optRow = (await query(
677
+ "SELECT id, archived_at FROM product_variant_axis_options WHERE axis_id = ?1 AND option_value = ?2",
678
+ [axisRow.id, optValue],
679
+ )).rows[0];
680
+ if (!optRow) {
681
+ throw new TypeError("variants.archiveAxisOption: no option " + JSON.stringify(optValue) + " on axis " + JSON.stringify(axisName));
682
+ }
683
+ var ts = Date.now();
684
+ await query(
685
+ "UPDATE product_variant_axis_options SET archived_at = ?1 WHERE id = ?2 AND archived_at IS NULL",
686
+ [ts, optRow.id],
687
+ );
688
+
689
+ // Cascade: archive every live variant whose axis_values_json
690
+ // carries this (axis_name, option_value). We pull the candidate
691
+ // set in a single query, then walk in JS — the table is small
692
+ // per product (max axes × max options = 8 × 64 = 512 rows),
693
+ // and the JSON-match is more reliable than a LIKE pattern
694
+ // against a textual JSON encoding.
695
+ var liveRows = (await query(
696
+ "SELECT id, axis_values_json FROM product_variants WHERE product_id = ?1 AND archived_at IS NULL",
697
+ [pid],
698
+ )).rows;
699
+ var cascadedIds = [];
700
+ for (var i = 0; i < liveRows.length; i += 1) {
701
+ var map;
702
+ try { map = JSON.parse(liveRows[i].axis_values_json); }
703
+ catch (_e) { continue; }
704
+ if (map && map[axisName] === optValue) {
705
+ cascadedIds.push(liveRows[i].id);
706
+ }
707
+ }
708
+ for (var ci = 0; ci < cascadedIds.length; ci += 1) {
709
+ await query(
710
+ "UPDATE product_variants SET archived_at = ?1, updated_at = ?1 WHERE id = ?2",
711
+ [ts, cascadedIds[ci]],
712
+ );
713
+ }
714
+ return {
715
+ axis_id: axisRow.id,
716
+ option_id: optRow.id,
717
+ archived_at: ts,
718
+ cascaded_variant_ids: cascadedIds,
719
+ };
720
+ },
721
+ };
722
+ }
723
+
724
+ module.exports = {
725
+ create: create,
726
+ };