@blamejs/blamejs-shop 0.3.45 → 0.3.47

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/product-qa.js CHANGED
@@ -652,6 +652,45 @@ function create(opts) {
652
652
  return rows;
653
653
  }
654
654
 
655
+ // Batched answersForQuestion — every approved answer under EACH of
656
+ // `questionIds`, grouped into a map keyed by question_id. One `IN`
657
+ // query replaces the per-question `answersForQuestion` loop the PDP
658
+ // ran (one D1-bridge hop per question). The approved-only filter and
659
+ // the (pinned DESC, vote_count DESC, occurred_at ASC, id ASC) order
660
+ // are byte-for-byte the single-question method's, so the stitched
661
+ // thread renders identically. `opts.limit` caps the answers PER
662
+ // question (validated like the single method) — applied
663
+ // application-side after the grouped read so a high-fan-out question
664
+ // can't starve another. Each id is validated at entry (a malformed
665
+ // id throws TypeError, which the route catches). An empty input or a
666
+ // question with no approved answers maps to an empty array.
667
+ async function answersForQuestions(questionIds, listOpts) {
668
+ if (!Array.isArray(questionIds) || questionIds.length === 0) return {};
669
+ listOpts = listOpts || {};
670
+ var limit = _limit(listOpts.limit);
671
+ var ids = [];
672
+ var byId = {};
673
+ for (var qi = 0; qi < questionIds.length; qi += 1) {
674
+ var id = _uuid(questionIds[qi], "question_id");
675
+ if (byId[id]) continue;
676
+ byId[id] = [];
677
+ ids.push(id);
678
+ }
679
+ var placeholders = ids.map(function (_v, n) { return "?" + (n + 1); }).join(", ");
680
+ var r = await query(
681
+ "SELECT * FROM product_qa_answers " +
682
+ "WHERE question_id IN (" + placeholders + ") AND status = 'approved' " +
683
+ "ORDER BY pinned DESC, vote_count DESC, occurred_at ASC, id ASC",
684
+ ids,
685
+ );
686
+ for (var i = 0; i < r.rows.length; i += 1) {
687
+ var ans = _decodeAnswer(r.rows[i]);
688
+ var bucket = byId[ans.question_id];
689
+ if (bucket && bucket.length < limit) bucket.push(ans);
690
+ }
691
+ return byId;
692
+ }
693
+
655
694
  // Every answer under a question regardless of status — the operator
656
695
  // moderation detail needs the pending / rejected answers too, which
657
696
  // answersForQuestion (approved-only, storefront-facing) hides. Pinned
