@blamejs/blamejs-shop 0.0.64 → 0.0.66

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,797 @@
1
+ "use strict";
2
+ /**
3
+ * @module shop.productBulkOps
4
+ * @title Product bulk operations — operator-side mass-mutation surface
5
+ *
6
+ * @intro
7
+ * Composes on top of the `catalog` primitive to add the bulk mutation
8
+ * operations an operator console needs when a single change has to
9
+ * land across hundreds of products at once:
10
+ *
11
+ * bulkSetPrice — set a flat new amount_minor on every matched
12
+ * variant in the given currency (versioned —
13
+ * the prior price is closed via the catalog
14
+ * primitive's `prices.set` so price history is
15
+ * preserved).
16
+ * bulkAdjustPrice — apply a percent delta (e.g. -1500 bps for a
17
+ * 15% markdown) to every matched variant's
18
+ * current price in the given currency. The new
19
+ * amount is rounded half-away-from-zero in
20
+ * minor units; the floor is 0 (no negative
21
+ * prices). Variants without a current price in
22
+ * the target currency are skipped.
23
+ * bulkArchive — set products.status='archived' on every
24
+ * matched row that isn't already archived.
25
+ * bulkUnarchive — restore archived products to 'draft'.
26
+ * bulkAddTag — insert (product_id, tag) into product_tags
27
+ * for every matched product. Idempotent — the
28
+ * composite PK refuses duplicate tags on the
29
+ * same product, and the affected_count reflects
30
+ * actual inserts.
31
+ * bulkRemoveTag — delete (product_id, tag) rows. Idempotent on
32
+ * the no-such-tag case.
33
+ * bulkSetInventory — upsert inventory.stock_on_hand to a flat
34
+ * value for every variant of every matched
35
+ * product. Variants without an inventory row
36
+ * get one created at the target stock.
37
+ * previewFilter — resolve the filter to the matched product
38
+ * rows + count without mutating anything.
39
+ * The operator console renders this as the
40
+ * "you're about to change N rows" affordance.
41
+ * auditTrail — read the append-only product_bulk_audit
42
+ * rows back, optionally narrowed by kind /
43
+ * since / limit.
44
+ *
45
+ * Filter shape `{ skus?, vendor_slug?, category?, tag_any?, tag_all? }`:
46
+ *
47
+ * - `skus` — array of SKU strings. The matched products are
48
+ * the parents of variants matching any of these
49
+ * SKUs. Caps at 1000 entries per call.
50
+ * - `vendor_slug` — single slug string. Matches products whose any
51
+ * variant SKU is assigned to that vendor in
52
+ * `vendor_skus`.
53
+ * - `category` — single category string. Matches products with
54
+ * a row in `product_categories` for that
55
+ * category.
56
+ * - `tag_any` — array of tag strings; matches products with at
57
+ * least ONE of the tags.
58
+ * - `tag_all` — array of tag strings; matches products with
59
+ * EVERY tag.
60
+ *
61
+ * Multiple filter keys AND together. An empty filter `{}` is refused
62
+ * to refuse a "modify every product in the catalog" footgun — the
63
+ * operator passes an explicit `skus: [...]` to opt into a global
64
+ * change.
65
+ *
66
+ * Pre-flight cap: the resolved product set is capped at
67
+ * `MAX_BULK_ROWS` (default 1000) BEFORE any write fires. A filter
68
+ * that resolves to a larger set throws — the operator narrows the
69
+ * filter or raises the cap explicitly via `opts.maxBulkRows` at
70
+ * create() time.
71
+ *
72
+ * Audit trail: every bulk write inserts one row into
73
+ * `product_bulk_audit` with the resolved filter (JSON), the op
74
+ * parameters (JSON), and the actual affected count. The id is a v7
75
+ * UUID so the log sorts by insertion order.
76
+ *
77
+ * Atomicity story: pre-flight validation + cap check fire BEFORE any
78
+ * mutation. Once the per-row writes begin, each row's write runs in
79
+ * sequence; a partial failure (e.g. SQLite reports a CHECK constraint
80
+ * violation on row K) leaves rows 0..K-1 written and rows K..N
81
+ * unwritten. The audit row reflects the actual affected_count from
82
+ * `rowCount` summing, so post-hoc reconciliation sees the true tally.
83
+ * D1's per-statement boundary makes batch-level transactions
84
+ * unavailable; the in-batch ordering keeps the failure mode honest.
85
+ *
86
+ * Pricing math: `bulkAdjustPrice` takes `percent_bps` (basis points)
87
+ * so the operator passes integer values (-1500 = -15%, +500 = +5%).
88
+ * The new amount is `Math.round(prior * (10000 + percent_bps) /
89
+ * 10000)` clamped to 0. Tie-rounding follows `Math.round` (half-up
90
+ * towards +Infinity for positive numbers); the catalog primitive
91
+ * stores integer amount_minor only, so the round is unavoidable.
92
+ *
93
+ * The factory accepts:
94
+ * query — query function shared with `catalog`. Defaults to
95
+ * `b.externalDb`.
96
+ * catalog — REQUIRED. The catalog handle, used for variant
97
+ * lookups and price set.
98
+ * maxBulkRows — OPTIONAL. Caps the resolved product set per call
99
+ * (default 1000).
100
+ *
101
+ * @composes b.guardUuid, b.uuid.v7, b.safeSql, catalog.prices.set
102
+ */
103
+
104
+ var bShop;
105
+ function _b() {
106
+ if (!bShop) bShop = require("./index");
107
+ return bShop.framework;
108
+ }
109
+
110
+ var SLUG_RE = /^[a-z0-9](?:[a-z0-9-]{0,198}[a-z0-9])?$/;
111
+ var SKU_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
112
+ var TAG_RE = /^[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?$/;
113
+ var CATEGORY_RE = /^[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?$/;
114
+ var CURRENCY_RE = /^[A-Z]{3}$/;
115
+ var DEFAULT_CAP = 1000;
116
+ var MAX_CAP = 10000;
117
+ var MAX_TAG_LIST = 32;
118
+ var MAX_SKU_LIST = 1000;
119
+ var MIN_BPS = -10000; // -100%, clamped to zero floor on apply
120
+ var MAX_BPS = 1000000; // +10000%, soft sanity ceiling
121
+ var AUDIT_KINDS = Object.freeze([
122
+ "set_price", "adjust_price", "archive", "unarchive",
123
+ "add_tag", "remove_tag", "set_inventory",
124
+ ]);
125
+
126
+ // ---- validators ----------------------------------------------------------
127
+
128
+ function _uuid(s, label) {
129
+ try { return _b().guardUuid.sanitize(s, { profile: "strict" }); }
130
+ catch (e) { throw new TypeError("productBulkOps: " + label + " — " + (e && e.message || "invalid UUID")); }
131
+ }
132
+ function _slug(s, label) {
133
+ if (typeof s !== "string" || !SLUG_RE.test(s)) {
134
+ throw new TypeError("productBulkOps: " + label + " must match /^[a-z0-9][a-z0-9-]*[a-z0-9]?$/ (lowercase alnum + dash, ≤ 200 chars)");
135
+ }
136
+ }
137
+ function _sku(s, label) {
138
+ if (typeof s !== "string" || !SKU_RE.test(s)) {
139
+ throw new TypeError("productBulkOps: " + label + " must match /^[A-Za-z0-9][A-Za-z0-9._-]*$/ (≤ 128 chars)");
140
+ }
141
+ }
142
+ function _tag(s, label) {
143
+ if (typeof s !== "string" || !TAG_RE.test(s)) {
144
+ throw new TypeError("productBulkOps: " + label + " must match /^[a-z0-9][a-z0-9-]*[a-z0-9]?$/ (lowercase alnum + dash, 1-64 chars)");
145
+ }
146
+ }
147
+ function _category(s, label) {
148
+ if (typeof s !== "string" || !CATEGORY_RE.test(s)) {
149
+ throw new TypeError("productBulkOps: " + label + " must match /^[a-z0-9][a-z0-9-]*[a-z0-9]?$/ (lowercase alnum + dash, 1-64 chars)");
150
+ }
151
+ }
152
+ function _currency(s) {
153
+ if (typeof s !== "string" || !CURRENCY_RE.test(s)) {
154
+ throw new TypeError("productBulkOps: currency must be a 3-letter ISO 4217 code (uppercase)");
155
+ }
156
+ }
157
+ function _nonNegInt(n, label) {
158
+ if (!Number.isInteger(n) || n < 0) {
159
+ throw new TypeError("productBulkOps: " + label + " must be a non-negative integer");
160
+ }
161
+ }
162
+ function _bps(n) {
163
+ if (!Number.isInteger(n) || n < MIN_BPS || n > MAX_BPS) {
164
+ throw new TypeError("productBulkOps: percent_bps must be an integer in [" + MIN_BPS + ", " + MAX_BPS + "]");
165
+ }
166
+ }
167
+ var _lastTs = 0;
168
+ function _now() {
169
+ var t = Date.now();
170
+ if (t <= _lastTs) { t = _lastTs + 1; }
171
+ _lastTs = t;
172
+ return t;
173
+ }
174
+ function _id() { return _b().uuid.v7(); }
175
+
176
+ // ---- filter ---------------------------------------------------------------
177
+
178
+ function _validateFilter(filter, label) {
179
+ if (!filter || typeof filter !== "object" || Array.isArray(filter)) {
180
+ throw new TypeError("productBulkOps." + label + ": filter object required");
181
+ }
182
+ var keys = Object.keys(filter);
183
+ if (keys.length === 0) {
184
+ throw new TypeError("productBulkOps." + label + ": filter must specify at least one of skus / vendor_slug / category / tag_any / tag_all");
185
+ }
186
+ if (filter.skus !== undefined) {
187
+ if (!Array.isArray(filter.skus) || filter.skus.length === 0) {
188
+ throw new TypeError("productBulkOps." + label + ": filter.skus must be a non-empty array");
189
+ }
190
+ if (filter.skus.length > MAX_SKU_LIST) {
191
+ throw new TypeError("productBulkOps." + label + ": filter.skus must be ≤ " + MAX_SKU_LIST + " entries");
192
+ }
193
+ for (var i = 0; i < filter.skus.length; i += 1) {
194
+ _sku(filter.skus[i], "filter.skus[" + i + "]");
195
+ }
196
+ }
197
+ if (filter.vendor_slug !== undefined) {
198
+ _slug(filter.vendor_slug, "filter.vendor_slug");
199
+ }
200
+ if (filter.category !== undefined) {
201
+ _category(filter.category, "filter.category");
202
+ }
203
+ if (filter.tag_any !== undefined) {
204
+ if (!Array.isArray(filter.tag_any) || filter.tag_any.length === 0) {
205
+ throw new TypeError("productBulkOps." + label + ": filter.tag_any must be a non-empty array");
206
+ }
207
+ if (filter.tag_any.length > MAX_TAG_LIST) {
208
+ throw new TypeError("productBulkOps." + label + ": filter.tag_any must be ≤ " + MAX_TAG_LIST + " entries");
209
+ }
210
+ for (var ai = 0; ai < filter.tag_any.length; ai += 1) {
211
+ _tag(filter.tag_any[ai], "filter.tag_any[" + ai + "]");
212
+ }
213
+ }
214
+ if (filter.tag_all !== undefined) {
215
+ if (!Array.isArray(filter.tag_all) || filter.tag_all.length === 0) {
216
+ throw new TypeError("productBulkOps." + label + ": filter.tag_all must be a non-empty array");
217
+ }
218
+ if (filter.tag_all.length > MAX_TAG_LIST) {
219
+ throw new TypeError("productBulkOps." + label + ": filter.tag_all must be ≤ " + MAX_TAG_LIST + " entries");
220
+ }
221
+ for (var li = 0; li < filter.tag_all.length; li += 1) {
222
+ _tag(filter.tag_all[li], "filter.tag_all[" + li + "]");
223
+ }
224
+ }
225
+ // Reject unknown keys so a typo doesn't silently widen the match.
226
+ var allowed = { skus: 1, vendor_slug: 1, category: 1, tag_any: 1, tag_all: 1 };
227
+ for (var k = 0; k < keys.length; k += 1) {
228
+ if (!allowed[keys[k]]) {
229
+ throw new TypeError("productBulkOps." + label + ": unknown filter key '" + keys[k] + "'");
230
+ }
231
+ }
232
+ }
233
+
234
+ // Resolve the filter to a sorted array of product ids. Each filter
235
+ // key contributes a candidate set; the result is the intersection of
236
+ // those sets. An unfiltered key contributes "all product ids" — but
237
+ // since _validateFilter refuses the empty filter, at least one
238
+ // constraint is always present.
239
+ function _resolveFilter(query, filter, cap, label) {
240
+ var keysUsed = [];
241
+ if (filter.skus !== undefined) keysUsed.push("skus");
242
+ if (filter.vendor_slug !== undefined) keysUsed.push("vendor_slug");
243
+ if (filter.category !== undefined) keysUsed.push("category");
244
+ if (filter.tag_any !== undefined) keysUsed.push("tag_any");
245
+ if (filter.tag_all !== undefined) keysUsed.push("tag_all");
246
+
247
+ return _resolveFilterAsync(query, filter, keysUsed, cap, label);
248
+ }
249
+
250
+ async function _resolveFilterAsync(query, filter, keysUsed, cap, label) {
251
+ var sets = [];
252
+ for (var i = 0; i < keysUsed.length; i += 1) {
253
+ var key = keysUsed[i];
254
+ var ids = await _idsForKey(query, key, filter[key]);
255
+ sets.push(ids);
256
+ }
257
+ // Intersect.
258
+ var result;
259
+ if (sets.length === 1) {
260
+ result = sets[0];
261
+ } else {
262
+ var smallest = sets[0];
263
+ for (var s = 1; s < sets.length; s += 1) {
264
+ if (sets[s].length < smallest.length) smallest = sets[s];
265
+ }
266
+ var lookup = {};
267
+ smallest.forEach(function (id) { lookup[id] = 1; });
268
+ for (var j = 0; j < sets.length; j += 1) {
269
+ if (sets[j] === smallest) continue;
270
+ var next = {};
271
+ for (var n = 0; n < sets[j].length; n += 1) {
272
+ if (lookup[sets[j][n]]) next[sets[j][n]] = 1;
273
+ }
274
+ lookup = next;
275
+ }
276
+ result = Object.keys(lookup);
277
+ }
278
+ result.sort();
279
+ if (result.length > cap) {
280
+ throw new TypeError("productBulkOps." + label + ": filter resolved to " + result.length + " products — exceeds cap of " + cap + " per call. Narrow the filter or raise opts.maxBulkRows at create() time.");
281
+ }
282
+ return result;
283
+ }
284
+
285
+ // Build the candidate id set for one filter key. Each clause produces
286
+ // distinct product ids; duplicates inside a result set are collapsed
287
+ // before return.
288
+ async function _idsForKey(query, key, value) {
289
+ var rows;
290
+ var seen;
291
+ var out;
292
+ var i;
293
+ if (key === "skus") {
294
+ var placeholders = [];
295
+ var params = [];
296
+ for (i = 0; i < value.length; i += 1) {
297
+ placeholders.push("?" + (i + 1));
298
+ params.push(value[i]);
299
+ }
300
+ rows = (await query(
301
+ "SELECT DISTINCT product_id FROM variants WHERE sku IN (" + placeholders.join(", ") + ")",
302
+ params,
303
+ )).rows;
304
+ } else if (key === "vendor_slug") {
305
+ rows = (await query(
306
+ "SELECT DISTINCT v.product_id FROM variants v " +
307
+ "INNER JOIN vendor_skus vs ON vs.sku = v.sku " +
308
+ "WHERE vs.vendor_slug = ?1",
309
+ [value],
310
+ )).rows;
311
+ } else if (key === "category") {
312
+ rows = (await query(
313
+ "SELECT product_id FROM product_categories WHERE category = ?1",
314
+ [value],
315
+ )).rows;
316
+ } else if (key === "tag_any") {
317
+ var ph = [];
318
+ var pa = [];
319
+ for (i = 0; i < value.length; i += 1) {
320
+ ph.push("?" + (i + 1));
321
+ pa.push(value[i]);
322
+ }
323
+ rows = (await query(
324
+ "SELECT DISTINCT product_id FROM product_tags WHERE tag IN (" + ph.join(", ") + ")",
325
+ pa,
326
+ )).rows;
327
+ } else if (key === "tag_all") {
328
+ // Products with every requested tag — group by product_id and
329
+ // require COUNT(DISTINCT tag) == requested length.
330
+ var ph2 = [];
331
+ var pa2 = [];
332
+ for (i = 0; i < value.length; i += 1) {
333
+ ph2.push("?" + (i + 1));
334
+ pa2.push(value[i]);
335
+ }
336
+ pa2.push(value.length);
337
+ rows = (await query(
338
+ "SELECT product_id FROM product_tags WHERE tag IN (" + ph2.join(", ") + ") " +
339
+ "GROUP BY product_id HAVING COUNT(DISTINCT tag) = ?" + (value.length + 1),
340
+ pa2,
341
+ )).rows;
342
+ } else {
343
+ rows = [];
344
+ }
345
+ seen = {};
346
+ out = [];
347
+ for (i = 0; i < rows.length; i += 1) {
348
+ var pid = rows[i].product_id;
349
+ if (!seen[pid]) { seen[pid] = 1; out.push(pid); }
350
+ }
351
+ return out;
352
+ }
353
+
354
+ // ---- factory --------------------------------------------------------------
355
+
356
+ function create(opts) {
357
+ opts = opts || {};
358
+ if (!opts.catalog) {
359
+ throw new TypeError("productBulkOps.create: catalog handle required");
360
+ }
361
+ var catalog = opts.catalog;
362
+ var maxBulkRows = opts.maxBulkRows == null ? DEFAULT_CAP : opts.maxBulkRows;
363
+ if (!Number.isInteger(maxBulkRows) || maxBulkRows <= 0 || maxBulkRows > MAX_CAP) {
364
+ throw new TypeError("productBulkOps.create: maxBulkRows must be a positive integer ≤ " + MAX_CAP);
365
+ }
366
+ var query = opts.query;
367
+ if (!query) {
368
+ query = function (sql, params) { return _b().externalDb.query(sql, params); };
369
+ }
370
+
371
+ // Write one append-only audit row. Drop-noise: a failed audit-row
372
+ // insert here is a hard error — the audit trail is the contract
373
+ // every bulk op honours. The catalog primitive owns the rollback
374
+ // story for the data writes; this row is the breadcrumb so the
375
+ // operator can reconcile after the fact.
376
+ async function _writeAudit(kind, filter, params, affectedCount, actorId) {
377
+ var id = _id();
378
+ var ts = _now();
379
+ await query(
380
+ "INSERT INTO product_bulk_audit " +
381
+ "(id, kind, filter_json, params_json, affected_count, performed_at, actor_id) " +
382
+ "VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
383
+ [
384
+ id, kind,
385
+ JSON.stringify(filter || {}),
386
+ JSON.stringify(params || {}),
387
+ affectedCount, ts,
388
+ actorId == null ? null : actorId,
389
+ ],
390
+ );
391
+ return {
392
+ id: id,
393
+ kind: kind,
394
+ filter: filter,
395
+ params: params,
396
+ affected_count: affectedCount,
397
+ performed_at: ts,
398
+ actor_id: actorId == null ? null : actorId,
399
+ };
400
+ }
401
+
402
+ function _maybeActor(input) {
403
+ if (input.actor_id == null) return null;
404
+ return _uuid(input.actor_id, "actor_id");
405
+ }
406
+
407
+ // Resolve filter → product ids, cap-checked, ready for write loop.
408
+ async function _matchProductIds(filter, label) {
409
+ _validateFilter(filter, label);
410
+ return await _resolveFilter(query, filter, maxBulkRows, label);
411
+ }
412
+
413
+ // Load every variant row for the matched product set. Variants are
414
+ // the unit price + inventory mutations target; products own the
415
+ // status + tag set + category set.
416
+ async function _variantsForProducts(productIds) {
417
+ if (productIds.length === 0) return [];
418
+ var ph = [];
419
+ var pa = [];
420
+ for (var i = 0; i < productIds.length; i += 1) {
421
+ ph.push("?" + (i + 1));
422
+ pa.push(productIds[i]);
423
+ }
424
+ var r = await query(
425
+ "SELECT id, product_id, sku FROM variants WHERE product_id IN (" + ph.join(", ") + ")",
426
+ pa,
427
+ );
428
+ return r.rows;
429
+ }
430
+
431
+ return {
432
+ AUDIT_KINDS: AUDIT_KINDS,
433
+ MAX_BULK_ROWS: maxBulkRows,
434
+ DEFAULT_CAP: DEFAULT_CAP,
435
+
436
+ // Read-only — render the matched product set for an operator
437
+ // preview affordance. Never mutates anything.
438
+ previewFilter: async function (input) {
439
+ if (!input || typeof input !== "object") throw new TypeError("productBulkOps.previewFilter: input object required");
440
+ var filter = input.filter;
441
+ _validateFilter(filter, "previewFilter");
442
+ var ids = await _resolveFilter(query, filter, maxBulkRows, "previewFilter");
443
+ if (ids.length === 0) {
444
+ return { matched: 0, product_ids: [], products: [] };
445
+ }
446
+ var ph = [];
447
+ var pa = [];
448
+ for (var i = 0; i < ids.length; i += 1) {
449
+ ph.push("?" + (i + 1));
450
+ pa.push(ids[i]);
451
+ }
452
+ var r = await query(
453
+ "SELECT * FROM products WHERE id IN (" + ph.join(", ") + ") ORDER BY id ASC",
454
+ pa,
455
+ );
456
+ return {
457
+ matched: r.rows.length,
458
+ product_ids: ids,
459
+ products: r.rows,
460
+ };
461
+ },
462
+
463
+ // Set a flat amount_minor on every matched variant in the given
464
+ // currency. Goes through catalog.prices.set so the prior price
465
+ // is versioned (closed with effective_until) rather than
466
+ // overwritten.
467
+ bulkSetPrice: async function (input) {
468
+ if (!input || typeof input !== "object") throw new TypeError("productBulkOps.bulkSetPrice: input object required");
469
+ var filter = input.filter;
470
+ _currency(input.currency);
471
+ _nonNegInt(input.amount_minor, "amount_minor");
472
+ var actorId = _maybeActor(input);
473
+ var productIds = await _matchProductIds(filter, "bulkSetPrice");
474
+ var variants = await _variantsForProducts(productIds);
475
+ var affected = 0;
476
+ for (var i = 0; i < variants.length; i += 1) {
477
+ await catalog.prices.set(variants[i].id, {
478
+ currency: input.currency,
479
+ amount_minor: input.amount_minor,
480
+ });
481
+ affected += 1;
482
+ }
483
+ var paramsOut = { currency: input.currency, amount_minor: input.amount_minor };
484
+ var auditRow = await _writeAudit("set_price", filter, paramsOut, affected, actorId);
485
+ return {
486
+ affected_count: affected,
487
+ matched_products: productIds.length,
488
+ matched_variants: variants.length,
489
+ audit_id: auditRow.id,
490
+ };
491
+ },
492
+
493
+ // Apply a percent_bps delta to every matched variant's CURRENT
494
+ // price in the given currency. Variants without a current price
495
+ // in the target currency are skipped (they don't have a "before"
496
+ // amount to multiply against). The new amount is rounded with
497
+ // Math.round and floored at 0.
498
+ bulkAdjustPrice: async function (input) {
499
+ if (!input || typeof input !== "object") throw new TypeError("productBulkOps.bulkAdjustPrice: input object required");
500
+ var filter = input.filter;
501
+ _currency(input.currency);
502
+ _bps(input.percent_bps);
503
+ var actorId = _maybeActor(input);
504
+ var productIds = await _matchProductIds(filter, "bulkAdjustPrice");
505
+ var variants = await _variantsForProducts(productIds);
506
+ var affected = 0;
507
+ var skipped = 0;
508
+ var multiplier = (10000 + input.percent_bps) / 10000;
509
+ for (var i = 0; i < variants.length; i += 1) {
510
+ var current = await catalog.prices.current(variants[i].id, input.currency);
511
+ if (!current) { skipped += 1; continue; }
512
+ var next = Math.round(current.amount_minor * multiplier);
513
+ if (next < 0) next = 0;
514
+ await catalog.prices.set(variants[i].id, {
515
+ currency: input.currency,
516
+ amount_minor: next,
517
+ });
518
+ affected += 1;
519
+ }
520
+ var paramsOut = { currency: input.currency, percent_bps: input.percent_bps };
521
+ var auditRow = await _writeAudit("adjust_price", filter, paramsOut, affected, actorId);
522
+ return {
523
+ affected_count: affected,
524
+ skipped_count: skipped,
525
+ matched_products: productIds.length,
526
+ matched_variants: variants.length,
527
+ audit_id: auditRow.id,
528
+ };
529
+ },
530
+
531
+ bulkArchive: async function (input) {
532
+ if (!input || typeof input !== "object") throw new TypeError("productBulkOps.bulkArchive: input object required");
533
+ var filter = input.filter;
534
+ var actorId = _maybeActor(input);
535
+ var productIds = await _matchProductIds(filter, "bulkArchive");
536
+ if (productIds.length === 0) {
537
+ var emptyRow = await _writeAudit("archive", filter, {}, 0, actorId);
538
+ return { affected_count: 0, matched_products: 0, audit_id: emptyRow.id };
539
+ }
540
+ var ph = [];
541
+ var pa = [];
542
+ for (var i = 0; i < productIds.length; i += 1) {
543
+ ph.push("?" + (i + 1));
544
+ pa.push(productIds[i]);
545
+ }
546
+ pa.push(_now());
547
+ var r = await query(
548
+ "UPDATE products SET status = 'archived', updated_at = ?" + (productIds.length + 1) + " " +
549
+ "WHERE id IN (" + ph.join(", ") + ") AND status != 'archived'",
550
+ pa,
551
+ );
552
+ var affected = r.rowCount;
553
+ var auditRow = await _writeAudit("archive", filter, {}, affected, actorId);
554
+ return {
555
+ affected_count: affected,
556
+ matched_products: productIds.length,
557
+ audit_id: auditRow.id,
558
+ };
559
+ },
560
+
561
+ bulkUnarchive: async function (input) {
562
+ if (!input || typeof input !== "object") throw new TypeError("productBulkOps.bulkUnarchive: input object required");
563
+ var filter = input.filter;
564
+ var actorId = _maybeActor(input);
565
+ var productIds = await _matchProductIds(filter, "bulkUnarchive");
566
+ if (productIds.length === 0) {
567
+ var emptyRow = await _writeAudit("unarchive", filter, {}, 0, actorId);
568
+ return { affected_count: 0, matched_products: 0, audit_id: emptyRow.id };
569
+ }
570
+ var ph = [];
571
+ var pa = [];
572
+ for (var i = 0; i < productIds.length; i += 1) {
573
+ ph.push("?" + (i + 1));
574
+ pa.push(productIds[i]);
575
+ }
576
+ pa.push(_now());
577
+ var r = await query(
578
+ "UPDATE products SET status = 'draft', updated_at = ?" + (productIds.length + 1) + " " +
579
+ "WHERE id IN (" + ph.join(", ") + ") AND status = 'archived'",
580
+ pa,
581
+ );
582
+ var affected = r.rowCount;
583
+ var auditRow = await _writeAudit("unarchive", filter, {}, affected, actorId);
584
+ return {
585
+ affected_count: affected,
586
+ matched_products: productIds.length,
587
+ audit_id: auditRow.id,
588
+ };
589
+ },
590
+
591
+ // Insert (product_id, tag) for every matched product. The PK
592
+ // refuses duplicates so a re-add is a no-op per row. We do the
593
+ // insert one row at a time and count successes — INSERT OR
594
+ // IGNORE keeps the loop forward-progressing without a per-row
595
+ // SELECT-then-INSERT race.
596
+ bulkAddTag: async function (input) {
597
+ if (!input || typeof input !== "object") throw new TypeError("productBulkOps.bulkAddTag: input object required");
598
+ var filter = input.filter;
599
+ _tag(input.tag, "tag");
600
+ var actorId = _maybeActor(input);
601
+ var productIds = await _matchProductIds(filter, "bulkAddTag");
602
+ var ts = _now();
603
+ var affected = 0;
604
+ for (var i = 0; i < productIds.length; i += 1) {
605
+ var r = await query(
606
+ "INSERT OR IGNORE INTO product_tags (product_id, tag, added_at) VALUES (?1, ?2, ?3)",
607
+ [productIds[i], input.tag, ts],
608
+ );
609
+ affected += r.rowCount;
610
+ }
611
+ var paramsOut = { tag: input.tag };
612
+ var auditRow = await _writeAudit("add_tag", filter, paramsOut, affected, actorId);
613
+ return {
614
+ affected_count: affected,
615
+ matched_products: productIds.length,
616
+ audit_id: auditRow.id,
617
+ };
618
+ },
619
+
620
+ bulkRemoveTag: async function (input) {
621
+ if (!input || typeof input !== "object") throw new TypeError("productBulkOps.bulkRemoveTag: input object required");
622
+ var filter = input.filter;
623
+ _tag(input.tag, "tag");
624
+ var actorId = _maybeActor(input);
625
+ var productIds = await _matchProductIds(filter, "bulkRemoveTag");
626
+ if (productIds.length === 0) {
627
+ var emptyRow = await _writeAudit("remove_tag", filter, { tag: input.tag }, 0, actorId);
628
+ return { affected_count: 0, matched_products: 0, audit_id: emptyRow.id };
629
+ }
630
+ var ph = [];
631
+ var pa = [];
632
+ for (var i = 0; i < productIds.length; i += 1) {
633
+ ph.push("?" + (i + 1));
634
+ pa.push(productIds[i]);
635
+ }
636
+ pa.push(input.tag);
637
+ var r = await query(
638
+ "DELETE FROM product_tags WHERE product_id IN (" + ph.join(", ") + ") AND tag = ?" + (productIds.length + 1),
639
+ pa,
640
+ );
641
+ var affected = r.rowCount;
642
+ var auditRow = await _writeAudit("remove_tag", filter, { tag: input.tag }, affected, actorId);
643
+ return {
644
+ affected_count: affected,
645
+ matched_products: productIds.length,
646
+ audit_id: auditRow.id,
647
+ };
648
+ },
649
+
650
+ // Upsert inventory.stock_on_hand to a flat value for every
651
+ // variant of every matched product. Missing inventory rows are
652
+ // created at the target stock. `stock_held` is preserved on
653
+ // existing rows — the operator changes "on hand", not the
654
+ // checkout-side reservation count.
655
+ bulkSetInventory: async function (input) {
656
+ if (!input || typeof input !== "object") throw new TypeError("productBulkOps.bulkSetInventory: input object required");
657
+ var filter = input.filter;
658
+ _nonNegInt(input.stock_on_hand, "stock_on_hand");
659
+ var actorId = _maybeActor(input);
660
+ var productIds = await _matchProductIds(filter, "bulkSetInventory");
661
+ var variants = await _variantsForProducts(productIds);
662
+ var ts = _now();
663
+ var affected = 0;
664
+ for (var i = 0; i < variants.length; i += 1) {
665
+ var sku = variants[i].sku;
666
+ var existing = (await query(
667
+ "SELECT sku FROM inventory WHERE sku = ?1",
668
+ [sku],
669
+ )).rows[0];
670
+ if (existing) {
671
+ var u = await query(
672
+ "UPDATE inventory SET stock_on_hand = ?1, updated_at = ?2 WHERE sku = ?3",
673
+ [input.stock_on_hand, ts, sku],
674
+ );
675
+ affected += u.rowCount;
676
+ } else {
677
+ await query(
678
+ "INSERT INTO inventory (sku, stock_on_hand, stock_held, updated_at) VALUES (?1, ?2, 0, ?3)",
679
+ [sku, input.stock_on_hand, ts],
680
+ );
681
+ affected += 1;
682
+ }
683
+ }
684
+ var paramsOut = { stock_on_hand: input.stock_on_hand };
685
+ var auditRow = await _writeAudit("set_inventory", filter, paramsOut, affected, actorId);
686
+ return {
687
+ affected_count: affected,
688
+ matched_products: productIds.length,
689
+ matched_variants: variants.length,
690
+ audit_id: auditRow.id,
691
+ };
692
+ },
693
+
694
+ // Append-only audit-trail reader. Narrow by kind / since / limit
695
+ // / actor_id; default is "newest 50 across every kind".
696
+ auditTrail: async function (opts2) {
697
+ opts2 = opts2 || {};
698
+ var clauses = [];
699
+ var params = [];
700
+ var idx = 1;
701
+ if (opts2.kind !== undefined) {
702
+ if (AUDIT_KINDS.indexOf(opts2.kind) === -1) {
703
+ throw new TypeError("productBulkOps.auditTrail: kind must be one of " + AUDIT_KINDS.join(", "));
704
+ }
705
+ clauses.push("kind = ?" + (idx++));
706
+ params.push(opts2.kind);
707
+ }
708
+ if (opts2.since !== undefined) {
709
+ if (!Number.isInteger(opts2.since) || opts2.since < 0) {
710
+ throw new TypeError("productBulkOps.auditTrail: since must be a non-negative epoch ms integer");
711
+ }
712
+ clauses.push("performed_at >= ?" + (idx++));
713
+ params.push(opts2.since);
714
+ }
715
+ if (opts2.actor_id !== undefined) {
716
+ var actor = _uuid(opts2.actor_id, "actor_id");
717
+ clauses.push("actor_id = ?" + (idx++));
718
+ params.push(actor);
719
+ }
720
+ var limit = opts2.limit == null ? 50 : opts2.limit;
721
+ if (!Number.isInteger(limit) || limit <= 0 || limit > 500) {
722
+ throw new TypeError("productBulkOps.auditTrail: limit must be 1..500");
723
+ }
724
+ var sql = "SELECT * FROM product_bulk_audit" +
725
+ (clauses.length ? " WHERE " + clauses.join(" AND ") : "") +
726
+ " ORDER BY performed_at DESC, id DESC LIMIT ?" + idx;
727
+ params.push(limit);
728
+ var r = await query(sql, params);
729
+ return r.rows.map(function (row) {
730
+ return {
731
+ id: row.id,
732
+ kind: row.kind,
733
+ filter: JSON.parse(row.filter_json),
734
+ params: JSON.parse(row.params_json),
735
+ affected_count: row.affected_count,
736
+ performed_at: row.performed_at,
737
+ actor_id: row.actor_id,
738
+ };
739
+ });
740
+ },
741
+
742
+ // Small admin surface for the category join table — used by the
743
+ // operator console to assign / remove a category from a product.
744
+ // The bulk-ops primitive owns this because the table is its
745
+ // domain (the filter resolution is the only consumer).
746
+ categories: {
747
+ assign: async function (productId, category) {
748
+ _uuid(productId, "product_id");
749
+ _category(category, "category");
750
+ var ts = _now();
751
+ await query(
752
+ "INSERT OR IGNORE INTO product_categories (product_id, category, added_at) VALUES (?1, ?2, ?3)",
753
+ [productId, category, ts],
754
+ );
755
+ return { product_id: productId, category: category, added_at: ts };
756
+ },
757
+ remove: async function (productId, category) {
758
+ _uuid(productId, "product_id");
759
+ _category(category, "category");
760
+ var r = await query(
761
+ "DELETE FROM product_categories WHERE product_id = ?1 AND category = ?2",
762
+ [productId, category],
763
+ );
764
+ return { removed: r.rowCount };
765
+ },
766
+ listForProduct: async function (productId) {
767
+ _uuid(productId, "product_id");
768
+ var r = await query(
769
+ "SELECT category FROM product_categories WHERE product_id = ?1 ORDER BY category ASC",
770
+ [productId],
771
+ );
772
+ return r.rows.map(function (row) { return row.category; });
773
+ },
774
+ },
775
+
776
+ // Same small admin surface for the tag table — operators address
777
+ // single-product tag mutations through here; the bulk paths are
778
+ // the loop-N variant.
779
+ tags: {
780
+ listForProduct: async function (productId) {
781
+ _uuid(productId, "product_id");
782
+ var r = await query(
783
+ "SELECT tag FROM product_tags WHERE product_id = ?1 ORDER BY tag ASC",
784
+ [productId],
785
+ );
786
+ return r.rows.map(function (row) { return row.tag; });
787
+ },
788
+ },
789
+ };
790
+ }
791
+
792
+ module.exports = {
793
+ create: create,
794
+ AUDIT_KINDS: AUDIT_KINDS,
795
+ DEFAULT_CAP: DEFAULT_CAP,
796
+ MAX_CAP: MAX_CAP,
797
+ };