@blamejs/blamejs-shop 0.0.56 → 0.0.58

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/lib/bundles.js ADDED
@@ -0,0 +1,587 @@
1
+ "use strict";
2
+ /**
3
+ * @module shop.bundles
4
+ * @title Bundles primitive — virtual composite SKUs (kit products)
5
+ *
6
+ * @intro
7
+ * A bundle is a virtual SKU that resolves to N child SKUs with
8
+ * integer quantity multipliers. The storefront sells the bundle as
9
+ * a single line item; the cart-line resolution layer composes
10
+ * `bundles.expand()` to convert that line into the underlying
11
+ * component lines so inventory deduction, fulfillment picking and
12
+ * tax/shipping see the real components.
13
+ *
14
+ * Composition rules:
15
+ *
16
+ * - Component SKUs MUST exist in the catalog at define time. The
17
+ * primitive composes `catalog.variants.bySku(sku)` to verify;
18
+ * an unknown SKU throws synchronously.
19
+ *
20
+ * - A component MAY itself be a bundle. Nesting is capped at two
21
+ * levels — a top-level bundle whose components are themselves
22
+ * bundles is allowed; a three-level chain (bundle → bundle →
23
+ * bundle) is refused at `expand()` time.
24
+ *
25
+ * - Cyclic references are refused at `defineBundle()`. The
26
+ * primitive walks every component (and their components) up to
27
+ * the nesting cap, looking for a back-edge to the bundle being
28
+ * defined.
29
+ *
30
+ * Pricing:
31
+ *
32
+ * `priceBundle({ bundle_sku, pricing })` sums every leaf
33
+ * component's `pricing.priceFor(sku) → { amount_minor, currency }`
34
+ * (multiplied by its effective quantity), optionally applying the
35
+ * stored `bundle_discount_bps` override. The caller is
36
+ * responsible for currency consistency across components; mixed
37
+ * currencies throw rather than silently coercing.
38
+ *
39
+ * Deletion:
40
+ *
41
+ * `deleteBundle()` refuses while any active cart references the
42
+ * bundle. Reactivation of an "abandoned" cart that referenced the
43
+ * bundle is the operator's problem to resolve; the primitive
44
+ * only blocks on rows currently held by `carts.status = 'active'`.
45
+ *
46
+ * Subscriptions in this codebase bind to a variant_id (not an
47
+ * SKU), so a subscription cannot directly hold a bundle today.
48
+ * The deletion check stays scoped to cart_lines; a future
49
+ * subscription-by-sku surface composes the same check by passing
50
+ * an `extraReferenceCheck` callback to the factory.
51
+ */
52
+
53
+ var bShop;
54
+ function _b() {
55
+ if (!bShop) bShop = require("./index");
56
+ return bShop.framework;
57
+ }
58
+
59
+ // SKU shape is shared with the catalog primitive — same alphabet, same
60
+ // length cap. Component lookup goes through the catalog, so the two
61
+ // validators must stay in lockstep; if catalog.SKU_RE ever changes,
62
+ // this regex changes with it. The two are pinned by tests so drift
63
+ // surfaces immediately.
64
+ var SKU_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
65
+ var MAX_TITLE_LEN = 500;
66
+ var MAX_LIMIT = 200;
67
+ var MAX_NESTING_DEPTH = 2;
68
+
69
+ // Mutable columns for updateBundle — `bundle_sku` is the natural key
70
+ // and is immutable post-create (operators delete + redefine to rename
71
+ // a bundle SKU). `created_at` is also immutable.
72
+ var ALLOWED_BUNDLE_COLUMNS = Object.freeze(["title", "bundle_discount_bps"]);
73
+
74
+ var BUNDLE_ORDER_KEY = ["updated_at:desc", "bundle_sku:desc"];
75
+
76
+ // ---- validators ----------------------------------------------------------
77
+
78
+ function _sku(s, label) {
79
+ if (typeof s !== "string" || !SKU_RE.test(s)) {
80
+ throw new TypeError("bundles: " + label + " must match /^[A-Za-z0-9][A-Za-z0-9._-]*$/ (alnum + . _ -, <= 128 chars)");
81
+ }
82
+ }
83
+
84
+ function _title(s) {
85
+ if (typeof s !== "string" || !s.length || s.length > MAX_TITLE_LEN) {
86
+ throw new TypeError("bundles: title must be a non-empty string <= " + MAX_TITLE_LEN + " chars");
87
+ }
88
+ }
89
+
90
+ function _positiveInt(n, label) {
91
+ if (!Number.isInteger(n) || n <= 0) {
92
+ throw new TypeError("bundles: " + label + " must be a positive integer");
93
+ }
94
+ }
95
+
96
+ function _discountBps(n) {
97
+ if (n == null) return null;
98
+ if (!Number.isInteger(n) || n < 0 || n > 10000) {
99
+ throw new TypeError("bundles: bundle_discount_bps must be an integer in [0, 10000] (basis points) or null");
100
+ }
101
+ return n;
102
+ }
103
+
104
+ function _now() { return Date.now(); }
105
+
106
+ // ---- factory -------------------------------------------------------------
107
+
108
+ function create(opts) {
109
+ opts = opts || {};
110
+ var query = opts.query;
111
+ if (!query) {
112
+ query = function (sql, params) { return _b().externalDb.query(sql, params); };
113
+ }
114
+ var catalog = opts.catalog;
115
+ if (!catalog || !catalog.variants || typeof catalog.variants.bySku !== "function") {
116
+ throw new TypeError("bundles.create: opts.catalog must expose variants.bySku(sku)");
117
+ }
118
+ if (typeof opts.cursorSecret !== "string" || !opts.cursorSecret.length) {
119
+ if (process.env.NODE_ENV === "production") {
120
+ throw new Error("bundles.create: opts.cursorSecret is required in production");
121
+ }
122
+ opts.cursorSecret = "bundles-cursor-secret-dev-only";
123
+ }
124
+ var cursorSecret = opts.cursorSecret;
125
+
126
+ // Optional caller-supplied reference check fires alongside the
127
+ // built-in active-cart check inside deleteBundle. Returns truthy to
128
+ // veto the delete (signature: `async (bundle_sku) => bool`). Lets a
129
+ // downstream subscription-by-sku surface compose its own veto
130
+ // without a schema change here.
131
+ var extraReferenceCheck = typeof opts.extraReferenceCheck === "function"
132
+ ? opts.extraReferenceCheck
133
+ : null;
134
+
135
+ // ---- internal helpers --------------------------------------------------
136
+
137
+ async function _bundleRow(bundleSku) {
138
+ var r = await query("SELECT * FROM bundles WHERE bundle_sku = ?1", [bundleSku]);
139
+ return r.rows[0] || null;
140
+ }
141
+
142
+ async function _componentRows(bundleSku) {
143
+ var r = await query(
144
+ "SELECT bundle_sku, sku, quantity, sort_order FROM bundle_components " +
145
+ "WHERE bundle_sku = ?1 ORDER BY sort_order ASC, sku ASC",
146
+ [bundleSku],
147
+ );
148
+ return r.rows;
149
+ }
150
+
151
+ // Cycle + missing-SKU walker. Used at defineBundle to verify that
152
+ // none of the proposed components (or their transitive components,
153
+ // up to MAX_NESTING_DEPTH) loops back to `rootSku`, and that every
154
+ // referenced SKU exists in the catalog OR resolves to another
155
+ // already-stored bundle (which itself was verified at its own
156
+ // define time).
157
+ //
158
+ // The walker is bounded: it returns control after MAX_NESTING_DEPTH
159
+ // levels regardless of whether the leaves are bundles or variants.
160
+ // expand() applies the same cap with the matching error message.
161
+ async function _verifyComponentsAcyclic(rootSku, components) {
162
+ // Components are already shape-validated by the caller.
163
+ for (var i = 0; i < components.length; i += 1) {
164
+ var sku = components[i].sku;
165
+ if (sku === rootSku) {
166
+ throw new TypeError("bundles.defineBundle: cyclic reference — component sku " + JSON.stringify(sku) + " equals bundle_sku");
167
+ }
168
+ }
169
+ // For each component, classify as bundle vs variant and walk
170
+ // bundle children one more level deep. A component that itself
171
+ // points to bundles forms a 2-level structure; we must look at
172
+ // those grandchildren to catch a back-edge to rootSku.
173
+ for (var j = 0; j < components.length; j += 1) {
174
+ var csku = components[j].sku;
175
+ var childBundle = await _bundleRow(csku);
176
+ if (childBundle) {
177
+ var grand = await _componentRows(csku);
178
+ for (var k = 0; k < grand.length; k += 1) {
179
+ if (grand[k].sku === rootSku) {
180
+ throw new TypeError("bundles.defineBundle: cyclic reference — component " + JSON.stringify(csku) +
181
+ " contains " + JSON.stringify(rootSku));
182
+ }
183
+ // Walking further would exceed MAX_NESTING_DEPTH; the
184
+ // grandchild MUST be a leaf variant (not a third-level
185
+ // bundle) for the structure to be expandable. Verify the
186
+ // leaf is a real catalog SKU; if it's another bundle, we
187
+ // reject here because expand() would refuse it anyway.
188
+ var grandIsBundle = await _bundleRow(grand[k].sku);
189
+ if (grandIsBundle) {
190
+ throw new TypeError("bundles.defineBundle: nesting exceeds " + MAX_NESTING_DEPTH +
191
+ " levels — component " + JSON.stringify(csku) + " contains bundle " + JSON.stringify(grand[k].sku));
192
+ }
193
+ var leafVariant = await catalog.variants.bySku(grand[k].sku);
194
+ if (!leafVariant) {
195
+ throw new TypeError("bundles.defineBundle: nested component sku " + JSON.stringify(grand[k].sku) +
196
+ " (in bundle " + JSON.stringify(csku) + ") not found in catalog");
197
+ }
198
+ }
199
+ continue;
200
+ }
201
+ // Not a bundle — must be a catalog variant.
202
+ var variant = await catalog.variants.bySku(csku);
203
+ if (!variant) {
204
+ throw new TypeError("bundles.defineBundle: component sku " + JSON.stringify(csku) + " not found in catalog");
205
+ }
206
+ }
207
+ }
208
+
209
+ // ---- defineBundle ------------------------------------------------------
210
+
211
+ async function defineBundle(input) {
212
+ if (!input || typeof input !== "object") {
213
+ throw new TypeError("bundles.defineBundle: input object required");
214
+ }
215
+ _sku(input.bundle_sku, "bundle_sku");
216
+ _title(input.title);
217
+ if (!Array.isArray(input.components) || input.components.length === 0) {
218
+ throw new TypeError("bundles.defineBundle: components must be a non-empty array");
219
+ }
220
+ var discountBps = _discountBps(input.bundle_discount_bps == null ? null : input.bundle_discount_bps);
221
+
222
+ // Validate every component shape before any DB write.
223
+ var seen = Object.create(null);
224
+ var components = [];
225
+ for (var i = 0; i < input.components.length; i += 1) {
226
+ var c = input.components[i];
227
+ if (!c || typeof c !== "object") {
228
+ throw new TypeError("bundles.defineBundle: components[" + i + "] must be an object");
229
+ }
230
+ _sku(c.sku, "components[" + i + "].sku");
231
+ _positiveInt(c.quantity, "components[" + i + "].quantity");
232
+ if (seen[c.sku]) {
233
+ throw new TypeError("bundles.defineBundle: duplicate component sku " + JSON.stringify(c.sku));
234
+ }
235
+ seen[c.sku] = true;
236
+ components.push({ sku: c.sku, quantity: c.quantity });
237
+ }
238
+
239
+ // Refuse if the bundle_sku collides with an existing catalog
240
+ // variant — bundle SKUs and variant SKUs share a namespace and
241
+ // an ambiguous lookup would silently route the wrong row.
242
+ var collision = await catalog.variants.bySku(input.bundle_sku);
243
+ if (collision) {
244
+ throw new TypeError("bundles.defineBundle: bundle_sku " + JSON.stringify(input.bundle_sku) +
245
+ " collides with an existing catalog variant SKU");
246
+ }
247
+
248
+ // Refuse on existing bundle_sku.
249
+ var existing = await _bundleRow(input.bundle_sku);
250
+ if (existing) {
251
+ throw new TypeError("bundles.defineBundle: bundle_sku " + JSON.stringify(input.bundle_sku) + " already exists");
252
+ }
253
+
254
+ // Verify every component exists in catalog (or resolves to a
255
+ // bundle whose own components live in catalog), and that no
256
+ // transitive component loops back to bundle_sku. Pure read-only
257
+ // — runs before any insert.
258
+ await _verifyComponentsAcyclic(input.bundle_sku, components);
259
+
260
+ var ts = _now();
261
+ await query(
262
+ "INSERT INTO bundles (bundle_sku, title, bundle_discount_bps, created_at, updated_at) " +
263
+ "VALUES (?1, ?2, ?3, ?4, ?4)",
264
+ [input.bundle_sku, input.title, discountBps, ts],
265
+ );
266
+ for (var k = 0; k < components.length; k += 1) {
267
+ await query(
268
+ "INSERT INTO bundle_components (bundle_sku, sku, quantity, sort_order) VALUES (?1, ?2, ?3, ?4)",
269
+ [input.bundle_sku, components[k].sku, components[k].quantity, k],
270
+ );
271
+ }
272
+ return await getBundle(input.bundle_sku);
273
+ }
274
+
275
+ // ---- getBundle ---------------------------------------------------------
276
+
277
+ async function getBundle(bundleSku) {
278
+ _sku(bundleSku, "bundle_sku");
279
+ var row = await _bundleRow(bundleSku);
280
+ if (!row) return null;
281
+ var comps = await _componentRows(bundleSku);
282
+ return {
283
+ bundle_sku: row.bundle_sku,
284
+ title: row.title,
285
+ bundle_discount_bps: row.bundle_discount_bps,
286
+ created_at: row.created_at,
287
+ updated_at: row.updated_at,
288
+ components: comps.map(function (c) {
289
+ return { sku: c.sku, quantity: c.quantity, sort_order: c.sort_order };
290
+ }),
291
+ };
292
+ }
293
+
294
+ // ---- listBundles -------------------------------------------------------
295
+
296
+ async function listBundles(input) {
297
+ input = input || {};
298
+ var limit = input.limit == null ? 50 : input.limit;
299
+ if (!Number.isInteger(limit) || limit <= 0 || limit > MAX_LIMIT) {
300
+ throw new TypeError("bundles.listBundles: limit must be 1..." + MAX_LIMIT);
301
+ }
302
+ var cursorVals = null;
303
+ if (input.cursor != null) {
304
+ if (typeof input.cursor !== "string") {
305
+ throw new TypeError("bundles.listBundles: cursor must be an opaque string or null");
306
+ }
307
+ try {
308
+ var state = _b().pagination.decodeCursor(input.cursor, cursorSecret);
309
+ if (JSON.stringify(state.orderKey) !== JSON.stringify(BUNDLE_ORDER_KEY)) {
310
+ throw new TypeError("bundles.listBundles: cursor orderKey mismatch");
311
+ }
312
+ cursorVals = state.vals;
313
+ } catch (e) {
314
+ if (e instanceof TypeError) throw e;
315
+ throw new TypeError("bundles.listBundles: cursor — " + (e && e.message || "malformed"));
316
+ }
317
+ }
318
+ var sql, params;
319
+ if (cursorVals) {
320
+ sql = "SELECT * FROM bundles WHERE updated_at < ?1 OR (updated_at = ?1 AND bundle_sku < ?2) " +
321
+ "ORDER BY updated_at DESC, bundle_sku DESC LIMIT ?3";
322
+ params = [cursorVals[0], cursorVals[1], limit];
323
+ } else {
324
+ sql = "SELECT * FROM bundles ORDER BY updated_at DESC, bundle_sku DESC LIMIT ?1";
325
+ params = [limit];
326
+ }
327
+ var r = await query(sql, params);
328
+ var last = r.rows[r.rows.length - 1];
329
+ var next = null;
330
+ if (last && r.rows.length === limit) {
331
+ next = _b().pagination.encodeCursor({
332
+ orderKey: BUNDLE_ORDER_KEY,
333
+ vals: [last.updated_at, last.bundle_sku],
334
+ forward: true,
335
+ }, cursorSecret);
336
+ }
337
+ return { rows: r.rows, next_cursor: next };
338
+ }
339
+
340
+ // ---- expand ------------------------------------------------------------
341
+
342
+ // Flatten a bundle into its leaf components with multipliers applied.
343
+ // Handles nested bundles two levels deep — a leaf that is itself a
344
+ // bundle gets expanded once more; a third-level bundle throws.
345
+ //
346
+ // Returns `[{ sku, quantity }, ...]` where each row represents the
347
+ // total demanded quantity for that leaf SKU across the entire
348
+ // expansion. If two components (directly or via nesting) reference
349
+ // the same leaf SKU, their quantities are summed into one row.
350
+ async function expand(input) {
351
+ if (!input || typeof input !== "object") {
352
+ throw new TypeError("bundles.expand: input object required");
353
+ }
354
+ _sku(input.bundle_sku, "bundle_sku");
355
+ _positiveInt(input.quantity, "quantity");
356
+
357
+ var row = await _bundleRow(input.bundle_sku);
358
+ if (!row) {
359
+ throw new TypeError("bundles.expand: bundle_sku " + JSON.stringify(input.bundle_sku) + " not found");
360
+ }
361
+ var comps = await _componentRows(input.bundle_sku);
362
+
363
+ var totals = Object.create(null);
364
+ function _add(sku, qty) {
365
+ totals[sku] = (totals[sku] || 0) + qty;
366
+ }
367
+
368
+ for (var i = 0; i < comps.length; i += 1) {
369
+ var c = comps[i];
370
+ var demanded = c.quantity * input.quantity;
371
+ var childBundle = await _bundleRow(c.sku);
372
+ if (childBundle) {
373
+ var childComps = await _componentRows(c.sku);
374
+ for (var j = 0; j < childComps.length; j += 1) {
375
+ var gc = childComps[j];
376
+ var grandIsBundle = await _bundleRow(gc.sku);
377
+ if (grandIsBundle) {
378
+ throw new TypeError("bundles.expand: nesting exceeds " + MAX_NESTING_DEPTH +
379
+ " levels — " + JSON.stringify(c.sku) + " contains bundle " + JSON.stringify(gc.sku));
380
+ }
381
+ _add(gc.sku, gc.quantity * demanded);
382
+ }
383
+ } else {
384
+ _add(c.sku, demanded);
385
+ }
386
+ }
387
+
388
+ var out = [];
389
+ var keys = Object.keys(totals).sort();
390
+ for (var k = 0; k < keys.length; k += 1) {
391
+ out.push({ sku: keys[k], quantity: totals[keys[k]] });
392
+ }
393
+ return out;
394
+ }
395
+
396
+ // ---- priceBundle -------------------------------------------------------
397
+
398
+ async function priceBundle(input) {
399
+ if (!input || typeof input !== "object") {
400
+ throw new TypeError("bundles.priceBundle: input object required");
401
+ }
402
+ _sku(input.bundle_sku, "bundle_sku");
403
+ var pricing = input.pricing;
404
+ if (!pricing || typeof pricing.priceFor !== "function") {
405
+ throw new TypeError("bundles.priceBundle: pricing.priceFor(sku) required");
406
+ }
407
+ var row = await _bundleRow(input.bundle_sku);
408
+ if (!row) {
409
+ throw new TypeError("bundles.priceBundle: bundle_sku " + JSON.stringify(input.bundle_sku) + " not found");
410
+ }
411
+
412
+ // Expand to leaf quantities at multiplier=1 so the per-leaf price
413
+ // lookup happens once. The bundle is priced at its own "unit"
414
+ // (one bundle); cart math multiplies that by the line qty
415
+ // downstream.
416
+ var leaves = await expand({ bundle_sku: input.bundle_sku, quantity: 1 });
417
+
418
+ var currency = null;
419
+ var listTotal = 0;
420
+ for (var i = 0; i < leaves.length; i += 1) {
421
+ var leaf = leaves[i];
422
+ var priced = await pricing.priceFor(leaf.sku);
423
+ if (!priced || typeof priced !== "object") {
424
+ throw new TypeError("bundles.priceBundle: pricing.priceFor(" + JSON.stringify(leaf.sku) + ") returned no row");
425
+ }
426
+ if (!Number.isInteger(priced.amount_minor) || priced.amount_minor < 0) {
427
+ throw new TypeError("bundles.priceBundle: pricing.priceFor(" + JSON.stringify(leaf.sku) + ").amount_minor must be a non-negative integer");
428
+ }
429
+ if (typeof priced.currency !== "string" || !priced.currency.length) {
430
+ throw new TypeError("bundles.priceBundle: pricing.priceFor(" + JSON.stringify(leaf.sku) + ").currency must be a non-empty string");
431
+ }
432
+ if (currency === null) {
433
+ currency = priced.currency;
434
+ } else if (priced.currency !== currency) {
435
+ throw new TypeError("bundles.priceBundle: mixed currencies — " + JSON.stringify(currency) +
436
+ " vs " + JSON.stringify(priced.currency) + " on sku " + JSON.stringify(leaf.sku));
437
+ }
438
+ listTotal += priced.amount_minor * leaf.quantity;
439
+ }
440
+
441
+ var discountBps = row.bundle_discount_bps;
442
+ var discountMinor = 0;
443
+ if (discountBps != null && discountBps > 0) {
444
+ // Integer floor — basis points are 1/100 of a percent (10000 =
445
+ // 100%). The floor keeps the customer from paying a fractional
446
+ // cent and matches the rounding convention the pricing
447
+ // primitive uses for line totals.
448
+ discountMinor = Math.floor((listTotal * discountBps) / 10000);
449
+ }
450
+ var grandTotal = listTotal - discountMinor;
451
+ return {
452
+ bundle_sku: input.bundle_sku,
453
+ currency: currency,
454
+ list_total_minor: listTotal,
455
+ bundle_discount_bps: discountBps,
456
+ discount_minor: discountMinor,
457
+ amount_minor: grandTotal,
458
+ };
459
+ }
460
+
461
+ // ---- updateBundle ------------------------------------------------------
462
+
463
+ async function updateBundle(bundleSku, patch) {
464
+ _sku(bundleSku, "bundle_sku");
465
+ if (!patch || typeof patch !== "object") {
466
+ throw new TypeError("bundles.updateBundle: patch object required");
467
+ }
468
+ var sets = [];
469
+ var params = [];
470
+ var idx = 1;
471
+ function _addSet(col, val) {
472
+ // Allow-list defense even though the column name is a literal —
473
+ // a future refactor that introduces a dynamic patch key path
474
+ // can't widen the surface to an attacker-controlled column.
475
+ _b().safeSql.assertOneOf(col, ALLOWED_BUNDLE_COLUMNS);
476
+ sets.push(_b().safeSql.quoteIdentifier(col, "sqlite") + " = ?" + (idx++));
477
+ params.push(val);
478
+ }
479
+ if (patch.title !== undefined) {
480
+ _title(patch.title);
481
+ _addSet("title", patch.title);
482
+ }
483
+ if (patch.bundle_discount_bps !== undefined) {
484
+ _addSet("bundle_discount_bps", _discountBps(patch.bundle_discount_bps));
485
+ }
486
+ if (patch.components !== undefined) {
487
+ // Component rewrites are a structural change: validate every
488
+ // proposed component, re-run the cycle walker against the
489
+ // existing bundle_sku, then atomically (best-effort under D1's
490
+ // no-cross-statement-transaction model) delete + reinsert.
491
+ if (!Array.isArray(patch.components) || patch.components.length === 0) {
492
+ throw new TypeError("bundles.updateBundle: components must be a non-empty array");
493
+ }
494
+ var seen = Object.create(null);
495
+ var newComponents = [];
496
+ for (var i = 0; i < patch.components.length; i += 1) {
497
+ var pc = patch.components[i];
498
+ if (!pc || typeof pc !== "object") {
499
+ throw new TypeError("bundles.updateBundle: components[" + i + "] must be an object");
500
+ }
501
+ _sku(pc.sku, "components[" + i + "].sku");
502
+ _positiveInt(pc.quantity, "components[" + i + "].quantity");
503
+ if (seen[pc.sku]) {
504
+ throw new TypeError("bundles.updateBundle: duplicate component sku " + JSON.stringify(pc.sku));
505
+ }
506
+ seen[pc.sku] = true;
507
+ newComponents.push({ sku: pc.sku, quantity: pc.quantity });
508
+ }
509
+ var existing = await _bundleRow(bundleSku);
510
+ if (!existing) return null;
511
+ await _verifyComponentsAcyclic(bundleSku, newComponents);
512
+ await query("DELETE FROM bundle_components WHERE bundle_sku = ?1", [bundleSku]);
513
+ for (var m = 0; m < newComponents.length; m += 1) {
514
+ await query(
515
+ "INSERT INTO bundle_components (bundle_sku, sku, quantity, sort_order) VALUES (?1, ?2, ?3, ?4)",
516
+ [bundleSku, newComponents[m].sku, newComponents[m].quantity, m],
517
+ );
518
+ }
519
+ }
520
+ if (sets.length === 0 && patch.components === undefined) {
521
+ throw new TypeError("bundles.updateBundle: patch contained no updatable fields");
522
+ }
523
+ var ts = _now();
524
+ if (sets.length > 0) {
525
+ sets.push("updated_at = ?" + (idx++));
526
+ params.push(ts);
527
+ params.push(bundleSku);
528
+ var r = await query(
529
+ "UPDATE bundles SET " + sets.join(", ") + " WHERE bundle_sku = ?" + idx,
530
+ params,
531
+ );
532
+ if (r.rowCount === 0 && patch.components === undefined) return null;
533
+ } else {
534
+ // Components-only patch — still bump updated_at so cursors order
535
+ // recent edits correctly.
536
+ await query("UPDATE bundles SET updated_at = ?1 WHERE bundle_sku = ?2", [ts, bundleSku]);
537
+ }
538
+ return await getBundle(bundleSku);
539
+ }
540
+
541
+ // ---- deleteBundle ------------------------------------------------------
542
+
543
+ async function deleteBundle(bundleSku) {
544
+ _sku(bundleSku, "bundle_sku");
545
+ // Active-cart reference check. `carts.status = 'active'` is the
546
+ // only state that holds a live customer claim on the bundle;
547
+ // converted/abandoned carts are historical and don't block
548
+ // deletion (the order primitive owns post-conversion state).
549
+ var refs = await query(
550
+ "SELECT COUNT(*) AS n FROM cart_lines cl " +
551
+ "JOIN carts c ON c.id = cl.cart_id " +
552
+ "WHERE cl.sku = ?1 AND c.status = 'active'",
553
+ [bundleSku],
554
+ );
555
+ var refCount = refs.rows[0] && (refs.rows[0].n || refs.rows[0].N);
556
+ if (refCount && refCount > 0) {
557
+ throw new TypeError("bundles.deleteBundle: bundle_sku " + JSON.stringify(bundleSku) +
558
+ " is referenced by " + refCount + " active cart line(s) — clear those carts before deleting");
559
+ }
560
+ if (extraReferenceCheck) {
561
+ var extra = await extraReferenceCheck(bundleSku);
562
+ if (extra) {
563
+ throw new TypeError("bundles.deleteBundle: bundle_sku " + JSON.stringify(bundleSku) +
564
+ " is referenced by an external subscription / order surface");
565
+ }
566
+ }
567
+ // FK ON DELETE CASCADE drops the bundle_components rows.
568
+ var r = await query("DELETE FROM bundles WHERE bundle_sku = ?1", [bundleSku]);
569
+ return r.rowCount > 0;
570
+ }
571
+
572
+ return {
573
+ defineBundle: defineBundle,
574
+ getBundle: getBundle,
575
+ listBundles: listBundles,
576
+ expand: expand,
577
+ priceBundle: priceBundle,
578
+ updateBundle: updateBundle,
579
+ deleteBundle: deleteBundle,
580
+ };
581
+ }
582
+
583
+ module.exports = {
584
+ create: create,
585
+ MAX_NESTING_DEPTH: MAX_NESTING_DEPTH,
586
+ ALLOWED_BUNDLE_COLUMNS: ALLOWED_BUNDLE_COLUMNS,
587
+ };