@@ -808,6 +847,7 @@ function create(opts) {
808
847
  getQuestion: getQuestion,
809
848
  topAnswerForQuestion: topAnswerForQuestion,
810
849
  answersForQuestion: answersForQuestion,
850
+ answersForQuestions: answersForQuestions,
811
851
  listAnswersForQuestion: listAnswersForQuestion,
812
852
  listQuestionsByStatus: listQuestionsByStatus,
813
853
  listAnswersByStatus: listAnswersByStatus,
package/lib/storefront.js CHANGED
@@ -8555,23 +8555,33 @@ function mount(router, deps) {
8555
8555
  currency_redirect_to: path,
8556
8556
  };
8557
8557
  var chosen = _readCurrencyCookie(req);
8558
- try {
8559
- var presenter = await currencyDisplayModule.loadPresenter({
8560
- fx: deps.currencyDisplay,
8561
- rounding: deps.currencyRounding || null,
8562
- baseCurrency: base,
8563
- displayCurrency: chosen,
8564
- });
8565
- // The presenter only activates when the chosen currency is in the
8566
- // live allow-list AND has a usable rate. A cookie naming a currency
8567
- // the operator since removed from the list resolves to base.
8568
- if (presenter && presenter.active && options.indexOf(presenter.displayCurrency) !== -1) {
8569
- bundle.format_price = presenter.format;
8570
- bundle.currency_selected = presenter.displayCurrency;
8571
- if (presenter.note) bundle.currency_note = presenter.note;
8558
+ // Short-circuit the FX presenter hop for visitors who never picked a
8559
+ // currency, or chose the live base: `loadPresenter` returns an
8560
+ // INACTIVE base presenter in exactly those cases (currency-display:
8561
+ // requested unset / === baseCurrency → base presenter), so the bundle
8562
+ // is the base bundle above unchanged. Skipping the FX rate read +
8563
+ // presenter build on every base-currency render is the common-case
8564
+ // win — only resolve the presenter when the visitor chose a non-base
8565
+ // currency that could actually convert.
8566
+ if (chosen != null && chosen !== base) {
8567
+ try {
8568
+ var presenter = await currencyDisplayModule.loadPresenter({
8569
+ fx: deps.currencyDisplay,
8570
+ rounding: deps.currencyRounding || null,
8571
+ baseCurrency: base,
8572
+ displayCurrency: chosen,
8573
+ });
8574
+ // The presenter only activates when the chosen currency is in the
8575
+ // live allow-list AND has a usable rate. A cookie naming a currency
8576
+ // the operator since removed from the list resolves to base.
8577
+ if (presenter && presenter.active && options.indexOf(presenter.displayCurrency) !== -1) {
8578
+ bundle.format_price = presenter.format;
8579
+ bundle.currency_selected = presenter.displayCurrency;
8580
+ if (presenter.note) bundle.currency_note = presenter.note;
8581
+ }
8582
+ } catch (_e) {
8583
+ // FX / rounding backend unavailable — fall back to base display.
8572
8584
  }
8573
- } catch (_e) {
8574
- // FX / rounding backend unavailable — fall back to base display.
8575
8585
  }
8576
8586
  return bundle;
8577
8587
  }
@@ -9229,37 +9239,16 @@ function mount(router, deps) {
9229
9239
  [productId],
9230
9240
  )).rows[0];
9231
9241
  if (!primary) return [];
9232
- var siblingRows = (await query(
9233
- "SELECT cm.product_id AS pid FROM collection_members cm " +
9234
- "JOIN products p ON p.id = cm.product_id " +
9235
- "WHERE cm.collection_slug = ?1 AND cm.product_id != ?2 AND p.status = 'active' " +
9236
- "ORDER BY cm.position ASC, cm.product_id ASC LIMIT ?3",
9237
- [primary.collection_slug, productId, limit],
9238
- )).rows;
9239
- var out = [];
9240
- for (var i = 0; i < siblingRows.length; i += 1) {
9241
- var pid = siblingRows[i].pid;
9242
- var prod = await deps.catalog.products.get(pid);
9243
- if (!prod || prod.status !== "active") continue;
9244
- var priceMinor = null;
9245
- var priceCurrency = "USD";
9246
- var variants = await deps.catalog.variants.listForProduct(pid);
9247
- if (variants.length) {
9248
- var pr = await deps.catalog.prices.current(variants[0].id, "USD");
9249
- if (pr) { priceMinor = pr.amount_minor; priceCurrency = pr.currency; }
9250
- }
9251
- var media = await deps.catalog.media.listForProduct(pid);
9252
- var hero = media.length ? media[0] : null;
9253
- out.push({
9254
- slug: prod.slug,
9255
- title: prod.title,
9256
- hero_r2_key: hero ? hero.r2_key : null,
9257
- hero_alt_text: hero ? (hero.alt_text || prod.title) : null,
9258
- price_minor: priceMinor,
9259
- price_currency: priceCurrency,
9260
- });
9261
- }
9262
- return out;
9242
+ // The sibling decoration (price + hero media per pick) now lands
9243
+ // in one batched query instead of per-sibling `products.get` +
9244
+ // `variants.listForProduct` + `prices.current` +
9245
+ // `media.listForProduct`. The helper returns the exact
9246
+ // { slug, title, hero_r2_key, hero_alt_text, price_minor,
9247
+ // price_currency } card shape the rail renderer consumes, in the
9248
+ // same (membership position ASC, product_id ASC) order, self +
9249
+ // inactive excluded, capped at limit — mirroring the edge
9250
+ // listRelatedProducts.
9251
+ return await deps.catalog.batch.relatedSiblings(primary.collection_slug, productId, "USD", limit);
9263
9252
  } catch (_e) {
9264
9253
  return [];
9265
9254
  }
@@ -9341,58 +9330,102 @@ function mount(router, deps) {
9341
9330
  var seen = Object.create(null);
9342
9331
  var offers = [];
9343
9332
  try {
9333
+ // First pass: collect every distinct bundle reachable from the
9334
+ // product's variant SKUs (deduped), preserving discovery order.
9335
+ var bundles = [];
9344
9336
  for (var s = 0; s < variantSkus.length; s += 1) {
9345
9337
  var bundleList = await deps.bundles.bundlesForComponent(variantSkus[s]);
9346
9338
  for (var i = 0; i < bundleList.length; i += 1) {
9347
- var bundle = bundleList[i];
9348
- if (seen[bundle.bundle_sku]) continue;
9349
- seen[bundle.bundle_sku] = true;
9350
-
9351
- // Decorate each member with a display title; flag unbuyable.
9352
- var componentsOut = [];
9353
- var allBuyable = true;
9354
- for (var j = 0; j < bundle.components.length; j += 1) {
9355
- var comp = bundle.components[j];
9356
- var buyable = await _skuBuyable(comp.sku);
9357
- if (!buyable) allBuyable = false;
9358
- var memberVariant = buyable || (await deps.catalog.variants.bySku(comp.sku));
9359
- var memberTitle = comp.sku;
9360
- if (memberVariant) {
9361
- var memberProduct = await deps.catalog.products.get(memberVariant.product_id);
9362
- memberTitle = (memberProduct && memberProduct.title) || memberVariant.title || comp.sku;
9363
- }
9364
- componentsOut.push({ sku: comp.sku, quantity: comp.quantity, title: memberTitle });
9365
- }
9366
-
9367
- var priced = null;
9368
- try {
9369
- priced = await deps.bundles.priceBundle({ bundle_sku: bundle.bundle_sku, pricing: _skuPricer(currency) });
9370
- } catch (_e) {
9371
- // A missing member price / mixed currency makes the bundle
9372
- // unpriceable surface it as unavailable rather than 500.
9373
- priced = null;
9339
+ if (seen[bundleList[i].bundle_sku]) continue;
9340
+ seen[bundleList[i].bundle_sku] = true;
9341
+ bundles.push(bundleList[i]);
9342
+ }
9343
+ }
9344
+ if (!bundles.length) return [];
9345
+
9346
+ // Member-title batching: resolve every member SKU's variant + its
9347
+ // owning product in two batched reads instead of a per-member
9348
+ // `variants.bySku` + `products.get`. (The full single-query bundle
9349
+ // resolver the edge has — getBundlesForProduct — is deferred;
9350
+ // member-title batching is the cheap win without touching the
9351
+ // server-authoritative pricing path.)
9352
+ var memberSkus = [];
9353
+ for (var bi = 0; bi < bundles.length; bi += 1) {
9354
+ var comps = bundles[bi].components || [];
9355
+ for (var ci = 0; ci < comps.length; ci += 1) memberSkus.push(comps[ci].sku);
9356
+ }
9357
+ var memberVariantBySku = {};
9358
+ var memberProductById = {};
9359
+ if (deps.catalog.batch && typeof deps.catalog.batch.variantsBySkus === "function") {
9360
+ memberVariantBySku = await deps.catalog.batch.variantsBySkus(memberSkus);
9361
+ var memberProductIds = [];
9362
+ var pidSeen = Object.create(null);
9363
+ for (var ms in memberVariantBySku) {
9364
+ if (!Object.prototype.hasOwnProperty.call(memberVariantBySku, ms)) continue;
9365
+ var pid = memberVariantBySku[ms].product_id;
9366
+ if (pid && !pidSeen[pid]) { pidSeen[pid] = true; memberProductIds.push(pid); }
9367
+ }
9368
+ if (memberProductIds.length && typeof deps.catalog.batch.productsByIds === "function") {
9369
+ memberProductById = await deps.catalog.batch.productsByIds(memberProductIds);
9370
+ }
9371
+ }
9372
+
9373
+ for (var b2 = 0; b2 < bundles.length; b2 += 1) {
9374
+ var bundle = bundles[b2];
9375
+ // Decorate each member with a display title; flag unbuyable.
9376
+ var componentsOut = [];
9377
+ var allBuyable = true;
9378
+ for (var j = 0; j < bundle.components.length; j += 1) {
9379
+ var comp = bundle.components[j];
9380
+ var buyable = await _skuBuyable(comp.sku);
9381
+ if (!buyable) allBuyable = false;
9382
+ // The prior path always read the member variant via bySku
9383
+ // (regardless of buyability) for the title; the batched map is
9384
+ // that same variant. Falls back to a per-SKU bySku only if the
9385
+ // batch surface is absent.
9386
+ var memberVariant = Object.prototype.hasOwnProperty.call(memberVariantBySku, comp.sku)
9387
+ ? memberVariantBySku[comp.sku]
9388
+ : (buyable || (await deps.catalog.variants.bySku(comp.sku)));
9389
+ var memberTitle = comp.sku;
9390
+ if (memberVariant) {
9391
+ var memberProduct = Object.prototype.hasOwnProperty.call(memberProductById, memberVariant.product_id)
9392
+ ? memberProductById[memberVariant.product_id]
9393
+ : (deps.catalog.batch && typeof deps.catalog.batch.variantsBySkus === "function"
9394
+ ? null
9395
+ : await deps.catalog.products.get(memberVariant.product_id));
9396
+ memberTitle = (memberProduct && memberProduct.title) || memberVariant.title || comp.sku;
9374
9397
  }
9375
-
9376
- var available = allBuyable && priced != null;
9377
- var listMinor = priced ? priced.list_total_minor : 0;
9378
- var amountMinor = priced ? priced.amount_minor : 0;
9379
- var discountMinor = priced ? priced.discount_minor : 0;
9380
- var cur = (priced && priced.currency) || currency;
9381
- offers.push({
9382
- bundle_sku: bundle.bundle_sku,
9383
- title: bundle.title,
9384
- components: componentsOut,
9385
- list_total_str: fmt(listMinor, cur),
9386
- amount_str: fmt(amountMinor, cur),
9387
- discount_str: discountMinor > 0 ? fmt(discountMinor, cur) : null,
9388
- available: available,
9389
- unavailable_reason: available
9390
- ? null
9391
- : (priced == null
9392
- ? "Pricing for this bundle isn't available right now."
9393
- : "One or more items in this bundle are out of stock."),
9394
- });
9398
+ componentsOut.push({ sku: comp.sku, quantity: comp.quantity, title: memberTitle });
9395
9399
  }
9400
+
9401
+ var priced = null;
9402
+ try {
9403
+ priced = await deps.bundles.priceBundle({ bundle_sku: bundle.bundle_sku, pricing: _skuPricer(currency) });
9404
+ } catch (_e) {
9405
+ // A missing member price / mixed currency makes the bundle
9406
+ // unpriceable — surface it as unavailable rather than 500.
9407
+ priced = null;
9408
+ }
9409
+
9410
+ var available = allBuyable && priced != null;
9411
+ var listMinor = priced ? priced.list_total_minor : 0;
9412
+ var amountMinor = priced ? priced.amount_minor : 0;
9413
+ var discountMinor = priced ? priced.discount_minor : 0;
9414
+ var cur = (priced && priced.currency) || currency;
9415
+ offers.push({
9416
+ bundle_sku: bundle.bundle_sku,
9417
+ title: bundle.title,
9418
+ components: componentsOut,
9419
+ list_total_str: fmt(listMinor, cur),
9420
+ amount_str: fmt(amountMinor, cur),
9421
+ discount_str: discountMinor > 0 ? fmt(discountMinor, cur) : null,
9422
+ available: available,
9423
+ unavailable_reason: available
9424
+ ? null
9425
+ : (priced == null
9426
+ ? "Pricing for this bundle isn't available right now."
9427
+ : "One or more items in this bundle are out of stock."),
9428
+ });
9396
9429
  }
9397
9430
  } catch (_e) {
9398
9431
  // Bundles table not migrated / read failure — degrade to no rail.
@@ -9457,12 +9490,27 @@ function mount(router, deps) {
9457
9490
  // the cart total.
9458
9491
  async function _repriceCartLines(lines) {
9459
9492
  if (!deps.quantityDiscounts) return lines;
9493
+ // The owning product_id per line drives the quantity-discount
9494
+ // product-scope match. Resolve every line's variant in one batched
9495
+ // id lookup instead of a per-line `variants.get`, keyed by the same
9496
+ // stable variant_id the line carries (so the resolved product_id is
9497
+ // byte-identical to the per-line read). Best-effort — a read failure
9498
+ // leaves the map empty and each line falls back to no product scope,
9499
+ // exactly as a per-line failure did before.
9500
+ var variantById = {};
9501
+ if (deps.catalog.batch && typeof deps.catalog.batch.variantsByIds === "function") {
9502
+ try {
9503
+ variantById = await deps.catalog.batch.variantsByIds(
9504
+ lines.map(function (l) { return l.variant_id; }),
9505
+ );
9506
+ } catch (_e) { variantById = {}; }
9507
+ }
9460
9508
  var out = [];
9461
9509
  for (var i = 0; i < lines.length; i += 1) {
9462
9510
  var l = lines[i];
9463
9511
  var unit = l.unit_amount_minor;
9464
9512
  try {
9465
- var variant = await deps.catalog.variants.get(l.variant_id);
9513
+ var variant = variantById[l.variant_id] || null;
9466
9514
  var applied = await deps.quantityDiscounts.applyToLine({
9467
9515
  line: {
9468
9516
  sku: l.sku,
@@ -9625,7 +9673,7 @@ function mount(router, deps) {
9625
9673
  // applied to available-on-hand (stock_on_hand − stock_held).
9626
9674
  async function _cartLineStock(lines) {
9627
9675
  var out = {};
9628
- if (!deps.catalog || !deps.catalog.inventory || typeof deps.catalog.inventory.get !== "function") {
9676
+ if (!deps.catalog || !deps.catalog.batch || typeof deps.catalog.batch.inventoryForSkus !== "function") {
9629
9677
  return out;
9630
9678
  }
9631
9679
  var threshold = 5;
@@ -9635,19 +9683,24 @@ function mount(router, deps) {
9635
9683
  if (Number.isInteger(t) && t >= 0) threshold = t;
9636
9684
  } catch (_e) { /* keep the default */ }
9637
9685
  }
9686
+ // One batched inventory read keyed by SKU instead of a per-line
9687
+ // `inventory.get`. Drop-silent on a read failure — the empty map
9688
+ // means every line reads "ok", matching the prior per-line stance.
9689
+ var invBySku = {};
9690
+ try {
9691
+ invBySku = await deps.catalog.batch.inventoryForSkus(
9692
+ lines.map(function (l) { return l.sku; }).filter(function (s) { return !!s; }),
9693
+ );
9694
+ } catch (_e) { invBySku = {}; /* a read failure never blocks the cart */ }
9638
9695
  for (var i = 0; i < lines.length; i += 1) {
9639
9696
  var vId = lines[i].variant_id;
9640
9697
  if (Object.prototype.hasOwnProperty.call(out, vId)) continue;
9641
9698
  var sku = lines[i].sku;
9642
9699
  if (!sku) { out[vId] = "ok"; continue; }
9643
- try {
9644
- var inv = await deps.catalog.inventory.get(sku);
9645
- if (!inv) { out[vId] = "ok"; continue; }
9646
- var avail = Number(inv.stock_on_hand || 0) - Number(inv.stock_held || 0);
9647
- out[vId] = avail <= 0 ? "out" : (avail <= threshold ? "low" : "ok");
9648
- } catch (_e) {
9649
- out[vId] = "ok"; // drop-silent — a read failure never blocks the cart
9650
- }
9700
+ var inv = Object.prototype.hasOwnProperty.call(invBySku, sku) ? invBySku[sku] : null;
9701
+ if (!inv) { out[vId] = "ok"; continue; }
9702
+ var avail = Number(inv.stock_on_hand || 0) - Number(inv.stock_held || 0);
9703
+ out[vId] = avail <= 0 ? "out" : (avail <= threshold ? "low" : "ok");
9651
9704
  }
9652
9705
  return out;
9653
9706
  }
@@ -9681,95 +9734,37 @@ function mount(router, deps) {
9681
9734
  }
9682
9735
 
9683
9736
  router.get("/", async function (req, res) {
9684
- var page = await deps.catalog.products.list({ status: "active", limit: 24 });
9685
- // Best-effort "starting price" + "hero media" lookup. Each
9686
- // product on the home grid carries its first variant's USD
9687
- // price (when one exists) and its first attached media row
9688
- // (when one exists). Both are best-effort products without
9689
- // a price render `—`; products without media render the
9690
- // text-only PRODUCT_CARD fallback.
9691
- var products = [];
9692
- for (var i = 0; i < page.rows.length; i += 1) {
9693
- var p = page.rows[i];
9694
- var variants = await deps.catalog.variants.listForProduct(p.id);
9695
- var startingPrice = null;
9696
- if (variants.length) {
9697
- var price = await deps.catalog.prices.current(variants[0].id, "USD");
9698
- if (price) startingPrice = price;
9699
- }
9700
- var media = await deps.catalog.media.listForProduct(p.id);
9701
- var heroMedia = media.length ? media[0] : null;
9702
- products.push(Object.assign({}, p, {
9703
- starting_price_minor: startingPrice ? startingPrice.amount_minor : null,
9704
- starting_price_currency: startingPrice ? startingPrice.currency : "USD",
9705
- hero_media: heroMedia,
9706
- }));
9707
- }
9737
+ // One decorated query for the whole home grid: each active product
9738
+ // carries its first variant's USD price (when one exists) and its
9739
+ // first attached media row (when one exists), pre-joined via window
9740
+ // functions the same shape `renderHome` consumes (products
9741
+ // without a price render `—`; products without media render the
9742
+ // text-only PRODUCT_CARD fallback). Replaces the prior 1 + 24×3
9743
+ // per-product fan-out over the D1 bridge. Order (updated_at DESC,
9744
+ // id DESC) matches the prior `products.list` order.
9745
+ var decorated = await deps.catalog.batch.decoratedActive({ currency: "USD", limit: 24 });
9746
+ var products = decorated.rows;
9708
9747
  var ccy = await _currencyForReq(req);
9709
9748
  var html = renderHome(Object.assign({ products: products, shop_name: shopName, theme: theme }, _requestUrls(req), ccy));
9710
9749
  _send(res, 200, html);
9711
9750
  });
9712
9751
 
9713
- // Decorate one product row with the display columns the search
9714
- // cards need (first variant's price, first media) AND the facet
9715
- // fields the searchFacets primitive computes against: `collection`
9716
- // (array of collection slugs the product belongs to), `price_minor`
9717
- // (the starting price), and `in_stock` (any variant with available
9718
- // stock). Composes the wired catalog / collections primitives; a
9719
- // collection facet only populates when `deps.collections` is present.
9720
- async function _decorateForSearch(p) {
9721
- var variants = await deps.catalog.variants.listForProduct(p.id);
9722
- var startingPrice = null;
9723
- var inStock = false;
9724
- for (var vi = 0; vi < variants.length; vi += 1) {
9725
- if (vi === 0) {
9726
- var price = await deps.catalog.prices.current(variants[0].id, "USD");
9727
- if (price) startingPrice = price;
9728
- }
9729
- var inv = await deps.catalog.inventory.get(variants[vi].sku);
9730
- if (inv && (Number(inv.stock_on_hand) - Number(inv.stock_held)) > 0) inStock = true;
9731
- }
9732
- var media = await deps.catalog.media.listForProduct(p.id);
9733
- var heroMedia = media.length ? media[0] : null;
9734
- var collectionSlugs = [];
9735
- if (deps.collections && typeof deps.collections.collectionsForProduct === "function") {
9736
- var cols = await deps.collections.collectionsForProduct(p.id);
9737
- for (var ci = 0; ci < cols.length; ci += 1) {
9738
- if (cols[ci] && cols[ci].slug) collectionSlugs.push(cols[ci].slug);
9739
- }
9740
- }
9741
- return Object.assign({}, p, {
9742
- starting_price_minor: startingPrice ? startingPrice.amount_minor : null,
9743
- starting_price_currency: startingPrice ? startingPrice.currency : "USD",
9744
- hero_media: heroMedia,
9745
- collection: collectionSlugs,
9746
- price_minor: startingPrice ? startingPrice.amount_minor : null,
9747
- in_stock: inStock,
9748
- });
9749
- }
9750
-
9751
9752
  // Pull the facetable universe for a set of search terms: every
9752
9753
  // active product matching ANY term (canonical query + synonym
9753
9754
  // expansions) on title / description, decorated once. The
9754
9755
  // searchFacets primitive consumes this through a `catalog.list`
9755
9756
  // adapter and walks the rows in-memory for counts; the route reuses
9756
9757
  // the same rows for the narrowed result grid (one decoration pass,
9757
- // no double round trip).
9758
+ // no double round trip). One batched query (an OR-of-LIKE term clause
9759
+ // + a collection_members `IN` + a grouped-inventory `IN`) replaces
9760
+ // the prior per-term search + per-row decoration fan-out, and aligns
9761
+ // the `collection` facet field with the edge: MANUAL collection
9762
+ // membership only (smart-collection rule matches no longer populate
9763
+ // the search facet — the edge has always faceted manual-only, so this
9764
+ // removes the cross-substrate drift). Order (updated_at DESC, id DESC)
9765
+ // matches the edge `searchFacetableProducts`.
9758
9766
  async function _facetableUniverse(terms) {
9759
- var byId = {};
9760
- var rows = [];
9761
- for (var ti = 0; ti < terms.length; ti += 1) {
9762
- var term = typeof terms[ti] === "string" ? terms[ti].trim() : "";
9763
- if (!term.length) continue;
9764
- var page = await deps.catalog.products.search({ q: term, status: "active", limit: 100 });
9765
- for (var i = 0; i < page.rows.length; i += 1) {
9766
- var p = page.rows[i];
9767
- if (byId[p.id]) continue;
9768
- byId[p.id] = true;
9769
- rows.push(await _decorateForSearch(p));
9770
- }
9771
- }
9772
- return rows;
9767
+ return (await deps.catalog.batch.searchDecorate({ terms: terms, currency: "USD" })).rows;
9773
9768
  }
9774
9769
 
9775
9770
  router.get("/search", async function (req, res) {
@@ -9872,77 +9867,88 @@ function mount(router, deps) {
9872
9867
  if (!slug) return _send(res, 400, renderNotFound({ shop_name: shopName, theme: theme }));
9873
9868
  var product = await deps.catalog.products.bySlug(slug);
9874
9869
  if (!product) return _send(res, 404, renderNotFound({ shop_name: shopName, theme: theme }));
9875
- var variants = await deps.catalog.variants.listForProduct(product.id);
9876
- var prices = {};
9870
+ // Variants + their current USD price in one query (the LEFT JOIN
9871
+ // collapses the prior per-variant `prices.current` loop). `vwp.rows`
9872
+ // is byte-identical to `variants.listForProduct` (options parsed),
9873
+ // `vwp.prices` is the { variantId: priceRow } map the renderer +
9874
+ // first-variant pricing read.
9875
+ var vwp = await deps.catalog.batch.variantsWithPrices(product.id, "USD");
9876
+ var variants = vwp.rows;
9877
+ var prices = vwp.prices;
9877
9878
  // Per-SKU inventory map driving the truthful availability badge +
9878
- // JSON-LD. Best-effort: a SKU with no inventory row (or a read
9879
- // failure) is omitted from the map, which the renderer treats as
9880
- // available the never-block-on-missing-inventory stance the
9881
- // cart-hold path already takes. Only populated when the operator has
9882
- // wired the inventory primitive.
9879
+ // JSON-LD. One batched read keyed by SKU, full row (the availability
9880
+ // resolver reads `low_stock_threshold` for the "Only N left" nudge).
9881
+ // Best-effort: a SKU with no inventory row (or a read failure) is
9882
+ // omitted from the map, which the renderer treats as available — the
9883
+ // never-block-on-missing-inventory stance the cart-hold path already
9884
+ // takes. Only populated when the operator has wired the inventory
9885
+ // batch surface.
9883
9886
  var inventory = {};
9884
- for (var i = 0; i < variants.length; i += 1) {
9885
- var p = await deps.catalog.prices.current(variants[i].id, "USD");
9886
- if (p) prices[variants[i].id] = p;
9887
- if (deps.catalog.inventory && typeof deps.catalog.inventory.get === "function") {
9888
- try {
9889
- var invRow = await deps.catalog.inventory.get(variants[i].sku);
9890
- if (invRow) inventory[variants[i].sku] = invRow;
9891
- } catch (_e) { /* drop-silent — missing inventory reads as available */ }
9892
- }
9887
+ if (variants.length && deps.catalog.batch && typeof deps.catalog.batch.inventoryRowsForSkus === "function") {
9888
+ try {
9889
+ inventory = await deps.catalog.batch.inventoryRowsForSkus(variants.map(function (v) { return v.sku; }));
9890
+ } catch (_e) { inventory = {}; /* drop-silent — missing inventory reads as available */ }
9893
9891
  }
9894
- // Media — first row drives the hero image, the next three feed
9895
- // the thumbnail strip. `listForProduct` is product-level only;
9896
- // variant-level media (`listForVariant`) would feed a swap-on-
9897
- // variant-select interaction we don't ship yet.
9898
- var media = await deps.catalog.media.listForProduct(product.id);
9899
9892
  // Render cart count from the current session's cart, if any.
9900
9893
  var cartCount = await _cartCountForReq(req);
9901
- // Published reviews aggregate + list. A failed read (e.g. the
9902
- // reviews table not yet migrated) degrades to the empty state
9903
- // rather than 500-ing the whole PDP — reviews are supplementary
9904
- // to the buy path. Mirrors the edge renderer's missing-table
9905
- // resilience.
9894
+ // The remaining supplementary PDP reads are independent of each
9895
+ // other, so they run concurrently (mirroring the edge's Promise.all
9896
+ // at worker/index.js). Each preserves its own degrade-to-empty
9897
+ // guard so a missing/unmigrated table never 500s the buy path.
9906
9898
  var reviewSummary, reviewRows, reviewCta;
9907
- if (deps.reviews) {
9908
- try {
9909
- reviewSummary = await deps.reviews.summaryForProduct(product.id);
9910
- reviewRows = (await deps.reviews.listForProduct(product.id, { limit: 10 })).rows;
9911
- } catch (_e) { reviewSummary = undefined; reviewRows = []; }
9912
- // The form route enforces auth + the verified-purchase gate, so
9913
- // the CTA links there unconditionally; logged-out shoppers get
9914
- // redirected to login, non-purchasers get a clear "not eligible".
9915
- reviewCta = "<a class=\"btn-secondary reviews__cta\" href=\"/products/" +
9916
- b.template.escapeHtml(product.slug) + "/review\">Write a review</a>";
9917
- }
9918
- // Wishlist social-proof count — degrades to 0 on a read failure
9919
- // (e.g. table not yet migrated) rather than 500-ing the PDP.
9920
9899
  var wishlistCount = 0;
9921
- if (deps.wishlist) {
9922
- try { wishlistCount = await deps.wishlist.countForProduct(product.id); }
9923
- catch (_e) { wishlistCount = 0; }
9924
- }
9925
- // Published Q&A — approved questions + their approved answers. A
9926
- // failed read (e.g. the product_qa tables not yet migrated) degrades
9927
- // to the empty state rather than 500-ing the PDP — Q&A is
9928
- // supplementary to the buy path, like reviews. Mirrors the edge
9929
- // renderer's missing-table resilience.
9930
9900
  var qaQuestions, qaCta;
9931
- if (deps.productQa) {
9932
- qaQuestions = [];
9933
- try {
9934
- var qList = (await deps.productQa.questionsForProduct({ product_id: product.id, limit: 20 })).rows;
9935
- for (var qi = 0; qi < qList.length; qi += 1) {
9936
- var qrow = qList[qi];
9937
- qrow.answers = await deps.productQa.answersForQuestion(qrow.id, { limit: 20 });
9938
- qaQuestions.push(qrow);
9939
- }
9940
- } catch (_e) { qaQuestions = []; }
9941
- // The form route enforces auth, so the CTA links there
9942
- // unconditionally; logged-out shoppers get redirected to login.
9943
- qaCta = "<a class=\"btn-secondary reviews__cta\" href=\"/products/" +
9944
- b.template.escapeHtml(product.slug) + "/question\">Ask a question</a>";
9945
- }
9901
+ var media;
9902
+ await Promise.all([
9903
+ // Media — first row drives the hero image, the next three feed the
9904
+ // thumbnail strip. `listForProduct` is product-level only.
9905
+ (async function () {
9906
+ media = await deps.catalog.media.listForProduct(product.id);
9907
+ })(),
9908
+ // Published reviews aggregate + list.
9909
+ (async function () {
9910
+ if (!deps.reviews) return;
9911
+ try {
9912
+ reviewSummary = await deps.reviews.summaryForProduct(product.id);
9913
+ reviewRows = (await deps.reviews.listForProduct(product.id, { limit: 10 })).rows;
9914
+ } catch (_e) { reviewSummary = undefined; reviewRows = []; }
9915
+ // The form route enforces auth + the verified-purchase gate, so
9916
+ // the CTA links there unconditionally; logged-out shoppers get
9917
+ // redirected to login, non-purchasers get a clear "not eligible".
9918
+ reviewCta = "<a class=\"btn-secondary reviews__cta\" href=\"/products/" +
9919
+ b.template.escapeHtml(product.slug) + "/review\">Write a review</a>";
9920
+ })(),
9921
+ // Wishlist social-proof count — degrades to 0 on a read failure.
9922
+ (async function () {
9923
+ if (!deps.wishlist) return;
9924
+ try { wishlistCount = await deps.wishlist.countForProduct(product.id); }
9925
+ catch (_e) { wishlistCount = 0; }
9926
+ })(),
9927
+ // Published Q&A — approved questions + their approved answers. The
9928
+ // answers for ALL questions land in one batched
9929
+ // `answersForQuestions` read (one D1 hop) instead of a per-question
9930
+ // loop, then are stitched back onto each question in order.
9931
+ (async function () {
9932
+ if (!deps.productQa) return;
9933
+ qaQuestions = [];
9934
+ try {
9935
+ var qList = (await deps.productQa.questionsForProduct({ product_id: product.id, limit: 20 })).rows;
9936
+ var answersByQ = await deps.productQa.answersForQuestions(
9937
+ qList.map(function (q) { return q.id; }),
9938
+ { limit: 20 },
9939
+ );
9940
+ for (var qi = 0; qi < qList.length; qi += 1) {
9941
+ var qrow = qList[qi];
9942
+ qrow.answers = answersByQ[qrow.id] || [];
9943
+ qaQuestions.push(qrow);
9944
+ }
9945
+ } catch (_e) { qaQuestions = []; }
9946
+ // The form route enforces auth, so the CTA links there
9947
+ // unconditionally; logged-out shoppers get redirected to login.
9948
+ qaCta = "<a class=\"btn-secondary reviews__cta\" href=\"/products/" +
9949
+ b.template.escapeHtml(product.slug) + "/question\">Ask a question</a>";
9950
+ })(),
9951
+ ]);
9946
9952
  // Log the view for a signed-in customer so it surfaces on their
9947
9953
  // "Recently viewed" account page. Drop-silent — a recording failure
9948
9954
  // (table not migrated, write contention) must never break the PDP